From bf8ff09bfb6950718ebd65991297dceb8717062d Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Jul 2025 19:03:19 +0200 Subject: [PATCH 1/4] chore: Use local copy of tiptap-text-direction extension Signed-off-by: Jonas --- package-lock.json | 17 ---- package.json | 1 - src/extensions/RichText.js | 2 +- src/extensions/TextDirection.ts | 157 ++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 src/extensions/TextDirection.ts diff --git a/package-lock.json b/package-lock.json index 76c62d168ad..4363b320d19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,6 @@ "proxy-polyfill": "^0.3.2", "slug": "^11.0.0", "tippy.js": "^6.3.7", - "tiptap-text-direction": "^0.3.2", "uuid": "^11.1.0", "vue": "^2.7.16", "vue-click-outside": "^1.1.0", @@ -18547,16 +18546,6 @@ "@popperjs/core": "^2.9.0" } }, - "node_modules/tiptap-text-direction": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tiptap-text-direction/-/tiptap-text-direction-0.3.2.tgz", - "integrity": "sha512-EBiJq4urei+joaBqhPUwaZ7bfmsSf62Lzd4KXm3T0kjtDC8pj3EFoXdi/9MEvbTsiY8iqx3S/kfRInRCMiN7ng==", - "license": "MIT", - "peerDependencies": { - "@tiptap/core": "^2.0.0", - "@tiptap/pm": "^2.0.0" - } - }, "node_modules/tldts": { "version": "6.1.76", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.76.tgz", @@ -33239,12 +33228,6 @@ "@popperjs/core": "^2.9.0" } }, - "tiptap-text-direction": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tiptap-text-direction/-/tiptap-text-direction-0.3.2.tgz", - "integrity": "sha512-EBiJq4urei+joaBqhPUwaZ7bfmsSf62Lzd4KXm3T0kjtDC8pj3EFoXdi/9MEvbTsiY8iqx3S/kfRInRCMiN7ng==", - "requires": {} - }, "tldts": { "version": "6.1.76", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.76.tgz", diff --git a/package.json b/package.json index 40ed07982b9..a98d1f82e2f 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,6 @@ "proxy-polyfill": "^0.3.2", "slug": "^11.0.0", "tippy.js": "^6.3.7", - "tiptap-text-direction": "^0.3.2", "uuid": "^11.1.0", "vue": "^2.7.16", "vue-click-outside": "^1.1.0", diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index ce676a95c13..c6003bba170 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -16,7 +16,6 @@ import Gapcursor from '@tiptap/extension-gapcursor' import HorizontalRule from '@tiptap/extension-horizontal-rule' import ListItem from '@tiptap/extension-list-item' import Text from '@tiptap/extension-text' -import TextDirection from 'tiptap-text-direction' import MentionSuggestion from '../components/Suggestion/Mention/suggestions.js' import Heading from '../nodes/Heading.js' import EmojiSuggestion from './../components/Suggestion/Emoji/suggestions.js' @@ -25,6 +24,7 @@ import LinkPicker from './../extensions/LinkPicker.js' import Markdown from './../extensions/Markdown.js' import Mention from './../extensions/Mention.js' import Search from './../extensions/Search.js' +import TextDirection from './../extensions/TextDirection.ts' import BulletList from './../nodes/BulletList.js' import Callouts from './../nodes/Callouts.js' import CodeBlock from './../nodes/CodeBlock.js' diff --git a/src/extensions/TextDirection.ts b/src/extensions/TextDirection.ts new file mode 100644 index 00000000000..8b5e2221ca1 --- /dev/null +++ b/src/extensions/TextDirection.ts @@ -0,0 +1,157 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +const RTL = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC"; +const LTR = + "A-Za-z\u00C0-\u00D6\u00D8-\u00F6" + + "\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C" + + "\uFE00-\uFE6F\uFEFD-\uFFFF"; + +const RTL_REGEX = new RegExp("^[^" + LTR + "]*[" + RTL + "]"); +const LTR_REGEX = new RegExp("^[^" + RTL + "]*[" + LTR + "]"); + +// Source: https://github.com/facebook/lexical/blob/429e3eb5b5a244026fa4776650aabe3c8e17536b/packages/lexical/src/LexicalUtils.ts#L163 +export function getTextDirection(text: string): "ltr" | "rtl" | null { + if (text.length == 0) { + return null; + } + if (RTL_REGEX.test(text)) { + return "rtl"; + } + if (LTR_REGEX.test(text)) { + return "ltr"; + } + return null; +} + +const validDirections = ["ltr", "rtl", "auto"] as const; + +type Direction = (typeof validDirections)[number]; + +function TextDirectionPlugin({ types }: { types: string[] }) { + return new Plugin({ + key: new PluginKey("textDirection"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some( + (transaction) => transaction.docChanged, + ); + if (!docChanges) { + return; + } + + let modified = false; + const tr = newState.tr; + tr.setMeta("addToHistory", false); + + newState.doc.descendants((node, pos) => { + if (types.includes(node.type.name)) { + if (node.attrs.dir !== null && node.textContent.length > 0) { + return; + } + const marks = tr.storedMarks || []; + tr.setNodeAttribute(pos, "dir", getTextDirection(node.textContent)); + // `tr.setNodeAttribute` resets the stored marks so we'll restore them + for (const mark of marks) { + tr.addStoredMark(mark); + } + modified = true; + } + }); + + return modified ? tr : null; + }, + }); +} + +declare module "@tiptap/core" { + interface Commands { + textDirection: { + /** + * Set the text direction attribute + */ + setTextDirection: (direction: Direction) => ReturnType; + /** + * Unset the text direction attribute + */ + unsetTextDirection: () => ReturnType; + }; + } +} + +export interface TextDirectionOptions { + types: string[]; + defaultDirection: Direction | null; +} + +export const TextDirection = Extension.create({ + name: "textDirection", + + addOptions() { + return { + types: [], + defaultDirection: null, + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + dir: { + default: null, + parseHTML: (element) => + element.dir || this.options.defaultDirection, + renderHTML: (attributes) => { + if (attributes.dir === this.options.defaultDirection) { + return {}; + } + return { dir: attributes.dir }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setTextDirection: + (direction: Direction) => + ({ commands }) => { + if (!validDirections.includes(direction)) { + return false; + } + + return this.options.types.every((type) => + commands.updateAttributes(type, { dir: direction }), + ); + }, + + unsetTextDirection: + () => + ({ commands }) => { + return this.options.types.every((type) => + commands.resetAttributes(type, "dir"), + ); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-Alt-l": () => this.editor.commands.setTextDirection("ltr"), + "Mod-Alt-r": () => this.editor.commands.setTextDirection("rtl"), + }; + }, + + addProseMirrorPlugins() { + return [ + TextDirectionPlugin({ + types: this.options.types, + }), + ]; + }, +}); + +export default TextDirection; From 34043b71a0b8a6ea91b5425952eb70cf91d8bd0e Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Jul 2025 19:12:54 +0200 Subject: [PATCH 2/4] fix(TextDirection): Adjust to our code style Signed-off-by: Jonas --- src/extensions/TextDirection.ts | 302 +++++++++++++++++--------------- 1 file changed, 160 insertions(+), 142 deletions(-) diff --git a/src/extensions/TextDirection.ts b/src/extensions/TextDirection.ts index 8b5e2221ca1..849b8737066 100644 --- a/src/extensions/TextDirection.ts +++ b/src/extensions/TextDirection.ts @@ -1,157 +1,175 @@ -import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +/** + * SPDX-FileCopyrightText: 2023 Amir Hossein Hashemi + * SPDX-License-Identifier: MIT + */ -const RTL = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC"; +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' + +const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC' const LTR = - "A-Za-z\u00C0-\u00D6\u00D8-\u00F6" + - "\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C" + - "\uFE00-\uFE6F\uFEFD-\uFFFF"; - -const RTL_REGEX = new RegExp("^[^" + LTR + "]*[" + RTL + "]"); -const LTR_REGEX = new RegExp("^[^" + RTL + "]*[" + LTR + "]"); - -// Source: https://github.com/facebook/lexical/blob/429e3eb5b5a244026fa4776650aabe3c8e17536b/packages/lexical/src/LexicalUtils.ts#L163 -export function getTextDirection(text: string): "ltr" | "rtl" | null { - if (text.length == 0) { - return null; - } - if (RTL_REGEX.test(text)) { - return "rtl"; - } - if (LTR_REGEX.test(text)) { - return "ltr"; - } - return null; + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + + '\uFE00-\uFE6F\uFEFD-\uFFFF' + +/* eslint-disable no-misleading-character-class */ +const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']') +const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']') + +/** + * @param text Text string + * + *Source: https://github.com/facebook/lexical/blob/429e3eb5b5a244026fa4776650aabe3c8e17536b/packages/lexical/src/LexicalUtils.ts#L163 + */ +export function getTextDirection(text: string): 'ltr' | 'rtl' | null { + if (text.length === 0) { + return null + } + if (RTL_REGEX.test(text)) { + return 'rtl' + } + if (LTR_REGEX.test(text)) { + return 'ltr' + } + return null } -const validDirections = ["ltr", "rtl", "auto"] as const; +const validDirections = ['ltr', 'rtl', 'auto'] as const -type Direction = (typeof validDirections)[number]; +type Direction = (typeof validDirections)[number] +/** + * @param object Property object + * @param object.types List of node types to consider + */ function TextDirectionPlugin({ types }: { types: string[] }) { - return new Plugin({ - key: new PluginKey("textDirection"), - appendTransaction: (transactions, oldState, newState) => { - const docChanges = transactions.some( - (transaction) => transaction.docChanged, - ); - if (!docChanges) { - return; - } - - let modified = false; - const tr = newState.tr; - tr.setMeta("addToHistory", false); - - newState.doc.descendants((node, pos) => { - if (types.includes(node.type.name)) { - if (node.attrs.dir !== null && node.textContent.length > 0) { - return; - } - const marks = tr.storedMarks || []; - tr.setNodeAttribute(pos, "dir", getTextDirection(node.textContent)); - // `tr.setNodeAttribute` resets the stored marks so we'll restore them - for (const mark of marks) { - tr.addStoredMark(mark); - } - modified = true; - } - }); - - return modified ? tr : null; - }, - }); + return new Plugin({ + key: new PluginKey('textDirection'), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some( + (transaction) => transaction.docChanged, + ) + if (!docChanges) { + return + } + + let modified = false + const tr = newState.tr + tr.setMeta('addToHistory', false) + + newState.doc.descendants((node, pos) => { + if (types.includes(node.type.name)) { + if (node.attrs.dir !== null && node.textContent.length > 0) { + return + } + const marks = tr.storedMarks || [] + tr.setNodeAttribute( + pos, + 'dir', + getTextDirection(node.textContent), + ) + // `tr.setNodeAttribute` resets the stored marks so we'll restore them + for (const mark of marks) { + tr.addStoredMark(mark) + } + modified = true + } + }) + + return modified ? tr : null + }, + }) } -declare module "@tiptap/core" { - interface Commands { - textDirection: { - /** - * Set the text direction attribute - */ - setTextDirection: (direction: Direction) => ReturnType; - /** - * Unset the text direction attribute - */ - unsetTextDirection: () => ReturnType; - }; - } +declare module '@tiptap/core' { + interface Commands { + textDirection: { + /** + * Set the text direction attribute + */ + setTextDirection: (direction: Direction) => ReturnType + /** + * Unset the text direction attribute + */ + unsetTextDirection: () => ReturnType + } + } } export interface TextDirectionOptions { - types: string[]; - defaultDirection: Direction | null; + types: string[] + defaultDirection: Direction | null } export const TextDirection = Extension.create({ - name: "textDirection", - - addOptions() { - return { - types: [], - defaultDirection: null, - }; - }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - dir: { - default: null, - parseHTML: (element) => - element.dir || this.options.defaultDirection, - renderHTML: (attributes) => { - if (attributes.dir === this.options.defaultDirection) { - return {}; - } - return { dir: attributes.dir }; - }, - }, - }, - }, - ]; - }, - - addCommands() { - return { - setTextDirection: - (direction: Direction) => - ({ commands }) => { - if (!validDirections.includes(direction)) { - return false; - } - - return this.options.types.every((type) => - commands.updateAttributes(type, { dir: direction }), - ); - }, - - unsetTextDirection: - () => - ({ commands }) => { - return this.options.types.every((type) => - commands.resetAttributes(type, "dir"), - ); - }, - }; - }, - - addKeyboardShortcuts() { - return { - "Mod-Alt-l": () => this.editor.commands.setTextDirection("ltr"), - "Mod-Alt-r": () => this.editor.commands.setTextDirection("rtl"), - }; - }, - - addProseMirrorPlugins() { - return [ - TextDirectionPlugin({ - types: this.options.types, - }), - ]; - }, -}); - -export default TextDirection; + name: 'textDirection', + + addOptions() { + return { + types: [], + defaultDirection: null, + } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + dir: { + default: null, + parseHTML: (element) => + element.dir || this.options.defaultDirection, + renderHTML: (attributes) => { + if (attributes.dir === this.options.defaultDirection) { + return {} + } + return { dir: attributes.dir } + }, + }, + }, + }, + ] + }, + + addCommands() { + return { + setTextDirection: + (direction: Direction) => + ({ commands }) => { + if (!validDirections.includes(direction)) { + return false + } + + return this.options.types.every((type) => + commands.updateAttributes(type, { dir: direction }), + ) + }, + + unsetTextDirection: + () => + ({ commands }) => { + return this.options.types.every((type) => + commands.resetAttributes(type, 'dir'), + ) + }, + } + }, + + addKeyboardShortcuts() { + return { + 'Mod-Alt-l': () => this.editor.commands.setTextDirection('ltr'), + 'Mod-Alt-r': () => this.editor.commands.setTextDirection('rtl'), + } + }, + + addProseMirrorPlugins() { + return [ + TextDirectionPlugin({ + types: this.options.types, + }), + ] + }, +}) + +export default TextDirection From dbca7285b6a7afacd553e1a493f53519c168d732 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Jul 2025 19:18:33 +0200 Subject: [PATCH 3/4] fix(TextDirection): Ignore unchanged nodes in appendTransaction Amongh other things, fixes the `undoInputRule()` command. Signed-off-by: Jonas --- src/extensions/TextDirection.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/extensions/TextDirection.ts b/src/extensions/TextDirection.ts index 849b8737066..ccb0682e413 100644 --- a/src/extensions/TextDirection.ts +++ b/src/extensions/TextDirection.ts @@ -62,12 +62,13 @@ function TextDirectionPlugin({ types }: { types: string[] }) { if (node.attrs.dir !== null && node.textContent.length > 0) { return } + const newTextDirection = getTextDirection(node.textContent) + if (node.attrs.dir === newTextDirection) { + return + } + const marks = tr.storedMarks || [] - tr.setNodeAttribute( - pos, - 'dir', - getTextDirection(node.textContent), - ) + tr.setNodeAttribute(pos, 'dir', newTextDirection) // `tr.setNodeAttribute` resets the stored marks so we'll restore them for (const mark of marks) { tr.addStoredMark(mark) From 30aa6ad5be87a05de3c487fc6f23ff2c36a4fdf6 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 28 Jul 2025 19:20:08 +0200 Subject: [PATCH 4/4] fix(TextDirection): Add taskItem and blockquote to regarded node types Also fix some CSS rules to work with rtl script. Signed-off-by: Jonas --- src/css/prosemirror.scss | 17 ++++++++--------- src/extensions/RichText.js | 8 +++++++- src/nodes/Callout.vue | 10 +++++----- src/tests/tiptap.spec.js | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 3a481202566..c6b5497d3c2 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -51,7 +51,7 @@ div.ProseMirror { display: flex; align-items: start; // Leave space for checkbox (14px width + 2x 1px border + 6px margin-right) - margin-left: -24px; + margin-inline-start: -24px; input[type='checkbox'] { display: none; @@ -262,7 +262,7 @@ div.ProseMirror { pre.frontmatter { margin-bottom: 2em; - border-left: 4px solid var(--color-primary-element); + border-inline-start: 4px solid var(--color-primary-element); } pre.frontmatter::before { @@ -280,7 +280,7 @@ div.ProseMirror { li { position: relative; - padding-left: 3px; + padding-inline-start: 3px; p { position: relative; @@ -298,8 +298,8 @@ div.ProseMirror { ul, ol { - padding-left: 10px; - margin-left: 10px; + padding-inline-start: 10px; + margin-inline-start: 10px; margin-bottom: 1em; } @@ -318,11 +318,10 @@ div.ProseMirror { } blockquote { - padding-left: 1em; - border-left: 4px solid var(--color-primary-element); + padding-inline-start: 1em; + border-inline-start: 4px solid var(--color-primary-element); color: var(--color-text-maxcontrast); - margin-left: 0; - margin-right: 0; + margin-inline: 0; } // table variables diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index c6003bba170..c0d19c7be3e 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -120,7 +120,13 @@ export default Extension.create({ LinkBubble, TrailingNode, TextDirection.configure({ - types: ['heading', 'paragraph', 'listItem', 'orderedList'], + types: [ + 'heading', + 'paragraph', + 'listItem', + 'taskItem', + 'blockquote', + ], }), ] const additionalExtensionNames = this.options.extensions.map((e) => e.name) diff --git a/src/nodes/Callout.vue b/src/nodes/Callout.vue index d4e23f59300..6672d679c05 100644 --- a/src/nodes/Callout.vue +++ b/src/nodes/Callout.vue @@ -51,12 +51,12 @@ export default {