mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-25 09:32:33 +08:00
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:
parent
a53e72f542
commit
10e0fcc965
33 changed files with 1210 additions and 14 deletions
|
@ -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
|
||||
|
|
136
internal_packages/message-list/lib/find-in-thread.jsx
Normal file
136
internal_packages/message-list/lib/find-in-thread.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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%);
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' }
|
||||
] }
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -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' }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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' }
|
||||
] }
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
|
||||
|
|
36
src/components/scrollbar-ticks.jsx
Normal file
36
src/components/scrollbar-ticks.jsx
Normal 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>
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
209
src/flux/stores/searchable-component-store.es6
Normal file
209
src/flux/stores/searchable-component-store.es6
Normal 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()
|
|
@ -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'
|
||||
|
|
17
src/searchable-components/iframe-searcher.es6
Normal file
17
src/searchable-components/iframe-searcher.es6
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
92
src/searchable-components/real-dom-parser.es6
Normal file
92
src/searchable-components/real-dom-parser.es6
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
src/searchable-components/search-match.jsx
Normal file
21
src/searchable-components/search-match.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
83
src/searchable-components/searchable-component-maker.jsx
Normal file
83
src/searchable-components/searchable-component-maker.jsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
193
src/searchable-components/unified-dom-parser.es6
Normal file
193
src/searchable-components/unified-dom-parser.es6
Normal 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() { }
|
||||
}
|
161
src/searchable-components/virtual-dom-parser.es6
Normal file
161
src/searchable-components/virtual-dom-parser.es6
Normal 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
28
src/virtual-dom-utils.es6
Normal 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
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
BIN
static/images/message-list/ic-findinthread-close@1x.png
Normal file
BIN
static/images/message-list/ic-findinthread-close@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
static/images/message-list/ic-findinthread-close@2x.png
Normal file
BIN
static/images/message-list/ic-findinthread-close@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
static/images/message-list/ic-findinthread-next@1x.png
Normal file
BIN
static/images/message-list/ic-findinthread-next@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
static/images/message-list/ic-findinthread-next@2x.png
Normal file
BIN
static/images/message-list/ic-findinthread-next@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
static/images/message-list/ic-findinthread-previous@1x.png
Normal file
BIN
static/images/message-list/ic-findinthread-previous@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
static/images/message-list/ic-findinthread-previous@2x.png
Normal file
BIN
static/images/message-list/ic-findinthread-previous@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue