feat(find-in-thread): add the ability to find in a thread

Summary: Find in thread

Test Plan: todo

Reviewers: bengotow, juan

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2660
This commit is contained in:
Evan Morikawa 2016-03-02 14:46:27 -08:00
parent a53e72f542
commit 10e0fcc965
33 changed files with 1210 additions and 14 deletions

View file

@ -12,7 +12,8 @@ class EmailFrame extends React.Component
content: React.PropTypes.string.isRequired
render: =>
<EventedIFrame ref="iframe" seamless="seamless" onResize={@_setFrameHeight}/>
<EventedIFrame ref="iframe" seamless="seamless" searchable={true}
onResize={@_setFrameHeight}/>
componentDidMount: =>
@_mounted = true

View file

@ -0,0 +1,136 @@
import React from 'react'
import classnames from 'classnames'
import {Actions, MessageStore, SearchableComponentStore} from 'nylas-exports'
import {RetinaImg, KeyCommandsRegion} from 'nylas-component-kit'
export default class FindInThread extends React.Component {
static displayName = "FindInThread";
constructor(props) {
super(props);
this.state = SearchableComponentStore.getCurrentSearchData()
}
componentDidMount() {
this._usub = SearchableComponentStore.listen(this._onSearchableChange)
}
componentWillUnmount() {
this._usub()
}
_globalKeymapHandlers() {
return {
'application:find-in-thread': this._onFindInThread,
'application:find-in-thread-next': this._onNextResult,
'application:find-in-thread-previous': this._onPrevResult,
}
}
_onFindInThread = () => {
if (this.state.searchTerm === null) {
Actions.findInThread("");
if (MessageStore.hasCollapsedItems()) {
Actions.toggleAllMessagesExpanded()
}
}
this._focusSearch()
}
_onSearchableChange = () => {
this.setState(SearchableComponentStore.getCurrentSearchData())
}
_onFindChange = (event) => {
Actions.findInThread(event.target.value)
}
_onFindKeyDown = (event) => {
if (event.key === "Enter") {
return event.shiftKey ? this._onPrevResult() : this._onNextResult()
} else if (event.key === "Escape") {
this._clearSearch()
React.findDOMNode(this.refs.searchBox).blur()
}
}
_selectionText() {
if (this.state.globalIndex !== null && this.state.resultsLength > 0) {
return `${this.state.globalIndex + 1} of ${this.state.resultsLength}`
}
return ""
}
_navEnabled() { return this.state.resultsLength > 0 }
_onPrevResult = () => {
if (this._navEnabled()) { Actions.previousSearchResult() }
}
_onNextResult = () => {
if (this._navEnabled()) { Actions.nextSearchResult() }
}
_clearSearch = () => {
Actions.findInThread(null)
}
_focusSearch = (event) => {
const cw = React.findDOMNode(this.refs.controlsWrap)
if (!event || !(cw && cw.contains(event.target))) {
React.findDOMNode(this.refs.searchBox).focus()
}
}
render() {
const rootCls = classnames({
"find-in-thread": true,
"enabled": this.state.searchTerm !== null,
})
const btnCls = "btn btn-find-in-thread";
return (
<div className={rootCls} onClick={this._focusSearch}>
<KeyCommandsRegion globalHandlers={this._globalKeymapHandlers()}>
<div className="controls-wrap" ref="controlsWrap">
<div className="input-wrap">
<input type="text"
ref="searchBox"
placeholder="Find in thread"
onChange={this._onFindChange}
onKeyDown={this._onFindKeyDown}
value={this.state.searchTerm || ""}/>
<div className="selection-progress">{this._selectionText()}</div>
<div className="btn-wrap">
<button className={btnCls}
disabled={!this._navEnabled()}
onClick={this._onPrevResult}>
<RetinaImg name="ic-findinthread-previous.png"
mode={RetinaImg.Mode.ContentIsMask}/>
</button>
<button className={btnCls}
disabled={!this._navEnabled()}
onClick={this._onNextResult}>
<RetinaImg name="ic-findinthread-next.png"
mode={RetinaImg.Mode.ContentIsMask}/>
</button>
</div>
</div>
<button className={btnCls}
onClick={this._clearSearch}>
<RetinaImg name="ic-findinthread-close.png"
mode={RetinaImg.Mode.ContentIsMask}/>
</button>
</div>
</KeyCommandsRegion>
</div>
)
}
}

View file

