From 4b154ac8ec7d4b8e4d2c4bcb4a408029da6f6610 Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Thu, 3 Sep 2026 17:07:43 +0700 Subject: [PATCH] feat(comparison): backport version comparison to stable35 Backport the complete Text stack from merge 20dcd03de5081e9c00705fc365a325e9af507be1, relative to its first parent. Includes #9047, #9048, #9049, and #9050 without changing their implementation. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: Hoang Pham --- .github/workflows/playwright.yml | 8 +- package-lock.json | 32 +- package.json | 2 + playwright.config.ts | 101 ++- playwright/comparison/comparison.spec.ts | 792 ++++++++++++++++ playwright/comparison/fixtures.ts | 26 + .../comparison/support/comparisonHarness.ts | 335 +++++++ src/comparison/comparisonAlignment.ts | 392 ++++++++ src/comparison/comparisonDocumentIndex.ts | 169 ++++ src/comparison/comparisonNavigation.ts | 84 ++ src/comparison/comparisonPresentation.ts | 64 ++ src/comparison/comparisonSections.ts | 157 ++++ src/comparison/createComparisonEditor.ts | 44 + .../hierarchicalMarkdownComparisonModel.ts | 842 ++++++++++++++++++ src/comparison/markdownComparison.ts | 257 ++++++ .../markdownComparisonClassification.ts | 540 +++++++++++ src/comparison/markdownComparisonMoves.ts | 57 ++ src/comparison/markdownComparisonTypes.ts | 148 +++ src/comparison/markdownSourceComparison.ts | 526 +++++++++++ .../markdownSourceComparison.worker.ts | 12 + .../markdownSourceComparisonProtocol.ts | 26 + src/comparison/markdownSourceDisplay.ts | 91 ++ src/components/CollaborativeEditor.vue | 2 +- src/components/ComparisonChangeList.vue | 474 ++++++++++ src/components/ComparisonEditorContent.vue | 32 + src/components/MarkdownContentComparison.vue | 769 ++++++++++++++++ src/components/MarkdownSourceComparison.vue | 527 +++++++++++ src/components/MarkdownSourceFallback.vue | 60 ++ src/composables/useEditorMethods.ts | 11 +- src/createMarkdownContentComparison.ts | 123 +++ src/editor.ts | 4 +- src/extensions/RichText.ts | 12 +- src/extensions/TextDirection.ts | 12 +- src/markdownit/details.ts | 26 +- src/nodes/Details.js | 7 +- src/nodes/DetailsView.vue | 36 +- src/nodes/Image.ts | 54 +- src/nodes/ImageView.vue | 20 +- src/services/AttachmentResolver.js | 3 +- .../comparison/ComparisonChangeList.spec.ts | 173 ++++ .../MarkdownSourceComponent.spec.ts | 187 ++++ .../comparison/MarkdownSourceFallback.spec.ts | 30 + .../comparison/a15HistoryDifferential.spec.ts | 183 ++++ .../comparison/comparisonAlignment.spec.ts | 343 +++++++ .../comparisonAlignmentOracle.spec.ts | 167 ++++ .../comparison/comparisonDecorations.spec.ts | 90 ++ .../comparisonDocumentLocation.spec.ts | 52 ++ .../comparisonEditorLifecycle.spec.ts | 28 + .../comparison/comparisonNavigation.spec.ts | 61 ++ .../comparison/comparisonPerformance.spec.ts | 171 ++++ .../comparison/comparisonPresentation.spec.ts | 28 + .../comparison/comparisonSections.spec.ts | 76 ++ src/tests/comparison/comparisonTestEditor.ts | 19 + .../comparison/createComparisonEditor.spec.ts | 28 + .../createMarkdownContentComparison.spec.ts | 281 ++++++ .../hierarchicalMarkdownComparison.spec.ts | 363 ++++++++ .../markdownSourceComparison.spec.ts | 214 +++++ .../markdownSourceComparisonWorker.spec.ts | 30 + .../renderedComparisonLimit.spec.ts | 23 + .../comparison/tableGridComparison.spec.ts | 655 ++++++++++++++ src/tests/markdown.spec.js | 1 + src/tests/markdownit/details.spec.js | 4 + .../nodes/DetailsViewAccessibility.spec.ts | 49 + src/tests/nodes/Image.spec.ts | 19 + .../nodes/ImageViewAccessibility.spec.ts | 106 +++ src/tests/playwrightConfig.spec.ts | 76 ++ src/tests/services/AttachmentResolver.spec.js | 13 +- vite.config.ts | 9 + 68 files changed, 10257 insertions(+), 99 deletions(-) create mode 100644 playwright/comparison/comparison.spec.ts create mode 100644 playwright/comparison/fixtures.ts create mode 100644 playwright/comparison/support/comparisonHarness.ts create mode 100644 src/comparison/comparisonAlignment.ts create mode 100644 src/comparison/comparisonDocumentIndex.ts create mode 100644 src/comparison/comparisonNavigation.ts create mode 100644 src/comparison/comparisonPresentation.ts create mode 100644 src/comparison/comparisonSections.ts create mode 100644 src/comparison/createComparisonEditor.ts create mode 100644 src/comparison/hierarchicalMarkdownComparisonModel.ts create mode 100644 src/comparison/markdownComparison.ts create mode 100644 src/comparison/markdownComparisonClassification.ts create mode 100644 src/comparison/markdownComparisonMoves.ts create mode 100644 src/comparison/markdownComparisonTypes.ts create mode 100644 src/comparison/markdownSourceComparison.ts create mode 100644 src/comparison/markdownSourceComparison.worker.ts create mode 100644 src/comparison/markdownSourceComparisonProtocol.ts create mode 100644 src/comparison/markdownSourceDisplay.ts create mode 100644 src/components/ComparisonChangeList.vue create mode 100644 src/components/ComparisonEditorContent.vue create mode 100644 src/components/MarkdownContentComparison.vue create mode 100644 src/components/MarkdownSourceComparison.vue create mode 100644 src/components/MarkdownSourceFallback.vue create mode 100644 src/createMarkdownContentComparison.ts create mode 100644 src/tests/comparison/ComparisonChangeList.spec.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/a15HistoryDifferential.spec.ts create mode 100644 src/tests/comparison/comparisonAlignment.spec.ts create mode 100644 src/tests/comparison/comparisonAlignmentOracle.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/comparisonPresentation.spec.ts create mode 100644 src/tests/comparison/comparisonSections.spec.ts create mode 100644 src/tests/comparison/comparisonTestEditor.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/hierarchicalMarkdownComparison.spec.ts create mode 100644 src/tests/comparison/markdownSourceComparison.spec.ts create mode 100644 src/tests/comparison/markdownSourceComparisonWorker.spec.ts create mode 100644 src/tests/comparison/renderedComparisonLimit.spec.ts create mode 100644 src/tests/comparison/tableGridComparison.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 create mode 100644 src/tests/playwrightConfig.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 1de225766f6..1272f3fb083 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -55,17 +55,21 @@ jobs: npm run build --if-present - name: Install Playwright Browsers - run: npx playwright install chromium --only-shell + run: npx playwright install --with-deps chromium webkit - name: Run Playwright tests run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + TEXT_COMPARISON_E2E: '1' - name: Upload results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: name: playwright-report_shard${{ matrix.shardIndex }} - path: test-results/ + path: | + test-results/ + blob-report/ retention-days: 7 summary: diff --git a/package-lock.json b/package-lock.json index c35c079b67e..e402058a6d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@tiptap/vue-3": "^3.30.5", "@vueuse/shared": "^14.4.0", "debounce": "^3.0.0", + "diff": "^8.0.4", "escape-html": "^1.0.3", "highlight.js": "^11.12.0", "katex": "^0.18.4", @@ -90,6 +91,7 @@ "yjs": "^13.6.32" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.5.1", @@ -242,6 +244,19 @@ "node": "20 || >=22" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -7362,6 +7377,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -9747,10 +9772,9 @@ } }, "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "dev": true, + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" diff --git a/package.json b/package.json index 78496e4c1c4..60de2fc109d 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@tiptap/vue-3": "^3.30.5", "@vueuse/shared": "^14.4.0", "debounce": "^3.0.0", + "diff": "^8.0.4", "escape-html": "^1.0.3", "highlight.js": "^11.12.0", "katex": "^0.18.4", @@ -108,6 +109,7 @@ "yjs": "^13.6.32" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/e2e-test-server": "^0.5.1", diff --git a/playwright.config.ts b/playwright.config.ts index d16c75500fb..f62d2c65b2b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,11 +2,57 @@ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable jsdoc/require-jsdoc */ import type { ReporterDescription } from '@playwright/test' import { defineConfig, devices } from '@playwright/test' +const COMPARISON_E2E = process.env.TEXT_COMPARISON_E2E === '1' +const COMPARISON_TESTS = /playwright\/comparison\/.*\.spec\.ts/ +const COMPARISON_BASE_URL = process.env.TEXT_COMPARISON_BASE_URL || process.env.baseURL || 'http://localhost:8089/index.php/' +const EXTERNAL_COMPARISON_SERVER = Boolean(process.env.TEXT_COMPARISON_BASE_URL || process.env.baseURL) + +function comparisonProjects() { + if (!COMPARISON_E2E) { + return [] + } + return [{ + name: 'comparison-chromium', + testMatch: COMPARISON_TESTS, + grepInvert: /@memory/, + use: { + ...devices['Desktop Chrome'], + baseURL: COMPARISON_BASE_URL, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure' as const, + trace: 'retain-on-failure' as const, + }, + }, { + name: 'comparison-webkit', + testMatch: COMPARISON_TESTS, + grepInvert: /@memory/, + use: { + ...devices['Desktop Safari'], + baseURL: COMPARISON_BASE_URL, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure' as const, + trace: 'retain-on-failure' as const, + }, + }, { + name: 'comparison-chromium-memory', + testMatch: COMPARISON_TESTS, + grep: /@memory/, + use: { + ...devices['Desktop Chrome'], + baseURL: COMPARISON_BASE_URL, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure' as const, + trace: 'retain-on-failure' as const, + }, + }] +} + /** * Used locally - i.e. if `CI` is not set as an environment variable. */ @@ -24,22 +70,48 @@ const CI_CONFIG = { // blob (so we can merge reports and download them for inspection), // dot (so we have a quick overview in the logs while the tests are running) // github (to have annotations in the PR) - reporter: [['blob'], ['line'], ['github']] as ReporterDescription[], + reporter: [ + ['blob'], + ['json', { outputFile: 'test-results/results.json' }], + ['line'], + ['github'], + ] as ReporterDescription[], retries: 1, timeout: 45_000, // we shard to speed up the tests so no parallelism in workers workers: 1, } as const +function comparisonWebServer() { + if (EXTERNAL_COMPARISON_SERVER) { + return undefined + } + return { + command: 'npm run start:nextcloud', + gracefulShutdown: { + signal: 'SIGTERM' as const, + timeout: 10000, + }, + reuseExistingServer: false, + stderr: 'pipe' as const, + stdout: 'pipe' as const, + timeout: 5 * 60 * 1000, + wait: { + stdout: /Nextcloud is now ready to use/, + }, + } +} + /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './playwright', ...(process.env.CI ? CI_CONFIG : LOCAL_CONFIG), + workers: COMPARISON_E2E ? 1 : undefined, use: { // Base URL to use in actions like `await page.goto('./')`. - baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/', + baseURL: COMPARISON_BASE_URL, // record traces but only keep them when the test fails trace: 'on-first-retry', }, @@ -47,32 +119,13 @@ export default defineConfig({ projects: [ { name: 'chromium', + testIgnore: COMPARISON_TESTS, use: { ...devices['Desktop Chrome'], }, }, + ...comparisonProjects(), ], - webServer: { - // Don't set `url` as it would take precedence over `wait.stdout` and tests start too early - // url: 'http://127.0.0.1:8089', - // Starts the Nextcloud docker container - command: 'npm run start:nextcloud', - // we use sigterm to notify the script to stop the container - // if it does not respond, we force kill it after 10 seconds - gracefulShutdown: { - signal: 'SIGTERM', - timeout: 10000, - }, - // `start-nextcloud-server.mjs` only starts the server if not reachable yet. - reuseExistingServer: false, - stderr: 'pipe', - stdout: 'pipe', - // max. 5 minutes for creating the container - timeout: 5 * 60 * 1000, - wait: { - // we wait for this line to appear in the output of the webserver until consider it done - stdout: /Nextcloud is now ready to use/, - }, - }, + webServer: comparisonWebServer(), }) diff --git a/playwright/comparison/comparison.spec.ts b/playwright/comparison/comparison.spec.ts new file mode 100644 index 00000000000..2e4eab7f681 --- /dev/null +++ b/playwright/comparison/comparison.spec.ts @@ -0,0 +1,792 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { CDPSession, Page, TestInfo } from '@playwright/test' +import type { ComparisonContents, ComparisonHarness, ComparisonMeasurement } from './support/comparisonHarness.ts' + +import { expect, test } from './fixtures.ts' + +const CELL_LEDGER = 40_000 +const MAXIMUM_SQUARE_AXIS = Math.floor(Math.sqrt(CELL_LEDGER)) +const CORE_LOGO = '/core/img/logo/logo.svg' +const HIGH_CARDINALITY_CHANGES = 6_490 +const MAXIMUM_HIGH_CARDINALITY_HEAP_DELTA = 256_000_000 + +const headingReplacement: ComparisonContents = { + before: 'A semantic block', + after: '# A semantic block', +} +const retainedTableCellEdit: ComparisonContents = { + before: '| Name | Value |\n| --- | --- |\n| retained | old value |\n| stable | same |', + after: '| Name | Value |\n| --- | --- |\n| retained | new value |\n| stable | same |', +} + +test.describe('Text comparison production bundle acceptance', () => { + test('A12: the largest admitted square gap remains precise', async ({ comparison, page }) => { + test.setTimeout(180_000) + await mountMaximumSquare(comparison) + + await expect(page.locator('.text-comparison > [data-comparison-source-fallback]')).toHaveCount(0) + await expect(page.locator('[data-comparison-select]')).toHaveCount(80) + await expect(page.getByRole('navigation', { name: 'Change pages' })).toContainText(`of ${MAXIMUM_SQUARE_AXIS}`) + }) + + test('T07: one edited retained table column is precise at cell altitude', async ({ comparison, page }) => { + await comparison.mount(retainedTableCellEdit) + + const change = page.locator('[data-comparison-select]') + await expect(change).toHaveCount(1) + await change.click() + const changedCells = page.locator('td.text-comparison-change, td .text-comparison-change') + await expect(changedCells).toHaveCount(2) + await expect(page.locator('.text-comparison__document--before td').filter({ hasText: 'old value' })).toContainText('old value') + await expect(page.locator('.text-comparison__document--after td').filter({ hasText: 'new value' })).toContainText('new value') + }) + + test('T18: a later over-budget table coarsens without corrupting the admitted table plan', async ({ comparison, page }) => { + test.setTimeout(180_000) + await comparison.mount(tableLedgerFixture()) + + const changes = page.locator('[data-comparison-select]') + const pages = page.getByRole('navigation', { name: 'Change pages' }) + await expect(changes).toHaveCount(80) + await expect(pages).toContainText('of 201') + await pages.getByRole('button', { name: 'Next' }).click() + await pages.getByRole('button', { name: 'Next' }).click() + await expect(changes).toHaveCount(41) + await expect(changes.last()).toContainText(/Structure changed|Table changed/) + await changes.last().click() + const current = page.locator('[data-comparison-change][aria-current="true"]') + await expect(current).toHaveCount(2) + expect(await current.evaluateAll((elements) => elements.every((element) => element.closest('table') === element.parentElement?.closest('table')))).toBe(true) + }) + + test('V01: one first-class edit owns one row, ordinal, identity, and complete target set', async ({ comparison, page }) => { + await comparison.mount(headingReplacement) + + const row = page.locator('[data-comparison-select]') + await expect(row).toHaveCount(1) + await expect(row).toHaveAttribute('aria-current', 'true') + await expect(row).toHaveAttribute('aria-label', /Changed|Heading|Structure/) + await expect(page.locator('.text-comparison__sr-only')).toContainText('Change 1 of 1') + await row.click() + const identities = await page.locator('.text-comparison__documents [data-comparison-change]').evaluateAll((elements) => ( + [...new Set(elements.map((element) => element.getAttribute('data-comparison-change')))] + )) + expect(identities).toHaveLength(1) + await expect(page.locator('.text-comparison__documents [data-comparison-change]')).toHaveCount(2) + }) + + test('V02: filtering moves current selection next and then previous when needed', async ({ comparison, page }) => { + await assertFormattingFilterMove(comparison, page, { + before: 'Old first.\n\nFormatting only.\n\nOld last.', + after: 'New first.\n\n**Formatting only.**\n\nNew last.', + }, 'next') + await comparison.destroy() + await assertFormattingFilterMove(comparison, page, { + before: 'Old first.\n\nFormatting only.', + after: 'New first.\n\n**Formatting only.**', + }, 'previous') + }) + + test('V03: selecting a Changes row activates the identical edit in both Documents panes', async ({ comparison, page }) => { + await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.' }) + + const selectedEdit = page.locator('[data-comparison-select]').nth(1) + await selectedEdit.click() + const currentBySide: string[] = [] + for (const side of ['before', 'after']) { + const current = page.locator(`.text-comparison__document--${side} [data-comparison-change][aria-current="true"]`) + await expect(current).toHaveCount(1) + currentBySide.push((await current.getAttribute('data-comparison-change')) ?? '') + } + expect(currentBySide[0]).toBe(currentBySide[1]) + }) + + test('V04: an empty-side change has no synthetic marker and remains navigable', async ({ comparison, page }) => { + await comparison.mount({ before: '# Removed first\n\n# Removed second', after: '' }) + + await page.locator('[data-comparison-select]').first().click() + await expect(page.locator('.text-comparison__document--after [data-comparison-change]')).toHaveCount(0) + await expect(page.locator('.text-comparison__document--after [data-comparison-placeholder], .text-comparison__document--after .text-comparison-placeholder')).toHaveCount(0) + const announcement = page.locator('.text-comparison__sr-only') + const firstAnnouncement = await announcement.textContent() + await page.getByRole('button', { name: 'Next' }).click() + await expect(announcement).not.toHaveText(firstAnnouncement ?? '') + await expect(page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]')).toHaveCount(1) + }) + + test('V05: paired Documents panes preserve independent scroll positions', async ({ comparison, page }) => { + const paragraphs = Array.from({ length: 100 }, (_, index) => `Paragraph ${index}.`).join('\n\n') + await comparison.mount({ before: `Old first.\n\n${paragraphs}\n\nOld tail.`, after: `New first.\n\n${paragraphs}\n\nNew tail.` }) + + await page.locator('[data-comparison-select]').first().click() + const beforeScroller = page.locator('.text-comparison__document--before .text-comparison__document-scroller') + const afterScroller = page.locator('.text-comparison__document--after .text-comparison__document-scroller') + await beforeScroller.evaluate((element) => { + element.scrollTop = 120 + }) + await afterScroller.evaluate((element) => { + element.scrollTop = 360 + }) + expect(await beforeScroller.evaluate(({ scrollTop }) => scrollTop)).not.toBe(await afterScroller.evaluate(({ scrollTop }) => scrollTop)) + await page.getByRole('button', { name: 'Next' }).click() + expect(await beforeScroller.evaluate(({ scrollTop }) => scrollTop)).not.toBe(await afterScroller.evaluate(({ scrollTop }) => scrollTop)) + }) + + test('V06: responsive single-pane Documents retain side and selection state', async ({ comparison, page }) => { + await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.', width: 620 }) + + await page.locator('[data-comparison-select]').nth(1).click() + await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/) + const sideTabs = page.getByRole('tablist', { name: 'Version to display' }) + const beforeCurrent = page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]') + await expect(beforeCurrent).toBeVisible() + const identity = await beforeCurrent.getAttribute('data-comparison-change') + await sideTabs.getByRole('tab', { name: 'After' }).click() + await expect(sideTabs.getByRole('tab', { name: 'After' })).toHaveAttribute('aria-selected', 'true') + const afterCurrent = page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]') + await expect(afterCurrent).toBeVisible() + await expect(afterCurrent).toHaveAttribute('data-comparison-change', identity ?? '') + await page.locator('#text-comparison-harness').evaluate((element: HTMLElement) => { + element.style.inlineSize = '390px' + }) + await expect.poll(() => page.locator('.toolbar').evaluate(({ clientWidth, scrollWidth }) => scrollWidth <= clientWidth)).toBe(true) + const viewTabsTop = await page.getByRole('tablist', { name: 'Comparison view' }).evaluate((element) => (element as HTMLElement).offsetTop) + const navigationTop = await page.getByLabel('Change navigation').evaluate((element) => (element as HTMLElement).offsetTop) + expect(navigationTop).toBeGreaterThan(viewTabsTop) + }) + + test('AUD-24: narrow Documents show the side that contains a one-sided edit', async ({ comparison, page }) => { + await comparison.mount({ before: '', after: '# Added first\n\n# Added second', width: 620 }) + + await page.locator('[data-comparison-select]').first().click() + const sideTabs = page.getByRole('tablist', { name: 'Version to display' }) + await expect(sideTabs.getByRole('tab', { name: 'After' })).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]')).toBeVisible() + await expect(page.locator('.text-comparison__document--before')).toBeHidden() + + await comparison.destroy() + await comparison.mount({ before: '# Removed first\n\n# Removed second', after: '', width: 620 }) + await page.locator('[data-comparison-select]').first().click() + const deletionTabs = page.getByRole('tablist', { name: 'Version to display' }) + await deletionTabs.getByRole('tab', { name: 'After' }).click() + await page.getByRole('tab', { name: 'Changes' }).click() + await page.locator('[data-comparison-select]').nth(1).click() + await expect(deletionTabs.getByRole('tab', { name: 'Before' })).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('.text-comparison__document--before [data-comparison-change][aria-current="true"]')).toBeVisible() + await expect(page.locator('.text-comparison__document--after')).toBeHidden() + }) + + test('V06a: desktop comparison fills a flex mount host', async ({ comparison, page }) => { + await comparison.mount({ before: 'Old document.', after: 'New document.', width: 1100 }) + await page.locator('#text-comparison-harness').evaluate((host) => { + host.style.display = 'flex' + }) + + await expect(page.locator('.text-comparison-root')).toHaveCSS('width', '1100px') + await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--paired/) + }) + + test('V06b: desktop Changes rows keep the reviewed full-width list presentation', async ({ comparison, page }) => { + await comparison.mount({ before: '# Old heading\n\nOld paragraph.', after: '# New heading\n\nNew paragraph.', width: 1100 }) + + const host = page.locator('#text-comparison-harness') + const section = page.locator('.text-comparison__section-toggle').first() + const row = page.locator('[data-comparison-select]').first() + const [hostBox, sectionBox, rowBox] = await Promise.all([host.boundingBox(), section.boundingBox(), row.boundingBox()]) + expect(hostBox).not.toBeNull() + expect(sectionBox).not.toBeNull() + expect(rowBox).not.toBeNull() + expect(rowBox!.width).toBeGreaterThanOrEqual(900) + expect(sectionBox!.width).toBe(rowBox!.width) + expect(Math.abs(rowBox!.x + rowBox!.width / 2 - (hostBox!.x + hostBox!.width / 2))).toBeLessThan(2) + await expect(section).toHaveCSS('border-radius', '0px') + await expect(row).toHaveCSS('border-radius', '0px') + }) + + test('V07: tabs, navigation, focus, and announcements expose accessible state', async ({ comparison, page }) => { + await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.' }) + await comparison.assertAccessibleComparison() + + await page.locator('[data-comparison-select]').first().click() + const announcement = page.locator('.text-comparison__sr-only') + const initialAnnouncement = await announcement.textContent() + await page.getByRole('button', { name: 'Next' }).click() + await expect(announcement).not.toHaveText(initialAnnouncement ?? '') + await page.getByRole('button', { name: 'Previous' }).click() + await expect(announcement).toHaveText(initialAnnouncement ?? '') + const documentsTab = page.getByRole('tab', { name: 'Full documents' }) + await documentsTab.press('End') + await expect(page.getByRole('tab', { name: 'Markdown source' })).toBeFocused() + await page.getByRole('tab', { name: 'Markdown source' }).press('Home') + await expect(page.getByRole('tab', { name: 'Changes' })).toBeFocused() + await expect(announcement).toHaveAttribute('aria-live', 'polite') + await expect(announcement).toHaveAttribute('aria-atomic', 'true') + }) + + test('V08: a changed real image node-view receives scoped visible treatment', async ({ comparison, page }, testInfo) => { + await comparison.mount({ + before: `![Before logo](${CORE_LOGO})\n\nOld paragraph.`, + after: `![After logo](${CORE_LOGO})\n\nNew paragraph.`, + }) + const changes = page.locator('[data-comparison-select]') + await expect(changes).toHaveCount(2) + await changes.first().click() + + const wrappers = page.locator('[data-node-view-wrapper].text-comparison-change:has(img)') + await expect(wrappers).toHaveCount(2) + for (const wrapper of await wrappers.all()) { + await expect(wrapper.locator('figure[data-component="image-view"] img')).toHaveCount(1) + await expect(wrapper).toHaveClass(/text-comparison-change--current/) + const boxShadow = await wrapper.evaluate((element) => getComputedStyle(element).boxShadow) + expect(boxShadow).not.toBe('none') + expect(boxShadow).toMatch(/inset/) + } + await testInfo.attach('real-image-node-view.png', { + body: await page.locator('#text-comparison-harness').screenshot(), + contentType: 'image/png', + }) + + await page.getByRole('button', { name: 'Next' }).click() + for (const wrapper of await wrappers.all()) { + await expect(wrapper).not.toHaveClass(/text-comparison-change--current/) + const boxShadow = await wrapper.evaluate((element) => getComputedStyle(element).boxShadow) + expect(boxShadow).not.toBe('none') + expect(boxShadow).toMatch(/inset/) + } + + await comparison.destroy() + await comparison.mount({ + before: '| Name |\n| --- |\n| retained |\n| removed |', + after: '| Name |\n| --- |\n| retained |', + }) + await page.locator('[data-comparison-select]').click() + const structuralRow = page.locator('tr.text-comparison-change') + await expect(structuralRow).toHaveCount(1) + const structuralTreatment = await structuralRow.evaluate((element) => getComputedStyle(element).boxShadow) + expect(structuralTreatment).not.toBe('none') + expect(structuralTreatment).toMatch(/inset/) + }) + + test('V09: syntax-only Markdown reports no semantic edit and opens Source', async ({ comparison, page }) => { + await comparison.mount({ before: '*same rendered text*', after: '_same rendered text_' }) + + await expect(page.getByRole('status')).toContainText('No rendered differences') + await expect(page.locator('[data-comparison-select]')).toHaveCount(0) + await page.getByRole('button', { name: 'Open Markdown source' }).click() + await expect(page.getByRole('tab', { name: 'Markdown source' })).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('[data-source-hunk]')).toHaveCount(1) + }) + + test('V10: Source preserves literal EOL, tab, trailing-space, control, and final-newline differences', async ({ comparison, page }) => { + await comparison.mount({ before: 'first\tline \r\nzero\u200Bwidth', after: 'first\tline \nzero\u200Cwidth\n' }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + + const source = page.locator('.text-source-comparison') + for (const token of ['TAB', 'ZWSP', 'ZWNJ', 'CRLF', 'LF', 'TRAILING SPACE', 'No newline at end of file']) { + await expect(source).toContainText(token) + } + const sourceText = await source.textContent() + expect(sourceText).not.toContain('\u200B') + expect(sourceText).not.toContain('\u200C') + }) + + test('V11: Source processing limits retain complete before and after fallback text', async ({ comparison, page }) => { + const before = Array.from({ length: 3000 }, (_, index) => `before-${index}`).join('\n') + const after = Array.from({ length: 3000 }, (_, index) => `after-${index}`).join('\n') + await comparison.mount({ before, after }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + + await expect(page.locator('[data-source-limited]')).toBeVisible() + const fallback = page.locator('[data-comparison-source-fallback]') + await expect(fallback).toContainText('before-0') + await expect(fallback).toContainText('before-2999') + await expect(fallback).toContainText('after-0') + await expect(fallback).toContainText('after-2999') + }) + + test('V12: repeated idempotent destroy leaves no editors, observers, or root DOM', async ({ comparison, page }) => { + await comparison.open() + const baseline = await comparison.observerCounts() + + for (let iteration = 0; iteration < 2; iteration++) { + const measurement = await comparison.mount({ before: `Before ${iteration}`, after: `After ${iteration}` }) + expect(measurement.rootCount).toBe(1) + expect(measurement.proseMirrorCount).toBe(0) + await page.getByRole('tab', { name: 'Full documents' }).click() + await expect(page.locator('.ProseMirror')).toHaveCount(2) + await comparison.destroy(2) + await expect(page.locator('#text-comparison-harness')).toBeEmpty() + expect(await comparison.observerCounts()).toEqual(baseline) + } + }) + + test('F05: editor initialization failure mounts complete literal Source for both snapshots', async ({ comparison, page }) => { + let fallbackChunkRequests = 0 + await page.route('**/*MarkdownSourceFallback*', async (route) => { + fallbackChunkRequests++ + await route.abort('failed') + }) + await comparison.forceEditorInitializationFailure() + await comparison.mount({ before: 'complete before', after: 'complete after' }) + + const fallback = page.locator('[data-comparison-source-fallback]') + await expect(fallback).toContainText('complete before') + await expect(fallback).toContainText('complete after') + await expect(page.locator('.ProseMirror')).toHaveCount(0) + expect(fallbackChunkRequests).toBe(0) + }) + + test('F06: projection failure mounts Source without partial Documents', async ({ comparison, page }) => { + await comparison.forceProjectionFailure() + await comparison.mount({ before: 'Projection before', after: 'Projection after' }) + await page.getByRole('tab', { name: 'Full documents' }).click() + + const fallback = page.locator('[data-comparison-source-fallback]') + await expect(fallback).toContainText('Projection before') + await expect(fallback).toContainText('Projection after') + await expect(page.locator('.text-comparison__documents .ProseMirror')).toHaveCount(0) + await expect(page.locator('.text-comparison__documents [data-comparison-change]')).toHaveCount(0) + }) + + test('F11: normal comparison modes emit no unexplained browser or network failures', async ({ comparison, page }) => { + comparison.resetCapture() + await comparison.mount({ before: 'Old content.', after: '**New content.**' }) + await page.locator('[data-comparison-select]').click() + await page.getByRole('tab', { name: 'Markdown source' }).click() + await expect(page.locator('[data-source-hunk]')).toBeVisible() + + expect(comparison.failures).toEqual([]) + expect(comparison.consoleMessages.filter(({ type }) => type === 'error')).toEqual([]) + expect(comparison.network.filter(({ failure, status }) => failure || (status ?? 200) >= 400)).toEqual([]) + }) + + test('P01: the near-line-floor one-change fixture stays precise with bounded readiness', async ({ comparison, page }, testInfo) => { + test.setTimeout(180_000) + const measurement = await comparison.mount(nearLineFloorFixture()) + + await expect(page.locator('[data-comparison-select]')).toHaveCount(1) + await attachMeasurement(testInfo, 'near-line-floor', measurement, { weightedDebit: 0 }) + }) + + test('AUD-02: pre-mount selection and filtering initialize both Documents decoration plugins', async ({ comparison, page }) => { + for (const width of [1000, 620]) { + await comparison.mount({ before: 'Old first.\n\nOld second.', after: 'New first.\n\nNew second.', width }) + await page.locator('[data-comparison-select]').nth(1).click() + for (const side of ['before', 'after']) { + await expect(page.locator(`.text-comparison__document--${side} [data-comparison-change="change-1"][aria-current="true"]`)).toHaveCount(1) + } + await comparison.destroy() + + await comparison.mount({ before: 'Formatting only.\n\nOld content.', after: '**Formatting only.**\n\nNew content.', width }) + await page.getByRole('checkbox', { name: 'Hide formatting-only changes' }).check() + await page.getByRole('tab', { name: 'Full documents' }).click() + await expect(page.locator('.text-comparison__documents .text-comparison-change--formatting')).toHaveCount(0) + for (const side of ['before', 'after']) { + await expect(page.locator(`.text-comparison__document--${side} [data-comparison-change][aria-current="true"]`)).toHaveCount(1) + } + await comparison.destroy() + } + }) + + test('AUD-05: Source visibly exposes side, operation, whitespace, EOL, control, and final-newline semantics', async ({ comparison, page }) => { + await comparison.mount({ before: 'old\t\u200B\r\ntrail ', after: 'new\ntrail\n' }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + + const source = page.locator('.text-source-comparison') + await expect(source.locator('[data-source-side="before"]')).toHaveText('Before') + await expect(source.locator('[data-source-side="after"]')).toHaveText('After') + await expect(source.locator('[data-source-operation="removed"]').first()).toHaveAttribute('aria-label', /Removed line/) + await expect(source.locator('[data-source-operation="added"]').first()).toHaveAttribute('aria-label', /Added line/) + await expect(source.locator('[data-source-operation="removed"] [data-source-cue]').first()).toHaveText('−') + await expect(source.locator('[data-source-operation="added"] [data-source-cue]').first()).toHaveText('+') + for (const token of ['TAB', 'ZWSP', 'CRLF', 'LF', '2 TRAILING SPACES', 'No newline at end of file']) { + await expect(source).toContainText(token) + } + }) + + test('AUD-09: settled image dialog focus is contained and restored on close', async ({ comparison, page }, testInfo) => { + await comparison.mount({ before: `![Before logo](${CORE_LOGO})`, after: `![After logo](${CORE_LOGO})` }) + await page.locator('[data-comparison-select]').first().click() + const action = page.getByRole('button', { name: 'Open image Before logo' }) + await action.focus() + await action.press('Enter') + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect.poll(() => page.evaluate(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement) ?? false)).toBe(true) + if (testInfo.project.name.includes('chromium')) { + await page.keyboard.press('Tab') + await expect.poll(() => page.evaluate(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement) ?? false)).toBe(true) + } + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + await expect(action).toBeFocused() + }) + + test('AUD-10: Changes tokens wrap with spacing and selected tabs have visible treatment', async ({ comparison, page }) => { + await comparison.mount({ before: 'A short value.', after: '**A substantially longer changed value that must remain readable.**' }) + + const item = page.locator('.text-comparison__change-item') + const content = item.locator('.text-comparison__change-item-content') + const itemStyle = await item.evaluate((element) => { + const style = getComputedStyle(element) + return { columnGap: style.columnGap, display: style.display, rowGap: style.rowGap } + }) + const contentStyle = await content.evaluate((element) => { + const style = getComputedStyle(element) + return { display: style.display, gap: style.gap, overflowWrap: style.overflowWrap } + }) + expect(itemStyle.display).toBe('grid') + expect(itemStyle.columnGap).not.toBe('0px') + expect(contentStyle.display).toBe('flex') + expect(contentStyle.gap).not.toBe('0px') + expect(contentStyle.overflowWrap).toBe('anywhere') + + const selectedTab = page.getByRole('tab', { name: 'Changes' }) + const selectedStyle = await selectedTab.evaluate((element) => { + const style = getComputedStyle(element) + return { + borderRadius: style.borderRadius, + borderWidth: style.borderBottomWidth, + boxShadow: style.boxShadow, + fontWeight: style.fontWeight, + } + }) + expect(selectedStyle.borderRadius).toBe('0px') + expect(Number.parseFloat(selectedStyle.borderWidth)).toBeGreaterThan(0) + expect(selectedStyle.boxShadow).toBe('none') + expect(Number.parseInt(selectedStyle.fontWeight, 10)).toBeGreaterThanOrEqual(700) + + await comparison.destroy() + await comparison.mount({ + before: '# B1 duplicate-body deletion\n\n| A | B | C |\n| --- | --- | --- |\n| x | x | x |\n| x | x | x |', + after: '# B1 duplicate-body deletion\n\n| A | B |\n| --- | --- |\n| x | x |\n| x | x |', + width: 340, + }) + await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/) + const narrowItem = page.locator('.text-comparison__change-item').first() + const narrowLabel = narrowItem.getByText('Table column removed', { exact: true }) + await expect(narrowLabel).toBeVisible() + await expect(narrowItem.locator('.badge')).toHaveCount(2) + expect(await narrowLabel.evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getClientRects().length + })).toBe(1) + expect(await narrowItem.locator('.title').evaluate((element) => { + const bounds = element.getBoundingClientRect() + return [...element.children].every((child) => { + const rect = child.getBoundingClientRect() + return rect.left >= bounds.left && rect.right <= bounds.right + }) + })).toBe(true) + expect(await narrowItem.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + }) + + test('AUD-11: revealing a responsive hidden side locates the already-current edit', async ({ comparison, page }) => { + const middle = Array.from({ length: 120 }, (_, index) => `Stable paragraph ${index}.`).join('\n\n') + await comparison.mount({ before: `Old first.\n\n${middle}\n\nOld tail.`, after: `New first.\n\n${middle}\n\nNew tail.`, width: 620, height: 360 }) + await page.locator('[data-comparison-select]').nth(1).click() + + await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--single/) + await expect(page.locator('.text-comparison__document--after')).toBeHidden() + const afterScroller = page.locator('.text-comparison__document--after .text-comparison__document-scroller') + await afterScroller.evaluate((element) => { + element.scrollTop = 0 + }) + await page.locator('#text-comparison-harness').evaluate((element: HTMLElement) => { + element.style.inlineSize = '900px' + }) + await expect(page.locator('.text-comparison')).toHaveClass(/text-comparison--paired/) + await expect(page.locator('.text-comparison__document--after [data-comparison-change][aria-current="true"]')).toBeVisible() + await expect.poll(() => afterScroller.evaluate(({ scrollTop }) => scrollTop)).toBeGreaterThan(0) + }) + + test('AUD-13: audited bidi and control characters render only as visible inert tokens', async ({ comparison, page }) => { + const controls = '\u061C\u00AD\u200E\u200F\u0085\u2028\u2029' + await comparison.mount({ before: `old${controls}`, after: 'new\n' }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + + const source = page.locator('.text-source-comparison') + for (const token of ['ALM', 'SHY', 'LRM', 'RLM', 'NEL', 'LS', 'PS']) { + await expect(source).toContainText(token) + } + const text = await source.textContent() + for (const control of controls) { + expect(text).not.toContain(control) + } + }) + + test('AUD-14: high-cardinality Changes, Documents, and Source stay within explicit budgets', { tag: '@memory' }, async ({ comparison, page }, testInfo) => { + test.setTimeout(180_000) + expect(testInfo.project.name).toBe('comparison-chromium-memory') + const cdp = await page.context().newCDPSession(page) + await cdp.send('Performance.enable') + const heapBeforeBytes = await readChromiumHeap(cdp) + const before = Array.from({ length: HIGH_CARDINALITY_CHANGES }, (_, index) => `# Removed section ${index}`).join('\n') + const measurement = await comparison.mount({ before, after: '' }) + + await expect(page.locator('[data-comparison-select]')).toHaveCount(80) + const changesDomCount = await page.locator('.text-comparison__changes *').count() + expect(changesDomCount).toBeLessThanOrEqual(1_500) + + const documentsStarted = await page.evaluate(() => performance.now()) + await page.getByRole('tab', { name: 'Full documents' }).click() + await expect(page.locator('.text-comparison__document--before h1')).toHaveCount(HIGH_CARDINALITY_CHANGES) + const documentsMilliseconds = await page.evaluate((started) => performance.now() - started, documentsStarted) + const documentsDomCount = await page.locator('.text-comparison__documents *').count() + expect(documentsDomCount).toBeLessThanOrEqual(20_000) + + const sourceStarted = await page.evaluate(() => performance.now()) + await page.getByRole('tab', { name: 'Markdown source' }).click() + const source = page.locator('[data-comparison-source-fallback]') + await expect(source).toBeVisible() + const sourceMilliseconds = await page.evaluate((started) => performance.now() - started, sourceStarted) + const sourceDomCount = await source.locator('*').count() + const sourceCharacters = await source.evaluate((element) => element.textContent?.length ?? 0) + expect(sourceDomCount).toBeLessThanOrEqual(50) + expect(sourceCharacters).toBeLessThanOrEqual(2_000_000) + + const heapAfterModes = await readChromiumHeap(cdp) + const heapDeltaBytes = heapAfterModes - heapBeforeBytes + expect(heapDeltaBytes).toBeLessThan(MAXIMUM_HIGH_CARDINALITY_HEAP_DELTA) + await testInfo.attach('high-cardinality-metrics.json', { + body: Buffer.from(JSON.stringify({ changesDomCount, documentsDomCount, documentsMilliseconds, heapAfterModes, heapBeforeBytes, heapDeltaBytes, memoryMetric: 'chromium-cdp/Performance.JSHeapUsedSize', mountMilliseconds: measurement.durationMilliseconds, sourceCharacters, sourceDomCount, sourceMilliseconds }, null, 2)), + contentType: 'application/json', + }) + }) + + test('AUD-18: read-only image action is named, focusable, rendered, and operable with Enter', async ({ comparison, page }) => { + await comparison.mount({ before: `![Before logo](${CORE_LOGO})`, after: `![After logo](${CORE_LOGO})` }) + await page.locator('[data-comparison-select]').first().click() + const action = page.getByRole('button', { name: 'Open image Before logo' }) + + await expect(action.locator('img')).toBeVisible() + await action.focus() + await expect(action).toBeFocused() + await action.press('Enter') + await expect(page.getByRole('dialog')).toBeVisible() + }) + + test('AUD-18: read-only attachment action retains its preview and operates with Space', async ({ comparison, page }) => { + const attachmentPath = '/Documents/document.pdf' + await page.route('**/apps/text/attachments', async (route) => route.fulfill({ + json: [{ davPath: attachmentPath, fullUrl: '/document.pdf', isImage: false, metadata: null, mimetype: 'application/pdf', name: 'document.pdf', previewUrl: CORE_LOGO, size: 100 }], + })) + await page.evaluate(() => { + sessionStorage.removeItem('attachment-viewer-path') + Object.assign(window.OCA, { + Viewer: { + file: null, + mimetypes: ['application/pdf'], + open: ({ path }: { path: string }) => sessionStorage.setItem('attachment-viewer-path', path), + }, + }) + }) + await comparison.mount({ before: '![Before document](.attachments.123/document.pdf)', after: '![After document](.attachments.123/document.pdf)', fileId: 123 }) + await page.locator('[data-comparison-select]').first().click() + const action = page.getByRole('button', { name: 'Open attachment Before document' }) + + await expect(action.locator('img')).toBeVisible() + await action.focus() + await expect(action).toBeFocused() + await action.press('Space') + await expect.poll(() => page.evaluate(() => sessionStorage.getItem('attachment-viewer-path'))).toBe(attachmentPath) + }) + + test('AUD-21: a rejected loaded callback settles once and keeps the comparison', async ({ comparison, page }) => { + const measurement = await comparison.mount({ before: 'Before callback.', after: 'After callback.', rejectLoaded: true }) + + expect(measurement.loadedCallbackCalls).toBe(1) + await expect(page.locator('.text-comparison')).toBeVisible() + await expect(page.locator('[data-comparison-source-fallback]')).toHaveCount(0) + await expect(page.locator('[data-comparison-select]')).toHaveCount(1) + await expect(page.locator('.ProseMirror')).toHaveCount(0) + }) + + test('AUD-22: complete Source fallback responds to host width instead of viewport width', async ({ comparison, page }) => { + const oversized = Array.from({ length: 6501 }, (_, index) => `line ${index}`).join('\n') + await page.setViewportSize({ width: 1280, height: 800 }) + + for (const [width, columns] of [[620, 1], [900, 2]] as const) { + await comparison.mount({ before: oversized, after: `${oversized}\nchanged`, width }) + const documents = page.locator('.text-source-fallback__documents') + await expect(documents).toBeVisible() + const tracks = await documents.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length) + expect(tracks).toBe(columns) + await comparison.destroy() + } + }) + + test('oversized source lines fall back without blocking the page', async ({ comparison, page }) => { + const before = `# Oversized source fallback\n\n\`\`\`\n${'\t'.repeat(200_000)}😀\n\`\`\`` + const after = `${before}\n\ncurrent marker` + await comparison.mount({ before, after }) + await expect(page.locator('[data-comparison-source-fallback]')).toBeVisible() + await expect(page.getByText('Source preview was truncated to the display limit.')).toBeVisible() + }) + + test('AUD-23: Source rows and navigation retain deliberate geometry', async ({ comparison, page }) => { + const middle = Array.from({ length: 10 }, (_, index) => `stable ${index}`).join('\n') + await comparison.mount({ before: `stable first\r\nold value \r\n${middle}\r\nold tail`, after: `stable first\nnew value \n${middle}\nnew tail\n` }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + + const source = page.locator('.text-source-comparison') + const changed = source.locator('[data-source-operation="removed"]').first() + const unchanged = source.locator('.text-source-comparison__line').filter({ hasText: 'stable first' }).first() + const changedGeometry = await changed.evaluate((element) => [...element.children].map((child) => { + const rect = child.getBoundingClientRect() + return { left: rect.left, top: rect.top } + })) + const unchangedGeometry = await unchanged.evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().left)) + expect(changedGeometry).toHaveLength(5) + expect(new Set(changedGeometry.map(({ top }) => Math.round(top))).size).toBe(1) + expect(Math.round(changedGeometry[1]!.left)).toBe(Math.round(unchangedGeometry[0]!)) + expect(Math.round(changedGeometry[2]!.left)).toBe(Math.round(unchangedGeometry[1]!)) + + const navigation = source.locator('.text-source-comparison__navigation') + const navigationCenters = await navigation.evaluate((element) => [...element.children].map((child) => { + const rect = child.getBoundingClientRect() + return rect.top + rect.height / 2 + })) + expect(Math.max(...navigationCenters) - Math.min(...navigationCenters)).toBeLessThan(4) + for (const button of await navigation.getByRole('button').all()) { + await expect(button).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + } + const sourceFontSize = await source.evaluate((element) => getComputedStyle(element).fontSize) + await expect(source.locator('[data-source-side="before"]')).toHaveCSS('font-size', sourceFontSize) + await expect(source.locator('[data-source-side="after"]')).toHaveCSS('font-size', sourceFontSize) + await expect(source.locator('[data-source-hunk]').first().getByRole('button')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + await navigation.getByRole('button', { name: 'Next' }).click() + await expect(navigation).toContainText('Source change 2 of 2') + + await comparison.destroy() + await comparison.mount({ before: `old value \r\n${middle}\r\nold tail`, after: `new value \n${middle}\nnew tail\n`, width: 390 }) + await page.getByRole('tab', { name: 'Markdown source' }).click() + const narrowNavigation = source.locator('.text-source-comparison__navigation') + const narrowTops = await narrowNavigation.evaluate((element) => [...element.children].map((child) => Math.round(child.getBoundingClientRect().top))) + expect(narrowTops[0]).toBe(narrowTops[2]) + expect(narrowTops[1]).toBeGreaterThan(narrowTops[0]!) + const sourceSideTabs = page.getByRole('tablist', { name: 'Source version to display' }) + const removedLine = source.locator('[data-source-operation="removed"]').first() + const addedLine = source.locator('[data-source-operation="added"]').first() + await expect(removedLine).toBeVisible() + await expect(addedLine).toBeHidden() + await sourceSideTabs.getByRole('tab', { name: 'After' }).click() + await expect(removedLine).toBeHidden() + await expect(addedLine).toBeVisible() + }) + + test('AUD-24: Full document headings align in paired and single layouts', async ({ comparison, page }) => { + for (const width of [1100, 620]) { + await comparison.mount({ before: 'Old document.', after: 'New document.', width }) + await page.getByRole('tab', { name: 'Full documents' }).click() + const sideTabs = page.getByRole('tablist', { name: 'Version to display' }) + if (width === 1100) { + await expect(page.locator('.text-comparison__document--before')).toBeVisible() + await expect(page.locator('.text-comparison__document--after')).toBeVisible() + for (const button of await page.getByLabel('Change navigation').getByRole('button').all()) { + await expect(button).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + } + } + for (const side of ['before', 'after']) { + if (width === 620) { + await sideTabs.getByRole('tab', { name: side === 'before' ? 'Before' : 'After' }).click() + } + const article = page.locator(`.text-comparison__document--${side}`) + await expect(article).toBeVisible() + const header = page.locator(`.text-comparison__document--${side} > header`) + const headings = header.locator('.document-heading > span, .document-heading > h2, .document-legend') + await expect(headings).toHaveCount(3) + await expect(header.getByRole('heading', { level: 2 })).toHaveCSS( + 'font-size', + await article.evaluate((element) => getComputedStyle(element).fontSize), + ) + const rectangles = await headings.evaluateAll((elements) => elements.map((element) => { + const rect = element.getBoundingClientRect() + return { width: rect.width, height: rect.height, center: rect.top + rect.height / 2 } + })) + expect(rectangles.every(({ width, height }) => width > 0 && height > 0)).toBe(true) + const centers = rectangles.map(({ center }) => center) + expect(Math.max(...centers) - Math.min(...centers)).toBeLessThan(4) + } + await comparison.destroy() + } + }) +}) + +async function assertFormattingFilterMove(comparison: ComparisonHarness, page: Page, contents: ComparisonContents, direction: 'next' | 'previous') { + await comparison.mount(contents) + const rows = page.locator('[data-comparison-select]') + const formatting = rows.filter({ hasText: /Bold changed/ }) + await expect(formatting).toHaveCount(1) + const ids = await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-comparison-select') ?? '')) + const formattingId = await formatting.getAttribute('data-comparison-select') + const formattingIndex = ids.indexOf(formattingId ?? '') + const expectedIndex = direction === 'next' ? formattingIndex + 1 : formattingIndex - 1 + await formatting.click() + await page.getByRole('checkbox', { name: 'Hide formatting-only changes' }).check() + const current = page.locator('[data-comparison-select][aria-current="true"]') + await expect(current).toHaveCount(1) + await expect(current).toHaveAttribute('data-comparison-select', ids[expectedIndex]!) +} + +async function mountMaximumSquare(comparison: ComparisonHarness) { + const maximumBefore = axis('maximum', MAXIMUM_SQUARE_AXIS, 'before') + const maximumAfter = axis('maximum', MAXIMUM_SQUARE_AXIS, 'after') + return comparison.mount(maximumSquareFixture(maximumBefore, maximumAfter)) +} + +async function attachMeasurement(testInfo: TestInfo, fixture: string, measurement: ComparisonMeasurement, work?: Record) { + await testInfo.attach(`${fixture}-measurement.json`, { + body: Buffer.from(JSON.stringify({ measurement, work }, null, 2)), + contentType: 'application/json', + }) +} + +async function readChromiumHeap(cdp: CDPSession) { + const { metrics } = await cdp.send('Performance.getMetrics') + const heap = metrics.find(({ name }) => name === 'JSHeapUsedSize')?.value + if (typeof heap !== 'number' || !Number.isFinite(heap)) { + throw new Error('AUD-14 requires Chromium CDP Performance.JSHeapUsedSize memory evidence') + } + return heap +} + +function nearLineFloorFixture(): ComparisonContents { + const before = Array.from({ length: 6490 }, (_, index) => `# fixed floor ${index}`).join('\n') + const after = before.replace('# fixed floor 3245', '# changed floor 3245') + return { before, after } +} + +function maximumSquareFixture(maximumBefore: readonly string[], maximumAfter: readonly string[]): ComparisonContents { + const before = ['# exact start', ...maximumBefore, '# exact end'].join('\n\n') + const after = ['# exact start', ...maximumAfter, '# exact end'].join('\n\n') + return { before, after } +} + +function tableLedgerFixture(): ComparisonContents { + return { + before: `${ledgerTable(200, 12, 'a')}\n\n# exact table separator\n\n${ledgerTable(10, 12, 'c')}`, + after: `${ledgerTable(200, 12, 'b')}\n\n# exact table separator\n\n${ledgerTable(10, 12, 'd')}`, + } +} + +function ledgerTable(columns: number, textLength: number, suffix: string) { + const cell = (column: number) => { + const prefix = `000-${column.toString().padStart(3, '0')}-` + return `${prefix}${suffix.repeat(textLength - prefix.length)}` + } + const header = Array.from({ length: columns }, (_value, column) => ` ${cell(column)} `).join('|') + const divider = Array.from({ length: columns }, () => ' --- ').join('|') + return `|${header}|\n|${divider}|` +} + +function axis(prefix: string, count: number, suffix: string) { + const axisId = prefix.match(/\d+/)?.[0] ?? prefix[0] + return Array.from({ length: count }, (_, index) => `${axisId}:${index.toString(36)}:${suffix[0]}`) +} diff --git a/playwright/comparison/fixtures.ts b/playwright/comparison/fixtures.ts new file mode 100644 index 00000000000..17961fc0ecb --- /dev/null +++ b/playwright/comparison/fixtures.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { ComparisonHarness } from './support/comparisonHarness.ts' + +interface ComparisonFixtures { + comparison: ComparisonHarness +} + +export const test = base.extend({ + comparison: async ({ page }, use, testInfo) => { + const comparison = new ComparisonHarness(page) + try { + await comparison.open() + await use(comparison) + } finally { + await comparison.attachEvidence(testInfo) + comparison.assertNoUnexpectedFailures() + } + }, +}) + +export { expect } from '@playwright/test' diff --git a/playwright/comparison/support/comparisonHarness.ts b/playwright/comparison/support/comparisonHarness.ts new file mode 100644 index 00000000000..201a0a7b75e --- /dev/null +++ b/playwright/comparison/support/comparisonHarness.ts @@ -0,0 +1,335 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ConsoleMessage, Page, Request, Response, TestInfo } from '@playwright/test' + +import AxeBuilder from '@axe-core/playwright' +import { expect } from '@playwright/test' + +const HARNESS_PATH = '/index.php/login' + +export interface ComparisonContents { + before: string + after: string + fileId?: number + rejectLoaded?: boolean + width?: number + height?: number +} + +export interface ComparisonMeasurement { + durationMilliseconds: number + loadedCallbackCalls: number + rootCount: number + proseMirrorCount: number +} + +interface RuntimeFailure { + type: 'console' | 'pageerror' | 'requestfailed' | 'response' + message: string + url?: string + status?: number +} + +interface ObserverCounts { + resize: number + mutation: number +} + +export class ComparisonHarness { + readonly page: Page + readonly failures: RuntimeFailure[] = [] + readonly consoleMessages: Array<{ type: string, text: string }> = [] + readonly network: Array<{ method: string, status?: number, url: string, failure?: string }> = [] + #allowedFailures: RegExp[] = [] + + constructor(page: Page) { + this.page = page + page.on('console', (message) => this.#captureConsole(message)) + page.on('pageerror', (error) => this.failures.push({ type: 'pageerror', message: error.message })) + page.on('requestfailed', (request) => this.#captureFailedRequest(request)) + page.on('response', (response) => this.#captureResponse(response)) + } + + async open() { + await this.page.route(`**${HARNESS_PATH}`, async (route) => { + const response = await route.fetch() + const headers = response.headers() + const policy = headers['content-security-policy'] ?? '' + if (policy && !policy.includes('worker-src')) { + headers['content-security-policy'] = `${policy}; worker-src 'self'` + } + await route.fulfill({ response, headers }) + }) + await this.page.goto(HARNESS_PATH, { waitUntil: 'domcontentloaded' }) + await this.page.waitForFunction(() => Boolean(window.OC?.filePath && window.OCA)) + const textRoot = (await this.page.evaluate(() => ( + window as typeof window & { OC: { appswebroots: Record } } + ).OC.appswebroots.text)).replace(/\/$/, '') + const editorBundle = `${textRoot}/js/text-editor.mjs` + await this.page.evaluate(async ({ bundle, root }) => { + const entryUrl = new URL(`${root}/css/text-editor.css`, location.href) + const entryResponse = await fetch(entryUrl) + if (!entryResponse.ok) { + throw new Error('Could not load the Text editor stylesheet entry') + } + const imports = [...(await entryResponse.text()).matchAll(/@import\s+['"]([^'"]+)['"]/g)] + await Promise.all(imports.map(([, path]) => new Promise((resolve, reject) => { + const stylesheet = document.createElement('link') + stylesheet.rel = 'stylesheet' + stylesheet.href = new URL(path, entryUrl).href + stylesheet.addEventListener('load', () => resolve(), { once: true }) + stylesheet.addEventListener('error', () => reject(new Error('Could not load a Text editor stylesheet chunk')), { once: true }) + document.head.append(stylesheet) + }))) + await import(bundle) + }, { bundle: editorBundle, root: textRoot }) + await expect.poll(() => this.page.evaluate(() => typeof window.OCA?.Text?.createMarkdownContentComparison), { + message: `The mounted production bundle ${editorBundle} must expose the public comparison factory`, + }).toBe('function') + await this.page.evaluate((root) => { + const appRoot = new URL(`${root}/`, location.href).href + document.querySelectorAll('link[rel="stylesheet"]') + .forEach((link) => !link.href.startsWith(appRoot) && link.remove()) + const style = document.createElement('style') + style.textContent = ` + html, body { + --color-element-info: #007aa3; + --color-error: #f0b5b5; + --color-error-hover: #fbeaea; + --color-main-background: #fff; + --color-main-text: #222; + --color-primary-element: #00679e; + --color-primary-element-light: #e5f2f8; + --color-success: #b5dfb8; + --color-success-hover: #eaf5eb; + --color-text-maxcontrast: #4a4a4a; + --color-warning: #8a6116; + block-size: 100%; + margin: 0; + } + body { background: var(--color-main-background); color: var(--color-main-text); } + #text-comparison-harness { + box-sizing: border-box; + color: var(--color-main-text); + margin: 0 auto; + overflow: hidden; + } + ` + const host = document.createElement('main') + host.id = 'text-comparison-harness' + document.head.append(style) + document.body.removeAttribute('id') + document.body.removeAttribute('class') + document.body.replaceChildren(host) + + const state = { + instances: [] as Array<{ destroy: () => void }>, + resizeObservers: new Set(), + mutationObservers: new Set(), + } + Object.assign(window, { __textComparisonAcceptance: state }) + + if (typeof ResizeObserver !== 'undefined') { + const originalObserve = ResizeObserver.prototype.observe + const originalDisconnect = ResizeObserver.prototype.disconnect + ResizeObserver.prototype.observe = function(target, options) { + state.resizeObservers.add(this) + return originalObserve.call(this, target, options) + } + ResizeObserver.prototype.disconnect = function() { + state.resizeObservers.delete(this) + return originalDisconnect.call(this) + } + } + if (typeof MutationObserver !== 'undefined') { + const originalObserve = MutationObserver.prototype.observe + const originalDisconnect = MutationObserver.prototype.disconnect + MutationObserver.prototype.observe = function(target, options) { + state.mutationObservers.add(this) + return originalObserve.call(this, target, options) + } + MutationObserver.prototype.disconnect = function() { + state.mutationObservers.delete(this) + return originalDisconnect.call(this) + } + } + }, textRoot) + await this.page.waitForLoadState('networkidle') + await this.page.waitForTimeout(500) + this.resetCapture() + } + + resetCapture() { + this.failures.splice(0) + this.consoleMessages.splice(0) + this.network.splice(0) + this.#allowedFailures = [] + } + + allowFailure(pattern: RegExp) { + this.#allowedFailures.push(pattern) + } + + async mount(contents: ComparisonContents): Promise { + if (contents.rejectLoaded) { + this.allowFailure(/acceptance forced loaded callback failure/) + } + return this.page.evaluate(async ({ before, after, fileId, rejectLoaded = false, width = 1100, height = 760 }) => { + const state = window.__textComparisonAcceptance + const host = document.querySelector('#text-comparison-harness')! + host.style.inlineSize = `${width}px` + host.style.blockSize = `${height}px` + const started = performance.now() + let loadedCallbackCalls = 0 + const instance = await window.OCA.Text.createMarkdownContentComparison({ + afterContent: after, + beforeContent: before, + el: host, + fileId, + noLazyImages: true, + onLoaded: rejectLoaded + ? async () => { + loadedCallbackCalls++ + throw new Error('acceptance forced loaded callback failure') + } + : undefined, + }) + const durationMilliseconds = performance.now() - started + state.instances.push(instance) + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + return { + durationMilliseconds, + loadedCallbackCalls, + rootCount: host.querySelectorAll('.text-comparison-root').length, + proseMirrorCount: host.querySelectorAll('.ProseMirror').length, + } + }, contents) + } + + async destroy(times = 2) { + await this.page.evaluate((count) => { + const state = window.__textComparisonAcceptance + for (const instance of state.instances.splice(0)) { + for (let index = 0; index < count; index++) { + instance.destroy() + } + } + }, times) + } + + async observerCounts(): Promise { + return this.page.evaluate(() => ({ + mutation: window.__textComparisonAcceptance.mutationObservers.size, + resize: window.__textComparisonAcceptance.resizeObservers.size, + })) + } + + async forceEditorInitializationFailure() { + await this.page.evaluate(() => { + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')! + Object.defineProperty(Element.prototype, 'innerHTML', { + ...descriptor, + set(value: string) { + void value + Object.defineProperty(Element.prototype, 'innerHTML', descriptor) + throw new Error('acceptance forced editor initialization failure') + }, + }) + }) + this.allowFailure(/acceptance forced editor initialization failure/) + } + + async forceProjectionFailure() { + await this.page.evaluate(() => { + const original = Element.prototype.setAttribute + Element.prototype.setAttribute = function(name, value) { + if (name === 'data-comparison-change') { + Element.prototype.setAttribute = original + throw new Error('acceptance forced projection failure') + } + return original.call(this, name, value) + } + }) + this.allowFailure(/acceptance forced projection failure/) + } + + async assertAccessibleComparison() { + const violations = await this.page.locator('#text-comparison-harness').evaluate((root) => { + const visible = (element: HTMLElement) => Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length) + const violations: string[] = [] + const ids = [...root.querySelectorAll('[id]')].map(({ id }) => id) + for (const id of new Set(ids)) { + if (ids.filter((candidate) => candidate === id).length > 1) { + violations.push(`duplicate id: ${id}`) + } + } + for (const button of root.querySelectorAll('button')) { + if (visible(button) && !(button.getAttribute('aria-label') || button.textContent?.trim() || button.title)) { + violations.push('visible button has no accessible name') + } + } + for (const tablist of root.querySelectorAll('[role="tablist"]')) { + const selected = [...tablist.querySelectorAll('[role="tab"]')].filter((tab) => tab.getAttribute('aria-selected') === 'true') + if (visible(tablist) && selected.length !== 1) { + violations.push('visible tablist must have exactly one selected tab') + } + } + const liveRegion = root.querySelector('[aria-live="polite"][aria-atomic="true"]') + if (!liveRegion) { + violations.push('comparison has no polite atomic live region') + } + return violations + }) + expect(violations).toEqual([]) + const axe = await new AxeBuilder({ page: this.page }) + .include('#text-comparison-harness') + .analyze() + expect(axe.violations, 'axe accessibility violations').toEqual([]) + } + + async attachEvidence(testInfo: TestInfo) { + await testInfo.attach('comparison-console-network.json', { + body: Buffer.from(JSON.stringify({ console: this.consoleMessages, failures: this.failures, network: this.network }, null, 2)), + contentType: 'application/json', + }) + } + + assertNoUnexpectedFailures() { + const unexpected = this.failures.filter(({ message }) => !this.#allowedFailures.some((pattern) => pattern.test(message))) + expect(unexpected, 'unexpected browser console, page, or network failures').toEqual([]) + } + + #captureConsole(message: ConsoleMessage) { + this.consoleMessages.push({ type: message.type(), text: message.text() }) + if (message.type() === 'error') { + this.failures.push({ type: 'console', message: message.text() }) + } + } + + #captureFailedRequest(request: Request) { + const failure = request.failure()?.errorText ?? 'request failed' + this.network.push({ method: request.method(), url: request.url(), failure }) + this.failures.push({ type: 'requestfailed', message: failure, url: request.url() }) + } + + #captureResponse(response: Response) { + this.network.push({ method: response.request().method(), status: response.status(), url: response.url() }) + if (response.status() >= 400) { + this.failures.push({ type: 'response', message: `HTTP ${response.status()}`, status: response.status(), url: response.url() }) + } + } +} + +declare global { + interface Window { + OC?: { filePath?: (...parts: string[]) => string } + __textComparisonAcceptance: { + instances: Array<{ destroy: () => void }> + resizeObservers: Set + mutationObservers: Set + } + } +} diff --git a/src/comparison/comparisonAlignment.ts b/src/comparison/comparisonAlignment.ts new file mode 100644 index 00000000000..40b4747fa4e --- /dev/null +++ b/src/comparison/comparisonAlignment.ts @@ -0,0 +1,392 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonCoarseReason as CoarseReason } from './markdownComparisonTypes.ts' + +export const DEFAULT_COMPARISON_CELL_LEDGER = 40_000 +export const DEFAULT_COMPARISON_TOKEN_LEDGER = 84_000_000 + +export interface ComparisonWorkLedger { + remainingCells: number + remainingTokenComparisons: number +} + +export interface ComparisonAlignmentOptions { + work: ComparisonWorkLedger + fingerprint: (item: T) => string + profile: (item: T) => readonly string[] + compatible: (before: T, after: T) => boolean +} + +export interface ComparisonAlignmentStep { + before: number | null + after: number | null +} + +export interface ComparisonCoarseAlignmentRegion { + before: { from: number, to: number } + after: { from: number, to: number } + coarseReason: CoarseReason +} + +export type ComparisonAlignmentRegion = ComparisonAlignmentStep | ComparisonCoarseAlignmentRegion + +export interface ExactComparisonPair { + before: number + after: number +} +type Options = ComparisonAlignmentOptions +type Step = ComparisonAlignmentStep +type Region = ComparisonAlignmentRegion +type Pair = ExactComparisonPair +type Ledger = ComparisonWorkLedger + +export function createComparisonWorkLedger(): Ledger { + return { + remainingCells: DEFAULT_COMPARISON_CELL_LEDGER, + remainingTokenComparisons: DEFAULT_COMPARISON_TOKEN_LEDGER, + } +} + +export function forcedIncreasingPairs(pairs: readonly Pair[]): readonly Pair[] { + if (pairs.length < 2) { + return pairs + } + const ordered = pairs.toSorted((a, b) => a.before - b.before || a.after - b.after) + const left = increasingSubsequence(ordered.map(({ after }) => after)).lengths + const right = increasingSubsequence(ordered.toReversed().map(({ after }) => -after)).lengths.toReversed() + let maximum = 0 + for (const length of left) { + if (length > maximum) { + maximum = length + } + } + const candidatesPerLevel = new Uint32Array(maximum + 1) + for (let index = 0; index < ordered.length; index++) { + if (left[index]! + right[index]! - 1 === maximum) { + candidatesPerLevel[left[index]!]++ + } + } + return ordered.filter((_pair, index) => left[index]! + right[index]! - 1 === maximum + && candidatesPerLevel[left[index]!] === 1) +} + +export function increasingSubsequence(values: readonly number[]) { + const tails: number[] = [] + const previous = new Int32Array(values.length).fill(-1) + const lengths = values.map((value, candidate) => { + let low = 0 + let high = tails.length + while (low < high) { + const middle = (low + high) >>> 1 + if (values[tails[middle]!]! < value) { + low = middle + 1 + } else { + high = middle + } + } + if (low > 0) { + previous[candidate] = tails[low - 1]! + } + tails[low] = candidate + return low + 1 + }) + const indices: number[] = [] + for (let index = tails.at(-1) ?? -1; index >= 0; index = previous[index]!) { + indices.push(index) + } + return { lengths, indices: indices.reverse() } +} + +export function alignComparisonAxis(before: readonly T[], after: readonly T[], options: Options): readonly Region[] { + const beforeKeys = before.map(options.fingerprint) + const afterKeys = after.map(options.fingerprint) + return equalAxis(beforeKeys, afterKeys) + ?? planAxis(before, after, beforeKeys, afterKeys, options, uniqueExactPairs(beforeKeys, afterKeys), true) +} + +export function alignComparisonColumns(before: readonly T[], after: readonly T[], options: Options): readonly Region[] { + const beforeKeys = before.map(options.fingerprint) + const afterKeys = after.map(options.fingerprint) + return equalAxis(beforeKeys, afterKeys) + ?? planAxis(before, after, beforeKeys, afterKeys, options, rankedExactPairs(beforeKeys, afterKeys), false) +} + +function equalAxis(beforeKeys: readonly string[], afterKeys: readonly string[]) { + if (beforeKeys.length !== afterKeys.length + || beforeKeys.some((key, index) => key !== afterKeys[index])) { + return null + } + return beforeKeys.map((_key, index) => ({ before: index, after: index })) +} + +function planAxis(before: readonly T[], after: readonly T[], beforeKeys: readonly string[], afterKeys: readonly string[], options: Options, exactPairs: readonly Pair[], trimEdges: boolean): readonly Region[] { + const regions: Region[] = [] + let beforeStart = 0 + let afterStart = 0 + for (const anchor of [...forcedIncreasingPairs(exactPairs), { before: before.length, after: after.length }]) { + let beforeEnd = anchor.before + let afterEnd = anchor.after + const suffix: Step[] = [] + if (trimEdges) { + while (beforeStart < beforeEnd && afterStart < afterEnd + && beforeKeys[beforeStart] === afterKeys[afterStart]) { + regions.push({ before: beforeStart++, after: afterStart++ }) + } + while (beforeStart < beforeEnd && afterStart < afterEnd + && beforeKeys[beforeEnd - 1] === afterKeys[afterEnd - 1]) { + suffix.unshift({ before: --beforeEnd, after: --afterEnd }) + } + } + if (beforeEnd - beforeStart === 1 && afterEnd - afterStart === 1) { + if (options.compatible(before[beforeStart]!, after[afterStart]!)) { + regions.push({ before: beforeStart, after: afterStart }) + } else { + regions.push({ before: beforeStart, after: null }) + regions.push({ before: null, after: afterStart }) + } + beforeStart++ + afterStart++ + } else if (beforeStart < beforeEnd && afterStart < afterEnd) { + const solved = solveWeightedGap( + before.slice(beforeStart, beforeEnd), + after.slice(afterStart, afterEnd), + options, + ) + if ('coarseReason' in solved) { + regions.push({ + before: { from: beforeStart, to: beforeEnd }, + after: { from: afterStart, to: afterEnd }, + coarseReason: solved.coarseReason, + }) + } else { + regions.push(...solved.steps.map((step) => ({ + before: step.before === null ? null : step.before + beforeStart, + after: step.after === null ? null : step.after + afterStart, + }))) + } + beforeStart = beforeEnd + afterStart = afterEnd + } + for (let index = beforeStart; index < beforeEnd; index++) { + regions.push({ before: index, after: null }) + } + for (let index = afterStart; index < afterEnd; index++) { + regions.push({ before: null, after: index }) + } + regions.push(...suffix) + if (anchor.before < before.length) { + regions.push(anchor) + } + beforeStart = anchor.before + 1 + afterStart = anchor.after + 1 + } + return regions +} + +function uniqueExactPairs(beforeKeys: readonly string[], afterKeys: readonly string[]) { + const beforeIndices = groupIndices(beforeKeys) + const afterIndices = groupIndices(afterKeys) + return beforeKeys.flatMap((key, index) => ( + beforeIndices.get(key)!.length === 1 && afterIndices.get(key)?.length === 1 + ? [{ before: index, after: afterIndices.get(key)![0]! }] + : [] + )) +} + +function uniqueCompatiblePairs(before: readonly T[], after: readonly T[], compatible: (before: T, after: T) => boolean) { + const afterMatches = before.map((item) => after + .map((candidate, index) => compatible(item, candidate) ? index : -1) + .filter((index) => index >= 0)) + const beforeMatches = after.map((item) => before + .map((candidate, index) => compatible(candidate, item) ? index : -1) + .filter((index) => index >= 0)) + return afterMatches.flatMap((matches, beforeIndex) => { + const afterIndex = matches[0] + return matches.length === 1 && beforeMatches[afterIndex!]?.length === 1 + ? [{ before: beforeIndex, after: afterIndex! }] + : [] + }) +} + +function rankedExactPairs(beforeKeys: readonly string[], afterKeys: readonly string[]) { + const beforeIndices = groupIndices(beforeKeys) + const afterIndices = groupIndices(afterKeys) + return [...beforeIndices].flatMap(([key, indices]) => { + const matches = afterIndices.get(key) + return matches?.length === indices.length + ? indices.map((before, rank) => ({ before, after: matches[rank]! })) + : [] + }) +} + +function groupIndices(keys: readonly string[]) { + const grouped = new Map() + for (const [index, key] of keys.entries()) { + const indices = grouped.get(key) + if (indices) { + indices.push(index) + } else { + grouped.set(key, [index]) + } + } + return grouped +} + +interface RationalScore { + numerator: bigint + denominator: bigint +} + +interface AlignmentState { + score: RationalScore + signatures: readonly number[] +} + +export function solveWeightedGap(before: readonly T[], after: readonly T[], options: Options): { steps: readonly Step[] } | { coarseReason: CoarseReason } { + const cellCharge = before.length * after.length + if (cellCharge > options.work.remainingCells) { + return { coarseReason: 'comparison-limit' } + } + const beforeProfiles = before.map(options.profile) + const afterProfiles = after.map(options.profile) + const tokenCharge = weightedTokenCharge(beforeProfiles, afterProfiles) + if (tokenCharge > BigInt(options.work.remainingTokenComparisons)) { + return { coarseReason: 'comparison-limit' } + } + options.work.remainingCells -= cellCharge + options.work.remainingTokenComparisons -= Number(tokenCharge) + const structuralPairs = uniqueCompatiblePairs(before, after, options.compatible) + if (before.length === after.length + && structuralPairs.length === before.length + && structuralPairs.every((pair, index) => pair.before === index && pair.after === index)) { + return { steps: alignmentSteps(before.length, after.length, structuralPairs) } + } + + const parents = [-1] + const beforeOf = [-1] + const afterOf = [-1] + const zero: AlignmentState = { + score: { numerator: 0n, denominator: 1n }, + signatures: [0], + } + let previous = Array.from({ length: after.length + 1 }).fill(zero) + for (let beforeIndex = 1; beforeIndex <= before.length; beforeIndex++) { + const current = Array.from({ length: after.length + 1 }) + current[0] = zero + for (let afterIndex = 1; afterIndex <= after.length; afterIndex++) { + let best = betterState(previous[afterIndex]!, current[afterIndex - 1]!) + const beforeItem = before[beforeIndex - 1]! + const afterItem = after[afterIndex - 1]! + if (options.compatible(beforeItem, afterItem)) { + const source = previous[afterIndex - 1]! + best = betterState(best, { + score: addScores(source.score, pairScore( + beforeProfiles[beforeIndex - 1]!, + afterProfiles[afterIndex - 1]!, + options.fingerprint(beforeItem) === options.fingerprint(afterItem), + )), + signatures: source.signatures.map((parent) => { + parents.push(parent) + beforeOf.push(beforeIndex - 1) + afterOf.push(afterIndex - 1) + return parents.length - 1 + }), + }) + } + current[afterIndex] = best + } + previous = current + } + + const signatures = previous[after.length]!.signatures + if (signatures.length > 1) { + return { coarseReason: 'ambiguous-attribution' } + } + const matches: Pair[] = [] + for (let id = signatures[0]!; id > 0; id = parents[id]!) { + matches.push({ before: beforeOf[id]!, after: afterOf[id]! }) + } + return { steps: alignmentSteps(before.length, after.length, matches.reverse()) } +} + +function betterState(a: AlignmentState, b: AlignmentState): AlignmentState { + const order = compareScores(a.score, b.score) + if (order > 0) { + return a + } + if (order < 0) { + return b + } + return { + score: a.score, + signatures: [...new Set([...a.signatures, ...b.signatures])].slice(0, 2), + } +} + +function weightedTokenCharge(before: readonly (readonly string[])[], after: readonly (readonly string[])[]) { + let charge = 0n + for (const beforeProfile of before) { + for (const afterProfile of after) { + charge += BigInt(Math.min(beforeProfile.length, afterProfile.length)) + } + } + return charge * 2n +} + +function pairScore(before: readonly string[], after: readonly string[], exact: boolean): RationalScore { + if (exact) { + return { numerator: 3n, denominator: 1n } + } + let prefix = 0 + while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) { + prefix++ + } + let suffix = 0 + const maximumSuffix = Math.min(before.length, after.length) - prefix + while (suffix < maximumSuffix + && before[before.length - suffix - 1] === after[after.length - suffix - 1]) { + suffix++ + } + const denominator = BigInt(Math.max(before.length, after.length, 1)) + return { + numerator: denominator + BigInt(prefix + suffix), + denominator, + } +} + +function addScores(a: RationalScore, b: RationalScore): RationalScore { + return { + numerator: a.numerator * b.denominator + b.numerator * a.denominator, + denominator: a.denominator * b.denominator, + } +} + +function compareScores(a: RationalScore, b: RationalScore) { + const difference = a.numerator * b.denominator - b.numerator * a.denominator + return difference < 0n ? -1 : difference > 0n ? 1 : 0 +} + +function alignmentSteps(beforeCount: number, afterCount: number, matches: readonly Pair[]) { + const steps: Step[] = [] + let beforeIndex = 0 + let afterIndex = 0 + for (const match of matches) { + while (beforeIndex < match.before) { + steps.push({ before: beforeIndex++, after: null }) + } + while (afterIndex < match.after) { + steps.push({ before: null, after: afterIndex++ }) + } + steps.push({ before: beforeIndex++, after: afterIndex++ }) + } + while (beforeIndex < beforeCount) { + steps.push({ before: beforeIndex++, after: null }) + } + while (afterIndex < afterCount) { + steps.push({ before: null, after: afterIndex++ }) + } + return steps +} diff --git a/src/comparison/comparisonDocumentIndex.ts b/src/comparison/comparisonDocumentIndex.ts new file mode 100644 index 00000000000..6a8383714c9 --- /dev/null +++ b/src/comparison/comparisonDocumentIndex.ts @@ -0,0 +1,169 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonRange as Range } from './markdownComparisonTypes.ts' + +export interface LocatedComparisonNode { + node: Node + path: readonly number[] + index: number + from: number + to: number + parent: Location | null + children: readonly Location[] +} +type Location = LocatedComparisonNode + +export interface ComparisonDocumentIndex { + children: readonly Location[] + nodeAtPath: (path: readonly number[]) => Location +} + +interface Mutable extends Omit { + children: Mutable[] +} + +const minimalRootsCache = new WeakMap() + +export function createComparisonDocumentIndex(doc: Node): ComparisonDocumentIndex { + const byPath = new Map() + const locateChildren = ( + parentNode: Node, + parentLocation: Mutable | null, + parentPath: readonly number[], + contentFrom: number, + ) => { + const children: Mutable[] = [] + parentNode.forEach((node, offset, index) => { + const from = contentFrom + offset + const path = [...parentPath, index] + const location: Mutable = { + node, + path, + index, + from, + to: from + node.nodeSize, + parent: parentLocation, + children: [], + } + children.push(location) + byPath.set(pathKey(path), location) + if (!node.isLeaf) { + location.children = locateChildren(node, location, path, from + 1) + } + }) + return children + } + const children = locateChildren(doc, null, [], 0) + return { + children, + nodeAtPath(path) { + const location = byPath.get(pathKey(path)) + if (!location) { + throw new Error(`Comparison document path does not exist: ${path.join('.')}`) + } + return location + }, + } +} + +export function findComparisonNodes(range: Range, roots: readonly Location[]) { + const found = new Map() + const add = (location: Location) => found.set(pathKey(location.path), location) + const visit = (location: Location) => { + if (!touches(location, range)) { + return + } + add(location) + visitChildren(location.children, range, visit) + } + visitChildren(minimalRoots(roots), range, (root) => { + for (let ancestor = root.parent; ancestor; ancestor = ancestor.parent) { + add(ancestor) + } + visit(root) + }) + return [...found.values()].toSorted((a, b) => a.from - b.from || a.path.length - b.path.length) +} + +export function comparisonRangeText(range: Range, roots: readonly Location[]) { + if (range.from === range.to) { + return '' + } + return minimalRoots(roots) + .filter((root) => touches(root, range)) + .map((root) => textFromRoot(root, range)) + .join('\n') +} + +function visitChildren(children: readonly Location[], range: Range, visit: (location: Location) => void) { + let lower = 0 + let upper = children.length + while (lower < upper) { + const middle = (lower + upper) >>> 1 + const beforeRange = range.from === range.to + ? children[middle]!.to < range.from + : children[middle]!.to <= range.from + if (beforeRange) { + lower = middle + 1 + } else { + upper = middle + } + } + for (let index = lower; index < children.length; index++) { + const child = children[index]! + if (range.from === range.to ? child.from > range.from : child.from >= range.to) { + break + } + visit(child) + } +} + +function touches(location: Location, range: Range) { + return range.from === range.to + ? range.from >= location.from && range.from <= location.to + : range.from < location.to && range.to > location.from +} + +function minimalRoots(roots: readonly Location[]) { + const cached = minimalRootsCache.get(roots) + if (cached) { + return cached + } + const rootPaths = new Set(roots.map(({ path }) => pathKey(path))) + const minimal = roots.filter((root) => { + for (let ancestor = root.parent; ancestor; ancestor = ancestor.parent) { + if (rootPaths.has(pathKey(ancestor.path))) { + return false + } + } + return true + }) + minimalRootsCache.set(roots, minimal) + return minimal +} + +function textFromRoot(root: Location, range: Range) { + if (root.node.isText) { + return root.node.text?.slice( + Math.max(0, range.from - root.from), + Math.min(root.node.nodeSize, range.to - root.from), + ) ?? '' + } + if (root.node.isLeaf) { + return '\ufffc' + } + const contentFrom = root.from + 1 + return root.node.textBetween( + Math.max(0, range.from - contentFrom), + Math.min(root.node.content.size, range.to - contentFrom), + '\n', + '\ufffc', + ) +} +function pathKey(path: readonly number[]) { + return path.join('.') +} 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/comparisonPresentation.ts b/src/comparison/comparisonPresentation.ts new file mode 100644 index 00000000000..abc94771666 --- /dev/null +++ b/src/comparison/comparisonPresentation.ts @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComparisonAttributeCode as AttributeCode, ComparisonMarkCode as MarkCode, ComparisonSignal as Signal } from './markdownComparisonTypes.ts' + +import { t } from '@nextcloud/l10n' + +type Label = () => string +const attributes: Record = { + 'image-target': [219, () => t('text', 'Image changed')], + 'image-alt': [218, () => t('text', 'Image description changed')], + 'link-target': [217, () => t('text', 'Link target changed')], + link: [216, () => t('text', 'Link changed')], + 'mention-identity': [215, () => t('text', 'Mention changed')], + mathematics: [214, () => t('text', 'Mathematics changed')], + 'preview-target': [213, () => t('text', 'Link preview changed')], + 'footnote-reference': [212, () => t('text', 'Footnote changed')], + 'task-state': [211, () => t('text', 'Task state changed')], + 'heading-level': [210, () => t('text', 'Heading level changed')], + 'list-start': [209, () => t('text', 'List start changed')], + 'code-language': [208, () => t('text', 'Code language changed')], + 'text-direction': [207, () => t('text', 'Text direction changed')], + 'table-span': [206, () => t('text', 'Table structure changed')], + 'table-alignment': [205, () => t('text', 'Table alignment changed')], + 'callout-type': [204, () => t('text', 'Callout type changed')], + 'details-state': [203, () => t('text', 'Details state changed')], + 'unknown-attribute': [202, () => t('text', 'Attribute changed')], +} + +const marks: Record = { + bold: [106, () => t('text', 'Bold')], + italic: [105, () => t('text', 'Italic')], + strike: [104, () => t('text', 'Strikethrough')], + highlight: [103, () => t('text', 'Highlight')], + underline: [102, () => t('text', 'Underline')], + 'inline-code': [101, () => t('text', 'Inline code')], +} + +export function selectComparisonSignal(signals: readonly Signal[]): Signal | undefined { + return signals.reduce((selected, signal) => ( + !selected || signalPriority(signal) > signalPriority(selected) ? signal : selected + ), undefined) +} + +export function comparisonSignalLabel(signal: Signal) { + if (signal.type === 'attribute') { + return attributes[signal.attribute][1]() + } + if (signal.type === 'mark') { + return t('text', '{formatting} changed', { formatting: marks[signal.mark][1]() }) + } +} + +function signalPriority(signal: Signal) { + if (signal.type === 'attribute') { + return attributes[signal.attribute][0] + } + if (signal.type === 'mark') { + return marks[signal.mark][0] + } + return 150 +} diff --git a/src/comparison/comparisonSections.ts b/src/comparison/comparisonSections.ts new file mode 100644 index 00000000000..e3ec07a0af6 --- /dev/null +++ b/src/comparison/comparisonSections.ts @@ -0,0 +1,157 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonEdit } from './markdownComparisonTypes.ts' + +import { increasingSubsequence } from './comparisonAlignment.ts' + +export interface ComparisonHeading { + from: number + text: string +} + +export interface ComparisonSection { + id: string + title: string + edits: readonly ComparisonEdit[] +} +type Heading = ComparisonHeading + +export function headingLocations(doc: Node): readonly Heading[] { + const headings: Heading[] = [] + doc.forEach((node, from) => { + const text = node.textContent.trim() + if (node.type.name === 'heading' && text) { + headings.push({ from, text }) + } + }) + return headings +} + +function nearestHeadingIndex(headings: readonly Heading[], position: number) { + let lower = 0 + let upper = headings.length + while (lower < upper) { + const middle = Math.floor((lower + upper) / 2) + if (headings[middle]!.from <= position) { + lower = middle + 1 + } else { + upper = middle + } + } + return lower - 1 +} +export function nearestHeading(headings: readonly Heading[], position: number) { + return headings[nearestHeadingIndex(headings, position)]?.text ?? '' +} + +interface HeadingIndex { + headings: readonly Heading[] + keys: readonly string[] +} + +function indexByUniqueTitle(headings: readonly Heading[]) { + const indexes = new Map() + const repeated = new Set() + headings.forEach(({ text }, index) => { + if (indexes.has(text)) { + repeated.add(text) + } else { + indexes.set(text, index) + } + }) + for (const text of repeated) { + indexes.delete(text) + } + return indexes +} + +function headingAnchors(before: readonly Heading[], after: readonly Heading[]) { + const beforeIndexes = indexByUniqueTitle(before) + const afterIndexes = indexByUniqueTitle(after) + const pairs: Array = [] + after.forEach(({ text }, afterIndex) => { + const beforeIndex = beforeIndexes.get(text) + if (beforeIndex !== undefined && afterIndexes.get(text) === afterIndex) { + pairs.push([beforeIndex, afterIndex]) + } + }) + return increasingSubsequence(pairs.map(([index]) => index)).indices.map((index) => pairs[index]!) +} + +function correlateHeadings(before: readonly Heading[], after: readonly Heading[]) { + const beforeKeys: string[] = [] + const afterKeys: string[] = [] + let next = 0 + let row = 0 + let column = 0 + + function pairGap(rowEnd: number, columnEnd: number) { + const rowCount = rowEnd - row + const columnCount = columnEnd - column + if (rowCount !== columnCount || rowCount > 1) { + while (row < rowEnd) { + beforeKeys[row++] = `#${next++}` + } + while (column < columnEnd) { + afterKeys[column++] = `#${next++}` + } + return + } + while (row < rowEnd) { + const key = `#${next++}` + beforeKeys[row++] = key + afterKeys[column++] = key + } + } + + for (const [anchorRow, anchorColumn] of headingAnchors(before, after)) { + pairGap(anchorRow, anchorColumn) + const key = `#${next++}` + beforeKeys[row++] = key + afterKeys[column++] = key + } + pairGap(before.length, after.length) + return { before: beforeKeys, after: afterKeys } +} + +function resolveSection(edit: ComparisonEdit, before: HeadingIndex, after: HeadingIndex) { + const descriptor = edit.primary + const deleted = descriptor.operation === 'delete' + const side = deleted ? before : after + const position = deleted + ? descriptor.context.before?.from ?? descriptor.before.from + : descriptor.context.after?.from ?? descriptor.after.from + return side.keys[nearestHeadingIndex(side.headings, position)] ?? '' +} + +export function buildComparisonSections(edits: readonly ComparisonEdit[], beforeDocument: Node, afterDocument: Node): readonly ComparisonSection[] { + const beforeHeadings = headingLocations(beforeDocument) + const afterHeadings = headingLocations(afterDocument) + const correlation = correlateHeadings(beforeHeadings, afterHeadings) + const before: HeadingIndex = { headings: beforeHeadings, keys: correlation.before } + const after: HeadingIndex = { headings: afterHeadings, keys: correlation.after } + const titleByKey = new Map() + beforeHeadings.forEach((heading, index) => titleByKey.set(correlation.before[index]!, heading.text)) + afterHeadings.forEach((heading, index) => titleByKey.set(correlation.after[index]!, heading.text)) + + const sections: Array<{ id: string, key: string, title: string, edits: ComparisonEdit[] }> = [] + for (const edit of edits) { + const key = resolveSection(edit, before, after) + const title = titleByKey.get(key) ?? '' + const current = sections.at(-1) + if (current?.key === key) { + current.edits.push(edit) + } else { + sections.push({ id: edit.id, key, title, edits: [edit] }) + } + } + return sections.map(({ id, title, edits: sectionEdits }) => ({ + id, + title, + edits: sectionEdits, + })) +} diff --git a/src/comparison/createComparisonEditor.ts b/src/comparison/createComparisonEditor.ts new file mode 100644 index 00000000000..9adff9bcd34 --- /dev/null +++ b/src/comparison/createComparisonEditor.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Schema } from '@tiptap/pm/model' + +import { Editor } from '@tiptap/vue-3' +import { renderEditorContent } from '../composables/useEditorMethods.ts' +import RichText from '../extensions/RichText.ts' + +interface ComparisonEditorOptions { + ariaLabel?: string + filePath?: string + noLazyImages?: boolean + openLink?: (href: string) => void + schema?: Schema +} + +export function createComparisonEditor(content: string, options: ComparisonEditorOptions = {}) { + if (typeof content !== 'string') { + throw new TypeError('Comparison content must be a string') + } + const editor = new Editor({ + content: renderEditorContent(content, true), + editable: false, + editorProps: options.ariaLabel ? { attributes: { 'aria-label': options.ariaLabel } } : {}, + extensions: [RichText.configure({ + editing: false, + extensions: [], + isEmbedded: true, + noLazyImages: options.noLazyImages ?? false, + openLink: options.openLink, + relativePath: options.filePath, + })], + onBeforeCreate: ({ editor }) => { + if (options.schema) { + editor.schema = options.schema + editor.extensionManager.schema = options.schema + } + }, + }) + return editor +} diff --git a/src/comparison/hierarchicalMarkdownComparisonModel.ts b/src/comparison/hierarchicalMarkdownComparisonModel.ts new file mode 100644 index 00000000000..3f9122bfaa0 --- /dev/null +++ b/src/comparison/hierarchicalMarkdownComparisonModel.ts @@ -0,0 +1,842 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { ComparisonWorkLedger as Ledger, ComparisonAlignmentOptions as Options, ComparisonAlignmentRegion as Region, ComparisonAlignmentStep as Step } from './comparisonAlignment.ts' +import type { ComparisonDocumentIndex as DocumentIndex, LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' +import type { ReservedExactMovePair as MovePair } from './markdownComparisonMoves.ts' +import type { ComparisonAttributeCode as Attr, ComparisonDescriptor as Descriptor, ComparisonEdit as Edit, ComparisonEditKind as EditKind, MarkdownComparisonModel as Model, ComparisonRange as Range, ComparisonCoarseReason as Reason, ComparisonSide as Side } from './markdownComparisonTypes.ts' + +import { ChangeSet, simplifyChanges } from '@tiptap/pm/changeset' +import { StepMap } from '@tiptap/pm/transform' +import { alignComparisonAxis as alignAxis, alignComparisonColumns as alignColumns, createComparisonWorkLedger as createLedger } from './comparisonAlignment.ts' +import { createComparisonDocumentIndex as indexDocument, comparisonRangeText as rangeText } from './comparisonDocumentIndex.ts' +import { classifyComparisonDescriptor as classify, classifyNodeMarkupDescriptor as classifyMarkup, compareCodeUnits, deepFreeze, nodeFingerprint as nodeKey, semanticTokenEncoder, stableSerialize as serialize } from './markdownComparisonClassification.ts' +import { confirmReservedExactMoves as confirmMoves } from './markdownComparisonMoves.ts' + +export const MAX_INLINE_ENVELOPE_SIZE = 4500 +export const MAX_RENDERED_COMPARISON_DESCRIPTORS = 10_000 +export const MAX_TABLE_ROWS = 512 +export const MAX_TABLE_PHYSICAL_CELLS = 10_000 + +export class ComparisonModelLimitError extends Error { + constructor() { + super('Rendered comparison change limit reached') + this.name = 'ComparisonModelLimitError' + } +} +interface ComparisonModelOptions { + maximumDescriptors?: number +} +type PendingEdit = Omit + +interface Builder { + originalBefore: Node + originalAfter: Node + originalBeforeIndex: DocumentIndex + originalAfterIndex: DocumentIndex + comparisonBefore: Node + edits: PendingEdit[] + descriptorCount: number + maximumDescriptors: number + work: Ledger +} +interface AxisEntry { + before: readonly Location[] + after: readonly Location[] + coarseReason?: Reason +} +interface TableRow { + location: Location + kind: 'header' | 'body' + cells: readonly Location[] +} +interface RowPair { + before: TableRow + after: TableRow + slot: number +} +interface Column { + index: number + fingerprint: string + profile: () => readonly string[] + cells: ReadonlyMap +} +interface AxisNode { + location: Location + fingerprint: string + profile: () => readonly string[] +} +const NODE_TOKEN = '\u0000' +const CHARACTER_TOKEN = '\u0001' +const PAIR_COUNT_TOKEN = '\u0002' +const ORDINAL_TOKEN = '\u0003' +const ROW_KIND_TOKEN = '\u0004' +const COLUMN_TOKEN = '\u0005' +const ABSENT_CELL_TOKEN = '\u0006' + +const profileCache = new WeakMap() + +export function createHierarchicalMarkdownComparisonModel(originalBefore: Node, originalAfter: Node, options: ComparisonModelOptions = {}): Model { + const comparisonBefore = normalizeSchema(originalBefore, originalAfter) + const originalBeforeIndex = indexDocument(originalBefore) + const originalAfterIndex = indexDocument(originalAfter) + const comparisonBeforeIndex = comparisonBefore === originalBefore + ? originalBeforeIndex + : indexDocument(comparisonBefore) + const builder: Builder = { + originalBefore, + originalAfter, + originalBeforeIndex, + originalAfterIndex, + comparisonBefore, + edits: [], + descriptorCount: 0, + maximumDescriptors: options.maximumDescriptors ?? MAX_RENDERED_COMPARISON_DESCRIPTORS, + work: createLedger(), + } + compareSiblingAxis( + builder, + comparisonBeforeIndex.children, + originalAfterIndex.children, + 0, + comparisonBefore.content.size, + 0, + originalAfter.content.size, + true, + ) + + return deepFreeze({ edits: finalizeEdits(builder.edits) }) +} +function normalizeSchema(before: Node, after: Node) { + if (before.type.schema === after.type.schema) { + return before + } + const rebuilt = after.type.schema.nodeFromJSON(before.toJSON()) + if (rebuilt.nodeSize !== before.nodeSize) { + throw new Error('Markdown comparison schema normalization changed document positions') + } + if (rebuilt.textBetween(0, rebuilt.content.size, '\n', '\ufffc') + !== before.textBetween(0, before.content.size, '\n', '\ufffc')) { + throw new Error('Markdown comparison schema normalization changed document content') + } + if (serialize(rebuilt.toJSON()) !== serialize(before.toJSON())) { + throw new Error('Markdown comparison schema normalization lost semantics') + } + return rebuilt +} +function compareSiblingAxis(builder: Builder, before: readonly Location[], after: readonly Location[], beforeStart: number, beforeEnd: number, afterStart: number, afterEnd: number, topLevel: boolean, excluded: readonly Attr[] = []) { + const entries = axisEntries(before, after, alignAxis(before, after, axisOptions(builder))) + const groups = topLevel ? confirmedMoveGroups(builder, entries) : [] + const moved = new Set(groups.flat().flatMap(({ before: a, after: b }) => [a, b])) + const missingBefore = precomputeMissing(entries, 'before', beforeStart, beforeEnd) + const missingAfter = precomputeMissing(entries, 'after', afterStart, afterEnd) + for (const [index, entry] of entries.entries()) { + if (entry.coarseReason) { + emitCoarse(builder, entry.before, entry.after, entry.coarseReason, excluded) + } else if (entry.before[0] && entry.after[0]) { + compareNodes(builder, entry.before[0], entry.after[0], true, excluded) + } else if (entry.before[0] && !moved.has(entry.before[0])) { + const missing = missingAfter[index]! + emitBlock(builder, entry.before[0], null, missing, excluded) + } else if (entry.after[0] && !moved.has(entry.after[0])) { + const missing = missingBefore[index]! + emitBlock(builder, null, entry.after[0], missing, excluded) + } + } + for (const group of groups) { + emitMove(builder, group) + } +} +function axisEntries(before: readonly Location[], after: readonly Location[], regions: readonly Region[]): readonly AxisEntry[] { + return regions.map((region) => { + if ('coarseReason' in region) { + return { + before: before.slice(region.before.from, region.before.to), + after: after.slice(region.after.from, region.after.to), + coarseReason: region.coarseReason, + } + } + return { + before: region.before === null ? [] : [before[region.before]!], + after: region.after === null ? [] : [after[region.after]!], + } + }) +} +function confirmedMoveGroups(builder: Builder, entries: readonly AxisEntry[]) { + const deleted = entries.filter((entry) => !entry.coarseReason && entry.before[0] && !entry.after[0]) + const inserted = entries.filter((entry) => !entry.coarseReason && entry.after[0] && !entry.before[0]) + const insertedByFingerprint = new Map(inserted.map((entry) => [ + nodeKey(entry.after[0]!.node), + entry.after[0]!, + ])) + const candidates: MovePair[] = [] + for (const entry of deleted) { + const fingerprint = nodeKey(entry.before[0]!.node) + const match = insertedByFingerprint.get(fingerprint) + if (match) { + candidates.push({ before: entry.before[0]!, after: match, fingerprint }) + } + } + const groups = confirmMoves(builder.comparisonBefore, builder.originalAfter, candidates).groups + const deletedNodes = new Map(deleted.map((entry) => [entry.before[0]!.index, entry.before[0]!])) + const insertedNodes = new Map(inserted.map((entry) => [entry.after[0]!.index, entry.after[0]!])) + return groups.map((group) => extendMovedHeadingGroup(group, deletedNodes, insertedNodes)) +} +function extendMovedHeadingGroup(group: readonly MovePair[], deletedNodes: ReadonlyMap, insertedNodes: ReadonlyMap) { + const first = group[0]! + if (first.before.node.type.name !== 'heading' || first.after.node.type.name !== 'heading') { + return [...group] + } + const extended = [...group] + let beforeIndex = group.at(-1)!.before.index + 1 + let afterIndex = group.at(-1)!.after.index + 1 + while (true) { + const before = deletedNodes.get(beforeIndex) + const after = insertedNodes.get(afterIndex) + if (!before || !after) { + break + } + if (before.node.type.name === 'heading' || after.node.type.name === 'heading' + || !before.node.eq(after.node)) { + break + } + extended.push({ before, after, fingerprint: nodeKey(before.node) }) + beforeIndex++ + afterIndex++ + } + return extended +} +function precomputeMissing(entries: readonly AxisEntry[], side: Side, start: number, end: number) { + const previousAt: Array = new Array(entries.length) + let previous: Location | null = null + for (const [index, entry] of entries.entries()) { + previousAt[index] = previous + previous = entry[side].at(-1) ?? previous + } + const locations = new Array(entries.length) + let next: Location | null = null + for (let index = entries.length - 1; index >= 0; index--) { + previous = previousAt[index]! + locations[index] = next?.from + ?? previous?.to + ?? Math.min(Math.max(start, 0), end) + next = entries[index]![side][0] ?? next + } + return locations +} +function compareNodes(builder: Builder, before: Location, after: Location, markup = true, excluded: readonly Attr[] = []) { + if (before.node.eq(after.node)) { + return + } + if (before.node.type.name === 'table' && after.node.type.name === 'table') { + compareTables(builder, before, after) + return + } + if (before.node.isTextblock && after.node.isTextblock) { + compareTextblocks(builder, before, after, markup, excluded) + return + } + if (before.node.isLeaf || after.node.isLeaf || before.node.isAtom || after.node.isAtom) { + emitBlock(builder, before, after) + return + } + const nestedExcludedAttributes = markup && !before.node.sameMarkup(after.node) + ? [...new Set([...excluded, ...emitMarkup(builder, before, after)])] + : excluded + compareSiblingAxis( + builder, + before.children, + after.children, + before.from + 1, + before.to - 1, + after.from + 1, + after.to - 1, + false, + nestedExcludedAttributes, + ) +} +function compareTextblocks(builder: Builder, before: Location, after: Location, markup = true, excluded: readonly Attr[] = []) { + const start = before.node.content.findDiffStart(after.node.content) + if (start === null) { + if (markup && !before.node.sameMarkup(after.node)) { + emitMarkup(builder, before, after) + } + return + } + const diffEnd = before.node.content.findDiffEnd(after.node.content) + if (!diffEnd) { + emitBlock(builder, before, after, 0, excluded) + return + } + let { a: endA, b: endB } = diffEnd + if (endA < start) { + endB += start - endA + endA = start + } + if (endB < start) { + endA += start - endB + endB = start + } + if ((endA - start) + (endB - start) > MAX_INLINE_ENVELOPE_SIZE) { + emitBlock(builder, before, after, 0, excluded) + return + } + const map = new StepMap([0, before.node.content.size, after.node.content.size]) + const changes = simplifyChanges( + ChangeSet.create(before.node, undefined, semanticTokenEncoder) + .addSteps(after.node, [map], null) + .changes, + after.node, + ) + const beforeRoot = originalLocation(builder, 'before', before) + const afterRoot = originalLocation(builder, 'after', after) + const inlineRanges = changes.map((change) => ({ + before: { from: before.from + 1 + change.fromA, to: before.from + 1 + change.toA }, + after: { from: after.from + 1 + change.fromB, to: after.from + 1 + change.toB }, + local: change, + })) + const valid = inlineRanges.length > 0 && inlineRanges.every(({ before: beforeRange, after: afterRange, local }) => ( + validLocalRange(local.fromA, local.toA, before.node.content.size) + && validLocalRange(local.fromB, local.toB, after.node.content.size) + && before.node.textBetween(local.fromA, local.toA, '\n', '\ufffc') + === rangeText(beforeRange, [beforeRoot]) + && after.node.textBetween(local.fromB, local.toB, '\n', '\ufffc') + === rangeText(afterRange, [afterRoot]) + )) + if (!valid) { + emitBlock(builder, before, after, 0, excluded) + return + } + const contentExcludedAttributes = markup && !before.node.sameMarkup(after.node) + ? [...new Set([...excluded, ...emitMarkup(builder, before, after)])] + : excluded + for (const { before: beforeRange, after: afterRange } of inlineRanges) { + pushContent(builder, descriptorFor( + builder, + beforeRange, + afterRange, + [before], + [after], + 'inline', + contentExcludedAttributes, + )) + } +} +function validLocalRange(from: number, to: number, maximum: number) { + return Number.isInteger(from) && Number.isInteger(to) && from >= 0 && to >= from && to <= maximum +} +function tableShape(table: Location) { + const rows: TableRow[] = [] + let physicalCells = 0 + let index = table.children[0]?.node.type.name === 'tableCaption' ? 1 : 0 + if (table.children[index]?.node.type.name !== 'tableHeadRow') { + return null + } + for (; index < table.children.length; index++) { + const child = table.children[index]! + const name = child.node.type.name + const header = rows.length === 0 + if ((header ? name !== 'tableHeadRow' : name !== 'tableRow') || child.children.length === 0) { + return null + } + const expectedCell = header ? 'tableHeader' : 'tableCell' + for (const cell of child.children) { + if (cell.node.type.name !== expectedCell + || (cell.node.attrs.colspan ?? 1) !== 1 + || (cell.node.attrs.rowspan ?? 1) !== 1) { + return null + } + } + physicalCells += child.children.length + rows.push({ + location: child, + kind: header ? 'header' : 'body', + cells: child.children, + }) + if (rows.length > MAX_TABLE_ROWS || physicalCells > MAX_TABLE_PHYSICAL_CELLS) { + return null + } + } + return rows +} +function compareTables(builder: Builder, before: Location, after: Location) { + const beforeRows = tableShape(before) + const afterRows = tableShape(after) + if (!beforeRows || !afterRows) { + emitCoarse(builder, [before], [after], 'unsupported-table') + return + } + const seedEntries = axisEntries( + before.children, + after.children, + alignAxis(before.children, after.children, axisOptions(builder)), + ) + const coarse = seedEntries.find(({ coarseReason }) => coarseReason) + if (coarse) { + emitCoarse(builder, [before], [after], coarse.coarseReason!) + return + } + const beforeRowOf = new Map(beforeRows.map((row) => [row.location, row])) + const afterRowOf = new Map(afterRows.map((row) => [row.location, row])) + const seedRowPairs = pairedRows(seedEntries, beforeRowOf, afterRowOf) + const seedPlan = tablePlan(builder, seedRowPairs, seedEntries.length) + if ('coarseReason' in seedPlan) { + emitCoarse(builder, [before], [after], seedPlan.coarseReason) + return + } + const { beforeCols: seedBeforeColumns, afterCols: seedAfterColumns, steps: seedSteps } = seedPlan + if (seedSteps.every((step) => step.before !== null && step.after !== null)) { + if (exactEvidenceConflict(seedBeforeColumns, seedAfterColumns, seedSteps)) { + emitCoarse(builder, [before], [after], 'table-evidence-conflict') + return + } + emitTablePlan( + builder, + before, + after, + seedEntries, + seedRowPairs, + seedBeforeColumns, + seedAfterColumns, + seedSteps, + ) + return + } + const beforeAxis = tableAxisRecords(before.children, beforeRowOf, seedSteps, 'before') + const afterAxis = tableAxisRecords(after.children, afterRowOf, seedSteps, 'after') + const entries = axisEntries( + before.children, + after.children, + alignAxis(beforeAxis, afterAxis, { + work: builder.work, + fingerprint: ({ fingerprint }) => fingerprint, + profile: ({ profile }) => profile(), + compatible: (left, right) => left.location.node.type === right.location.node.type, + }), + ) + const sharedCoarse = entries.find(({ coarseReason }) => coarseReason) + if (sharedCoarse) { + emitCoarse(builder, [before], [after], tableConflictReason(sharedCoarse.coarseReason!)) + return + } + const rowPairs = pairedRows(entries, beforeRowOf, afterRowOf) + const plan = tablePlan(builder, rowPairs, entries.length) + if ('coarseReason' in plan) { + emitCoarse(builder, [before], [after], tableConflictReason(plan.coarseReason)) + return + } + const { beforeCols, afterCols, steps } = plan + if (!sameSteps(seedSteps, steps) + || exactEvidenceConflict(beforeCols, afterCols, steps)) { + emitCoarse(builder, [before], [after], 'table-evidence-conflict') + return + } + emitTablePlan(builder, before, after, entries, rowPairs, beforeCols, afterCols, steps) +} +function tableConflictReason(reason: Reason): Reason { + return reason === 'comparison-limit' ? reason : 'table-evidence-conflict' +} +function pairedRows(entries: readonly AxisEntry[], beforeRows: ReadonlyMap, afterRows: ReadonlyMap) { + return entries.flatMap((entry, slot) => { + const before = entry.before[0] && beforeRows.get(entry.before[0]) + const after = entry.after[0] && afterRows.get(entry.after[0]) + return before && after ? [{ before, after, slot }] : [] + }) +} +function tablePlan(builder: Builder, rows: readonly RowPair[], slots: number) { + const beforeCols = columnRecords(rows, 'before', slots) + const afterCols = columnRecords(rows, 'after', slots) + const plan = tableColumnPlan(builder, beforeCols, afterCols) + return 'coarseReason' in plan ? plan : { beforeCols, afterCols, steps: plan.steps } +} +function tableColumnPlan(builder: Builder, beforeCols: readonly Column[], afterCols: readonly Column[]): { steps: readonly Step[] } | { coarseReason: Reason } { + const steps: Step[] = [] + for (const region of alignColumns(beforeCols, afterCols, { + work: builder.work, + fingerprint: ({ fingerprint }) => fingerprint, + profile: ({ profile }) => profile(), + compatible: () => true, + })) { + if ('coarseReason' in region) { + return { coarseReason: region.coarseReason } + } + steps.push(region) + } + return { steps } +} +function columnRecords(rowPairs: readonly RowPair[], side: Side, slotCount: number): readonly Column[] { + let width = 0 + for (const pair of rowPairs) { + const row = pair[side] + width = Math.max(width, row.cells.length) + } + const cellsByColumn = Array.from({ length: width }, () => new Map()) + const rowKinds = new Map() + for (const pair of rowPairs) { + const row = pair[side] + rowKinds.set(pair.slot, row.kind) + for (const [index, cell] of row.cells.entries()) { + cellsByColumn[index]!.set(pair.slot, cell) + } + } + return cellsByColumn.map((cells, index) => { + const fingerprint = [ + `${slotCount}`, + ...[...cells].map(([slot, cell]) => `${slot}:${rowKinds.get(slot)!}:${nodeKey(cell.node)}`), + ].join('|') + let materializedProfile: readonly string[] | undefined + const profile = () => materializedProfile ??= [ + `${PAIR_COUNT_TOKEN}${slotCount}`, + ...[...cells].flatMap(([slot, cell]) => [ + `${ORDINAL_TOKEN}${slot}`, + `${ROW_KIND_TOKEN}${rowKinds.get(slot)!}`, + ...[...cell.node.textContent.normalize('NFC')].map((character) => `${CHARACTER_TOKEN}${character}`), + ]), + ] + return { index, fingerprint, profile, cells } + }) +} +function tableAxisRecords(locations: readonly Location[], rowOf: ReadonlyMap, steps: readonly Step[], side: Side): readonly AxisNode[] { + const retainedColumns = steps.flatMap((step) => { + if (step.before === null || step.after === null) { + return [] + } + return [side === 'before' ? step.before : step.after] + }) + return locations.map((location) => { + const row = rowOf.get(location) + if (!row) { + return { + location, + fingerprint: nodeKey(location.node), + profile: () => nodeProfile(location.node), + } + } + const cells = retainedColumns.map((column) => row.cells[column]) + const fingerprint = serialize([ + row.kind, + ...cells.map((cell) => cell ? nodeKey(cell.node) : null), + ]) + let materializedProfile: readonly string[] | undefined + const profile = () => materializedProfile ??= [ + `${ROW_KIND_TOKEN}${row.kind}`, + ...cells.flatMap((cell, column) => [ + `${COLUMN_TOKEN}${column}`, + ...(cell ? nodeProfile(cell.node) : [ABSENT_CELL_TOKEN]), + ]), + ] + return { location, fingerprint, profile } + }) +} +function sameSteps(candidate: readonly Step[], refined: readonly Step[]) { + return candidate.length === refined.length + && candidate.every((step, index) => ( + step.before === refined[index]!.before && step.after === refined[index]!.after + )) +} +function exactEvidenceConflict(beforeCols: readonly Column[], afterCols: readonly Column[], steps: readonly Step[]) { + const unmatchedColumns = { before: [] as string[], after: [] as string[] } + const unmatchedCells = { before: [] as string[], after: [] as string[] } + for (const step of steps) { + const columns = [ + step.before === null ? null : beforeCols[step.before]!, + step.after === null ? null : afterCols[step.after]!, + ] as const + if (!columns[0] || !columns[1]) { + const side = columns[0] ? 'before' : 'after' + const column = columns[0] ?? columns[1]! + unmatchedColumns[side].push(column.fingerprint) + unmatchedCells[side].push(...[...column.cells.values()].map(({ node }) => nodeKey(node))) + continue + } + if (columns[0].fingerprint !== columns[1].fingerprint) { + unmatchedColumns.before.push(columns[0].fingerprint) + unmatchedColumns.after.push(columns[1].fingerprint) + } + for (const ordinal of new Set([...columns[0].cells.keys(), ...columns[1].cells.keys()])) { + const cells = [columns[0].cells.get(ordinal), columns[1].cells.get(ordinal)] as const + const fingerprints = cells.map((cell) => cell && nodeKey(cell.node)) + if (fingerprints[0] !== fingerprints[1]) { + if (fingerprints[0]) { + unmatchedCells.before.push(fingerprints[0]) + } + if (fingerprints[1]) { + unmatchedCells.after.push(fingerprints[1]) + } + } + } + } + return sharesValue(unmatchedColumns.before, unmatchedColumns.after) + || sharesValue(unmatchedCells.before, unmatchedCells.after) +} +function sharesValue(before: readonly string[], after: readonly string[]) { + const known = new Set(before) + return after.some((value) => known.has(value)) +} +function emitTablePlan(builder: Builder, before: Location, after: Location, entries: readonly AxisEntry[], rowPairs: readonly RowPair[], beforeCols: readonly Column[], afterCols: readonly Column[], steps: readonly Step[]) { + const pairedRows = new Set(rowPairs.map(({ before: row }) => row.location)) + const missingBefore = precomputeMissing(entries, 'before', before.from + 1, before.to - 1) + const missingAfter = precomputeMissing(entries, 'after', after.from + 1, after.to - 1) + for (const [index, entry] of entries.entries()) { + if (entry.before[0] && entry.after[0]) { + if (!pairedRows.has(entry.before[0])) { + compareNodes(builder, entry.before[0], entry.after[0]) + } + } else if (entry.before[0]) { + const missing = missingAfter[index]! + emitBlock(builder, entry.before[0], null, missing) + } else if (entry.after[0]) { + const missing = missingBefore[index]! + emitBlock(builder, null, entry.after[0], missing) + } + } + const slots = counterpartSlots(steps, beforeCols.length, afterCols.length) + for (const step of steps) { + if (step.before !== null && step.after !== null) { + comparePairedColumn(builder, rowPairs, beforeCols[step.before]!, afterCols[step.after]!) + } else if (step.before !== null) { + emitColumnEdit(builder, rowPairs, beforeCols[step.before]!, 'before', slots.after[step.before]!) + } else if (step.after !== null) { + emitColumnEdit(builder, rowPairs, afterCols[step.after]!, 'after', slots.before[step.after]!) + } + } +} +function counterpartSlots(steps: readonly Step[], beforeCount: number, afterCount: number) { + const after = new Array(beforeCount).fill(afterCount) + const before = new Array(afterCount).fill(beforeCount) + let pendingAfter = afterCount + let pendingBefore = beforeCount + for (const step of steps.toReversed()) { + if (step.after !== null) { + pendingAfter = step.after + } else if (step.before !== null) { + after[step.before] = pendingAfter + } + if (step.before !== null) { + pendingBefore = step.before + } else if (step.after !== null) { + before[step.after] = pendingBefore + } + } + return { before, after } +} +function comparePairedColumn(builder: Builder, rowPairs: readonly RowPair[], before: Column, after: Column) { + const pairedCells = rowPairs.flatMap((pair) => { + const beforeCell = before.cells.get(pair.slot) + const afterCell = after.cells.get(pair.slot) + return beforeCell && afterCell ? [{ pair, beforeCell, afterCell }] : [] + }) + const markup = pairedCells.flatMap(({ pair, beforeCell, afterCell }) => { + const descriptor = markupDescriptor(builder, beforeCell, afterCell) + return descriptor ? [{ descriptor, row: pair.before.kind }] : [] + }) + if (markup.length) { + const header = markup.findIndex(({ row }) => row === 'header') + pushEdit( + builder, + 'content', + markup[header < 0 ? 0 : header]!.descriptor, + markup.map(({ descriptor }) => descriptor), + ) + } + for (const pair of rowPairs) { + const beforeCell = before.cells.get(pair.slot) + const afterCell = after.cells.get(pair.slot) + if (beforeCell && afterCell) { + compareNodes(builder, beforeCell, afterCell, false, ['table-alignment']) + } else if (beforeCell) { + const slot = cellSlot(pair.after, after.index) + emitBlock(builder, beforeCell, null, slot) + } else if (afterCell) { + const slot = cellSlot(pair.before, before.index) + emitBlock(builder, null, afterCell, slot) + } + } +} +function emitColumnEdit(builder: Builder, rowPairs: readonly RowPair[], column: Column, side: Side, counterpart: number) { + const rowPairOf = new Map(rowPairs.map((pair) => [pair.slot, pair])) + const present = [...column.cells].flatMap(([slot, cell]) => { + const pair = rowPairOf.get(slot) + return pair ? [{ pair, cell }] : [] + }) + if (present.length === 0) { + return + } + const descriptors = present.map(({ pair, cell }) => { + const slot = cellSlot(side === 'before' ? pair.after : pair.before, counterpart) + return blockDescriptor( + builder, + side === 'before' ? cell : null, + side === 'before' ? null : cell, + slot, + ) + }) + const header = present.findIndex(({ pair }) => pair[side].kind === 'header') + pushEdit(builder, 'table-column', descriptors[header < 0 ? 0 : header]!, descriptors) +} +function cellSlot(row: TableRow, columnIndex: number) { + const cell = row.cells[columnIndex] + if (cell) { + return cell.from + } + return row.location.to - 1 +} +function emitMarkup(builder: Builder, before: Location, after: Location) { + const descriptor = markupDescriptor(builder, before, after) + pushContent(builder, descriptor) + return descriptor?.signals.flatMap((signal) => signal.type === 'attribute' ? [signal.attribute] : []) ?? [] +} +function emitBlock(builder: Builder, before: Location | null, after: Location | null, absentPosition = 0, excluded: readonly Attr[] = []) { + pushContent(builder, blockDescriptor(builder, before, after, absentPosition, excluded)) +} +function blockDescriptor(builder: Builder, before: Location | null, after: Location | null, absentPosition: number, excluded: readonly Attr[] = []) { + return descriptorFor( + builder, + before ? rangeFor(before) : emptyRange(absentPosition), + after ? rangeFor(after) : emptyRange(absentPosition), + before ? [before] : [], + after ? [after] : [], + 'block', + excluded, + ) +} +function emitCoarse(builder: Builder, before: readonly Location[], after: readonly Location[], coarseReason: Reason, excluded: readonly Attr[] = []) { + pushContent(builder, { + ...descriptorFor( + builder, + { from: before[0]!.from, to: before.at(-1)!.to }, + { from: after[0]!.from, to: after.at(-1)!.to }, + before, + after, + 'block', + excluded, + ), + coarseReason, + }) +} +function emitMove(builder: Builder, group: readonly MovePair[]) { + const first = group[0]! + const last = group.at(-1)! + pushContent(builder, { + ...descriptorFor( + builder, + { from: first.before.from, to: last.before.to }, + { from: first.after.from, to: last.after.to }, + group.map(({ before }) => before), + group.map(({ after }) => after), + 'block', + ), + operation: 'move', + facets: ['structure'], + signals: [{ type: 'node' }], + }) +} +function pushContent(builder: Builder, descriptor: Descriptor | null) { + if (descriptor) { + pushEdit(builder, 'content', descriptor, [descriptor]) + } +} +function pushEdit(builder: Builder, kind: EditKind, primary: Descriptor, descriptors: Descriptor[]) { + builder.descriptorCount += descriptors.length + if (builder.descriptorCount > builder.maximumDescriptors) { + throw new ComparisonModelLimitError() + } + builder.edits.push({ kind, primary, descriptors }) +} +function finalizeEdits(pending: readonly PendingEdit[]): readonly Edit[] { + let descriptorCount = 0 + return pending + .toSorted((a, b) => compareDescriptors(a.primary, b.primary)) + .map((edit, index) => { + const identified = new Map(edit.descriptors + .toSorted(compareDescriptors) + .map((descriptor) => [descriptor, { + ...descriptor, + id: `change-${(descriptorCount++).toString(36)}`, + }])) + return { + id: `edit-${index.toString(36)}`, + kind: edit.kind, + primary: identified.get(edit.primary)!, + descriptors: [...identified.values()], + } + }) +} +function compareDescriptors(a: Descriptor, b: Descriptor) { + return a.after.from - b.after.from + || a.before.from - b.before.from + || a.after.to - b.after.to + || a.before.to - b.before.to + || compareCodeUnits(a.operation, b.operation) +} +function axisOptions(builder: Builder): Options { + return { + work: builder.work, + fingerprint: ({ node }) => nodeKey(node), + profile: ({ node }) => nodeProfile(node), + compatible: (before, after) => before.node.type === after.node.type + || (before.node.isTextblock && after.node.isTextblock), + } +} +function nodeProfile(node: Node): readonly string[] { + const cached = profileCache.get(node) + if (cached !== undefined) { + return cached + } + const profile = node.isTextblock + ? [...node.textContent.normalize('NFC').replace(/\s+/gu, ' ').trim()] + : structuralTokens(node, []) + profileCache.set(node, profile) + return profile +} +function structuralTokens(node: Node, tokens: string[]) { + node.forEach((child) => { + if (child.isText) { + for (const character of (child.text ?? '').normalize('NFC')) { + tokens.push(`${CHARACTER_TOKEN}${character}`) + } + } else { + tokens.push(`${NODE_TOKEN}${child.type.name}`) + structuralTokens(child, tokens) + } + }) + return tokens +} +function descriptorFor(builder: Builder, before: Range, after: Range, beforeNodes: readonly Location[], afterNodes: readonly Location[], detail: Descriptor['detail'], excluded: readonly Attr[] = []) { + return classify( + builder.originalBefore, + builder.originalAfter, + before, + after, + originalLocations(builder, 'before', beforeNodes), + originalLocations(builder, 'after', afterNodes), + detail, + excluded, + ) +} +function markupDescriptor(builder: Builder, before: Location, after: Location) { + return classifyMarkup( + builder.originalBefore, + builder.originalAfter, + rangeFor(before), + rangeFor(after), + originalLocation(builder, 'before', before), + originalLocation(builder, 'after', after), + ) +} +function originalLocations(builder: Builder, side: Side, locations: readonly Location[]) { + return locations.map((location) => originalLocation(builder, side, location)) +} +function originalLocation(builder: Builder, side: Side, location: Location) { + return (side === 'before' ? builder.originalBeforeIndex : builder.originalAfterIndex) + .nodeAtPath(location.path) +} +function rangeFor(node: Location): Range { + return { from: node.from, to: node.to } +} +function emptyRange(position: number): Range { + return { from: position, to: position } +} diff --git a/src/comparison/markdownComparison.ts b/src/comparison/markdownComparison.ts new file mode 100644 index 00000000000..ae0e19a4a05 --- /dev/null +++ b/src/comparison/markdownComparison.ts @@ -0,0 +1,257 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * 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/markdownComparisonClassification.ts b/src/comparison/markdownComparisonClassification.ts new file mode 100644 index 00000000000..42f48c1902e --- /dev/null +++ b/src/comparison/markdownComparisonClassification.ts @@ -0,0 +1,540 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Mark, Node } from '@tiptap/pm/model' +import type { LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' +import type { ComparisonAttributeCode as Attr, ComparisonContext as Context, ComparisonContextCode as ContextCode, ComparisonContextLocation as ContextLocation, ComparisonDescriptor as Descriptor, ComparisonFacet as Facet, ComparisonMarkCode as MarkCode, ComparisonOperation as Operation, ComparisonPreviewAtom as Preview, ComparisonRange as Range, ComparisonSignal as Signal } from './markdownComparisonTypes.ts' + +import { getTextDirection } from '../extensions/TextDirection.ts' +import { findComparisonNodes as findNodes, comparisonRangeText as rangeText } from './comparisonDocumentIndex.ts' + +interface AttributeRecord { + nodeName: string + attribute: string + value: unknown + textContent: string +} +const marksCache = new WeakMap() +const fingerprints = new WeakMap() +const nodeShapeCache = new WeakMap() +const graphemeSegmenter = typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null + +const contextCodes: Record = { + frontMatter: 'front-matter', + paragraph: 'paragraph', + heading: 'heading', + bulletList: 'list-item', + orderedList: 'list-item', + taskList: 'list-item', + listItem: 'list-item', + taskItem: 'task', + table: 'table', + tableRow: 'table-row', + tableHeadRow: 'table-row', + tableCell: 'table-cell', + tableHeader: 'table-cell', + codeBlock: 'code-block', + blockquote: 'quote', + callout: 'callout', + details: 'details', + detailsContent: 'details', + detailsSummary: 'details', + footnotes: 'footnote', + footnote: 'footnote', + footnoteReference: 'footnote-reference', + image: 'image', + imageInline: 'image', + mention: 'mention', + inlineMath: 'mathematics', + blockMath: 'mathematics', + preview: 'preview', +} +const contextPriority: Record = { + 'footnote-reference': 100, + image: 95, + mention: 95, + mathematics: 95, + preview: 95, + footnote: 90, + 'table-cell': 85, + task: 80, + 'list-item': 75, + 'front-matter': 70, + 'code-block': 70, + callout: 65, + details: 65, + quote: 60, + heading: 50, + paragraph: 40, + 'table-row': 20, + table: 10, + unknown: 0, +} +const markCodes: Record = { + strong: 'bold', + em: 'italic', + strike: 'strike', + highlight: 'highlight', + underline: 'underline', + code: 'inline-code', +} +const meaningfulAttributes: Record> = { + heading: { level: 'heading-level' }, + orderedList: { start: 'list-start' }, + taskItem: { checked: 'task-state' }, + codeBlock: { language: 'code-language' }, + image: { src: 'image-target', alt: 'image-alt' }, + imageInline: { src: 'image-target', alt: 'image-alt' }, + mention: { id: 'mention-identity', label: 'mention-identity' }, + inlineMath: { latex: 'mathematics' }, + blockMath: { latex: 'mathematics' }, + preview: { href: 'preview-target' }, + footnoteReference: { referenceId: 'footnote-reference' }, + footnote: { referenceId: 'footnote-reference' }, + callout: { type: 'callout-type' }, + details: { open: 'details-state' }, + tableCell: { align: 'table-alignment', colspan: 'table-span', rowspan: 'table-span' }, + tableHeader: { align: 'table-alignment', colspan: 'table-span', rowspan: 'table-span' }, +} +function serialize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? String(value) + } + if (Array.isArray(value)) { + return `[${value.map(serialize).join(',')}]` + } + return `{${Object.entries(value) + .toSorted(([a], [b]) => compareCodeUnits(a, b)) + .map(([key, child]) => `${JSON.stringify(key)}:${serialize(child)}`) + .join(',')}}` +} +export { serialize as stableSerialize } + +function fingerprint(node: Node) { + let value = fingerprints.get(node) + if (value === undefined) { + value = serialize(node.toJSON()) + fingerprints.set(node, value) + } + return value +} +export { fingerprint as nodeFingerprint } + +export function compareCodeUnits(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} +function encodeMarks(marks: readonly Mark[]) { + const cached = marksCache.get(marks) + if (cached !== undefined) { + return cached + } + const encoded = marks + .map((mark) => `${mark.type.name}:${serialize(mark.attrs)}`) + .toSorted() + .join('|') + marksCache.set(marks, encoded) + return encoded +} +export const semanticTokenEncoder = { + encodeCharacter(character: number, marks: readonly Mark[]) { + return `character:${character}:${encodeMarks(marks)}` + }, + encodeNodeStart(node: Node) { + return `node-start:${node.type.name}:${serialize(node.attrs)}:${encodeMarks(node.marks)}` + }, + encodeNodeEnd(node: Node) { + return `node-end:${node.type.name}` + }, + compareTokens(a: string, b: string) { + return a === b + }, +} +export function classifyComparisonDescriptor(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeRoots: readonly Location[], afterRoots: readonly Location[], detail: Descriptor['detail'] = 'inline', excluded: readonly Attr[] = []): Descriptor { + const safeBefore = boundedRange(before, beforeDoc.content.size) + const safeAfter = boundedRange(after, afterDoc.content.size) + const beforeNodes = findNodes(safeBefore, beforeRoots) + const afterNodes = findNodes(safeAfter, afterRoots) + const context: Context = { + before: resolveContext(beforeNodes, safeBefore), + after: resolveContext(afterNodes, safeAfter), + } + const facets = new Set() + const signals: Signal[] = [] + const beforeText = rangeText(safeBefore, beforeRoots) + const afterText = rangeText(safeAfter, afterRoots) + + if (beforeText !== afterText) { + facets.add('text') + } + classifyMarks(beforeDoc, afterDoc, safeBefore, safeAfter, beforeNodes, afterNodes, facets, signals) + classifyNodes(beforeNodes, afterNodes, safeBefore, safeAfter, facets, signals) + classifyAttributes(beforeNodes, afterNodes, facets, signals, excluded) + + if (facets.size === 0) { + facets.add('unknown') + } + return { + id: '', + operation: operationFor(safeBefore, safeAfter), + detail, + facets: orderedFacets(facets), + before: safeBefore, + after: safeAfter, + context, + preview: { + before: previewAtom(safeBefore, beforeText, beforeNodes), + after: previewAtom(safeAfter, afterText, afterNodes), + }, + signals: deduplicateSignals(signals), + } +} +export function classifyNodeMarkupDescriptor(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeRoot: Location, afterRoot: Location): Descriptor | null { + const safeBefore = boundedRange(before, beforeDoc.content.size) + const safeAfter = boundedRange(after, afterDoc.content.size) + const beforeNodes = findNodes(safeBefore, [beforeRoot]) + const afterNodes = findNodes(safeAfter, [afterRoot]) + const facets = new Set() + const signals: Signal[] = [] + classifyDirectAttributes(beforeRoot.node, afterRoot.node, facets, signals) + classifyDirectMarks(beforeRoot.node, afterRoot.node, facets, signals) + if (beforeRoot.node.type.name !== afterRoot.node.type.name) { + facets.add('structure') + signals.push({ type: 'node' }) + } + if (facets.size === 0) { + return null + } + return { + id: '', + operation: 'replace', + detail: 'block', + facets: orderedFacets(facets), + before: safeBefore, + after: safeAfter, + context: { + before: resolveContext(beforeNodes, safeBefore), + after: resolveContext(afterNodes, safeAfter), + }, + preview: { + before: previewAtom(safeBefore, rangeText(safeBefore, [beforeRoot]), beforeNodes), + after: previewAtom(safeAfter, rangeText(safeAfter, [afterRoot]), afterNodes), + }, + signals: deduplicateSignals(signals), + } +} +function operationFor(before: Range, after: Range): Operation { + const beforeEmpty = before.from === before.to + const afterEmpty = after.from === after.to + return beforeEmpty !== afterEmpty ? (beforeEmpty ? 'insert' : 'delete') : 'replace' +} +function classifyMarks(beforeDoc: Node, afterDoc: Node, before: Range, after: Range, beforeNodes: readonly Location[], afterNodes: readonly Location[], facets: Set, signals: Signal[]) { + classifyMarkMaps( + collectMarks(beforeDoc, before, beforeNodes), + collectMarks(afterDoc, after, afterNodes), + false, + facets, + signals, + ) +} +function classifyMarkMaps(previous: ReadonlyMap, next: ReadonlyMap, direct: boolean, facets: Set, signals: Signal[]) { + const names = new Set([...previous.keys(), ...next.keys()]) + for (const name of [...names].toSorted()) { + const before = previous.get(name) + const after = next.get(name) + if (serialize(before) === serialize(after)) { + continue + } + const change = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed' + if (name === 'link') { + facets.add('attribute') + signals.push({ + type: 'attribute', + attribute: direct || change === 'changed' ? 'link-target' : 'link', + change, + }) + } else if (markCodes[name]) { + facets.add('formatting') + signals.push({ type: 'mark', mark: markCodes[name], change }) + } else { + facets.add('unknown') + } + } +} +function classifyNodes(beforeNodes: readonly Location[], afterNodes: readonly Location[], before: Range, after: Range, facets: Set, signals: Signal[]) { + if (structuralShape(beforeNodes, before) === structuralShape(afterNodes, after)) { + return + } + facets.add('structure') + signals.push({ type: 'node' }) +} +function classifyAttributes(beforeNodes: readonly Location[], afterNodes: readonly Location[], facets: Set, signals: Signal[], excluded: readonly Attr[]) { + const previous = collectAttributes(beforeNodes) + const next = collectAttributes(afterNodes) + const keys = [...previous.keys()].filter((key) => next.has(key)).toSorted() + for (const key of keys) { + const before = previous.get(key)! + const after = next.get(key)! + if (serialize(before.value) === serialize(after.value)) { + continue + } + if (before.nodeName !== after.nodeName) { + continue + } + if (after.attribute === 'dir' && isInferredDirectionTransition( + before.value, + after.value, + before.textContent, + after.textContent, + )) { + continue + } + const code = attributeCode(after.nodeName, after.attribute) + if (code && excluded.includes(code)) { + continue + } + addAttributeSignal(code, 'changed', facets, signals) + } +} +function classifyDirectAttributes(before: Node, after: Node, facets: Set, signals: Signal[]) { + const names = new Set([...Object.keys(before.attrs), ...Object.keys(after.attrs)]) + for (const attribute of [...names].toSorted()) { + const previous = before.attrs[attribute] + const next = after.attrs[attribute] + if (serialize(previous) === serialize(next)) { + continue + } + if (attribute === 'dir' && isInferredDirectionTransition( + previous, + next, + before.textContent, + after.textContent, + )) { + continue + } + addAttributeSignal( + attributeCode(after.type.name, attribute), + previous === undefined ? 'added' : next === undefined ? 'removed' : 'changed', + facets, + signals, + ) + } +} +function attributeCode(nodeName: string, attribute: string) { + return attribute === 'dir' ? 'text-direction' : meaningfulAttributes[nodeName]?.[attribute] +} +function addAttributeSignal(code: Attr | undefined, change: 'added' | 'removed' | 'changed', facets: Set, signals: Signal[]) { + facets.add('attribute') + if (!code) { + facets.add('unknown') + } + signals.push({ type: 'attribute', attribute: code ?? 'unknown-attribute', change }) +} +function classifyDirectMarks(before: Node, after: Node, facets: Set, signals: Signal[]) { + const previous = new Map(before.marks.map((mark) => [mark.type.name, serialize(mark.attrs)])) + const next = new Map(after.marks.map((mark) => [mark.type.name, serialize(mark.attrs)])) + classifyMarkMaps(previous, next, true, facets, signals) +} +function collectMarks(doc: Node, range: Range, nodes: readonly Location[]) { + const marks = new Map() + const add = (mark: Mark) => { + const values = marks.get(mark.type.name) ?? [] + const encoded = serialize(mark.attrs) + if (!values.includes(encoded)) { + values.push(encoded) + values.sort() + } + marks.set(mark.type.name, values) + } + if (range.from === range.to) { + for (const mark of doc.resolve(range.from).marks()) { + add(mark) + } + } else { + for (const { node } of nodes) { + for (const mark of node.marks) { + add(mark) + } + } + } + return marks +} +function resolveContext(nodes: readonly Location[], range: Range): ContextLocation | null { + const candidate = contextCandidates(nodes).toSorted((a, b) => compareContextCandidates(a, b, range))[0] + if (!candidate) { + return nodes[0] + ? { + code: 'unknown', + path: nodes[0].path, + from: nodes[0].from, + to: nodes[0].to, + } + : null + } + return { + code: contextCodes[candidate.node.type.name]!, + path: candidate.path, + from: candidate.from, + to: candidate.to, + } +} +function contextCandidates(nodes: readonly Location[]) { + return nodes.filter(({ node }) => contextCodes[node.type.name] !== undefined) +} +function structuralShape(nodes: readonly Location[], range: Range) { + const contained = nodes.filter(({ node, from, to }) => !node.isText + && range.from <= from + && range.to >= to) + const containedNodes = new Set(contained) + const roots = contained.filter(({ parent }) => !parent || !containedNodes.has(parent)) + return roots.map(({ node }) => nodeShape(node)).join('|') +} +function isInferredDirectionTransition(before: unknown, after: unknown, beforeText: string, afterText: string) { + return (!before || !after) + && beforeText !== afterText + && before === getTextDirection(beforeText) + && after === getTextDirection(afterText) +} +function nodeShape(node: Node): string { + const cached = nodeShapeCache.get(node) + if (cached !== undefined) { + return cached + } + if (node.isText) { + return '' + } + const children: string[] = [] + node.forEach((child) => { + const shape = nodeShape(child) + if (shape && children.at(-1) !== shape) { + children.push(shape) + } + }) + const shape = `${node.type.name}(${children.join(',')})` + nodeShapeCache.set(node, shape) + return shape +} +function collectAttributes(nodes: readonly Location[]) { + const records = new Map() + const topLevelIndices = [...new Set(nodes.map(({ path }) => path[0]).filter((index) => index !== undefined))] + .toSorted((a, b) => a - b) + const topLevelOrder = new Map(topLevelIndices.map((index, order) => [index, order])) + for (const { node, path } of nodes) { + if (node.isText) { + continue + } + const relativePath = path.length + ? [topLevelOrder.get(path[0]!) ?? 0, ...path.slice(1)] + : [] + for (const [attribute, value] of Object.entries(node.attrs)) { + records.set(`${relativePath.join('.')}:${node.type.name}:${attribute}`, { + nodeName: node.type.name, + attribute, + value, + textContent: node.textContent, + }) + } + } + return records +} +function previewAtom(range: Range, rangeText: string, nodes: readonly Location[]): Preview | null { + if (range.from === range.to) { + return null + } + const text = frontMatterPreview(range, nodes) + || normalizePreview(rangeText.replaceAll('\ufffc', '')) + if (text) { + return { kind: 'text', text: truncateGraphemes(text, 96) } + } + const contextNode = contextCandidates(nodes).toSorted(compareContextCandidates)[0] ?? nodes[0] + const contextText = normalizePreview(contextNode?.node.textContent ?? '') + if (contextText) { + return { kind: 'text', text: truncateGraphemes(contextText, 96) } + } + const nodeName = contextNode?.node.type.name + const node = nodeName === 'frontMatter' + ? 'front-matter' + : nodeName === 'image' || nodeName === 'imageInline' + ? 'image' + : nodeName === 'mention' + ? 'mention' + : nodeName === 'inlineMath' || nodeName === 'blockMath' + ? 'mathematics' + : nodeName === 'footnoteReference' + ? 'footnote-reference' + : nodeName === 'horizontalRule' + ? 'horizontal-rule' + : 'changed-content' + return { kind: 'node', node } +} +function frontMatterPreview(range: Range, nodes: readonly Location[]) { + const frontMatter = nodes.find(({ node, from, to }) => ( + node.type.name === 'frontMatter' && range.from <= to && range.to >= from + )) + if (!frontMatter) { + return '' + } + const content = frontMatter.node.textContent + const contentStart = frontMatter.from + 1 + const from = clamp(range.from - contentStart, 0, content.length) + const to = clamp(range.to - contentStart, from, content.length) + const lineStart = content.lastIndexOf('\n', Math.max(0, from - 1)) + 1 + const nextBreak = content.indexOf('\n', to) + return normalizePreview(content.slice(lineStart, nextBreak < 0 ? content.length : nextBreak)) +} +function compareContextCandidates(a: Location, b: Location, range?: Range) { + const aCode = contextCodes[a.node.type.name]! + const bCode = contextCodes[b.node.type.name]! + return Number(coversRange(b, bCode, range)) - Number(coversRange(a, aCode, range)) + || contextPriority[bCode] - contextPriority[aCode] + || b.path.length - a.path.length + || a.from - b.from +} +function coversRange(location: Location, code: ContextCode, range: Range | undefined) { + return range !== undefined + && (code === 'table' || code === 'table-row') + && location.from === range.from + && location.to === range.to +} +function normalizePreview(value: string) { + return value.replace(/\s+/gu, ' ').trim() +} +export function truncateGraphemes(value: string, maximum: number) { + let count = 0 + let truncated = '' + const segments = graphemeSegmenter?.segment(value) ?? value + for (const item of segments) { + if (count++ === maximum) { + return `${truncated}…` + } + truncated += typeof item === 'string' ? item : item.segment + } + return value +} +function orderedFacets(facets: Set) { + const order: Facet[] = ['text', 'formatting', 'attribute', 'structure', 'unknown'] + return order.filter((facet) => facets.has(facet)) +} +function deduplicateSignals(signals: Signal[]) { + const byValue = new Map(signals.map((signal) => [serialize(signal), signal])) + return [...byValue.values()].toSorted((a, b) => compareCodeUnits(serialize(a), serialize(b))) +} +function boundedRange(range: Range, maximum: number) { + const from = clamp(range.from, 0, maximum) + return { from, to: clamp(range.to, from, maximum) } +} +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum) +} +export function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value) + for (const child of Object.values(value)) { + deepFreeze(child) + } + } + return value +} diff --git a/src/comparison/markdownComparisonMoves.ts b/src/comparison/markdownComparisonMoves.ts new file mode 100644 index 00000000000..c5eb8adb044 --- /dev/null +++ b/src/comparison/markdownComparisonMoves.ts @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' +import type { LocatedComparisonNode as Location } from './comparisonDocumentIndex.ts' + +import { nodeFingerprint as keyFor } from './markdownComparisonClassification.ts' + +export interface ReservedExactMovePair { + before: Location + after: Location + fingerprint: string +} +type Pair = ReservedExactMovePair + +export function confirmReservedExactMoves( + before: Node, + after: Node, + candidates: readonly Pair[], +) { + if (candidates.length === 0) { + return { groups: [] as Pair[][] } + } + const beforeCounts = documentFingerprintCounts(before) + const afterCounts = documentFingerprintCounts(after) + const confirmed = candidates.filter(({ fingerprint, before: beforeNode, after: afterNode }) => ( + beforeCounts.get(fingerprint) === 1 + && afterCounts.get(fingerprint) === 1 + && beforeNode.node.eq(afterNode.node) + )) + + const groups: Pair[][] = [] + for (const pair of confirmed.toSorted((a, b) => ( + a.before.index - b.before.index || a.after.index - b.after.index + ))) { + const previous = groups.at(-1)?.at(-1) + if (previous + && pair.before.index === previous.before.index + 1 + && pair.after.index === previous.after.index + 1) { + groups.at(-1)!.push(pair) + } else { + groups.push([pair]) + } + } + return { groups } +} + +function documentFingerprintCounts(doc: Node) { + const counts = new Map() + doc.descendants((node) => { + const fingerprint = keyFor(node) + counts.set(fingerprint, (counts.get(fingerprint) ?? 0) + 1) + }) + return counts +} diff --git a/src/comparison/markdownComparisonTypes.ts b/src/comparison/markdownComparisonTypes.ts new file mode 100644 index 00000000000..382e252d2a4 --- /dev/null +++ b/src/comparison/markdownComparisonTypes.ts @@ -0,0 +1,148 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type ComparisonOperation = 'insert' | 'delete' | 'replace' | 'move' + +export type ComparisonDetail = 'inline' | 'block' + +export type ComparisonFacet + = | 'text' + | 'formatting' + | 'attribute' + | 'structure' + | 'unknown' + +export interface ComparisonRange { + from: number + to: number +} + +export type ComparisonContextCode + = | 'front-matter' + | 'paragraph' + | 'heading' + | 'list-item' + | 'task' + | 'table' + | 'table-row' + | 'table-cell' + | 'code-block' + | 'quote' + | 'callout' + | 'details' + | 'footnote' + | 'footnote-reference' + | 'image' + | 'mention' + | 'mathematics' + | 'preview' + | 'unknown' + +export interface ComparisonContextLocation { + code: ComparisonContextCode + path: readonly number[] + from: number + to: number +} + +export interface ComparisonContext { + before: ComparisonContextLocation | null + after: ComparisonContextLocation | null +} + +export type ComparisonPreviewNode + = | 'front-matter' + | 'image' + | 'mention' + | 'mathematics' + | 'footnote-reference' + | 'horizontal-rule' + | 'changed-content' + +export type ComparisonPreviewAtom + = | { kind: 'text', text: string } + | { kind: 'node', node: ComparisonPreviewNode } + +export interface ComparisonPreview { + before: ComparisonPreviewAtom | null + after: ComparisonPreviewAtom | null +} + +export type ComparisonMarkCode + = | 'bold' + | 'italic' + | 'strike' + | 'highlight' + | 'underline' + | 'inline-code' + +export type ComparisonAttributeCode + = | 'link' + | 'link-target' + | 'heading-level' + | 'list-start' + | 'task-state' + | 'code-language' + | 'text-direction' + | 'image-target' + | 'image-alt' + | 'mention-identity' + | 'mathematics' + | 'preview-target' + | 'footnote-reference' + | 'callout-type' + | 'details-state' + | 'table-alignment' + | 'table-span' + | 'unknown-attribute' + +export type ComparisonSignal + = | { + type: 'mark' + mark: ComparisonMarkCode + change: 'added' | 'removed' | 'changed' + } + | { + type: 'attribute' + attribute: ComparisonAttributeCode + change: 'added' | 'removed' | 'changed' + } + | { + type: 'node' + } + +export type ComparisonCoarseReason + = | 'ambiguous-attribution' + | 'comparison-limit' + | 'table-evidence-conflict' + | 'unsupported-table' + +export interface ComparisonDescriptor { + id: string + operation: ComparisonOperation + detail: ComparisonDetail + facets: readonly ComparisonFacet[] + before: ComparisonRange + after: ComparisonRange + context: ComparisonContext + preview: ComparisonPreview + signals: readonly ComparisonSignal[] + coarseReason?: ComparisonCoarseReason +} + +export type ComparisonEditKind = 'content' | 'table-column' + +export interface ComparisonEdit { + id: string + kind: ComparisonEditKind + primary: ComparisonDescriptor + descriptors: readonly ComparisonDescriptor[] +} + +export interface MarkdownComparisonModel { + edits: readonly ComparisonEdit[] +} + +export type ComparisonSide = 'before' | 'after' diff --git a/src/comparison/markdownSourceComparison.ts b/src/comparison/markdownSourceComparison.ts new file mode 100644 index 00000000000..78e52f47668 --- /dev/null +++ b/src/comparison/markdownSourceComparison.ts @@ -0,0 +1,526 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Change } from 'diff' +import type { SourceComparisonWorkerRequest as WorkerRequest, SourceComparisonWorkerResponse as WorkerResponse } from './markdownSourceComparisonProtocol.ts' + +import { diffWordsWithSpace } from 'diff' +import { compareMarkdownSourceLines } from './markdownSourceComparisonProtocol.ts' +import { displayBoundedMarkdownSource } from './markdownSourceDisplay.ts' + +export type SourceEol = 'lf' | 'crlf' | 'cr' | 'none' +export type SourceLineEnding = Exclude | 'mixed' + +export interface SourceDiffSegment { + text: string + changed: boolean +} +export interface SourceDiffLine { + number: number + text: string + eol: SourceEol + changed: boolean + segments: readonly SourceDiffSegment[] +} +export interface SourceDiffRow { + before?: SourceDiffLine + after?: SourceDiffLine +} +export interface SourceDiffHunk { + id: string + beforeStart: number + afterStart: number + rows: readonly SourceDiffRow[] +} +export interface SourceDiffGap { + id: string + slot: number + beforeFrom: number + beforeTo: number + afterFrom: number + afterTo: number + count: number +} +export interface SourceLineEndingChange { + before: SourceLineEnding + after: SourceLineEnding +} +export interface SourceDiffReadyModel { + status: 'ready' + hunks: readonly SourceDiffHunk[] + gaps: readonly SourceDiffGap[] + lineEndingChange: SourceLineEndingChange | null +} +export interface SourceDiffLimitedModel { + status: 'limited' + reason: 'size' | 'complexity' +} +export type SourceDiffModel = SourceDiffReadyModel | SourceDiffLimitedModel +export type SourceGapMaterializer = typeof materializeSourceDiffGap + +type SourceLine = SourceDiffLine +interface ChangedRun { + beforeFrom: number + beforeTo: number + afterFrom: number + afterTo: number +} +interface HunkRange extends ChangedRun { + beforeContextFrom: number + beforeContextTo: number + afterContextFrom: number + afterContextTo: number +} + +export const SOURCE_DIFF_LIMITS = Object.freeze({ + maximumCharacters: 2_000_000, + maximumLines: 60_000, + maximumEditLength: 50_000, + timeoutMilliseconds: 2_000, + contextLines: 3, + maximumDisplayedRows: 5_000, + maximumGapPageRows: 500, + maximumWordDiffLines: 20, + maximumWordDiffCharacters: 2_000, + maximumWordDiffMilliseconds: 100, + maximumWordDiffPairMilliseconds: 10, +}) +const LIMITS = SOURCE_DIFF_LIMITS + +export async function createMarkdownSourceComparison(before: string, after: string, signal?: AbortSignal): Promise { + if ( + before.length + after.length > LIMITS.maximumCharacters + || sourceLineCount(before) + sourceLineCount(after) + > LIMITS.maximumLines + || displayBoundedMarkdownSource(before).truncated + || displayBoundedMarkdownSource(after).truncated + ) { + return { status: 'limited', reason: 'size' } + } + checkAbort(signal) + const normalizedBefore = normalize(before) + const normalizedAfter = normalize(after) + const changes = await computeLineChanges( + normalizedBefore, + normalizedAfter, + signal, + ) + if (!changes) { + return { status: 'limited', reason: 'complexity' } + } + checkAbort(signal) + return buildSourceModel( + before, + after, + normalizedBefore === normalizedAfter, + changes, + ) +} + +export function materializeSourceDiffGap(before: string, after: string, gap: SourceDiffGap, maximumRows: number = LIMITS.maximumGapPageRows, offset: number = 0) { + const finiteMaximum = Number.isFinite(maximumRows) + ? Math.trunc(maximumRows) + : LIMITS.maximumGapPageRows + const rowLimit = Math.max( + 0, + Math.min(finiteMaximum, LIMITS.maximumGapPageRows), + ) + const finiteOffset = Number.isFinite(offset) ? Math.trunc(offset) : 0 + const rowOffset = Math.max(0, Math.min(finiteOffset, gap.count)) + const beforeFrom = Math.min(gap.beforeFrom + rowOffset, gap.beforeTo) + const afterFrom = Math.min(gap.afterFrom + rowOffset, gap.afterTo) + const beforeGap = splitRange( + before, + beforeFrom, + Math.min(beforeFrom + rowLimit, gap.beforeTo), + rowLimit, + ) + const afterGap = splitRange( + after, + afterFrom, + Math.min(afterFrom + rowLimit, gap.afterTo), + rowLimit, + ) + return pairSourceRows(beforeGap, afterGap) +} + +async function computeLineChanges(before: string, after: string, signal?: AbortSignal) { + const request: WorkerRequest = { + before, + after, + maximumEditLength: LIMITS.maximumEditLength, + timeoutMilliseconds: LIMITS.timeoutMilliseconds, + } + if (typeof Worker === 'undefined') { + const response = compareMarkdownSourceLines(request) + return response.status === 'ready' ? response.changes : undefined + } + const worker = new Worker( + new URL('./markdownSourceComparison.worker.ts', import.meta.url), + { type: 'module' }, + ) + return new Promise((resolve, reject) => { + let settled = false + const settle = (action: () => void) => { + if (settled) { + return + } + settled = true + signal?.removeEventListener('abort', abort) + try { + worker.terminate() + } finally { + action() + } + } + const fail = (error: unknown) => settle(() => reject(error)) + function abort() { + fail(abortError()) + } + const workerError = () => fail(new Error('Source comparison worker failed')) + worker.onmessage = ({ data }: MessageEvent) => { + if (!isSourceComparisonWorkerResponse(data)) { + workerError() + return + } + settle(() => resolve(data.status === 'ready' ? data.changes : undefined)) + } + worker.onerror = workerError + worker.onmessageerror = workerError + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + abort() + return + } + try { + worker.postMessage(request) + } catch (error) { + fail(error) + } + }) +} + +function isSourceComparisonWorkerResponse(response: unknown): response is WorkerResponse { + if (!response || typeof response !== 'object') { + return false + } + const candidate = response as { status?: unknown, changes?: unknown } + return ( + candidate.status === 'limited' + || (candidate.status === 'ready' && Array.isArray(candidate.changes)) + ) +} + +function buildSourceModel(before: string, after: string, normalizedEqual: boolean, changes: Change[]): SourceDiffModel { + const beforeLines = splitRange(before) + const afterLines = splitRange(after) + const ranges = mergeRanges(changedRuns(changes).map((run) => withContext(run, beforeLines.length, afterLines.length))) + const displayedRows = ranges.reduce( + (total, range) => total + + Math.max(0, range.beforeContextTo - range.beforeContextFrom) + + Math.max(0, range.afterContextTo - range.afterContextFrom), + 0, + ) + if (displayedRows > LIMITS.maximumDisplayedRows) { + return { status: 'limited', reason: 'complexity' } + } + const hunks = ranges.map((range, index) => createHunk(range, beforeLines, afterLines, index)) + return { + status: 'ready', + hunks, + gaps: createGaps(ranges, beforeLines, afterLines), + lineEndingChange: summarizeLineEndingChange( + beforeLines, + afterLines, + normalizedEqual, + ), + } +} + +function changedRuns(changes: Change[]) { + const runs: ChangedRun[] = [] + let beforeLine = 0 + let afterLine = 0 + let current: ChangedRun | null = null + for (const change of changes) { + const count = change.count ?? sourceLineCount(change.value) + if (!change.added && !change.removed) { + if (current) { + runs.push(current) + } + current = null + beforeLine += count + afterLine += count + continue + } + current ??= { + beforeFrom: beforeLine, + beforeTo: beforeLine, + afterFrom: afterLine, + afterTo: afterLine, + } + if (change.removed) { + current.beforeTo += count + beforeLine += count + } else { + current.afterTo += count + afterLine += count + } + } + if (current) { + runs.push(current) + } + return runs +} + +function withContext(run: ChangedRun, beforeLength: number, afterLength: number): HunkRange { + const context = (from: number, to: number, length: number) => ({ + from: Math.max(0, from - LIMITS.contextLines), + to: Math.min(length, Math.max(to, from + 1) + LIMITS.contextLines), + }) + const before = context(run.beforeFrom, run.beforeTo, beforeLength) + const after = context(run.afterFrom, run.afterTo, afterLength) + return { + ...run, + beforeContextFrom: before.from, + beforeContextTo: before.to, + afterContextFrom: after.from, + afterContextTo: after.to, + } +} + +function mergeRanges(ranges: HunkRange[]) { + const merged: HunkRange[] = [] + for (const range of ranges) { + const previous = merged.at(-1) + if ( + previous + && range.beforeContextFrom <= previous.beforeContextTo + && range.afterContextFrom <= previous.afterContextTo + ) { + previous.beforeTo = Math.max(previous.beforeTo, range.beforeTo) + previous.afterTo = Math.max(previous.afterTo, range.afterTo) + previous.beforeContextTo = Math.max( + previous.beforeContextTo, + range.beforeContextTo, + ) + previous.afterContextTo = Math.max( + previous.afterContextTo, + range.afterContextTo, + ) + } else { + merged.push({ ...range }) + } + } + return merged +} + +function createHunk(range: HunkRange, before: SourceLine[], after: SourceLine[], index: number): SourceDiffHunk { + const select = (lines: SourceLine[], contextFrom: number, contextTo: number, from: number, to: number) => lines + .slice(contextFrom, contextTo) + .map((line, offset) => cloneLine(line, contextFrom + offset >= from && contextFrom + offset < to)) + const beforeHunk = select(before, range.beforeContextFrom, range.beforeContextTo, range.beforeFrom, range.beforeTo) + const afterHunk = select(after, range.afterContextFrom, range.afterContextTo, range.afterFrom, range.afterTo) + addWordEmphasis( + beforeHunk.filter(({ changed }) => changed), + afterHunk.filter(({ changed }) => changed), + ) + return { + id: `source-hunk-${index.toString(36)}`, + beforeStart: beforeHunk[0]?.number ?? 0, + afterStart: afterHunk[0]?.number ?? 0, + rows: pairSourceRows(beforeHunk, afterHunk), + } +} + +function pairSourceRows(before: readonly SourceLine[], after: readonly SourceLine[]) { + const rows: SourceDiffRow[] = [] + let left = 0 + let right = 0 + while (left < before.length || right < after.length) { + if (before[left]?.changed || after[right]?.changed) { + const removed: SourceLine[] = [] + const added: SourceLine[] = [] + while (before[left]?.changed) { + removed.push(before[left++]!) + } + while (after[right]?.changed) { + added.push(after[right++]!) + } + for ( + let index = 0; + index < Math.max(removed.length, added.length); + index++ + ) { + rows.push({ before: removed[index], after: added[index] }) + } + } else { + rows.push({ before: before[left++], after: after[right++] }) + } + } + return rows +} + +function addWordEmphasis(before: SourceLine[], after: SourceLine[]) { + const count = Math.min( + before.length, + after.length, + LIMITS.maximumWordDiffLines, + ) + const deadline = Date.now() + LIMITS.maximumWordDiffMilliseconds + for ( + let index = 0; + index < count; + index++ + ) { + const left = before[index]! + const right = after[index]! + if ( + left.text.length + right.text.length + > LIMITS.maximumWordDiffCharacters + || Date.now() > deadline + ) { + continue + } + const pairDeadline + = Date.now() + LIMITS.maximumWordDiffPairMilliseconds + const words = diffWordsWithSpace(left.text, right.text) + if (Date.now() > pairDeadline) { + continue + } + left.segments = words + .filter(({ added }) => !added) + .map(({ value, removed }) => ({ text: value, changed: Boolean(removed) })) + right.segments = words + .filter(({ removed }) => !removed) + .map(({ value, added }) => ({ text: value, changed: Boolean(added) })) + } +} + +function createGaps(ranges: HunkRange[], before: SourceLine[], after: SourceLine[]) { + const gaps: SourceDiffGap[] = [] + for (let slot = 0; slot <= ranges.length; slot++) { + const beforeFrom = slot === 0 ? 0 : ranges[slot - 1]!.beforeContextTo + const beforeTo + = slot === ranges.length ? before.length : ranges[slot]!.beforeContextFrom + const afterFrom = slot === 0 ? 0 : ranges[slot - 1]!.afterContextTo + const afterTo + = slot === ranges.length ? after.length : ranges[slot]!.afterContextFrom + const beforeCount = beforeTo - beforeFrom + const afterCount = afterTo - afterFrom + if (beforeCount || afterCount) { + gaps.push({ + id: `source-gap-${slot.toString(36)}`, + slot, + beforeFrom, + beforeTo, + afterFrom, + afterTo, + count: Math.max(beforeCount, afterCount), + }) + } + } + return gaps +} + +function splitRange(source: string, from = 0, to = Number.POSITIVE_INFINITY, maximumLines = Number.POSITIVE_INFINITY) { + if (!source || from >= to || maximumLines <= 0) { + return [] + } + const lines: SourceLine[] = [] + const pattern = /([^\r\n]*)(\r\n|\n|\r|$)/gu + let lineIndex = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(source))) { + if (!match[1] && !match[2] && match.index === source.length) { + break + } + if (lineIndex >= to || lines.length >= maximumLines) { + break + } + if (lineIndex >= from) { + lines.push(createSourceLine(match[1]!, match[2]!, lineIndex + 1)) + } + lineIndex++ + if (!match[2]) { + break + } + } + return lines +} + +function createSourceLine(text: string, rawEol: string, number: number): SourceLine { + const eol: SourceEol + = rawEol === '\r\n' + ? 'crlf' + : rawEol === '\n' + ? 'lf' + : rawEol === '\r' + ? 'cr' + : 'none' + return { + number, + text, + eol, + changed: false, + segments: [{ text, changed: false }], + } +} +function cloneLine(line: SourceLine, changed: boolean): SourceLine { + return { ...line, changed, segments: [{ text: line.text, changed: false }] } +} + +function summarizeLineEndingChange(before: SourceLine[], after: SourceLine[], normalizedEqual: boolean): SourceLineEndingChange | null { + if ( + before.length === after.length + && before.every(({ eol }, index) => eol === after[index]!.eol) + ) { + return null + } + const left = lineEndingConvention(before) + const right = lineEndingConvention(after) + return left && right && (normalizedEqual || left !== right) + ? { before: left, after: right } + : null +} + +function lineEndingConvention(lines: SourceLine[]): SourceLineEnding | null { + const endings = new Set(lines + .map(({ eol }) => eol) + .filter((eol): eol is Exclude => eol !== 'none')) + return endings.size === 0 + ? null + : endings.size === 1 + ? [...endings][0]! + : 'mixed' +} +function normalize(source: string) { + return source.replace(/\r\n?|\n/gu, '\n') +} + +function sourceLineCount(source: string) { + if (!source) { + return 0 + } + let count = 1 + for (let index = 0; index < source.length; index++) { + if ( + source[index] === '\n' + || (source[index] === '\r' && source[index + 1] !== '\n') + ) { + count++ + } + } + return count +} +function abortError() { + return new DOMException('Source comparison aborted', 'AbortError') +} + +function checkAbort(signal?: AbortSignal) { + if (signal?.aborted) { + throw abortError() + } +} diff --git a/src/comparison/markdownSourceComparison.worker.ts b/src/comparison/markdownSourceComparison.worker.ts new file mode 100644 index 00000000000..f4994c4966f --- /dev/null +++ b/src/comparison/markdownSourceComparison.worker.ts @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { SourceComparisonWorkerRequest } from './markdownSourceComparisonProtocol.ts' + +import { compareMarkdownSourceLines } from './markdownSourceComparisonProtocol.ts' + +addEventListener('message', ({ data }: MessageEvent) => { + postMessage(compareMarkdownSourceLines(data)) +}) diff --git a/src/comparison/markdownSourceComparisonProtocol.ts b/src/comparison/markdownSourceComparisonProtocol.ts new file mode 100644 index 00000000000..a6e4883993b --- /dev/null +++ b/src/comparison/markdownSourceComparisonProtocol.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Change } from 'diff' + +import { diffLines } from 'diff' + +export interface SourceComparisonWorkerRequest { + before: string + after: string + maximumEditLength: number + timeoutMilliseconds: number +} + +export type SourceComparisonWorkerResponse = { status: 'ready', changes: Change[] } | { status: 'limited' } + +export function compareMarkdownSourceLines(request: SourceComparisonWorkerRequest): SourceComparisonWorkerResponse { + const changes = diffLines(request.before, request.after, { + stripTrailingCr: false, + maxEditLength: request.maximumEditLength, + timeout: request.timeoutMilliseconds, + }) + return changes ? { status: 'ready', changes } : { status: 'limited' } +} 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/ComparisonChangeList.vue b/src/components/ComparisonChangeList.vue new file mode 100644 index 00000000000..5246b903044 --- /dev/null +++ b/src/components/ComparisonChangeList.vue @@ -0,0 +1,474 @@ + + + + + + + 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..a7ed89846fa --- /dev/null +++ b/src/components/MarkdownContentComparison.vue @@ -0,0 +1,769 @@ + + + + + + + diff --git a/src/components/MarkdownSourceComparison.vue b/src/components/MarkdownSourceComparison.vue new file mode 100644 index 00000000000..072458fbcd1 --- /dev/null +++ b/src/components/MarkdownSourceComparison.vue @@ -0,0 +1,527 @@ + + + + + + + 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/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index bee105461f7..7dfab0f7f7a 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -12,6 +12,12 @@ import Markdown from '../extensions/Markdown.js' import markdownit from '../markdownit/index.js' import { isUser } from '../services/SyncService.ts' +export function renderEditorContent(content: string, markdown: boolean) { + return markdown + ? markdownit.render(content) + '

