From a4186a90c5a24de193d48fa90dab551e7bb97ec3 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Wed, 2 Sep 2026 02:32:40 +0700 Subject: [PATCH] feat(comparison): render full document changes Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: Hoang Pham --- src/comparison/comparisonNavigation.ts | 84 ++ src/comparison/markdownComparison.ts | 250 ++++++ src/comparison/markdownSourceDisplay.ts | 91 +++ src/components/CollaborativeEditor.vue | 2 +- src/components/ComparisonEditorContent.vue | 32 + src/components/MarkdownContentComparison.vue | 730 ++++++++++++++++++ src/components/MarkdownSourceFallback.vue | 60 ++ src/createMarkdownContentComparison.ts | 123 +++ src/editor.ts | 4 +- src/extensions/RichText.ts | 12 +- src/extensions/TextDirection.ts | 12 +- src/nodes/DetailsView.vue | 36 +- src/nodes/Image.ts | 54 +- src/nodes/ImageView.vue | 20 +- src/services/AttachmentResolver.js | 3 +- .../MarkdownSourceComponent.spec.ts | 67 ++ .../comparison/MarkdownSourceFallback.spec.ts | 30 + .../comparison/comparisonDecorations.spec.ts | 90 +++ .../comparisonDocumentLocation.spec.ts | 52 ++ .../comparisonEditorLifecycle.spec.ts | 28 + .../comparison/comparisonNavigation.spec.ts | 61 ++ .../comparison/comparisonPerformance.spec.ts | 171 ++++ .../comparison/createComparisonEditor.spec.ts | 28 + .../createMarkdownContentComparison.spec.ts | 261 +++++++ .../renderedComparisonLimit.spec.ts | 23 + .../nodes/DetailsViewAccessibility.spec.ts | 49 ++ src/tests/nodes/Image.spec.ts | 19 + .../nodes/ImageViewAccessibility.spec.ts | 106 +++ src/tests/services/AttachmentResolver.spec.js | 13 +- 29 files changed, 2456 insertions(+), 55 deletions(-) create mode 100644 src/comparison/comparisonNavigation.ts create mode 100644 src/comparison/markdownSourceDisplay.ts create mode 100644 src/components/ComparisonEditorContent.vue create mode 100644 src/components/MarkdownContentComparison.vue create mode 100644 src/components/MarkdownSourceFallback.vue create mode 100644 src/createMarkdownContentComparison.ts create mode 100644 src/tests/comparison/MarkdownSourceComponent.spec.ts create mode 100644 src/tests/comparison/MarkdownSourceFallback.spec.ts create mode 100644 src/tests/comparison/comparisonDecorations.spec.ts create mode 100644 src/tests/comparison/comparisonDocumentLocation.spec.ts create mode 100644 src/tests/comparison/comparisonEditorLifecycle.spec.ts create mode 100644 src/tests/comparison/comparisonNavigation.spec.ts create mode 100644 src/tests/comparison/comparisonPerformance.spec.ts create mode 100644 src/tests/comparison/createComparisonEditor.spec.ts create mode 100644 src/tests/comparison/createMarkdownContentComparison.spec.ts create mode 100644 src/tests/comparison/renderedComparisonLimit.spec.ts create mode 100644 src/tests/nodes/DetailsViewAccessibility.spec.ts create mode 100644 src/tests/nodes/Image.spec.ts create mode 100644 src/tests/nodes/ImageViewAccessibility.spec.ts diff --git a/src/comparison/comparisonNavigation.ts b/src/comparison/comparisonNavigation.ts new file mode 100644 index 00000000000..c7ff5008a20 --- /dev/null +++ b/src/comparison/comparisonNavigation.ts @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonEdit as Edit, ComparisonSide as Side } from './markdownComparisonTypes.ts' + +export function isPureFormatting(edit: Edit) { + return edit.descriptors.every(({ facets }) => facets.length === 1 && facets[0] === 'formatting') +} + +export function currentIdAfterFilter( + edits: readonly Edit[], + activeIds: readonly string[], + currentId: string | null, +) { + const active = new Set(activeIds) + if (currentId && active.has(currentId)) { + return currentId + } + if (active.size === 0) { + return null + } + const currentIndex = edits.findIndex(({ id }) => id === currentId) + if (currentIndex >= 0) { + for (let index = currentIndex + 1; index < edits.length; index++) { + if (active.has(edits[index]!.id)) { + return edits[index]!.id + } + } + for (let index = currentIndex - 1; index >= 0; index--) { + if (active.has(edits[index]!.id)) { + return edits[index]!.id + } + } + } + return edits.find(({ id }) => active.has(id))?.id ?? null +} + +export function moveCurrentId(activeIds: readonly string[], currentId: string | null, offset: number) { + if (activeIds.length === 0) { + return null + } + const current = Math.max(0, activeIds.indexOf(currentId ?? '')) + const next = ((current + offset) % activeIds.length + activeIds.length) % activeIds.length + return activeIds[next]! +} + +export function currentOrdinal(activeIds: readonly string[], currentId: string | null) { + const index = currentId ? activeIds.indexOf(currentId) : -1 + return index < 0 ? 0 : index + 1 +} + +export function comparisonSideForKey(key: string): Side | null { + if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'Home') { + return 'before' + } + return key === 'ArrowRight' || key === 'ArrowDown' || key === 'End' ? 'after' : null +} +export function comparisonScrollBehavior(): ScrollBehavior { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' +} + +export function locateComparisonTarget(pane: HTMLElement | null, scroller: HTMLElement | null, id: string, behavior: ScrollBehavior, fallbackRect?: () => { top: number, height: number } | null) { + if (!pane || !scroller || !pane.contains(scroller) || pane.hidden || pane.style.display === 'none') { + return false + } + const target = [...pane.querySelectorAll('[data-comparison-change]')] + .find((element) => element.dataset.comparisonChange === id) + const targetRect = target?.getBoundingClientRect() ?? fallbackRect?.() + if (!targetRect) { + return false + } + const scrollerRect = scroller.getBoundingClientRect() + const centeredTop = scroller.scrollTop + targetRect.top - scrollerRect.top + - (scroller.clientHeight - targetRect.height) / 2 + const maximumTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight) + scroller.scrollTo({ + behavior, + left: scroller.scrollLeft, + top: Math.min(Math.max(0, centeredTop), maximumTop), + }) + return true +} diff --git a/src/comparison/markdownComparison.ts b/src/comparison/markdownComparison.ts index 5f67df1f445..ae0e19a4a05 100644 --- a/src/comparison/markdownComparison.ts +++ b/src/comparison/markdownComparison.ts @@ -3,5 +3,255 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { Editor } from '@tiptap/core' +import type { Node } from '@tiptap/pm/model' +import type { PluginKey } from '@tiptap/pm/state' +import type { LocatedComparisonNode as LocatedNode } from './comparisonDocumentIndex.ts' +import type { ComparisonDescriptor as Descriptor, ComparisonSide as Side } from './markdownComparisonTypes.ts' + +import { Plugin, PluginKey as ProseMirrorPluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { createComparisonDocumentIndex, findComparisonNodes } from './comparisonDocumentIndex.ts' + export { ComparisonModelLimitError, createHierarchicalMarkdownComparisonModel as createMarkdownComparisonModel } from './hierarchicalMarkdownComparisonModel.ts' export type * from './markdownComparisonTypes.ts' + +export interface ComparisonDecorationState { + activeIds: readonly string[] + currentIds: readonly string[] +} + +export interface PreparedComparisonDecoration { + descriptor: Descriptor + from: number + to: number + type: 'inline' | 'node' +} + +type State = ComparisonDecorationState +type Prepared = PreparedComparisonDecoration +interface PluginState extends State { + decorations: DecorationSet + prepared: readonly Prepared[] +} + +export type ComparisonDecorationKey = PluginKey + +export const RENDERED_COMPARISON_LIMITS = Object.freeze({ + maximumCharactersPerSnapshot: 210_000, + maximumCharactersPerLine: 20_000, + maximumLinesPerSnapshot: 6_500, +}) +const LIMITS = RENDERED_COMPARISON_LIMITS + +export class ComparisonProjectionError extends Error { + constructor(id: string) { + super(`Comparison range cannot be projected: ${id}`) + this.name = 'ComparisonProjectionError' + } +} + +export function exceedsRenderedComparisonLimit(before: string, after: string): boolean { + return [before, after].some((content) => { + if (content.length > LIMITS.maximumCharactersPerSnapshot) { + return true + } + let lines = content ? 1 : 0 + let lineCharacters = 0 + for (let index = 0; index < content.length; index++) { + if (content[index] === '\n' || (content[index] === '\r' && content[index + 1] !== '\n')) { + lines++ + lineCharacters = 0 + if (lines > LIMITS.maximumLinesPerSnapshot) { + return true + } + } else if (content[index] !== '\r' && ++lineCharacters > LIMITS.maximumCharactersPerLine) { + return true + } + } + return false + }) +} + +let pluginId = 0 +export function createComparisonDecorationPlugin(descriptors: readonly Descriptor[], side: Side, markerLabel: string, initialState: State = { activeIds: descriptors.map(({ id }) => id), currentIds: [] }) { + const key = new ProseMirrorPluginKey(`markdown-comparison-${side}-${pluginId++}`) + const plugin = new Plugin({ + key, + state: { + init: (_, state) => createPluginState(state.doc, descriptors, side, initialState, markerLabel), + apply: (transaction, current) => { + if (transaction.docChanged) { + return { activeIds: [], currentIds: [], decorations: DecorationSet.empty, prepared: [] } + } + const update = transaction.getMeta(key) as State | undefined + if (!update) { + return current + } + const selection = normalizeDecorationState(descriptors, update) + return { + ...selection, + prepared: current.prepared, + decorations: buildDecorationSet(transaction.doc, current.prepared, selection, side, markerLabel), + } + }, + }, + props: { + decorations: (state) => key.getState(state)?.decorations ?? DecorationSet.empty, + }, + }) + return { key, plugin } +} + +export function setComparisonDecorationState(editor: Editor, key: ComparisonDecorationKey, state: State) { + editor.view.dispatch(editor.state.tr.setMeta(key, state)) +} + +function createPluginState(doc: Node, descriptors: readonly Descriptor[], side: Side, selection: State, markerLabel: string): PluginState { + const prepared = prepareComparisonDecorations(doc, descriptors, side) + const normalized = normalizeDecorationState(descriptors, selection) + return { + ...normalized, + prepared, + decorations: buildDecorationSet(doc, prepared, normalized, side, markerLabel), + } +} + +function normalizeDecorationState(descriptors: readonly Descriptor[], state: State): State { + const known = new Set(descriptors.map(({ id }) => id)) + const activeIds = [...new Set(state.activeIds)].filter((id) => known.has(id)) + const active = new Set(activeIds) + return { + activeIds, + currentIds: [...new Set(state.currentIds)].filter((id) => active.has(id)), + } +} + +export function prepareComparisonDecorations(doc: Node, descriptors: readonly Descriptor[], side: Side) { + const index = createComparisonDocumentIndex(doc) + return descriptors.flatMap((descriptor): Prepared[] => { + const source = descriptor[side] + if (source.from === source.to) { + return [] + } + const from = clamp(source.from, 0, doc.content.size) + const to = clamp(source.to, 0, doc.content.size) + if (from >= to) { + throw new ComparisonProjectionError(descriptor.id) + } + const candidates = findComparisonNodes({ from, to }, index.children) + const parts = descriptor.detail === 'block' + ? projectBlock(descriptor, side, from, to, candidates) + : projectInline(descriptor, from, to, candidates) + if (parts.length > 0) { + return parts + } + const fallback = projectionFallback(candidates, descriptor, side, from, to) + if (!fallback) { + throw new ComparisonProjectionError(descriptor.id) + } + return [{ descriptor, ...fallback, type: 'node' }] + }) +} + +function projectBlock(descriptor: Descriptor, side: Side, from: number, to: number, nodes: readonly LocatedNode[]) { + const topLevel = nodes.filter(({ parent }) => parent === null) + const covered = topLevel + .filter((node) => from <= node.from && to >= node.to) + .map(({ from: nodeFrom, to: nodeTo }) => ({ descriptor, from: nodeFrom, to: nodeTo, type: 'node' as const })) + if (covered.length > 0) { + return covered + } + const enclosing = nodes + .filter(({ node, from: nodeFrom, to: nodeTo }) => !node.isText && nodeFrom <= from && nodeTo >= to) + .toSorted((a, b) => (a.to - a.from) - (b.to - b.from) || b.path.length - a.path.length)[0] + if (enclosing) { + return [{ descriptor, from: enclosing.from, to: enclosing.to, type: 'node' as const }] + } + const context = descriptor.context[side] + const exact = context && context.from < context.to + ? nodes.find((node) => node.from === context.from && node.to === context.to) + : undefined + return exact ? [{ descriptor, from: exact.from, to: exact.to, type: 'node' as const }] : [] +} + +function projectInline(descriptor: Descriptor, from: number, to: number, nodes: readonly LocatedNode[]) { + const parts: Prepared[] = [] + for (const { node, from: position, to: end } of nodes) { + if (node.isText) { + const partFrom = Math.max(from, position) + const partTo = Math.min(to, end) + if (partFrom < partTo) { + parts.push({ descriptor, from: partFrom, to: partTo, type: 'inline' }) + } + continue + } + const fullyCovered = from <= position && to >= end + const edgeChanged = (from <= position && to > position && to <= position + 1) + || (from < end && to >= end && from >= end - 1) + const semanticsChanged = descriptor.facets.some((facet) => facet !== 'text' && facet !== 'formatting') + if (node.isLeaf || (semanticsChanged && (fullyCovered || edgeChanged))) { + parts.push({ descriptor, from: position, to: end, type: 'node' }) + } + } + return parts +} + +function projectionFallback(nodes: readonly LocatedNode[], descriptor: Descriptor, side: Side, from: number, to: number) { + const context = descriptor.context[side] + if (context && context.from < context.to) { + const exact = nodes.find((node) => node.from === context.from && node.to === context.to) + if (exact) { + return { from: exact.from, to: exact.to } + } + } + const enclosing = nodes + .filter(({ node, from: nodeFrom, to: nodeTo }) => !node.isText && nodeFrom <= from && nodeTo >= to) + .toSorted((a, b) => (a.to - a.from) - (b.to - b.from) || b.path.length - a.path.length)[0] + return enclosing ? { from: enclosing.from, to: enclosing.to } : null +} + +function buildDecorationSet(doc: Node, prepared: readonly Prepared[], state: State, side: Side, markerLabel: string) { + const active = new Set(state.activeIds) + const current = new Set(state.currentIds) + const decorations = prepared.flatMap((item): Decoration[] => { + if (!active.has(item.descriptor.id)) { + return [] + } + const attributes = changeAttributes(item.descriptor, side, current.has(item.descriptor.id), markerLabel) + return [item.type === 'inline' + ? Decoration.inline(item.from, item.to, attributes) + : Decoration.node(item.from, item.to, attributes)] + }) + return DecorationSet.create(doc, decorations) +} + +function changeAttributes(descriptor: Descriptor, side: Side, current: boolean, label: string) { + const pureFormatting = descriptor.facets.length === 1 && descriptor.facets[0] === 'formatting' + const coarse = descriptor.detail === 'block' && descriptor.operation === 'replace' + const treatment = coarse + ? 'block' + : pureFormatting + ? 'formatting' + : descriptor.operation === 'move' + ? 'move' + : descriptor.facets.includes('attribute') && !descriptor.facets.includes('text') + ? 'attribute' + : side === 'before' ? 'removed' : 'added' + const classes = ['text-comparison-change', `text-comparison-change--${treatment}`] + if (descriptor.detail === 'block' && !coarse) { + classes.push('text-comparison-change--block') + } + if (current) { + classes.push('text-comparison-change--current') + } + return { + class: classes.join(' '), + 'data-comparison-change': descriptor.id, + 'aria-label': label, + ...(current ? { 'aria-current': 'true' } : {}), + } +} +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum) +} diff --git a/src/comparison/markdownSourceDisplay.ts b/src/comparison/markdownSourceDisplay.ts new file mode 100644 index 00000000000..16f799222b4 --- /dev/null +++ b/src/comparison/markdownSourceDisplay.ts @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const visibleControlNames: Readonly> = { + '\t': 'TAB', + '\u00AD': 'SHY', + '\u061C': 'ALM', + '\u0085': 'NEL', + '\u200B': 'ZWSP', + '\u200C': 'ZWNJ', + '\u200D': 'ZWJ', + '\u200E': 'LRM', + '\u200F': 'RLM', + '\u2060': 'WORD JOINER', + '\uFEFF': 'BOM', + '\u2028': 'LS', + '\u2029': 'PS', + '\u202A': 'LRE', + '\u202B': 'RLE', + '\u202C': 'PDF', + '\u202D': 'LRO', + '\u202E': 'RLO', + '\u2066': 'LRI', + '\u2067': 'RLI', + '\u2068': 'FSI', + '\u2069': 'PDI', +} + +export const COMPLETE_SOURCE_DISPLAY_LIMITS = Object.freeze({ + maximumInputCharactersPerSide: 1_000_000, + maximumVisibleCharactersPerSide: 1_000_000, + maximumVisibleCharactersPerLine: 20_000, +}) +const LIMITS = COMPLETE_SOURCE_DISPLAY_LIMITS + +function sourcePrefix(value: string, maximumCharacters: number) { + let prefix = value.slice(0, Math.max(0, maximumCharacters)) + const finalCodeUnit = prefix.charCodeAt(prefix.length - 1) + const nextCodeUnit = value.charCodeAt(prefix.length) + if (finalCodeUnit >= 0xD800 && finalCodeUnit <= 0xDBFF + && nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) { + prefix = prefix.slice(0, -1) + } + return prefix +} + +function renderMarkdownSource(source: string, maximumCharacters: number, maximumLineCharacters = Number.POSITIVE_INFINITY) { + let visible = '' + let lineCharacters = 0 + for (const character of source) { + const named = visibleControlNames[character] + const code = character.codePointAt(0)! + let rendered = character + if (named) { + rendered = `⟦${named}⟧` + } else if (character.length === 1 && code >= 0xD800 && code <= 0xDFFF) { + rendered = `⟦U+${code.toString(16).toUpperCase()}⟧` + } else if ((code < 0x20 && character !== '\n' && character !== '\r') || code === 0x7F) { + rendered = `⟦U+${code.toString(16).toUpperCase().padStart(4, '0')}⟧` + } + if (rendered.length > maximumCharacters - visible.length + || (character !== '\n' && character !== '\r' + && rendered.length > maximumLineCharacters - lineCharacters)) { + return { text: visible, complete: false } + } + visible += rendered + lineCharacters = character === '\n' || character === '\r' + ? 0 + : lineCharacters + rendered.length + } + return { text: visible, complete: true } +} + +export function displayMarkdownSource(source: string, maximumCharacters = Number.POSITIVE_INFINITY) { + return renderMarkdownSource(source, maximumCharacters).text +} + +export function displayBoundedMarkdownSource(source: string) { + const input = sourcePrefix(source, LIMITS.maximumInputCharactersPerSide) + const visible = renderMarkdownSource( + input, + LIMITS.maximumVisibleCharactersPerSide, + LIMITS.maximumVisibleCharactersPerLine, + ) + return { + text: visible.text, + truncated: input.length < source.length || !visible.complete, + } +} diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index da0b2f86d22..3ceefab14a4 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -804,7 +804,7 @@ export default defineComponent({ }, async save() { - await this.saveService.save() + return await this.saveService.save() }, async saveWhenDirty() { diff --git a/src/components/ComparisonEditorContent.vue b/src/components/ComparisonEditorContent.vue new file mode 100644 index 00000000000..b11fe6acfe2 --- /dev/null +++ b/src/components/ComparisonEditorContent.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/components/MarkdownContentComparison.vue b/src/components/MarkdownContentComparison.vue new file mode 100644 index 00000000000..1a26137c968 --- /dev/null +++ b/src/components/MarkdownContentComparison.vue @@ -0,0 +1,730 @@ + + + + + + + diff --git a/src/components/MarkdownSourceFallback.vue b/src/components/MarkdownSourceFallback.vue new file mode 100644 index 00000000000..8116444eb68 --- /dev/null +++ b/src/components/MarkdownSourceFallback.vue @@ -0,0 +1,60 @@ + + + + + + + diff --git a/src/createMarkdownContentComparison.ts b/src/createMarkdownContentComparison.ts new file mode 100644 index 00000000000..c525e532cd9 --- /dev/null +++ b/src/createMarkdownContentComparison.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import MarkdownSourceFallback from './components/MarkdownSourceFallback.vue' +import { OPEN_LINK_HANDLER } from './composables/useOpenLinkHandler.ts' +import { openLink } from './helpers/links.js' + +export interface MarkdownContentComparisonOptions { + el: HTMLElement + beforeContent: string + afterContent: string + fileId?: number + filePath?: string + shareToken?: string + noLazyImages?: boolean + openLinkHandler?: (href: string) => void + onLoaded?: () => void | Promise +} + +export interface MarkdownContentComparisonInstance { + destroy: () => void +} + +export async function createMarkdownContentComparison(options: MarkdownContentComparisonOptions): Promise { + if (!(options?.el instanceof HTMLElement)) { + throw new TypeError('Comparison el must be an HTMLElement') + } + if ( + typeof options.beforeContent !== 'string' + || typeof options.afterContent !== 'string' + ) { + throw new TypeError('beforeContent and afterContent must be strings') + } + + const root = document.createElement('div') + root.className = 'text-comparison-root' + options.el.replaceChildren(root) + let app: ReturnType | null = null + let destroyed = false + let fallbackPromise: Promise | null = null + let resolveReady!: () => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + const onReady = () => { + if (!destroyed) { + resolveReady() + } + } + const provide = (nextApp: ReturnType) => nextApp.provide(OPEN_LINK_HANDLER, { + openLink: options.openLinkHandler ?? openLink, + }) + + const mountFallback = () => { + fallbackPromise ??= (async () => { + try { + app?.unmount() + } catch (error) { + void error + } + root.replaceChildren() + if (destroyed) { + return + } + app = provide(createApp(MarkdownSourceFallback, { + beforeContent: options.beforeContent, + afterContent: options.afterContent, + })) + app.mount(root) + onReady() + })() + return fallbackPromise + } + + try { + const { default: MarkdownContentComparison } + = await import('./components/MarkdownContentComparison.vue') + if (destroyed) { + return { destroy() {} } + } + app = provide(createApp(MarkdownContentComparison, { + beforeContent: options.beforeContent, + afterContent: options.afterContent, + fileId: options.fileId, + filePath: options.filePath, + shareToken: options.shareToken, + noLazyImages: options.noLazyImages ?? false, + openLinkHandler: options.openLinkHandler ?? openLink, + onReady, + })) + app.config.errorHandler = () => { + void mountFallback() + } + app.mount(root) + await ready + } catch { + await mountFallback() + await ready + } + try { + await options.onLoaded?.() + } catch (error) { + void error + } + + return { + destroy() { + if (destroyed) { + return + } + destroyed = true + try { + app?.unmount() + } finally { + root.remove() + } + app = null + }, + } +} diff --git a/src/editor.ts b/src/editor.ts index 0a8a495589c..f130d8ecac3 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -4,11 +4,12 @@ */ import { createCollaborativeEditor, createEditor, createMarkdownContentEditor } from './createEditor.ts' +import { createMarkdownContentComparison } from './createMarkdownContentComparison.ts' import { createTable } from './createTable.ts' import 'vite/modulepreload-polyfill' -const apiVersion = '1.4' +const apiVersion = '1.5' window.OCA.Text = { ...window.OCA.Text, @@ -18,4 +19,5 @@ window.OCA.Text.apiVersion = apiVersion window.OCA.Text.createEditor = createEditor window.OCA.Text.createCollaborativeEditor = createCollaborativeEditor window.OCA.Text.createMarkdownContentEditor = createMarkdownContentEditor +window.OCA.Text.createMarkdownContentComparison = createMarkdownContentComparison window.OCA.Text.createTable = createTable diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index a38a6ce8a73..e1162e122ad 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -95,7 +95,7 @@ export default Extension.create({ Text, Paragraph, HardBreak, - Heading, + this.options.editing || !this.options.isEmbedded ? Heading : Heading.extend({ addProseMirrorPlugins: () => [] }), Strong, Highlight, Italic, @@ -123,7 +123,10 @@ export default Extension.create({ isEmbedded: this.options.isEmbedded, }), Underline, - Image.configure({ noLazyImages: this.options.noLazyImages }), + Image.configure({ + emitAttachmentEvents: this.options.editing, + noLazyImages: this.options.noLazyImages, + }), ImageInline.configure({ noLazyImages: this.options.noLazyImages }), Dropcursor.configure({ color: 'var(--color-primary-element)', @@ -131,7 +134,7 @@ export default Extension.create({ }), Gapcursor, KeepSyntax, - Keymap, + ...(this.options.editing ? [Keymap] : []), FrontMatter, Mention.configure({ suggestion: MentionSuggestion({ @@ -141,7 +144,7 @@ export default Extension.create({ }, }), }), - Search, + ...(this.options.editing ? [Search] : []), Emoji.configure({ suggestion: EmojiSuggestion(), }), @@ -163,6 +166,7 @@ export default Extension.create({ notAfter: ['paragraph', 'comments', 'footnotes'], }), TextDirection.configure({ + inferTextDirectionOnParse: !this.options.editing, types: [ 'blockquote', 'callout', diff --git a/src/extensions/TextDirection.ts b/src/extensions/TextDirection.ts index a9c3db0f751..4f266f4e89a 100644 --- a/src/extensions/TextDirection.ts +++ b/src/extensions/TextDirection.ts @@ -119,6 +119,7 @@ declare module '@tiptap/core' { export interface TextDirectionOptions { types: string[] defaultDirection: Direction | null + inferTextDirectionOnParse: boolean } export const TextDirection = Extension.create({ @@ -128,6 +129,7 @@ export const TextDirection = Extension.create({ return { types: [], defaultDirection: null, + inferTextDirectionOnParse: false, } }, @@ -138,7 +140,15 @@ export const TextDirection = Extension.create({ attributes: { dir: { default: null, - parseHTML: (element) => element.dir || this.options.defaultDirection, + parseHTML: (element) => { + if (!this.options.inferTextDirectionOnParse) { + return element.dir || this.options.defaultDirection + } + const explicitDirection = element.dir as Direction + return validDirections.includes(explicitDirection) + ? explicitDirection + : getTextDirection(element.textContent ?? '') ?? this.options.defaultDirection + }, renderHTML: (attributes) => { if (attributes.dir === this.options.defaultDirection) { return {} diff --git a/src/nodes/DetailsView.vue b/src/nodes/DetailsView.vue index 65677b0db96..2ec5c094d4e 100644 --- a/src/nodes/DetailsView.vue +++ b/src/nodes/DetailsView.vue @@ -5,13 +5,17 @@ diff --git a/src/nodes/Image.ts b/src/nodes/Image.ts index 405dc92a694..389be2d9947 100644 --- a/src/nodes/Image.ts +++ b/src/nodes/Image.ts @@ -17,6 +17,7 @@ const imageFileDropPluginKey = new PluginKey('imageFileDrop') const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments') interface ImageOptions extends TiptapImageOptions { + emitAttachmentEvents: boolean noLazyImages: boolean } @@ -53,6 +54,7 @@ const Image = TiptapImage.extend({ addOptions() { return { ...this.parent?.() as ImageOptions, + emitAttachmentEvents: true, noLazyImages: false, } }, @@ -120,31 +122,33 @@ const Image = TiptapImage.extend({ }, }, }), - new Plugin({ - key: imageExtractAttachmentsKey, - state: { - init(_, { doc }) { - const attachmentSrcs = extractAttachmentSrcs(doc) - emit('text:editor:attachments:updated', { attachmentSrcs }) - return { attachmentSrcs } - }, - apply(tr, value, _oldState, newState) { - if (!tr.docChanged) { - return value - } - const attachmentSrcs = extractAttachmentSrcs(newState.doc) - if ( - JSON.stringify(attachmentSrcs) - === JSON.stringify(value?.attachmentSrcs) - ) { - return value - } - - emit('text:editor:attachments:updated', { attachmentSrcs }) - return { attachmentSrcs } - }, - }, - }), + ...(this.options.emitAttachmentEvents + ? [new Plugin({ + key: imageExtractAttachmentsKey, + state: { + init(_, { doc }) { + const attachmentSrcs = extractAttachmentSrcs(doc) + emit('text:editor:attachments:updated', { attachmentSrcs }) + return { attachmentSrcs } + }, + apply(tr, value, _oldState, newState) { + if (!tr.docChanged) { + return value + } + const attachmentSrcs = extractAttachmentSrcs(newState.doc) + if ( + JSON.stringify(attachmentSrcs) + === JSON.stringify(value?.attachmentSrcs) + ) { + return value + } + + emit('text:editor:attachments:updated', { attachmentSrcs }) + return { attachmentSrcs } + }, + }, + })] + : []), ] }, diff --git a/src/nodes/ImageView.vue b/src/nodes/ImageView.vue index eb2c7576c4d..cec809cbc51 100644 --- a/src/nodes/ImageView.vue +++ b/src/nodes/ImageView.vue @@ -24,8 +24,10 @@ v-if="isMediaAttachment" contenteditable="false" class="media"> - {{ alt }} {{ attachmentSize }} - +