@ -1,6 +1,7 @@
_ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
FindInThread = require './find-in-thread'
MessageItemContainer = require './message-item-container'
{Utils,
@ -13,7 +14,9 @@ MessageItemContainer = require './message-item-container'
WorkspaceStore,
ChangeLabelsTask,
ComponentRegistry,
ChangeStarredTask} = require("nylas-exports")
ChangeStarredTask,
SearchableComponentStore
SearchableComponentMaker} = require("nylas-exports")
{Spinner,
RetinaImg,
@ -88,7 +91,7 @@ class MessageList extends React.Component
if newDraftClientIds.length > 0
@_focusDraft(@_getMessageContainer(newDraftClientIds[0]))
_keymapHandlers: ->
_globalKeymapHandlers: ->
'application:reply': => @_createReplyOrUpdateExistingDraft('reply')
'application:reply-all': => @_createReplyOrUpdateExistingDraft('reply-all')
'application:forward': => @_onForward()
@ -190,10 +193,12 @@ class MessageList extends React.Component
"messages-wrap": true
"ready": not @state.loading
<KeyCommandsRegion globalHandlers={@_keymapHandlers()}>
<KeyCommandsRegion globalHandlers={@_globalKeymapHandlers()}>
<FindInThread ref="findInThread" />
<div className="message-list" id="message-list">
<ScrollRegion tabIndex="-1"
className={wrapClass}
scrollbarTickProvider={SearchableComponentStore}
scrollTooltipComponent={MessageListScrollTooltip}
ref="messageWrap">
{@_renderSubject()}
@ -402,4 +407,4 @@ class MessageList extends React.Component
currentThread: MessageStore.thread()
loading: MessageStore.itemsLoading()
module.exports = MessageList
module.exports = SearchableComponentMaker.extend(MessageList)

View file

@ -0,0 +1,57 @@
@import 'ui-variables';
body.platform-win32 {
.find-in-thread {
}
}
.find-in-thread {
background: @background-secondary;
border-bottom: 1px solid @border-secondary-bg;
text-align: right;
overflow: hidden;
height: 0;
padding: 0 8px;
transition: all 125ms ease-in-out;
&.enabled {
padding: 4px 8px;
height: 35px;
}
.controls-wrap {
display: inline-block;
}
.selection-progress {
color: @text-color-very-subtle;
position: absolute;
top: 4px;
right: 54px;
font-size: 12px;
}
.btn.btn-find-in-thread {
border: 0;
box-shadow: 0 0 0;
border-radius: 0;
background: transparent;
display: inline-block;
}
.input-wrap {
display: inline-block;
position: relative;
input {
height: 26px;
width: 230px;
padding-left: 8px;
font-size: 12px;
}
.btn-wrap {
width: 54px;
position: absolute;
top: 0;
right: 0;
}
}
}

View file

@ -120,6 +120,15 @@ body.platform-win32 {
padding: 0;
order: 2;
search-match, .search-match {
background: @text-color-search-match;
border-radius: @border-radius-base;
box-shadow: 0 0.5px 0.5px rgba(0,0,0,0.25);
&.current-match {
background: @text-color-search-current-match;
}
}
.show-hidden-messages {
background-color: darken(@background-secondary, 4%);
border: 1px solid darken(@background-secondary, 8%);

View file

@ -66,6 +66,10 @@
'cmdctrl-8': 'application:select-account-7'
'cmdctrl-9': 'application:select-account-8'
'cmdctrl-f': 'application:find-in-thread'
'cmdctrl-g': 'application:find-in-thread-next'
'cmdctrl-shift-g': 'application:find-in-thread-previous'
'body *[contenteditable].contenteditable':
### Basic formatting commands ###
'cmdctrl-u': 'contenteditable:underline'

View file

@ -16,3 +16,7 @@
'cmdctrl-n' : 'application:new-message'
'cmdctrl-shift-m': 'application:new-message'
'cmdctrl-enter': 'send'
'F4': 'application:find-in-thread'
'shift-F4': 'application:find-in-thread-next'
'ctrl-shift-F4': 'application:find-in-thread-previous'

View file

@ -44,6 +44,12 @@
{ label: 'Paste', command: 'core:paste' }
{ label: 'Paste and Match Style', command: 'core:paste-and-match-style' }
{ label: 'Select All', command: 'core:select-all' }
{ type: 'separator' }
{ label: 'Find', submenu: [
{ label: 'Find in Thread...', command: 'application:find-in-thread' }
{ label: 'Find Next', command: 'application:find-in-thread-next' }
{ label: 'Find Previous', command: 'application:find-in-thread-previous' }
] }
]
}

View file

@ -25,6 +25,12 @@
{ label: 'Paste and Match Style', command: 'core:paste-and-match-style' }
{ label: 'Select &All', command: 'core:select-all' }
{ type: 'separator' }
{ label: 'Find', submenu: [
{ label: 'Find in Thread...', command: 'application:find-in-thread' }
{ label: 'Find Next', command: 'application:find-in-thread-next' }
{ label: 'Find Previous', command: 'application:find-in-thread-previous' }
] }
{ type: 'separator' }
{ label: 'Preferences', command: 'application:open-preferences' }
]
}

View file

@ -11,6 +11,12 @@
{ label: '&Paste', command: 'core:paste' }
{ label: 'Paste and Match Style', command: 'core:paste-and-match-style' }
{ label: 'Select &All', command: 'core:select-all' }
{ type: 'separator' }
{ label: 'Find', submenu: [
{ label: 'Find in Thread...', command: 'application:find-in-thread' }
{ label: 'Find Next', command: 'application:find-in-thread-next' }
{ label: 'Find Previous', command: 'application:find-in-thread-previous' }
] }
]
}

View file

@ -1,5 +1,9 @@
React = require 'react'
{RegExpUtils}= require 'nylas-exports'
{Utils,
RegExpUtils,
SearchableComponentMaker,
SearchableComponentStore}= require 'nylas-exports'
IFrameSearcher = require '../searchable-components/iframe-searcher'
url = require 'url'
_ = require "underscore"
@ -27,10 +31,25 @@ class EventedIFrame extends React.Component
<iframe seamless="seamless" {...@props} />
componentDidMount: =>
if @props.searchable
@_regionId = Utils.generateTempId()
@_searchUsub = SearchableComponentStore.listen @_onSearchableStoreChange
SearchableComponentStore.registerSearchRegion(@_regionId, React.findDOMNode(this))
@_subscribeToIFrameEvents()
componentWillUnmount: =>
@_unsubscribeFromIFrameEvents()
if @props.searchable
@_searchUsub()
SearchableComponentStore.unregisterSearchRegion(@_regionId)
componentDidUpdate: ->
if @props.searchable
SearchableComponentStore.registerSearchRegion(@_regionId, React.findDOMNode(this))
shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
###
Public: Call this method if you replace the contents of the iframe's document.
@ -40,6 +59,17 @@ class EventedIFrame extends React.Component
@_unsubscribeFromIFrameEvents()
@_subscribeToIFrameEvents()
_onSearchableStoreChange: =>
return unless @props.searchable
node = React.findDOMNode(@)
doc = node.contentDocument?.body ? node.contentDocument
searchIndex = SearchableComponentStore.getCurrentRegionIndex(@_regionId)
{searchTerm} = SearchableComponentStore.getCurrentSearchData()
if @lastSearchIndex isnt searchIndex or @lastSearchTerm isnt searchTerm
IFrameSearcher.highlightSearchInDocument(@_regionId, searchTerm, doc, searchIndex)
@lastSearchIndex = searchIndex
@lastSearchTerm = searchTerm
_unsubscribeFromIFrameEvents: =>
node = React.findDOMNode(@)
doc = node.contentDocument

