Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Move search highlighting to editor API #6199

Merged
merged 10 commits into from
Aug 13, 2024
92 changes: 92 additions & 0 deletions cypress/component/editor/search.cy.js
Original file line number Diff line number Diff line change
@@ -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())
}
}
7 changes: 7 additions & 0 deletions cypress/fixtures/lorem.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p>
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.
</p>

<p>
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.
</p>
23 changes: 23 additions & 0 deletions src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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?.()
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -212,5 +234,6 @@ window.OCA.Text.createEditor = async function({
.onLoaded(onLoaded)
.onUpdate(onUpdate)
.onOutlineToggle(onOutlineToggle)
.onSearch(onSearch)
.render(el)
}
15 changes: 0 additions & 15 deletions src/extensions/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

import { Extension } from '@tiptap/core'
import { subscribe } from '@nextcloud/event-bus'
import searchDecorations from '../plugins/searchDecorations.js'
import {
setSearchQuery,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/searchDecorations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion src/tests/plugins/searchQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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() {
Expand Down
Loading