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
+ + `) + 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) + }) +})