View file

@ -2,11 +2,21 @@ _ = require 'underscore'
React = require 'react/addons'
{Utils} = require 'nylas-exports'
classNames = require 'classnames'
ScrollbarTicks = require './scrollbar-ticks'
class Scrollbar extends React.Component
@displayName: 'Scrollbar'
@propTypes:
scrollTooltipComponent: React.PropTypes.func
# A scrollbarTickProvider is any object that has the `listen` and
# `scrollbarTicks` method. Since ScrollRegions tend to encompass large
# render trees it's more efficent for the scrollbar to listen for its
# own state then have it passed down as new props and potentially
# cause re-renders of the whole scroll region. The `scrollbarTicks`
# method must return an array of numbers between 0 and 1 which
# represent the height percentages at which tick marks will be
# rendered.
scrollbarTickProvider: React.PropTypes.object
getScrollRegion: React.PropTypes.func
constructor: (@props) ->
@ -17,9 +27,19 @@ class Scrollbar extends React.Component
viewportScrollTop: 0
dragging: false
scrolling: false
scrollbarTicks: []
componentDidMount: ->
if @props.scrollbarTickProvider?.listen
@_tickUnsub = @props.scrollbarTickProvider.listen(@_onTickProvider)
shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
componentWillUnmount: =>
@_onHandleUp({preventDefault: -> })
@_tickUnsub?()
setStateFromScrollRegion: (state) ->
@setState(state)
@ -29,6 +49,7 @@ class Scrollbar extends React.Component
'scrollbar-track': true
'dragging': @state.dragging
'scrolling': @state.scrolling
'with-ticks': @state.scrollbarTicks.length > 0
tooltip = []
if @props.scrollTooltipComponent and @state.dragging
@ -36,6 +57,7 @@ class Scrollbar extends React.Component
<div className={containerClasses} style={@_scrollbarWrapStyles()} onMouseEnter={@recomputeDimensions}>
<div className="scrollbar-track-inner" ref="track" onClick={@_onScrollJump}>
{@_renderScrollbarTicks()}
<div className="scrollbar-handle" onMouseDown={@_onHandleDown} style={@_scrollbarHandleStyles()} ref="handle" onClick={@_onHandleClick} >
<div className="tooltip">{tooltip}</div>
</div>
@ -47,6 +69,15 @@ class Scrollbar extends React.Component
@props.getScrollRegion()._recomputeDimensions(options)
@_recomputeDimensions(options)
_onTickProvider: =>
if not @props.scrollbarTickProvider?.scrollbarTicks
throw new Error("The scrollbarTickProvider must implement `scrollbarTicks`")
@setState scrollbarTicks: @props.scrollbarTickProvider.scrollbarTicks()
_renderScrollbarTicks: ->
return false unless @state.scrollbarTicks.length > 0
<ScrollbarTicks ticks={@state.scrollbarTicks}/>
_recomputeDimensions: ({useCachedValues}) =>
if not useCachedValues
trackNode = React.findDOMNode(@refs.track)
@ -115,6 +146,7 @@ class ScrollRegion extends React.Component
onScrollEnd: React.PropTypes.func
className: React.PropTypes.string
scrollTooltipComponent: React.PropTypes.func
scrollbarTickProvider: React.PropTypes.object
children: React.PropTypes.oneOfType([React.PropTypes.element, React.PropTypes.array])
getScrollbar: React.PropTypes.func
@ -193,6 +225,7 @@ class ScrollRegion extends React.Component
if not @props.getScrollbar
@_scrollbarComponent ?= <Scrollbar
ref="scrollbar"
scrollbarTickProvider={@props.scrollbarTickProvider}
scrollTooltipComponent={@props.scrollTooltipComponent}
getScrollRegion={@_getSelf} />

View file

@ -0,0 +1,36 @@
import React from 'react'
export default class ScrollbarTicks extends React.Component {
static displayName = "ScrollbarTicks";
static propTypes = {
ticks: React.PropTypes.array,
}
componentDidMount() {
this._updateTicks()
}
componentDidUpdate() {
this._updateTicks()
}
_updateTicks() {
const html = this.props.ticks.map((percentData) => {
let percent;
let className = ""
if (typeof percentData === "number") {
percent = percentData;
} else {
percent = percentData.percent;
className = " " + percentData.className
}
return `<div class="t${className}" style="top: ${percent * 100}%"></div>`
}).join("")
React.findDOMNode(this).innerHTML = html
}
render() {
return <div className="scrollbar-ticks"></div>
}
}

View file

@ -404,7 +404,7 @@ DOMUtils =
# WARNING. This is a fairly expensive operation and should be used
# sparingly.
nodeIsVisible: (node) ->
while node and node isnt window.document
while node and node.nodeType is Node.ELEMENT_NODE
style = window.getComputedStyle(node)
node = node.parentNode
continue unless style?
@ -413,6 +413,14 @@ DOMUtils =
return false
return true
# This checks for the `offsetParent` to be null. This will work for
# hidden elements, but not if they are in a `position:fixed` container.
#
# It is less thorough then Utils.nodeIsVisible, but is ~16x faster!!
# http://jsperf.com/check-hidden
# http://stackoverflow.com/a/21696585/793472
nodeIsLikelyVisible: (node) -> node.offsetParent isnt null
# Finds all of the non blank node in a {Document} object or HTML string.
#
# - `elementOrHTML` a dom element or an HTML string. If passed a
@ -471,7 +479,9 @@ DOMUtils =
return true if root.childNodes[0] is node
return DOMUtils.isFirstChild(root.childNodes[0], node)
commonAncestor: (nodes=[]) ->
commonAncestor: (nodes=[], parentFilter) ->
return null if nodes.length is 0
nodes = Array::slice.call(nodes)
minDepth = Number.MAX_VALUE
@ -479,20 +489,23 @@ DOMUtils =
# nodes. Since we're looking for a common ancestor we can really speed
# this up by keeping track of the min depth reached. We know that we
# won't need to check past that.
parents = ->
nodes = []
getParents = (node) ->
parentNodes = [node]
depth = 0
while node = node.parentNode
nodes.unshift(node)
if parentFilter
parentNodes.unshift(node) if parentFilter(node)
else
parentNodes.unshift(node)
depth += 1
if depth > minDepth then break
minDepth = Math.min(minDepth, depth)
return nodes
return parentNodes
# _.intersection will preserve the ordering of the parent node arrays.
# parents are ordered top to bottom, so the last node is the most
# specific common ancenstor
_.last(_.intersection.apply(null, nodes.map(DOMUtils.parents)))
_.last(_.intersection.apply(null, nodes.map(getParents)))
scrollAdjustmentToMakeNodeVisibleInContainer: (node, container) ->
return unless node

View file

@ -515,7 +515,6 @@ class Actions
@deleteMailRule: ActionScopeWindow
@disableMailRule: ActionScopeWindow
@openPopover: ActionScopeWindow
@closePopover: ActionScopeWindow
@ -531,6 +530,10 @@ class Actions
@draftParticipantsChanged: ActionScopeWindow
@findInThread: ActionScopeWindow
@nextSearchResult: ActionScopeWindow
@previousSearchResult: ActionScopeWindow
# Read the actions we declared on the dummy Actions object above
# and translate them into Reflux Actions

View file

@ -0,0 +1,209 @@
import _ from 'underscore'
import DOMUtils from '../../dom-utils'
import NylasStore from 'nylas-store'
import Actions from '../actions'
class SearchableComponentStore extends NylasStore {
constructor() {
super();
this.currentMatch = null
this.matches = []
this.globalIndex = null // null means nothing is selected
this.scrollAncestor = null
// null and empty string are different. Null means that search isn't
// even activated. Empty string means we're active but just not
// searching anything.
this.searchTerm = null
this.searchRegions = {}
this.listenTo(Actions.findInThread, this._findInThread)
this.listenTo(Actions.nextSearchResult, this._nextSearchResult)
this.listenTo(Actions.previousSearchResult, this._previousSearchResult)
}
/**
* The searchIndex
*/
getCurrentRegionIndex(regionId) {
let searchIndex = null;
if (regionId && this.currentMatch && this.currentMatch.node.getAttribute('data-region-id') === regionId) {
searchIndex = +this.currentMatch.node.getAttribute('data-render-index')
}
return searchIndex
}
getCurrentSearchData() {
return {
searchTerm: this.searchTerm,
globalIndex: this.globalIndex,
resultsLength: this.matches.length,
}
}
scrollbarTicks() {
let ticks = []
if (this.matches.length > 0 && this.scrollAncestor && this.scrollAncestor.scrollHeight > -1) {
ticks = this.matches.map((match) => {
if (match === this.currentMatch) {
return {
percent: match.top / this.scrollAncestor.scrollHeight,
className: "match",
}
}
return match.top / this.scrollAncestor.scrollHeight
})
}
return ticks
}
_nextSearchResult = () => {
this._moveGlobalIndexBy(1);
}
_previousSearchResult = () => {
this._moveGlobalIndexBy(-1);
}
// This needs to be debounced since it's called when all of our
// components are mounting and unmounting. It also is very expensive
// since it calls `getBoundingClientRect` and will trigger repaints.
_recalculateMatches = _.debounce(() => {
this.matches = []
// searchNodes need to all be under the root document. matches
// may contain nodes inside of iframes which are not attached ot the
// root document.
const searchNodes = []
if (this.searchTerm && this.searchTerm.length > 0) {
_.each(this.searchRegions, (node) => {
let refNode;
let topOffset = 0;
let leftOffset = 0;
if (node.nodeName === "IFRAME") {
searchNodes.push(node)
const iframeRect = node.getBoundingClientRect();
topOffset = iframeRect.top
leftOffset = iframeRect.left
refNode = node.contentDocument.body
if (!refNode) { refNode = node.contentDocument; }
} else {
refNode = node
}
const matches = refNode.querySelectorAll('search-match, .search-match');
for (let i = 0; i < matches.length; i++) {
if (!DOMUtils.nodeIsLikelyVisible(matches[i])) {
continue;
}
const rect = matches[i].getBoundingClientRect();
if (node.nodeName !== "IFRAME") {
searchNodes.push(matches[i])
}
this.matches.push({
node: matches[i],
top: rect.top + topOffset,
left: rect.left + leftOffset,
height: rect.height,
});
}
});
this.matches.sort((nodeA, nodeB) => {
const aScore = nodeA.top + nodeA.left / 1000
const bScore = nodeB.top + nodeB.left / 1000
return aScore - bScore
});
if (this.globalIndex !== null) {
this.globalIndex = Math.min(this.matches.length - 1, this.globalIndex);
this.currentMatch = this.matches[this.globalIndex]
}
const parentFilter = (node) => {
return _.contains(node.classList, "scroll-region-content")
}
this.scrollAncestor = DOMUtils.commonAncestor(searchNodes, parentFilter);
this.scrollAncestor = this.scrollAncestor.closest(".scroll-region-content")
if (this.scrollAncestor) {
const scrollRect = this.scrollAncestor.getBoundingClientRect();
const scrollTop = scrollRect.top - this.scrollAncestor.scrollTop;
// We save the position relative to the top of the scrollAncestor
// instead of the current getBoudingClientRect (which is dependent
// on the current scroll position)
this.matches.map((match) => {
match.top -= scrollTop
});
}
} else {
this.currentMatch = null;
this.globalIndex = null;
this.scrollAncestor = null;
}
if (this.matches.length > 0) {
if (this.globalIndex === null) {
this._moveGlobalIndexBy(1)
} else {
this._scrollIntoView()
}
}
this.trigger()
}, 33);
_moveGlobalIndexBy(amount) {
if (this.matches.length === 0) {
return
}
if (this.globalIndex === null) {
this.globalIndex = 0;
} else {
this.globalIndex += amount;
if (this.globalIndex < 0) {
this.globalIndex += this.matches.length
} else {
this.globalIndex = this.globalIndex % this.matches.length
}
}
this.currentMatch = this.matches[this.globalIndex]
this._scrollIntoView()
this.trigger()
}
_scrollIntoView() {
if (!this.currentMatch || !this.currentMatch.node || !this.scrollAncestor) {
return
}
const visibleRect = this.scrollAncestor.getBoundingClientRect();
const scrollTop = this.scrollAncestor.scrollTop
const matchMid = this.currentMatch.top + this.currentMatch.height / 2
if (matchMid < scrollTop || matchMid > scrollTop + visibleRect.height) {
const viewportMid = scrollTop + visibleRect.height / 2
const delta = matchMid - viewportMid
this.scrollAncestor.scrollTop = this.scrollAncestor.scrollTop + delta
}
}
_findInThread = (search) => {
if (search !== this.searchTerm) {
this.searchTerm = search;
this.trigger()
this._recalculateMatches()
}
}
registerSearchRegion(regionId, domNode) {
this.searchRegions[regionId] = domNode
this._recalculateMatches()
}
unregisterSearchRegion(regionId) {
delete this.searchRegions[regionId]
this._recalculateMatches()
}
}
export default new SearchableComponentStore()

