diff --git a/cypress/component/editor/search.cy.js b/cypress/component/editor/search.cy.js new file mode 100644 index 00000000000..7819608b016 --- /dev/null +++ b/cypress/component/editor/search.cy.js @@ -0,0 +1,92 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Editor } from '@tiptap/core' +import { Document } from '@tiptap/extension-document' +import { Text } from '@tiptap/extension-text' +import Search from '../../../src/extensions/Search.js' +import Paragraph from '../../../src/nodes/Paragraph.js' +import HardBreak from '../../../src/nodes/HardBreak.js' + +describe('editor search highlighting', () => { + let editor = null + + before(() => { + cy.fixture('lorem.txt').then((text) => { + editor = new Editor({ + element: document.querySelector('div[data-cy-root]'), + content: text, + extensions: [Document, Text, Search, Paragraph, HardBreak], + }) + }) + }) + + it('can highlight a match', () => { + const searchQuery = 'Lorem ipsum dolor sit amet' + editor.commands.setSearchQuery(searchQuery) + + const highlightedElements = document.querySelectorAll('span[data-text-el="search-decoration"]') + expect(highlightedElements).to.have.lengthOf(1) + verifyHighlights(highlightedElements, searchQuery) + }) + + it('can highlight multiple matches', () => { + const searchQuery = 'quod' + editor.commands.setSearchQuery(searchQuery) + + const highlightedElements = document.querySelectorAll('span[data-text-el="search-decoration"]') + expect(highlightedElements).to.have.lengthOf(3) + verifyHighlights(highlightedElements, searchQuery) + }) + + it('can toggle highlight all', () => { + const searchQuery = 'quod' + let highlightedElements = [] + + // Highlight only first occurrence + editor.commands.setSearchQuery(searchQuery, false) + highlightedElements = document.querySelectorAll('span[data-text-el="search-decoration"]') + + expect(highlightedElements).to.have.lengthOf(1) + verifyHighlights(highlightedElements, searchQuery) + }) + + it('can move to next occurrence', () => { + const searchQuery = 'quod' + + editor.commands.setSearchQuery(searchQuery, true) + const allHighlightedElements = document.querySelectorAll('span[data-text-el="search-decoration"]') + + editor.commands.nextMatch() + const currentlyHighlightedElement = document.querySelectorAll('span[data-text-el="search-decoration"]') + + expect(currentlyHighlightedElement).to.have.lengthOf(1) + expect(allHighlightedElements[1]).to.deep.equal(currentlyHighlightedElement[0]) + }) + + it('can move to previous occurrence', () => { + const searchQuery = 'quod' + + editor.commands.setSearchQuery(searchQuery, true) + const allHighlightedElements = document.querySelectorAll('span[data-text-el="search-decoration"]') + + editor.commands.previousMatch() + const currentlyHighlightedElement = document.querySelectorAll('span[data-text-el="search-decoration"]') + + expect(currentlyHighlightedElement).to.have.lengthOf(1) + expect(allHighlightedElements[0]).to.deep.equal(currentlyHighlightedElement[0]) + }) +}) + +/** + * Verifies the Nodes in the given NodeList match the search query + * @param {NodeList} highlightedElements - NodeList of highlighted elements + * @param {string} searchQuery - search query + */ +function verifyHighlights(highlightedElements, searchQuery) { + for (const element of highlightedElements) { + expect(element.innerText.toLowerCase()).to.equal(searchQuery.toLowerCase()) + } +} diff --git a/cypress/fixtures/lorem.txt b/cypress/fixtures/lorem.txt new file mode 100644 index 00000000000..5774a99e277 --- /dev/null +++ b/cypress/fixtures/lorem.txt @@ -0,0 +1,7 @@ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim aeque doleamus animo, cum corpore dolemus, fieri tamen permagna accessio potest, si aliquod aeternum et infinitum impendere malum nobis opinemur. Quod idem licet transferre in voluptatem, ut postea variari voluptas distinguique possit, augeri amplificarique non possit. At. +

+ +

+ Ullus investigandi veri, nisi inveneris, et quaerendi defatigatio turpis est, cum esset accusata et vituperata ab Hortensio. Qui liber cum et mortem contemnit, qua qui est imbutus quietus esse numquam potest. Praeterea bona praeterita grata recordatione renovata delectant. Est autem situm in nobis ut et voluptates et dolores nasci fatemur e corporis voluptatibus et doloribus -- itaque concedo, quod modo dicebas, cadere. +

\ No newline at end of file diff --git a/src/editor.js b/src/editor.js index 8ce4ff561f1..19e06b47619 100644 --- a/src/editor.js +++ b/src/editor.js @@ -5,6 +5,7 @@ import Vue from 'vue' import store from './store/index.js' +import { subscribe } from '@nextcloud/event-bus' import { EDITOR_UPLOAD, HOOK_MENTION_SEARCH, HOOK_MENTION_INSERT, ATTACHMENT_RESOLVER } from './components/Editor.provider.js' import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js' // eslint-disable-next-line import/no-unresolved, n/no-missing-import @@ -56,6 +57,11 @@ class TextEditorEmbed { return this } + onSearch(onSearchCallback = () => {}) { + subscribe('text:editor:search-results', onSearchCallback) + return this + } + render(el) { el.innerHTML = '' const element = document.createElement('div') @@ -77,6 +83,21 @@ class TextEditorEmbed { return this } + setSearchQuery(query, matchAll) { + const editor = this.#getEditorComponent()?.$editor + editor.commands.setSearchQuery(query, matchAll) + } + + searchNext() { + const editor = this.#getEditorComponent()?.$editor + editor.commands.nextMatch() + } + + searchPrevious() { + const editor = this.#getEditorComponent()?.$editor + editor.commands.previousMatch() + } + async save() { return this.#getEditorComponent().save?.() } @@ -138,6 +159,7 @@ window.OCA.Text.createEditor = async function({ onFileInsert = undefined, onMentionSearch = undefined, onMentionInsert = undefined, + onSearch = undefined, }) { const { default: MarkdownContentEditor } = await import(/* webpackChunkName: "editor" */'./components/Editor/MarkdownContentEditor.vue') const { default: Editor } = await import(/* webpackChunkName: "editor" */'./components/Editor.vue') @@ -212,5 +234,6 @@ window.OCA.Text.createEditor = async function({ .onLoaded(onLoaded) .onUpdate(onUpdate) .onOutlineToggle(onOutlineToggle) + .onSearch(onSearch) .render(el) } diff --git a/src/extensions/Search.js b/src/extensions/Search.js index 3363c29eb82..8ec66ebd5f8 100644 --- a/src/extensions/Search.js +++ b/src/extensions/Search.js @@ -4,7 +4,6 @@ */ import { Extension } from '@tiptap/core' -import { subscribe } from '@nextcloud/event-bus' import searchDecorations from '../plugins/searchDecorations.js' import { setSearchQuery, @@ -16,20 +15,6 @@ import { export default Extension.create({ name: 'Search', - onCreate() { - subscribe('text:editor:search', ({ query, matchAll }) => { - this.editor.commands.setSearchQuery(query, matchAll) - }) - - subscribe('text:editor:search-next', () => { - this.editor.commands.nextMatch() - }) - - subscribe('text:editor:search-previous', () => { - this.editor.commands.previousMatch() - }) - }, - addCommands() { return { setSearchQuery, diff --git a/src/plugins/searchDecorations.js b/src/plugins/searchDecorations.js index 1f0a66b7438..0e5af5d802d 100644 --- a/src/plugins/searchDecorations.js +++ b/src/plugins/searchDecorations.js @@ -37,8 +37,8 @@ export default function searchDecorations() { }) emit('text:editor:search-results', { - results: (newSearch.query === '' ? null : total), - index, + totalMatches: (newSearch.query === '' ? null : total), + matchIndex: index, }) return highlightResults(tr.doc, results) diff --git a/src/tests/plugins/searchQuery.spec.js b/src/tests/plugins/searchQuery.spec.js index 1ed5059770e..7082e96f3ae 100644 --- a/src/tests/plugins/searchQuery.spec.js +++ b/src/tests/plugins/searchQuery.spec.js @@ -5,7 +5,7 @@ import { searchQuery } from '../../plugins/searchQuery.js' import { Plugin, EditorState } from '@tiptap/pm/state' import { schema } from '@tiptap/pm/schema-basic' -import { setSearchQuery, nextMatch } from '../../plugins/searchQuery.js' +import { setSearchQuery, nextMatch, previousMatch } from '../../plugins/searchQuery.js' describe('searchQuery plugin', () => { it('can set up plugin and state', () => { @@ -61,6 +61,29 @@ describe('searchQuery plugin', () => { index: 1, // index is incremented to the next match }) }) + + it ('can accept previous match state', () => { + const { plugin, state } = pluginSetup() + + const setSearch = setSearchQuery('lorem')(state) + const previousSearch = previousMatch()(state) + + let newState = state.apply(setSearch) + + expect(plugin.getState(newState)).toEqual({ + query: 'lorem', + matchAll: true, + index: 0, + }) + + newState = newState.apply(previousSearch) + + expect(plugin.getState(newState)).toEqual({ + query: 'lorem', + matchAll: false, + index: -1, + }) + }) }) function pluginSetup() {