diff --git a/app/containers/appContainer/index.js b/app/containers/appContainer/index.js
index cd893000..873f4e28 100644
--- a/app/containers/appContainer/index.js
+++ b/app/containers/appContainer/index.js
@@ -5,6 +5,7 @@ import electronBridge from '../../utilities/electronBridge'
import { updateUpdateAvailableBarStatus } from '../../actions/index'
import AboutPage from '../aboutPage'
import Dashboard from '../dashboard'
+import FindInPage, { isFindInPageAvailable } from '../findInPage'
import LoginPage from '../loginPage'
import NavigationPanel from '../navigationPanel'
import NavigationPanelDetails from '../navigationPanelDetails'
@@ -65,6 +66,11 @@ function SplitPaneDivider (props) {
}
class AppContainer extends Component {
+ renderFindInPage () {
+ if (!isFindInPageAvailable(this.props)) return null
+ return
+ }
+
renderAboutPage () {
const { updateAboutModalStatus } = this.props
return (
@@ -198,6 +204,7 @@ class AppContainer extends Component {
const { userSession } = this.props
return (
+ { this.renderFindInPage() }
{ userSession.activeStatus === 'ACTIVE'
? this.renderActiveSection()
: this.renderInactiveSection() }
@@ -212,6 +219,12 @@ function mapStateToProps (state) {
searchWindowStatus: state.searchWindowStatus,
aboutModalStatus: state.aboutModalStatus,
dashboardModalStatus: state.dashboardModalStatus,
+ gistDeleteModalStatus: state.gistDeleteModalStatus,
+ gistEditModalStatus: state.gistEditModalStatus,
+ gistNewModalStatus: state.gistNewModalStatus,
+ gistRawModal: state.gistRawModal,
+ logoutModalStatus: state.logoutModalStatus,
+ pinnedTagsModalStatus: state.pinnedTagsModalStatus,
newVersionInfo: state.newVersionInfo,
updateAvailableBarStatus: state.updateAvailableBarStatus,
immersiveMode: state.immersiveMode
diff --git a/app/containers/findInPage/index.js b/app/containers/findInPage/index.js
new file mode 100644
index 00000000..76bb204f
--- /dev/null
+++ b/app/containers/findInPage/index.js
@@ -0,0 +1,253 @@
+import React, { PureComponent } from 'react'
+import electronBridge from '../../utilities/electronBridge'
+import { t } from '../../utilities/i18n'
+import { createPageFinder } from '../../utilities/pageFind'
+
+import './index.scss'
+
+const FIND_DEBOUNCE_MS = 100
+
+export function isFindInPageAvailable (state) {
+ const userSession = state.userSession || {}
+ const gistRawModal = state.gistRawModal || {}
+ const blockingStatuses = [
+ state.aboutModalStatus,
+ state.dashboardModalStatus,
+ state.gistDeleteModalStatus,
+ state.gistEditModalStatus,
+ state.gistNewModalStatus,
+ gistRawModal.status,
+ state.logoutModalStatus,
+ state.pinnedTagsModalStatus,
+ state.searchWindowStatus
+ ]
+
+ return userSession.activeStatus === 'ACTIVE' &&
+ blockingStatuses.every(status => status !== 'ON')
+}
+
+class FindInPage extends PureComponent {
+ constructor (props) {
+ super(props)
+ this.state = {
+ activeMatchOrdinal: 0,
+ hasQuery: false,
+ isOpen: false,
+ matches: 0
+ }
+ this.findFrame = null
+ this.inputRef = React.createRef()
+ this.findTimer = null
+ this.finder = props.finder || createPageFinder()
+ this.lastSearchedQuery = ''
+ this.query = ''
+ this.unsubscribeFindRequest = null
+
+ this.close = this.close.bind(this)
+ this.handleGlobalKeyDown = this.handleGlobalKeyDown.bind(this)
+ this.handleInputKeyDown = this.handleInputKeyDown.bind(this)
+ this.handleQueryChange = this.handleQueryChange.bind(this)
+ this.open = this.open.bind(this)
+ }
+
+ getBridge () {
+ return this.props.bridge || electronBridge
+ }
+
+ componentDidMount () {
+ const windowBridge = this.getBridge().window
+ this.unsubscribeFindRequest = windowBridge.onFindInPageRequest(this.open)
+ document.addEventListener('keydown', this.handleGlobalKeyDown, true)
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('keydown', this.handleGlobalKeyDown, true)
+ this.cancelScheduledFind()
+ if (this.unsubscribeFindRequest) this.unsubscribeFindRequest()
+ this.finder.clear()
+ }
+
+ cancelScheduledFind () {
+ if (this.findTimer !== null) {
+ clearTimeout(this.findTimer)
+ this.findTimer = null
+ }
+ if (this.findFrame !== null && typeof window.cancelAnimationFrame === 'function') {
+ window.cancelAnimationFrame(this.findFrame)
+ this.findFrame = null
+ }
+ }
+
+ focusInput (selectQuery = false) {
+ if (!this.inputRef.current) return
+ this.inputRef.current.focus()
+ if (selectQuery) this.inputRef.current.select()
+ }
+
+ open () {
+ const wasOpen = this.state.isOpen
+ this.setState({ isOpen: true }, () => {
+ this.focusInput(true)
+ if (!wasOpen && this.query) this.scheduleFind(this.query)
+ })
+ }
+
+ close () {
+ this.cancelScheduledFind()
+ this.lastSearchedQuery = ''
+ this.finder.clear()
+ this.setState({
+ activeMatchOrdinal: 0,
+ isOpen: false,
+ matches: 0
+ })
+ }
+
+ runFind (query) {
+ if (!query) return
+ this.lastSearchedQuery = query
+ this.updateResult(this.finder.search(query))
+ }
+
+ scheduleFind (query) {
+ this.cancelScheduledFind()
+ this.findTimer = setTimeout(() => {
+ this.findTimer = null
+ const runFind = () => {
+ this.findFrame = null
+ if (!this.state.isOpen || this.query !== query) return
+ this.runFind(query)
+ }
+
+ if (typeof window.requestAnimationFrame === 'function') {
+ this.findFrame = window.requestAnimationFrame(runFind)
+ } else {
+ runFind()
+ }
+ }, FIND_DEBOUNCE_MS)
+ }
+
+ navigate (forward) {
+ if (!this.query) return
+ this.cancelScheduledFind()
+ const result = this.lastSearchedQuery === this.query
+ ? this.finder.navigate(forward)
+ : this.finder.search(this.query)
+ this.lastSearchedQuery = this.query
+ this.updateResult(result)
+ this.focusInput()
+ }
+
+ handleGlobalKeyDown (event) {
+ const isFindShortcut = (event.metaKey || event.ctrlKey) &&
+ !event.altKey &&
+ String(event.key).toLowerCase() === 'f'
+
+ if (isFindShortcut) {
+ event.preventDefault()
+ event.stopPropagation()
+ this.open()
+ return
+ }
+
+ if (this.state.isOpen && event.key === 'Escape') {
+ event.preventDefault()
+ event.stopPropagation()
+ this.close()
+ }
+ }
+
+ handleInputKeyDown (event) {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ this.navigate(!event.shiftKey)
+ }
+
+ handleQueryChange (event) {
+ const query = event.target.value
+ const hasQuery = Boolean(query)
+ this.query = query
+ this.cancelScheduledFind()
+ this.lastSearchedQuery = ''
+ this.finder.clear()
+ if (this.state.activeMatchOrdinal !== 0 ||
+ this.state.matches !== 0 ||
+ this.state.hasQuery !== hasQuery) {
+ this.setState({
+ activeMatchOrdinal: 0,
+ hasQuery,
+ matches: 0
+ })
+ }
+
+ if (query) {
+ this.scheduleFind(query)
+ } else {
+ this.focusInput()
+ }
+ }
+
+ updateResult (result) {
+ if (!this.state.isOpen || !this.query || !result) return
+ const activeMatchOrdinal = result.activeMatchOrdinal || 0
+ const matches = result.matches || 0
+ if (this.state.activeMatchOrdinal === activeMatchOrdinal && this.state.matches === matches) {
+ return
+ }
+ this.setState({ activeMatchOrdinal, matches })
+ }
+
+ render () {
+ if (!this.state.isOpen) return null
+
+ const { activeMatchOrdinal, hasQuery, matches } = this.state
+ const currentMatch = matches === 0 ? 0 : activeMatchOrdinal
+ const canNavigate = matches > 1
+ const h = React.createElement
+
+ return h(
+ 'div',
+ { className: 'find-in-page', role: 'search' },
+ h('input', {
+ 'aria-label': t('findInPage.placeholder'),
+ className: 'find-in-page-input',
+ onInput: this.handleQueryChange,
+ onKeyDown: this.handleInputKeyDown,
+ placeholder: t('findInPage.placeholder'),
+ ref: this.inputRef,
+ spellCheck: false,
+ type: 'search',
+ defaultValue: this.query
+ }),
+ h('span', {
+ 'aria-live': 'polite',
+ className: 'find-in-page-count'
+ }, `${currentMatch}/${matches}`),
+ h('button', {
+ 'aria-label': t('findInPage.previous'),
+ className: 'find-in-page-button',
+ disabled: !hasQuery || !canNavigate,
+ onClick: () => this.navigate(false),
+ title: t('findInPage.previous'),
+ type: 'button'
+ }, h('span', { 'aria-hidden': true }, '\u2039')),
+ h('button', {
+ 'aria-label': t('findInPage.next'),
+ className: 'find-in-page-button',
+ disabled: !hasQuery || !canNavigate,
+ onClick: () => this.navigate(true),
+ title: t('findInPage.next'),
+ type: 'button'
+ }, h('span', { 'aria-hidden': true }, '\u203a')),
+ h('button', {
+ 'aria-label': t('dialog.close'),
+ className: 'find-in-page-button find-in-page-close',
+ onClick: this.close,
+ title: t('dialog.close'),
+ type: 'button'
+ }, h('span', { 'aria-hidden': true }, '\u00d7'))
+ )
+ }
+}
+
+export default FindInPage
diff --git a/app/containers/findInPage/index.scss b/app/containers/findInPage/index.scss
new file mode 100644
index 00000000..2a281715
--- /dev/null
+++ b/app/containers/findInPage/index.scss
@@ -0,0 +1,84 @@
+.find-in-page {
+ align-items: center;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 7px;
+ box-shadow: 0 4px 14px var(--shadow-color);
+ color: var(--text-primary);
+ display: flex;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ gap: 2px;
+ padding: 6px;
+ position: fixed;
+ right: 12px;
+ top: 12px;
+ z-index: 2000;
+}
+
+.find-in-page-input {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ color: var(--text-primary);
+ font-size: 13px;
+ height: 28px;
+ outline: none;
+ padding: 4px 8px;
+ width: 220px;
+}
+
+.find-in-page-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.find-in-page-input::-webkit-search-cancel-button {
+ display: none;
+}
+
+.find-in-page-count {
+ color: var(--text-secondary);
+ flex: 0 0 44px;
+ font-size: 12px;
+ text-align: center;
+}
+
+.find-in-page-button {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+ color: var(--text-primary);
+ display: inline-flex;
+ font-size: 20px;
+ height: 28px;
+ justify-content: center;
+ line-height: 1;
+ padding: 0;
+ width: 28px;
+}
+
+.find-in-page-button:hover:not(:disabled),
+.find-in-page-button:focus-visible {
+ background: var(--bg-secondary);
+ outline: none;
+}
+
+.find-in-page-button:disabled {
+ color: var(--text-secondary);
+ cursor: default;
+ opacity: .45;
+}
+
+.find-in-page-close {
+ font-size: 18px;
+}
+
+::highlight(lepton-find-match) {
+ background-color: rgba(255, 214, 10, .45);
+ color: inherit;
+}
+
+::highlight(lepton-find-active) {
+ background-color: #ff7a00;
+ color: #fff;
+}
diff --git a/app/utilities/electronBridge/index.js b/app/utilities/electronBridge/index.js
index c020b6f2..5fda629d 100644
--- a/app/utilities/electronBridge/index.js
+++ b/app/utilities/electronBridge/index.js
@@ -67,6 +67,7 @@ function createUnavailableBridge () {
set: unavailableBridgeMethod
},
window: {
+ onFindInPageRequest: unavailableBridgeMethod,
setTitle: unavailableBridgeMethod
}
}
diff --git a/app/utilities/i18n/locales/en.js b/app/utilities/i18n/locales/en.js
index 3151e19c..cb5ef883 100644
--- a/app/utilities/i18n/locales/en.js
+++ b/app/utilities/i18n/locales/en.js
@@ -41,6 +41,11 @@ module.exports = {
submit: 'Submit',
tips: 'tips'
},
+ findInPage: {
+ next: 'Next match',
+ placeholder: 'Find in page',
+ previous: 'Previous match'
+ },
login: {
continueAs: 'Continue as {{username}}',
githubLogin: 'GitHub Login',
@@ -67,6 +72,7 @@ module.exports = {
edit: 'Edit',
editGist: 'Edit Snippet',
exitEditor: 'Exit Editor',
+ findInPage: 'Find in Page',
gist: 'Snippet',
immersiveMode: 'Immersive Mode',
learnMore: 'Learn More',
diff --git a/app/utilities/i18n/locales/es.js b/app/utilities/i18n/locales/es.js
index 7d2e23e6..59adef47 100644
--- a/app/utilities/i18n/locales/es.js
+++ b/app/utilities/i18n/locales/es.js
@@ -41,6 +41,11 @@ module.exports = {
submit: 'Enviar',
tips: 'consejos'
},
+ findInPage: {
+ next: 'Siguiente coincidencia',
+ placeholder: 'Buscar en la pagina',
+ previous: 'Coincidencia anterior'
+ },
login: {
continueAs: 'Continuar como {{username}}',
githubLogin: 'Iniciar sesion con GitHub',
@@ -67,6 +72,7 @@ module.exports = {
edit: 'Editar',
editGist: 'Editar snippet',
exitEditor: 'Salir del editor',
+ findInPage: 'Buscar en la pagina',
gist: 'Snippet',
immersiveMode: 'Modo inmersivo',
learnMore: 'Mas informacion',
diff --git a/app/utilities/i18n/locales/fr.js b/app/utilities/i18n/locales/fr.js
index 038beeb2..6c64bb67 100644
--- a/app/utilities/i18n/locales/fr.js
+++ b/app/utilities/i18n/locales/fr.js
@@ -41,6 +41,11 @@ module.exports = {
submit: 'Envoyer',
tips: 'astuces'
},
+ findInPage: {
+ next: 'Resultat suivant',
+ placeholder: 'Rechercher dans la page',
+ previous: 'Resultat precedent'
+ },
login: {
continueAs: 'Continuer avec {{username}}',
githubLogin: 'Connexion GitHub',
@@ -67,6 +72,7 @@ module.exports = {
edit: 'Modifier',
editGist: 'Modifier l extrait',
exitEditor: 'Quitter l editeur',
+ findInPage: 'Rechercher dans la page',
gist: 'Extrait',
immersiveMode: 'Mode immersif',
learnMore: 'En savoir plus',
diff --git a/app/utilities/i18n/locales/ja.js b/app/utilities/i18n/locales/ja.js
index f5000c56..ef7f3720 100644
--- a/app/utilities/i18n/locales/ja.js
+++ b/app/utilities/i18n/locales/ja.js
@@ -41,6 +41,11 @@ module.exports = {
submit: '送信',
tips: 'ヒント'
},
+ findInPage: {
+ next: '次の一致',
+ placeholder: 'ページ内を検索',
+ previous: '前の一致'
+ },
login: {
continueAs: '{{username}}として続行',
githubLogin: 'GitHubでログイン',
@@ -67,6 +72,7 @@ module.exports = {
edit: '編集',
editGist: 'スニペットを編集',
exitEditor: 'エディターを終了',
+ findInPage: 'ページ内を検索',
gist: 'スニペット',
immersiveMode: '集中モード',
learnMore: '詳しく見る',
diff --git a/app/utilities/i18n/locales/ko.js b/app/utilities/i18n/locales/ko.js
index cf088c9d..d8d4da8a 100644
--- a/app/utilities/i18n/locales/ko.js
+++ b/app/utilities/i18n/locales/ko.js
@@ -41,6 +41,11 @@ module.exports = {
submit: '제출',
tips: '팁'
},
+ findInPage: {
+ next: '다음 일치',
+ placeholder: '페이지에서 찾기',
+ previous: '이전 일치'
+ },
login: {
continueAs: '{{username}}로 계속',
githubLogin: 'GitHub 로그인',
@@ -67,6 +72,7 @@ module.exports = {
edit: '편집',
editGist: '스니펫 편집',
exitEditor: '편집기 종료',
+ findInPage: '페이지에서 찾기',
gist: '스니펫',
immersiveMode: '몰입 모드',
learnMore: '자세히 보기',
diff --git a/app/utilities/i18n/locales/tr.js b/app/utilities/i18n/locales/tr.js
index 2e9b9782..5f89716f 100644
--- a/app/utilities/i18n/locales/tr.js
+++ b/app/utilities/i18n/locales/tr.js
@@ -41,6 +41,11 @@ module.exports = {
submit: 'Gönder',
tips: 'ipuçları'
},
+ findInPage: {
+ next: 'Sonraki eşleşme',
+ placeholder: 'Sayfada bul',
+ previous: 'Önceki eşleşme'
+ },
login: {
continueAs: '{{username}} olarak devam et',
githubLogin: 'GitHub ile giriş',
@@ -67,6 +72,7 @@ module.exports = {
edit: 'Düzenle',
editGist: 'Kod parçasını düzenle',
exitEditor: 'Düzenleyiciden çık',
+ findInPage: 'Sayfada bul',
gist: 'Kod parçası',
immersiveMode: 'Odak modu',
learnMore: 'Daha fazla bilgi',
diff --git a/app/utilities/i18n/locales/zh-Hans.js b/app/utilities/i18n/locales/zh-Hans.js
index 489eab65..4dc7404b 100644
--- a/app/utilities/i18n/locales/zh-Hans.js
+++ b/app/utilities/i18n/locales/zh-Hans.js
@@ -41,6 +41,11 @@ module.exports = {
submit: '提交',
tips: '提示'
},
+ findInPage: {
+ next: '下一个匹配项',
+ placeholder: '在页面中查找',
+ previous: '上一个匹配项'
+ },
login: {
continueAs: '以 {{username}} 继续',
githubLogin: '使用 GitHub 登录',
@@ -67,6 +72,7 @@ module.exports = {
edit: '编辑',
editGist: '编辑代码片段',
exitEditor: '退出编辑器',
+ findInPage: '在页面中查找',
gist: '代码片段',
immersiveMode: '沉浸模式',
learnMore: '了解更多',
diff --git a/app/utilities/i18n/locales/zh-Hant.js b/app/utilities/i18n/locales/zh-Hant.js
index fac35bce..89ad2aa8 100644
--- a/app/utilities/i18n/locales/zh-Hant.js
+++ b/app/utilities/i18n/locales/zh-Hant.js
@@ -41,6 +41,11 @@ module.exports = {
submit: '提交',
tips: '提示'
},
+ findInPage: {
+ next: '下一個相符項目',
+ placeholder: '在頁面中尋找',
+ previous: '上一個相符項目'
+ },
login: {
continueAs: '以 {{username}} 繼續',
githubLogin: '使用 GitHub 登入',
@@ -67,6 +72,7 @@ module.exports = {
edit: '編輯',
editGist: '編輯程式碼片段',
exitEditor: '離開編輯器',
+ findInPage: '在頁面中尋找',
gist: '程式碼片段',
immersiveMode: '沉浸模式',
learnMore: '了解更多',
diff --git a/app/utilities/menu/mainMenu.js b/app/utilities/menu/mainMenu.js
index 709074de..dd23a3b6 100644
--- a/app/utilities/menu/mainMenu.js
+++ b/app/utilities/menu/mainMenu.js
@@ -32,6 +32,18 @@ function buildMainMenuTemplate (t) {
},
{
role: 'selectall'
+ },
+ {
+ type: 'separator'
+ },
+ {
+ label: t('menu.findInPage'),
+ accelerator: 'CmdOrCtrl+F',
+ click (item, focusedWindow) {
+ if (focusedWindow) {
+ focusedWindow.webContents.send('lepton:window:open-find-in-page')
+ }
+ }
}
]
},
diff --git a/app/utilities/pageFind/index.js b/app/utilities/pageFind/index.js
new file mode 100644
index 00000000..cb6a39bc
--- /dev/null
+++ b/app/utilities/pageFind/index.js
@@ -0,0 +1,149 @@
+const ACTIVE_HIGHLIGHT_NAME = 'lepton-find-active'
+const MATCH_HIGHLIGHT_NAME = 'lepton-find-match'
+const EXCLUDED_CONTENT_SELECTOR = [
+ '.find-in-page',
+ 'script',
+ 'style',
+ 'noscript',
+ 'input',
+ 'textarea',
+ 'select',
+ 'option',
+ '[hidden]',
+ '[aria-hidden="true"]'
+].join(',')
+
+function escapeRegExp (value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function isRangeVisible (documentRef, range) {
+ if (typeof range.getBoundingClientRect !== 'function') return true
+ const rect = range.getBoundingClientRect()
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false
+
+ const windowRef = documentRef.defaultView
+ const insideViewport = rect.bottom > 0 &&
+ rect.right > 0 &&
+ rect.top < windowRef.innerHeight &&
+ rect.left < windowRef.innerWidth
+ if (!insideViewport || typeof documentRef.elementFromPoint !== 'function') return true
+
+ const x = Math.max(0, Math.min(windowRef.innerWidth - 1, rect.left + (rect.width / 2)))
+ const y = Math.max(0, Math.min(windowRef.innerHeight - 1, rect.top + (rect.height / 2)))
+ const hitTarget = documentRef.elementFromPoint(x, y)
+ const parent = range.startContainer.parentElement
+ return !hitTarget || hitTarget === parent || parent.contains(hitTarget) || hitTarget.contains(parent)
+}
+
+export function collectMatchRanges (documentRef, root, query) {
+ if (!documentRef || !root || !query) return []
+
+ const ranges = []
+ const matcher = new RegExp(escapeRegExp(query), 'giu')
+ const walker = documentRef.createTreeWalker(
+ root,
+ documentRef.defaultView.NodeFilter.SHOW_TEXT,
+ {
+ acceptNode: node => {
+ const parent = node.parentElement
+ if (!node.nodeValue || !parent || parent.closest(EXCLUDED_CONTENT_SELECTOR)) {
+ return documentRef.defaultView.NodeFilter.FILTER_REJECT
+ }
+ return documentRef.defaultView.NodeFilter.FILTER_ACCEPT
+ }
+ }
+ )
+
+ let node = walker.nextNode()
+ while (node) {
+ matcher.lastIndex = 0
+ let match = matcher.exec(node.nodeValue)
+ while (match) {
+ const range = documentRef.createRange()
+ range.setStart(node, match.index)
+ range.setEnd(node, match.index + match[0].length)
+ ranges.push(range)
+ match = matcher.exec(node.nodeValue)
+ }
+ node = walker.nextNode()
+ }
+
+ return ranges.filter(range => isRangeVisible(documentRef, range))
+}
+
+function scrollRangeIntoView (range, documentRef) {
+ if (!range || !range.startContainer) return
+ const element = range.startContainer.parentElement
+ if (!element || typeof element.scrollIntoView !== 'function') return
+
+ const rect = typeof range.getBoundingClientRect === 'function'
+ ? range.getBoundingClientRect()
+ : null
+ const viewportHeight = documentRef.defaultView.innerHeight || documentRef.documentElement.clientHeight
+ const isVisible = rect && rect.top >= 0 && rect.bottom <= viewportHeight
+ if (!isVisible) element.scrollIntoView({ block: 'center', behavior: 'smooth' })
+}
+
+export function createPageFinder (documentRef = document) {
+ const windowRef = documentRef.defaultView
+ const highlightRegistry = windowRef.CSS && windowRef.CSS.highlights
+ const HighlightConstructor = windowRef.Highlight
+ let activeIndex = -1
+ let ranges = []
+
+ function clearHighlights () {
+ if (!highlightRegistry) return
+ highlightRegistry.delete(ACTIVE_HIGHLIGHT_NAME)
+ highlightRegistry.delete(MATCH_HIGHLIGHT_NAME)
+ }
+
+ function result () {
+ return {
+ activeMatchOrdinal: activeIndex + 1,
+ matches: ranges.length
+ }
+ }
+
+ function applyHighlights (scroll = true) {
+ if (!highlightRegistry || !HighlightConstructor || activeIndex < 0) return
+ const inactiveRanges = ranges.filter((range, index) => index !== activeIndex)
+ if (inactiveRanges.length > 0) {
+ highlightRegistry.set(MATCH_HIGHLIGHT_NAME, new HighlightConstructor(...inactiveRanges))
+ } else {
+ highlightRegistry.delete(MATCH_HIGHLIGHT_NAME)
+ }
+ const activeHighlight = new HighlightConstructor(ranges[activeIndex])
+ highlightRegistry.set(ACTIVE_HIGHLIGHT_NAME, activeHighlight)
+ if (scroll) scrollRangeIntoView(ranges[activeIndex], documentRef)
+ }
+
+ return {
+ clear () {
+ clearHighlights()
+ activeIndex = -1
+ ranges = []
+ },
+
+ navigate (forward) {
+ if (ranges.length === 0) return result()
+ activeIndex = (activeIndex + (forward ? 1 : -1) + ranges.length) % ranges.length
+ applyHighlights()
+ return result()
+ },
+
+ search (query) {
+ clearHighlights()
+ ranges = collectMatchRanges(documentRef, documentRef.body, query)
+ activeIndex = ranges.length > 0 ? 0 : -1
+
+ if (highlightRegistry && HighlightConstructor && ranges.length > 0) {
+ applyHighlights()
+ }
+
+ return result()
+ }
+ }
+}
+
+export { ACTIVE_HIGHLIGHT_NAME, MATCH_HIGHLIGHT_NAME }
diff --git a/docs/img/portfolio/find-in-page.png b/docs/img/portfolio/find-in-page.png
new file mode 100644
index 00000000..418d3cdf
Binary files /dev/null and b/docs/img/portfolio/find-in-page.png differ
diff --git a/preload.js b/preload.js
index 13a01b0c..f1f6f0ad 100644
--- a/preload.js
+++ b/preload.js
@@ -83,6 +83,11 @@ const leptonApi = {
set: (configName, data) => ipcRenderer.sendSync('lepton:renderer-store:set', configName, data)
},
window: {
+ onFindInPageRequest: (listener) => {
+ const wrapped = () => listener()
+ ipcRenderer.on('lepton:window:open-find-in-page', wrapped)
+ return () => ipcRenderer.removeListener('lepton:window:open-find-in-page', wrapped)
+ },
setTitle: (title) => ipcRenderer.send('lepton:window:set-title', title)
}
}
diff --git a/tests/containers/findInPage.test.js b/tests/containers/findInPage.test.js
new file mode 100644
index 00000000..df3b7a62
--- /dev/null
+++ b/tests/containers/findInPage.test.js
@@ -0,0 +1,215 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { JSDOM } from 'jsdom'
+import React, { act } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import FindInPage, { isFindInPageAvailable } from '../../app/containers/findInPage'
+
+const h = React.createElement
+
+describe('find in page', () => {
+ let bridge
+ let container
+ let findRequestListener
+ let finder
+ let root
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ const dom = new JSDOM('
', {
+ url: 'http://localhost'
+ })
+
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ globalThis.window = dom.window
+ globalThis.document = dom.window.document
+ globalThis.HTMLElement = dom.window.HTMLElement
+ globalThis.Node = dom.window.Node
+ dom.window.HTMLElement.prototype.attachEvent = () => {}
+ dom.window.HTMLElement.prototype.detachEvent = () => {}
+
+ bridge = {
+ window: {
+ onFindInPageRequest: vi.fn(listener => {
+ findRequestListener = listener
+ return vi.fn()
+ })
+ }
+ }
+ finder = {
+ clear: vi.fn(),
+ navigate: vi.fn(forward => ({
+ activeMatchOrdinal: forward ? 2 : 3,
+ matches: 3
+ })),
+ search: vi.fn(() => ({ activeMatchOrdinal: 1, matches: 5 }))
+ }
+ container = document.getElementById('root')
+ root = createRoot(container)
+
+ act(() => {
+ root.render(h(FindInPage, { bridge, finder }))
+ })
+ })
+
+ afterEach(() => {
+ act(() => {
+ root.unmount()
+ })
+
+ delete globalThis.IS_REACT_ACT_ENVIRONMENT
+ delete globalThis.window
+ delete globalThis.document
+ delete globalThis.HTMLElement
+ delete globalThis.Node
+ vi.useRealTimers()
+ })
+
+ function openWithShortcut () {
+ act(() => {
+ document.dispatchEvent(new window.KeyboardEvent('keydown', {
+ bubbles: true,
+ key: 'f',
+ metaKey: true
+ }))
+ })
+ }
+
+ function typeQuery (query) {
+ const input = container.querySelector('.find-in-page-input')
+ const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
+ act(() => {
+ valueSetter.call(input, query)
+ input.dispatchEvent(new window.Event('input', { bubbles: true }))
+ })
+ return input
+ }
+
+ it('opens from Cmd/Ctrl+F and searches only the local page', () => {
+ expect(container.querySelector('.find-in-page')).toBeNull()
+
+ openWithShortcut()
+
+ const input = typeQuery('fixture')
+ expect(document.activeElement).toBe(input)
+ expect(finder.search).not.toHaveBeenCalled()
+
+ act(() => {
+ vi.runOnlyPendingTimers()
+ })
+
+ expect(finder.search).toHaveBeenLastCalledWith('fixture')
+
+ expect(container.querySelector('.find-in-page-count').textContent).toBe('1/5')
+ expect(document.activeElement).toBe(input)
+ })
+
+ it('moves between matches and clears highlights when closed', () => {
+ act(() => {
+ findRequestListener()
+ })
+ const input = typeQuery('snippet')
+ finder.search.mockReturnValueOnce({ activeMatchOrdinal: 1, matches: 3 })
+ act(() => {
+ vi.runOnlyPendingTimers()
+ })
+ const buttons = container.querySelectorAll('.find-in-page-button')
+
+ act(() => {
+ buttons[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
+ })
+ expect(finder.navigate).toHaveBeenLastCalledWith(true)
+ expect(container.querySelector('.find-in-page-count').textContent).toBe('2/3')
+ expect(document.activeElement).toBe(input)
+
+ act(() => {
+ input.dispatchEvent(new window.KeyboardEvent('keydown', {
+ bubbles: true,
+ key: 'Enter',
+ shiftKey: true
+ }))
+ })
+ expect(finder.navigate).toHaveBeenLastCalledWith(false)
+
+ const clearCallsBeforeClose = finder.clear.mock.calls.length
+
+ act(() => {
+ document.dispatchEvent(new window.KeyboardEvent('keydown', {
+ bubbles: true,
+ key: 'Escape'
+ }))
+ })
+
+ expect(finder.clear).toHaveBeenCalledTimes(clearCallsBeforeClose + 1)
+ expect(container.querySelector('.find-in-page')).toBeNull()
+ })
+
+ it('debounces typing and applies only the latest query', () => {
+ openWithShortcut()
+
+ typeQuery('f')
+ typeQuery('fi')
+ typeQuery('fixture')
+
+ expect(finder.search).not.toHaveBeenCalled()
+ expect(finder.clear).toHaveBeenCalledTimes(3)
+
+ act(() => {
+ vi.runOnlyPendingTimers()
+ })
+
+ expect(finder.search).toHaveBeenCalledTimes(1)
+ expect(finder.search).toHaveBeenCalledWith('fixture')
+ expect(container.querySelector('.find-in-page-count').textContent).toBe('1/5')
+ })
+
+ it('clears stale highlights immediately while debouncing backspace searches', () => {
+ openWithShortcut()
+ typeQuery('fixture')
+ act(() => {
+ vi.runOnlyPendingTimers()
+ })
+
+ typeQuery('fixtur')
+ typeQuery('fixtu')
+ typeQuery('fixt')
+
+ expect(finder.clear).toHaveBeenCalledTimes(4)
+ expect(finder.search).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ vi.runOnlyPendingTimers()
+ })
+
+ expect(finder.search).toHaveBeenCalledTimes(2)
+ expect(finder.search).toHaveBeenLastCalledWith('fixt')
+ })
+
+ it('keeps input focus after clearing the query', () => {
+ openWithShortcut()
+ const input = typeQuery('fixture')
+ typeQuery('')
+
+ expect(finder.clear).toHaveBeenCalled()
+ expect(document.activeElement).toBe(input)
+ })
+
+ it('is limited to the snippet-reading surface', () => {
+ const activeSnippetState = {
+ searchWindowStatus: 'OFF',
+ userSession: { activeStatus: 'ACTIVE' }
+ }
+
+ expect(isFindInPageAvailable(activeSnippetState)).toBe(true)
+ expect(isFindInPageAvailable(Object.assign({}, activeSnippetState, {
+ searchWindowStatus: 'ON'
+ }))).toBe(false)
+ expect(isFindInPageAvailable(Object.assign({}, activeSnippetState, {
+ aboutModalStatus: 'ON'
+ }))).toBe(false)
+ expect(isFindInPageAvailable({
+ searchWindowStatus: 'OFF',
+ userSession: { activeStatus: 'INACTIVE' }
+ })).toBe(false)
+ })
+})
diff --git a/tests/smoke/electron-render-smoke-main.js b/tests/smoke/electron-render-smoke-main.js
index ba6df782..fdfe9028 100644
--- a/tests/smoke/electron-render-smoke-main.js
+++ b/tests/smoke/electron-render-smoke-main.js
@@ -526,6 +526,251 @@ async function assertFixtureLoginModeSwitch (window) {
assertForbiddenFixtureTextAbsent(result)
}
+async function assertFixturePageFind (window) {
+ const fixture = process.env.LEPTON_RENDER_FIXTURE
+ if (fixture !== 'active' && fixture !== 'search') return
+
+ const shortcutState = await window.webContents.executeJavaScript(`
+ new Promise(resolve => {
+ document.dispatchEvent(new KeyboardEvent('keydown', {
+ bubbles: true,
+ ctrlKey: ${process.platform !== 'darwin'},
+ key: 'f',
+ metaKey: ${process.platform === 'darwin'}
+ }))
+
+ const deadline = Date.now() + 1000
+ function waitForFindBar() {
+ const input = document.querySelector('.find-in-page-input')
+ if (input || Date.now() > deadline) {
+ resolve({
+ focused: input === document.activeElement,
+ hasFindBar: Boolean(input)
+ })
+ return
+ }
+ setTimeout(waitForFindBar, 50)
+ }
+ waitForFindBar()
+ })
+ `, true)
+
+ if (fixture === 'active' && shortcutState.hasFindBar) {
+ await window.webContents.insertText('fixture')
+ const resultState = await window.webContents.executeJavaScript(`
+ new Promise(resolve => {
+ const deadline = Date.now() + 5000
+ function waitForResults() {
+ const count = document.querySelector('.find-in-page-count')
+ const input = document.querySelector('.find-in-page-input')
+ const countText = count ? count.textContent : ''
+ if (countText && countText !== '0/0') {
+ resolve({
+ countText,
+ focusedAfterFind: input === document.activeElement,
+ value: input ? input.value : ''
+ })
+ return
+ }
+ if (Date.now() > deadline) {
+ resolve({ countText, reason: 'find results did not arrive', value: input ? input.value : '' })
+ return
+ }
+ setTimeout(waitForResults, 50)
+ }
+ waitForResults()
+ })
+ `, true)
+ Object.assign(shortcutState, resultState)
+
+ shortcutState.rapidInput = await window.webContents.executeJavaScript(`
+ new Promise(resolve => {
+ const input = document.querySelector('.find-in-page-input')
+ const count = document.querySelector('.find-in-page-count')
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
+ const values = [
+ 'fixtur', 'fixtu', 'fixt', 'fix', 'fi', 'f', '',
+ 'f', 'fi', 'fix', 'fixt', 'fixtu', 'fixtur', 'fixture'
+ ]
+ const startedAt = performance.now()
+
+ input.focus()
+ for (const value of values) {
+ valueSetter.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ }
+
+ const dispatchDurationMs = performance.now() - startedAt
+ const deadline = Date.now() + 5000
+ requestAnimationFrame(() => {
+ const nextFrameDelayMs = performance.now() - startedAt
+ function waitForSettledResults() {
+ const countText = count ? count.textContent : ''
+ if (input.value === 'fixture' && countText && countText !== '0/0') {
+ resolve({
+ countText,
+ dispatchDurationMs,
+ focused: input === document.activeElement,
+ nextFrameDelayMs,
+ value: input.value
+ })
+ return
+ }
+ if (Date.now() > deadline) {
+ resolve({
+ countText,
+ dispatchDurationMs,
+ focused: input === document.activeElement,
+ nextFrameDelayMs,
+ reason: 'rapid input results did not settle',
+ value: input.value
+ })
+ return
+ }
+ setTimeout(waitForSettledResults, 25)
+ }
+ waitForSettledResults()
+ })
+ })
+ `, true)
+
+ const navigationStart = await window.webContents.executeJavaScript(`
+ (() => {
+ const count = document.querySelector('.find-in-page-count')
+ const buttons = document.querySelectorAll('.find-in-page-button')
+ return {
+ initial: count ? count.textContent : '',
+ targets: Array.from(buttons).slice(0, 2).map(button => {
+ const rect = button.getBoundingClientRect()
+ return {
+ x: Math.round(rect.left + rect.width / 2),
+ y: Math.round(rect.top + rect.height / 2)
+ }
+ })
+ }
+ })()
+ `, true)
+
+ async function clickFindTarget (target) {
+ window.webContents.sendInputEvent(Object.assign({
+ button: 'left',
+ clickCount: 1,
+ type: 'mouseDown'
+ }, target))
+ window.webContents.sendInputEvent(Object.assign({
+ button: 'left',
+ clickCount: 1,
+ type: 'mouseUp'
+ }, target))
+ }
+
+ async function waitForFindCountChange (previousCount) {
+ const deadline = Date.now() + 3000
+ while (Date.now() <= deadline) {
+ const state = await window.webContents.executeJavaScript(`
+ (() => {
+ const count = document.querySelector('.find-in-page-count')
+ const input = document.querySelector('.find-in-page-input')
+ return {
+ countText: count ? count.textContent : '',
+ focused: input === document.activeElement
+ }
+ })()
+ `, true)
+ if (state.countText && state.countText !== previousCount) return state
+ await wait(25)
+ }
+ return { countText: previousCount, focused: false }
+ }
+
+ await clickFindTarget(navigationStart.targets[1])
+ const nextState = await waitForFindCountChange(navigationStart.initial)
+ await clickFindTarget(navigationStart.targets[0])
+ const previousState = await waitForFindCountChange(nextState.countText)
+ shortcutState.navigation = {
+ focused: nextState.focused && previousState.focused,
+ initial: navigationStart.initial,
+ next: nextState.countText,
+ previous: previousState.countText,
+ targets: navigationStart.targets
+ }
+
+ shortcutState.highlights = await window.webContents.executeJavaScript(`
+ (() => {
+ const matches = CSS.highlights.get('lepton-find-match')
+ const active = CSS.highlights.get('lepton-find-active')
+ const activeRange = active ? active.values().next().value : null
+ const activeRect = activeRange ? activeRange.getBoundingClientRect() : null
+ const activeParent = activeRange ? activeRange.startContainer.parentElement : null
+ const activeHitTarget = activeRect
+ ? document.elementFromPoint(
+ activeRect.left + (activeRect.width / 2),
+ activeRect.top + (activeRect.height / 2)
+ )
+ : null
+ return {
+ active: active ? active.size : 0,
+ activeExposed: Boolean(activeParent && activeHitTarget && (
+ activeHitTarget === activeParent ||
+ activeParent.contains(activeHitTarget) ||
+ activeHitTarget.contains(activeParent)
+ )),
+ activeRect: activeRect ? {
+ bottom: activeRect.bottom,
+ left: activeRect.left,
+ right: activeRect.right,
+ top: activeRect.top
+ } : null,
+ activeParent: activeParent
+ ? activeParent.tagName + '.' + activeParent.className
+ : '',
+ activeText: activeRange ? activeRange.toString() : '',
+ activeStyle: activeRange && activeRange.startContainer.parentElement
+ ? getComputedStyle(activeRange.startContainer.parentElement, '::highlight(lepton-find-active)').backgroundColor
+ : '',
+ activeVisible: Boolean(activeRect && activeRect.width > 0 && activeRect.height > 0),
+ matches: matches ? matches.size : 0
+ }
+ })()
+ `, true)
+ }
+
+ await wait(750)
+ shortcutState.settledCountText = await window.webContents.executeJavaScript(`
+ (() => {
+ const count = document.querySelector('.find-in-page-count')
+ return count ? count.textContent : ''
+ })()
+ `, true)
+ if (fixture === 'search') {
+ if (shortcutState.hasFindBar) {
+ throw new Error(`Expected snippet-wide search to exclude page find: ${JSON.stringify(shortcutState)}`)
+ }
+ return
+ }
+
+ if (!shortcutState.hasFindBar ||
+ !shortcutState.focused ||
+ !shortcutState.focusedAfterFind ||
+ shortcutState.value !== 'fixture' ||
+ !shortcutState.rapidInput ||
+ !shortcutState.rapidInput.focused ||
+ shortcutState.rapidInput.value !== 'fixture' ||
+ !shortcutState.navigation ||
+ !shortcutState.navigation.focused ||
+ shortcutState.navigation.next === shortcutState.navigation.initial ||
+ shortcutState.navigation.previous === shortcutState.navigation.next ||
+ !shortcutState.highlights ||
+ shortcutState.highlights.active !== 1 ||
+ !shortcutState.highlights.activeExposed ||
+ !shortcutState.highlights.activeVisible ||
+ shortcutState.highlights.matches < 2 ||
+ !/^[1-9]\d*\/[1-9]\d*$/.test(shortcutState.rapidInput.countText || '') ||
+ !/^[1-9]\d*\/[1-9]\d*$/.test(shortcutState.settledCountText || '')) {
+ throw new Error(`Expected active snippet fixture to support local page find: ${JSON.stringify(shortcutState)}`)
+ }
+}
+
async function main () {
let window
@@ -538,6 +783,7 @@ async function main () {
await waitForFixtureUi(window)
assertFixtureRendererState(await getRendererState(window))
await assertFixtureLoginModeSwitch(window)
+ await assertFixturePageFind(window)
await captureScreenshot(window, `electron-render-${process.env.LEPTON_RENDER_FIXTURE}-success.png`)
console.log(`electron render fixture smoke test passed: ${process.env.LEPTON_RENDER_FIXTURE}`)
} else {
diff --git a/tests/utilities/menu.test.js b/tests/utilities/menu.test.js
index f4a05b0d..9bc0d782 100644
--- a/tests/utilities/menu.test.js
+++ b/tests/utilities/menu.test.js
@@ -22,4 +22,18 @@ describe('main menu template', () => {
item.submenu && item.submenu.some(submenuItem => submenuItem.label === 'tx:menu.learnMore')
)).toBe(true)
})
+
+ it('opens local page find from the standard keyboard shortcut', async () => {
+ const { buildMainMenuTemplate } = await import('../../app/utilities/menu/mainMenu')
+ const template = buildMainMenuTemplate(key => `tx:${key}`)
+ const editMenu = template.find(item => item.label === 'tx:menu.edit')
+ const findItem = editMenu.submenu.find(item => item.label === 'tx:menu.findInPage')
+ const send = vi.fn()
+
+ expect(findItem.accelerator).toBe('CmdOrCtrl+F')
+
+ findItem.click(null, { webContents: { send } })
+
+ expect(send).toHaveBeenCalledWith('lepton:window:open-find-in-page')
+ })
})
diff --git a/tests/utilities/pageFind.test.js b/tests/utilities/pageFind.test.js
new file mode 100644
index 00000000..0978c0e3
--- /dev/null
+++ b/tests/utilities/pageFind.test.js
@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { JSDOM } from 'jsdom'
+
+import {
+ ACTIVE_HIGHLIGHT_NAME,
+ createPageFinder,
+ MATCH_HIGHLIGHT_NAME
+} from '../../app/utilities/pageFind'
+
+class TestHighlight extends Set {
+ constructor (...ranges) {
+ super(ranges)
+ this.priority = 0
+ }
+}
+
+describe('page finder', () => {
+ let document
+ let finder
+ let highlights
+
+ beforeEach(() => {
+ const dom = new JSDOM(`
+
Fixture text and another FIXTURE.
+
fixture
+
fixture
+ `)
+ document = dom.window.document
+ highlights = new Map()
+ dom.window.CSS = { highlights }
+ dom.window.Highlight = TestHighlight
+ finder = createPageFinder(document)
+ })
+
+ it('highlights page text while excluding the find UI and hidden content', () => {
+ expect(finder.search('fixture')).toEqual({
+ activeMatchOrdinal: 1,
+ matches: 2
+ })
+ expect(highlights.get(MATCH_HIGHLIGHT_NAME).size).toBe(1)
+ expect(highlights.get(ACTIVE_HIGHLIGHT_NAME).size).toBe(1)
+ })
+
+ it('moves in both directions, wraps, and clears highlights', () => {
+ finder.search('fixture')
+ expect(finder.navigate(true).activeMatchOrdinal).toBe(2)
+ expect(finder.navigate(true).activeMatchOrdinal).toBe(1)
+ expect(finder.navigate(false).activeMatchOrdinal).toBe(2)
+
+ finder.clear()
+ expect(highlights.size).toBe(0)
+ })
+})