View file

@ -130,6 +130,7 @@ class NylasExports
@require "FocusedContactsStore", 'flux/stores/focused-contacts-store'
@require "PreferencesUIStore", 'flux/stores/preferences-ui-store'
@require "PopoverStore", 'flux/stores/popover-store'
@require "SearchableComponentStore", 'flux/stores/searchable-component-store'
@require "MessageBodyProcessor", 'flux/stores/message-body-processor'
@require "MailRulesTemplates", 'mail-rules-templates'
@ -160,6 +161,7 @@ class NylasExports
# Utils
@load "Utils", 'flux/models/utils'
@load "DOMUtils", 'dom-utils'
@load "VirtualDOMUtils", 'virtual-dom-utils'
@load "CanvasUtils", 'canvas-utils'
@load "RegExpUtils", 'regexp-utils'
@load "DateUtils", 'date-utils'
@ -172,6 +174,8 @@ class NylasExports
@load "SoundRegistry", 'sound-registry'
@load "NativeNotifications", 'native-notifications'
@load "SearchableComponentMaker", 'searchable-components/searchable-component-maker'
@load "QuotedHTMLTransformer", 'services/quoted-html-transformer'
@load "QuotedPlainTextTransformer", 'services/quoted-plain-text-transformer'
@load "SanitizeTransformer", 'services/sanitize-transformer'

View file

@ -0,0 +1,17 @@
import RealDOMParser from './real-dom-parser'
export default class IFrameSearcher {
/**
* An imperative renderer for iframes
*/
static highlightSearchInDocument(regionId, searchTerm, doc, searchIndex) {
const parser = new RealDOMParser(regionId)
if (parser.matchesSearch(doc, searchTerm)) {
parser.removeMatchesAndNormalize(doc)
const matchNodeMap = parser.getElementsWithNewMatchNodes(doc, searchTerm, searchIndex)
parser.highlightSearch(doc, matchNodeMap)
} else {
parser.removeMatchesAndNormalize(doc)
}
}
}

View file

