From b378ed45d2b51cd2fb1555bfb2ee869572f39497 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 6 Mar 2026 14:31:29 +0100 Subject: [PATCH] feat(editor): add support for highlighting text Uses Tiptap Highlight extension and markdown-it mark plugin. Requires nextcloud/server#58830 Fixes: #1575 Signed-off-by: Jonas --- cypress/e2e/shortcuts.spec.js | 1 + package-lock.json | 35 +++++++++++++++++++++++------ package.json | 2 ++ playwright/e2e/format-text.spec.ts | 1 + src/components/HelpModal.vue | 19 +++++++++++++--- src/components/Menu/entries.ts | 17 ++++++++++++-- src/components/icons.js | 2 ++ src/css/prosemirror.scss | 4 ++++ src/extensions/RichText.js | 10 ++++++++- src/markdownit/index.js | 2 ++ src/marks/Highlight.ts | 18 +++++++++++++++ src/marks/index.js | 3 ++- src/tests/marks/Highlight.spec.ts | 36 ++++++++++++++++++++++++++++++ 13 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 src/marks/Highlight.ts create mode 100644 src/tests/marks/Highlight.spec.ts diff --git a/cypress/e2e/shortcuts.spec.js b/cypress/e2e/shortcuts.spec.js index 7a30afa2b5b..6a53a63b184 100644 --- a/cypress/e2e/shortcuts.spec.js +++ b/cypress/e2e/shortcuts.spec.js @@ -37,6 +37,7 @@ describe('keyboard shortcuts', () => { it('italic', () => testShortcut(`${modKey}i`, 'em')) it('underline', () => testShortcut(`${modKey}u`, 'u')) it('strikethrough', () => testShortcut(`${modKey}{shift}s`, 's')) + it('highlight', () => testShortcut(`${modKey}{shift}h`, 'mark')) it('blockquote', () => testShortcut(`${modKey}{shift}b`, 'blockquote')) it('codeblock', () => testShortcut(`${modKey}{alt}c`, 'pre')) it('ordered-list', () => testShortcut(`${modKey}{shift}7`, 'ol')) diff --git a/package-lock.json b/package-lock.json index 1ac0cbd0e3a..77726ec9879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@tiptap/extension-drag-handle-vue-2": "^3.19.0", "@tiptap/extension-hard-break": "^3.19.0", "@tiptap/extension-heading": "^3.19.0", + "@tiptap/extension-highlight": "^3.20.1", "@tiptap/extension-horizontal-rule": "^3.19.0", "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-italic": "^3.19.0", @@ -68,6 +69,7 @@ "markdown-it-container": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", + "markdown-it-mark": "^4.0.0", "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.12.2", "mitt": "^3.0.1", @@ -5180,9 +5182,9 @@ "license": "MIT" }, "node_modules/@tiptap/core": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", - "integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz", + "integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==", "license": "MIT", "peer": true, "funding": { @@ -5190,7 +5192,7 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.19.0" + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-blockquote": { @@ -5406,6 +5408,19 @@ "@tiptap/core": "^3.19.0" } }, + "node_modules/@tiptap/extension-highlight": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.20.1.tgz", + "integrity": "sha512-Ufv1eNsQBR7NNSxQIvHaI9Bm3/MS8GW9uCXR/20WJ2r0GhDA6vwnS5MHAhP5JUXK6KOsd0Rvz2cfvYB7f3okbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.1" + } + }, "node_modules/@tiptap/extension-horizontal-rule": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.19.0.tgz", @@ -5630,9 +5645,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz", - "integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz", + "integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==", "license": "MIT", "peer": true, "dependencies": { @@ -14598,6 +14613,12 @@ "markdown-it": "*" } }, + "node_modules/markdown-it-mark": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-mark/-/markdown-it-mark-4.0.0.tgz", + "integrity": "sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==", + "license": "MIT" + }, "node_modules/markdown-it-multimd-table": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.3.tgz", diff --git a/package.json b/package.json index ac48ef0cb99..629a30a3a01 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@tiptap/extension-drag-handle-vue-2": "^3.19.0", "@tiptap/extension-hard-break": "^3.19.0", "@tiptap/extension-heading": "^3.19.0", + "@tiptap/extension-highlight": "^3.20.1", "@tiptap/extension-horizontal-rule": "^3.19.0", "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-italic": "^3.19.0", @@ -88,6 +89,7 @@ "markdown-it-container": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", + "markdown-it-mark": "^4.0.0", "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.12.2", "mitt": "^3.0.1", diff --git a/playwright/e2e/format-text.spec.ts b/playwright/e2e/format-text.spec.ts index f71c6e3f35a..be27c9692e4 100644 --- a/playwright/e2e/format-text.spec.ts +++ b/playwright/e2e/format-text.spec.ts @@ -26,6 +26,7 @@ new Map([ Italic: 'em', Underline: 'u', Strikethrough: 's', + Highlight: 'mark', } await editor.type('Format me') for (const [button] of Object.entries(buttons)) { diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index 828246c4c55..c33a262d4a8 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -77,6 +77,17 @@ I + + {{ t('text', 'Underline') }} + + __{{ t('text', 'Underlined text') }}__ + + + {{ ctrlOrModKey }} + + + U + + {{ t('text', 'Strikethrough') }} @@ -91,14 +102,16 @@ - {{ t('text', 'Underline') }} + {{ t('text', 'Highlight') }} - __{{ t('text', 'Underlined text') }}__ + =={{ t('text', 'Highlighted text') }}== {{ ctrlOrModKey }} + - U + {{ t('text', 'Shift') }} + + + H diff --git a/src/components/Menu/entries.ts b/src/components/Menu/entries.ts index 38aed1e0073..08e71aaed75 100644 --- a/src/components/Menu/entries.ts +++ b/src/components/Menu/entries.ts @@ -9,6 +9,7 @@ import { Danger, Emoticon, FormatBold, + FormatColorHighlight, FormatHeader1, FormatHeader2, FormatHeader3, @@ -282,6 +283,18 @@ export const getMenuEntries = (isRichWorkspace: boolean): MenuEntry[] => { }, priority: 13, }, + { + key: 'highlight', + label: t('text', 'Highlight'), + keyChar: 'h', + keyModifiers: [MODIFIERS.Mod, MODIFIERS.Shift], + icon: FormatColorHighlight, + isActive: 'highlight', + action: (command) => { + return command.toggleHighlight() + }, + priority: 14, + }, { key: 'lists', label: t('text', 'Lists'), @@ -477,7 +490,7 @@ export const getMenuEntries = (isRichWorkspace: boolean): MenuEntry[] => { action: (command) => { return command.insertTable() }, - priority: 14, + priority: 15, }, { key: 'details', @@ -487,7 +500,7 @@ export const getMenuEntries = (isRichWorkspace: boolean): MenuEntry[] => { action: (command) => { return command.toggleDetails() }, - priority: 15, + priority: 16, }, { key: 'insert-link', diff --git a/src/components/icons.js b/src/components/icons.js index 613119b99c7..b2ec9357ef7 100644 --- a/src/components/icons.js +++ b/src/components/icons.js @@ -24,6 +24,7 @@ import MDI_Emoticon from 'vue-material-design-icons/EmoticonOutline.vue' import MDI_Document from 'vue-material-design-icons/FileDocument.vue' import MDI_Folder from 'vue-material-design-icons/FolderOutline.vue' import MDI_FormatBold from 'vue-material-design-icons/FormatBold.vue' +import MDI_FormatColorHighlight from 'vue-material-design-icons/FormatColorHighlight.vue' import MDI_FormatHeader1 from 'vue-material-design-icons/FormatHeader1.vue' import MDI_FormatHeader2 from 'vue-material-design-icons/FormatHeader2.vue' import MDI_FormatHeader3 from 'vue-material-design-icons/FormatHeader3.vue' @@ -106,6 +107,7 @@ export const DotsHorizontal = makeIcon(MDI_DotsHorizontal) export const Emoticon = makeIcon(MDI_Emoticon) export const Folder = makeIcon(MDI_Folder) export const FormatBold = makeIcon(MDI_FormatBold) +export const FormatColorHighlight = makeIcon(MDI_FormatColorHighlight) export const FormatSize = makeIcon(MDI_FormatSize) export const FormatHeader1 = makeIcon(MDI_FormatHeader1) export const FormatHeader2 = makeIcon(MDI_FormatHeader2) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 5491128c31b..688f802a467 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -146,6 +146,10 @@ div.ProseMirror { font-style: italic; } + mark { + background-color: var(--color-mark, #fff0c7); + } + h1, h2, h3, diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index e1763c62edb..4a1501bbf39 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -26,7 +26,14 @@ import Mention from './../extensions/Mention.js' import Search from './../extensions/Search.ts' import TextDirection from './../extensions/TextDirection.ts' import Typography from './../extensions/Typography.ts' -import { Italic, Link, Strike, Strong, Underline } from './../marks/index.js' +import { + Highlight, + Italic, + Link, + Strike, + Strong, + Underline, +} from './../marks/index.js' import BulletList from './../nodes/BulletList.js' import Callouts from './../nodes/Callouts.js' import CodeBlock from './../nodes/CodeBlock.js' @@ -73,6 +80,7 @@ export default Extension.create({ HardBreak, Heading, Strong, + Highlight, Italic, Strike, Blockquote, diff --git a/src/markdownit/index.js b/src/markdownit/index.js index e0fc13c32c6..168330a6881 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -7,6 +7,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions' import MarkdownIt from 'markdown-it' import frontMatter from 'markdown-it-front-matter' import implicitFigures from 'markdown-it-image-figures' +import mark from 'markdown-it-mark' import multimdTable from 'markdown-it-multimd-table' import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' import callouts from './callouts.js' @@ -33,6 +34,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(keepSyntax) .use(markdownitMentions) .use(implicitFigures) + .use(mark) .use(mathematics) .use(multimdTable, { multiline: true, diff --git a/src/marks/Highlight.ts b/src/marks/Highlight.ts new file mode 100644 index 00000000000..acac9c83001 --- /dev/null +++ b/src/marks/Highlight.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import TipTapHighlight from '@tiptap/extension-highlight' + +const Highlight = TipTapHighlight.extend({ + // @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API + toMarkdown: { + open: '==', + close: '==', + mixable: true, + expelEnclosingWhitespace: true, + }, +}) + +export default Highlight diff --git a/src/marks/index.js b/src/marks/index.js index 20a8b7c151c..4839823e6ec 100644 --- a/src/marks/index.js +++ b/src/marks/index.js @@ -4,6 +4,7 @@ */ import TipTapItalic from '@tiptap/extension-italic' +import Highlight from './Highlight.ts' import Link from './Link.js' import Strike from './Strike.js' import Strong from './Strong.js' @@ -13,4 +14,4 @@ const Italic = TipTapItalic.extend({ name: 'em', }) -export { Italic, Link, Strike, Strong, Underline } +export { Highlight, Italic, Link, Strike, Strong, Underline } diff --git a/src/tests/marks/Highlight.spec.ts b/src/tests/marks/Highlight.spec.ts new file mode 100644 index 00000000000..396574c1c58 --- /dev/null +++ b/src/tests/marks/Highlight.spec.ts @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import createCustomEditor from '../testHelpers/createCustomEditor' +import Highlight from './../../marks/Highlight' + +describe('Highlight extension unit', () => { + it('exposes toMarkdown function', () => { + // @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API + const toMarkdown = Highlight.config.toMarkdown + expect(JSON.stringify(toMarkdown)).to.equal( + JSON.stringify({ + open: '==', + close: '==', + mixable: true, + expelEnclosingWhitespace: true, + }), + ) + }) +}) + +describe('Highlight extension integrated in the editor', () => { + it('is not active by default', () => { + const editor = createCustomEditor('

Test

', [Highlight]) + expect(editor.isActive('highlight')).to.equal(false) + editor.destroy() + }) + + it('is active within tags', () => { + const editor = createCustomEditor('

Test

', [Highlight]) + expect(editor.isActive('highlight')).to.equal(true) + editor.destroy() + }) +})