' + : `

\n${escapeHtml(content)}
` +} + /** * * @param editor to apply methods to @@ -29,12 +35,9 @@ export function useEditorMethods(editor: Editor) { ) => void = (content, { addToHistory = true } = {}) => { const hasMarkdownContent = editor.extensionManager.extensions.includes(Markdown) - const html = hasMarkdownContent - ? markdownit.render(content) + '

' - : `

\n${escapeHtml(content)}
` editor .chain() - .setContent(html, { emitUpdate: addToHistory }) + .setContent(renderEditorContent(content, hasMarkdownContent), { emitUpdate: addToHistory }) .command(({ tr }) => { tr.setMeta('addToHistory', addToHistory) return true 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/markdownit/details.ts b/src/markdownit/details.ts index fcfe0462a88..6a1f64214ea 100644 --- a/src/markdownit/details.ts +++ b/src/markdownit/details.ts @@ -7,9 +7,8 @@ import type MarkdownIt from 'markdown-it' import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs' import type Token from 'markdown-it/lib/token.mjs' -const DETAILS_START_REGEX = /^
\s*$/ -const DETAILS_AND_SUMMARY_START_REGEX - = /(?<=^
\s*).*(?=<\/summary>\s*$)/ +const DETAILS_START_REGEX = /^\s+open(?:=(?:""|''|open))?)?>\s*$/ +const DETAILS_AND_SUMMARY_START_REGEX = /^\s+open(?:=(?:""|''|open))?)?>\s*(?.*)<\/summary>\s*$/ const DETAILS_END_REGEX = /^<\/details>\s*$/ const SUMMARY_REGEX = /(?<=^).*(?=<\/summary>\s*$)/ @@ -32,16 +31,22 @@ function parseDetails( let detailsFound = false let detailsSummary = null + let openDetails: boolean let startLineCount = 2 - const m = state.src.slice(start, max).match(DETAILS_AND_SUMMARY_START_REGEX) - if (m) { + const openingLine = state.src.slice(start, max) + const combined = openingLine.match(DETAILS_AND_SUMMARY_START_REGEX) + if (combined) { // Details block start and summary in same line - detailsSummary = m[0].trim() + detailsSummary = combined.groups!.summary!.trim() + openDetails = Boolean(combined.groups!.open) startLineCount = 1 - } else if (!state.src.slice(start, max).match(DETAILS_START_REGEX)) { - // Details block start in separate line - return false + } else { + const opening = openingLine.match(DETAILS_START_REGEX) + if (!opening) { + return false + } + openDetails = Boolean(opening.groups!.open) } // Since start is found, we can report success here in validation mode @@ -105,6 +110,9 @@ function parseDetails( token.block = true token.info = detailsSummary token.map = [startLine, nextLine] + if (openDetails) { + token.attrSet('open', '') + } token = state.push('details_summary', 'summary', 1) token.block = false diff --git a/src/nodes/Details.js b/src/nodes/Details.js index 7b5f9073f8a..01b814f245c 100644 --- a/src/nodes/Details.js +++ b/src/nodes/Details.js @@ -67,6 +67,11 @@ const Details = Node.create({ openDetails: { default: false, }, + open: { + default: false, + parseHTML: (element) => element.hasAttribute('open'), + renderHTML: ({ open }) => open ? { open: '' } : {}, + }, } }, @@ -91,7 +96,7 @@ const Details = Node.create({ }, toMarkdown: (state, node) => { - state.write('
\n') + state.write(node.attrs.open ? '
\n' : '
\n') state.renderContent(node) state.closeBlock(node) state.ensureNewLine() 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 }} - +