@ -0,0 +1,92 @@
import _ from 'underscore'
import UnifiedDOMParser from './unified-dom-parser'
import {DOMUtils} from 'nylas-exports'
export default class RealDOMParser extends UnifiedDOMParser {
*_pruningDOMWalker({node, pruneFn, filterFn}) {
if (filterFn(node)) {
yield node;
}
if (node && !pruneFn(node) && node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
yield *this._pruningDOMWalker({node: node.childNodes[i], pruneFn, filterFn});
}
}
return;
}
getWalker(dom) {
const filterFn = (node) => {
return node.nodeType === Node.TEXT_NODE
}
const pruneFn = (node) => {
return node.nodeName === "STYLE"
}
return this._pruningDOMWalker({node: dom, pruneFn, filterFn});
}
isTextNode(node) {
return node.nodeType === Node.TEXT_NODE
}
textNodeLength(textNode) {
return (textNode.data || "").length
}
textNodeContents(textNode) {
return (textNode.data)
}
looksLikeBlockElement(node) {
return DOMUtils.looksLikeBlockElement(node)
}
getRawFullString(fullString) {
return _.pluck(fullString, "data").join('');
}
removeMatchesAndNormalize(element) {
const matches = element.querySelectorAll('search-match');
if (matches.length === 0) { return null }
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
DOMUtils.unwrapNode(match)
}
element.normalize();
return element
}
createTextNode({rawText}) {
return document.createTextNode(rawText);
}
createMatchNode({matchText, regionId, isCurrentMatch, renderIndex}) {
const text = document.createTextNode(matchText);
const newNode = document.createElement('search-match');
const className = isCurrentMatch ? "current-match" : "";
newNode.setAttribute('data-region-id', regionId)
newNode.setAttribute('data-render-index', renderIndex)
newNode.setAttribute('class', className)
newNode.appendChild(text);
return newNode
}
textNodeKey(textElement) {
return textElement;
}
highlightSearch(element, matchNodeMap) {
const walker = this.getWalker(element);
// We have to expand the whole generator because we're mutating in
// place
const textNodes = [...walker]
for (const textNode of textNodes) {
if (matchNodeMap.has(textNode)) {
const {originalTextNode, newTextNodes} = matchNodeMap.get(textNode);
const frag = document.createDocumentFragment();
for (const newNode of newTextNodes) {
frag.appendChild(newNode);
}
textNode.parentNode.replaceChild(frag, originalTextNode)
}
}
}
}

View file

@ -0,0 +1,21 @@
import React from 'react'
import {Utils} from 'nylas-exports'
export default class SearchMatch extends React.Component {
static displayName = "SearchMatch";
static propTypes = {
regionId: React.PropTypes.string,
className: React.PropTypes.string,
renderIndex: React.PropTypes.number,
}
render() {
return (
<span data-region-id={this.props.regionId}
data-render-index={this.props.renderIndex}
className={`search-match ${this.props.className}`}>{this.props.children}</span>
)
}
}

View file

@ -0,0 +1,83 @@
import _ from 'underscore'
import React from 'react'
import Utils from '../flux/models/utils'
import VirtualDOMParser from './virtual-dom-parser'
import SearchableComponentStore from '../flux/stores/searchable-component-store'
class SearchableComponent {
componentDidMount(superMethod, ...args) {
if (superMethod) superMethod.apply(this, args);
this.__regionId = Utils.generateTempId();
this._searchableListener = SearchableComponentStore.listen(() => {this._onSearchableComponentStoreChange()})
SearchableComponentStore.registerSearchRegion(this.__regionId, React.findDOMNode(this))
}
_onSearchableComponentStoreChange() {
const searchIndex = SearchableComponentStore.getCurrentRegionIndex(this.__regionId);
const {searchTerm} = SearchableComponentStore.getCurrentSearchData()
this.setState({
__searchTerm: searchTerm,
__searchIndex: searchIndex,
})
}
shouldComponentUpdate(superMethod, nextProps, nextState) {
let shouldUpdate = true;
if (superMethod) {
shouldUpdate = superMethod.apply(this, [nextProps, nextState]);
}
if (shouldUpdate && (this.__searchTerm || (this.__searchIndex !== null && this.__searchIndex !== undefined))) {
shouldUpdate = this.__searchTerm !== nextState.__searchTerm || this.__searchIndex !== nextState.__searchIndex
}
return shouldUpdate
}
componentWillUnmount(superMethod, ...args) {
if (superMethod) superMethod.apply(this, args);
this._searchableListener()
SearchableComponentStore.unregisterSearchRegion(this.__regionId)
}
componentDidUpdate(superMethod, ...args) {
if (superMethod) superMethod.apply(this, args);
SearchableComponentStore.registerSearchRegion(this.__regionId, React.findDOMNode(this))
}
render(superMethod, ...args) {
if (superMethod) {
const vDOM = superMethod.apply(this, args);
const parser = new VirtualDOMParser(this.__regionId);
const searchTerm = this.state.__searchTerm
if (parser.matchesSearch(vDOM, searchTerm)) {
const normalizedDOM = parser.removeMatchesAndNormalize(vDOM)
const matchNodeMap = parser.getElementsWithNewMatchNodes(normalizedDOM, searchTerm, this.state.__searchIndex);
return parser.highlightSearch(normalizedDOM, matchNodeMap)
}
return vDOM
}
}
}
/**
* Takes a React component and makes it searchable
*/
export default class SearchableComponentMaker {
static extend(component) {
const proto = SearchableComponent.prototype;
for (const propName of Object.getOwnPropertyNames(proto)) {
const origMethod = component.prototype[propName]
if (origMethod) {
if (propName === "constructor") { continue }
component.prototype[propName] = _.partial(proto[propName], origMethod)
} else {
component.prototype[propName] = proto[propName]
}
}
return component
}
static searchInIframe(contentDocument) {
return contentDocument;
}
}

View file

