diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 28b50565841..acce659ca6f 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -50,7 +50,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; @@ -256,7 +256,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 { @@ -274,7 +274,7 @@ div.ProseMirror { li { position: relative; - padding-left: 3px; + padding-inline-start: 3px; p { position: relative; @@ -282,9 +282,17 @@ div.ProseMirror { } } + li [dir="rtl"] { + text-align: right; + } + + li [dir="ltr"] { + text-align: left; + } + ul, ol { - padding-left: 10px; - margin-left: 10px; + padding-inline-start: 10px; + margin-inline-start: 10px; margin-bottom: 1em; } @@ -303,11 +311,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 70a84ef65e6..0ec87f97a50 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -41,6 +41,7 @@ import Table from './../nodes/Table.js' import TaskItem from './../nodes/TaskItem.js' import TaskList from './../nodes/TaskList.js' import Text from '@tiptap/extension-text' +import TextDirection from './../extensions/TextDirection.ts' import TrailingNode from './../nodes/TrailingNode.js' /* eslint-enable import/no-named-as-default */ @@ -113,6 +114,15 @@ export default Extension.create({ }), LinkBubble, TrailingNode, + TextDirection.configure({ + types: [ + 'heading', + 'paragraph', + 'listItem', + 'taskItem', + 'blockquote', + ], + }), ] const additionalExtensionNames = this.options.extensions.map(e => e.name) return [ diff --git a/src/extensions/TextDirection.ts b/src/extensions/TextDirection.ts new file mode 100644 index 00000000000..5142b171306 --- /dev/null +++ b/src/extensions/TextDirection.ts @@ -0,0 +1,175 @@ +/** + * SPDX-FileCopyrightText: 2023 Amir Hossein Hashemi + * SPDX-License-Identifier: MIT + */ + +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' + +/* 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 + +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 newTextDirection = getTextDirection(node.textContent) + if (node.attrs.dir === newTextDirection) { + return + } + + const marks = tr.storedMarks || [] + tr.setNodeAttribute(pos, 'dir', newTextDirection) + // `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 diff --git a/src/nodes/Callout.vue b/src/nodes/Callout.vue index b55579c6de6..6bc58d2af25 100644 --- a/src/nodes/Callout.vue +++ b/src/nodes/Callout.vue @@ -50,12 +50,12 @@ export default {