@ -0,0 +1,193 @@
import {Utils} from 'nylas-exports'
export default class UnifiedDOMParser {
constructor(regionId) {
this.regionId = regionId
this.matchRenderIndex = 0
}
matchesSearch(dom, searchTerm) {
if ((searchTerm || "").trim().length === 0) { return false; }
const fullStrings = this.buildNormalizedText(dom)
// For each match, we return an array of new elements.
for (const fullString of fullStrings) {
const matches = this.matchesFromFullString(fullString, searchTerm);
if (matches.length > 0) {
return true;
}
}
return false;
}
buildNormalizedText(dom) {
const walker = this.getWalker(dom);
const fullStrings = [];
let textElementAccumulator = [];
let stringIndex = 0;
for (const node of walker) {
if (this.isTextNode(node)) {
node.fullStringIndex = stringIndex
textElementAccumulator.push(node);
stringIndex += this.textNodeLength(node);
} else if (this.looksLikeBlockElement(node)) {
if (textElementAccumulator.length > 0) {
fullStrings.push(textElementAccumulator);
textElementAccumulator = [];
stringIndex = 0;
}
}
// else continue for inline elements
}
if (textElementAccumulator.length > 0) {
fullStrings.push(textElementAccumulator);
}
return fullStrings
}
// OVERRIDE ME
getWalker() { }
isTextNode() { }
textNodeLength() { }
looksLikeBlockElement() { }
textNodeContents() {}
matchesFromFullString(fullString, searchTerm) {
const re = this.searchRE(searchTerm);
if (!re) { return [] }
const rawString = this.getRawFullString(fullString)
const matches = []
let matchCount = 0;
let match = re.exec(rawString);
while (match && matchCount < 1000) {
const matchStart = match.index;
const matchEnd = match.index + match[0].length;
matches.push([matchStart, matchEnd])
match = re.exec(rawString)
matchCount += 1;
}
return matches;
}
getRawFullString() { }
searchRE(searchTerm) {
let re;
const regexRe = /^\/(.+)\/(.*)$/
try {
if (regexRe.test(searchTerm)) {
// Looks like regex
const matches = searchTerm.match(regexRe);
const reText = matches[1];
re = new RegExp(reText, "ig");
} else {
re = new RegExp(Utils.escapeRegExp(searchTerm), "ig");
}
} catch (e) {
return null
}
return re
}
// OVERRIDE ME
removeMatchesAndNormalize() { }
getElementsWithNewMatchNodes(rootNode, searchTerm, currentMatchRenderIndex) {
const fullStrings = this.buildNormalizedText(rootNode)
const modifiedElements = new Map()
// For each match, we return an array of new elements.
for (const fullString of fullStrings) {
const matches = this.matchesFromFullString(fullString, searchTerm);
if (matches.length === 0) {
continue;
}
for (const textNode of fullString) {
const slicePoints = this.slicePointsForMatches(textNode,
matches);
if (slicePoints.length > 0) {
const {key, originalTextNode, newTextNodes} = this.slicedTextElement(textNode, slicePoints, currentMatchRenderIndex);
modifiedElements.set(key, {originalTextNode, newTextNodes})
}
}
}
return modifiedElements;
}
slicePointsForMatches(textElement, matches) {
const textElStart = textElement.fullStringIndex;
const textLength = this.textNodeLength(textElement);
const textElEnd = textElement.fullStringIndex + textLength;
const slicePoints = [];
for (const [matchStart, matchEnd] of matches) {
if (matchStart < textElStart && matchEnd >= textElEnd) {
// textEl is completely inside of match
slicePoints.push([0, textLength])
} else if (matchStart >= textElStart && matchEnd < textElEnd) {
// match is completely inside of textEl
slicePoints.push([matchStart - textElStart, matchEnd - textElStart])
} else if (matchEnd >= textElStart && matchEnd < textElEnd) {
// match started in a previous el but ends in this one
slicePoints.push([0, matchEnd - textElStart])
} else if (matchStart >= textElStart && matchStart < textElEnd) {
// match starts in this el but ends in a future one
slicePoints.push([matchStart - textElStart, textLength])
} else {
// match is not in this element
continue;
}
}
return slicePoints;
}
/**
* Given some text element and a slice point, it will split that text
* element at the slice points and return the new nodes as a value,
* keyed by a way to find that insertion point in the DOM.
*/
slicedTextElement(textNode, slicePoints, currentMatchRenderIndex) {
const key = this.textNodeKey(textNode)
const text = this.textNodeContents(textNode)
const newTextNodes = [];
let sliceOffset = 0;
let remainingText = text;
for (let [sliceStart, sliceEnd] of slicePoints) {
sliceStart = sliceStart - sliceOffset;
sliceEnd = sliceEnd - sliceOffset;
const before = remainingText.slice(0, sliceStart);
if (before.length > 0) {
newTextNodes.push(this.createTextNode({rawText: before}))
}
const matchText = remainingText.slice(sliceStart, sliceEnd);
if (matchText.length > 0) {
let isCurrentMatch = false;
if (this.matchRenderIndex === currentMatchRenderIndex) {
isCurrentMatch = true;
}
newTextNodes.push(this.createMatchNode({regionId: this.regionId, renderIndex: this.matchRenderIndex, matchText, isCurrentMatch}));
this.matchRenderIndex += 1
}
remainingText = remainingText.slice(sliceEnd, remainingText.length)
sliceOffset += sliceEnd
}
newTextNodes.push(this.createTextNode({rawText: remainingText}));
return {
key: key,
originalTextNode: textNode,
newTextNodes: newTextNodes,
};
}
// OVERRIDE ME
createTextNode() {}
createMatchNode() {}
textNodeKey() {}
// OVERRIDE ME
highlightSearch() { }
}

View file

@ -0,0 +1,161 @@
import _ from 'underscore'
import React from 'react'
import SearchMatch from './search-match'
import UnifiedDOMParser from './unified-dom-parser'
import {VirtualDOMUtils} from 'nylas-exports'
export default class VirtualDOMParser extends UnifiedDOMParser {
getWalker(dom) {
const pruneFn = (node) => {
return node.type === "style";
}
return VirtualDOMUtils.walk({element: dom, pruneFn});
}
isTextNode({element}) {
return (typeof element === "string")
}
textNodeLength({element}) {
return element.length
}
textNodeContents(textNode) {
return textNode.element
}
looksLikeBlockElement({element}) {
if (!element) { return false; }
const blockTypes = ["br", "p", "blockquote", "div", "table", "iframe"]
if (_.isFunction(element.type)) {
return true
} else if (blockTypes.indexOf(element.type) >= 0) {
return true
}
return false
}
getRawFullString(fullString) {
return _.pluck(fullString, "element").join('');
}
removeMatchesAndNormalize(element) {
let newChildren = [];
let strAccumulator = [];
const resetAccumulator = () => {
if (strAccumulator.length > 0) {
newChildren.push(strAccumulator.join(''));
strAccumulator = [];
}
}
if (React.isValidElement(element) || _.isArray(element)) {
let children;
if (_.isArray(element)) {
children = element;
} else {
children = element.props.children;
}
if (!children) {
newChildren = null
} else if (React.isValidElement(children)) {
newChildren = children
} else if (typeof children === "string") {
strAccumulator.push(children)
} else if (children.length > 0) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (typeof child === "string") {
strAccumulator.push(child)
} else if (this._isSearchElement(child)) {
resetAccumulator();
newChildren.push(child.props.children);
} else {
resetAccumulator();
newChildren.push(this.removeMatchesAndNormalize(child));
}
}
} else {
newChildren = children
}
resetAccumulator();
if (_.isArray(element)) {
return newChildren;
}
return React.cloneElement(element, {}, newChildren)
}
return element;
}
_isSearchElement(element) {
return element.type === SearchMatch
}
createTextNode({rawText}) {
return rawText
}
createMatchNode({matchText, regionId, isCurrentMatch, renderIndex}) {
const className = isCurrentMatch ? "current-match" : ""
return React.createElement(SearchMatch, {className, regionId, renderIndex}, matchText);
}
textNodeKey(textElement) {
return textElement.parentNode
}
highlightSearch(element, matchNodeMap) {
if (React.isValidElement(element) || _.isArray(element)) {
let newChildren = []
let children;
if (_.isArray(element)) {
children = element;
} else {
children = element.props.children;
}
const matchNode = matchNodeMap.get(element);
let originalTextNode = null;
let newTextNodes = [];
if (matchNode) {
originalTextNode = matchNode.originalTextNode;
newTextNodes = matchNode.newTextNodes;
}
if (!children) {
newChildren = null
} else if (React.isValidElement(children)) {
if (originalTextNode && originalTextNode.childOffset === 0) {
newChildren = newTextNodes
} else {
newChildren = this.highlightSearch(children, matchNodeMap)
}
} else if (!_.isString(children) && children.length > 0) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (originalTextNode && originalTextNode.childOffset === i) {
newChildren.push(newTextNodes)
} else {
newChildren.push(this.highlightSearch(child, matchNodeMap))
}
}
} else {
if (originalTextNode && originalTextNode.childOffset === 0) {
newChildren = newTextNodes
} else {
newChildren = children
}
}
if (_.isArray(element)) {
return newChildren;
}
return React.cloneElement(element, {}, newChildren)
}
return element;
}
}

28
src/virtual-dom-utils.es6 Normal file
View file

@ -0,0 +1,28 @@
import _ from 'underscore'
import React from 'react'
const VirtualDOMUtils = {
*walk({element, parentNode, childOffset, pruneFn = ()=>{}}) {
yield {element, parentNode, childOffset};
if (React.isValidElement(element) && !pruneFn(element)) {
const children = element.props.children;
if (!children) {
return
} else if (_.isString(children)) {
yield {element: children, parentNode: element, childOffset: 0}
} else if (children.length > 0) {
for (let i = 0; i < children.length; i++) {
yield *this.walk({element: children[i], parentNode: element, childOffset: i, pruneFn})
}
} else {
yield *this.walk({element: children, parentNode: element, childOffset: 0, pruneFn})
}
} else if (_.isArray(element)) {
for (let i = 0; i < element.length; i++) {
yield *this.walk({element: element[i], parentNode: element, childOffset: i})
}
}
return
},
}
export default VirtualDOMUtils

View file

@ -67,6 +67,7 @@
}
.scrollbar-track {
position: relative;
opacity: 0;
transition: opacity 0.3s;
transition-delay: 0.5s;
@ -85,6 +86,11 @@
transition-delay: 0s;
}
&.with-ticks {
opacity: 1;
transition-delay: 0s;
}
&.dragging {
opacity: 1;
.scrollbar-handle {
@ -117,6 +123,27 @@
pointer-events: none;
}
}
.scrollbar-ticks {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
.t {
&.match {
background: @text-color-search-current-match;
z-index: 2;
}
position: absolute;
width: 90%;
left: 5%;
height: 2px;
background: @text-color-search-match;
box-shadow: 0 0.5px 0.5px rgba(0,0,0,0.25);
}
}
}
body.platform-win32 {
.scroll-tooltip {

View file

@ -102,4 +102,13 @@
border: 0;
}
search-match, .search-match {
background: @text-color-search-match;
border-radius: @border-radius-base;
box-shadow: 0 0.5px 0.5px rgba(0,0,0,0.25);
&.current-match {
background: @text-color-search-current-match;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -87,6 +87,9 @@
@text-color-error: @error-color;
@text-color-destructive: @danger-color;
@text-color-search-match: #fff000;
@text-color-search-current-match: #ff8b1a;
@font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif;
@font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif;
@font-family-serif: Georgia, "Times New Roman", Times, serif;