Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig(
...recommended,
globalIgnores(['src/tests/fixtures/*']),
{
name: 'production-public-jsdoc',
files: ['src/**/*.{js,ts,vue}'],
ignores: ['**/*.test.*', '**/*.spec.*', '**/*.cy.*', '**/test/**', '**/tests/**', '**/__tests__/**', '**/__mocks__/**'],
Comment thread
max-nextcloud marked this conversation as resolved.
rules: {
'jsdoc/require-jsdoc': ['warn', { publicOnly: true }],
},
},
{
files: ['cypress/**/*.js'],
extends: [pluginCypress.configs.globals],
Expand Down
34 changes: 34 additions & 0 deletions src/comparison/comparisonAlignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,21 @@ type Region = ComparisonAlignmentRegion
type Pair = ExactComparisonPair
type Ledger = ComparisonWorkLedger

/**
* Create a mutable work budget shared by all axes of one comparison.
*/
export function createComparisonWorkLedger(): Ledger {
return {
remainingCells: DEFAULT_COMPARISON_CELL_LEDGER,
remainingTokenComparisons: DEFAULT_COMPARISON_TOKEN_LEDGER,
}
}

/**
* Keep only pairs present in every longest increasing alignment.
*
* @param pairs Candidate indices on the Before and After axes.
*/
export function forcedIncreasingPairs(pairs: readonly Pair[]): readonly Pair[] {
if (pairs.length < 2) {
return pairs
Expand All @@ -73,6 +81,11 @@ export function forcedIncreasingPairs(pairs: readonly Pair[]): readonly Pair[] {
&& candidatesPerLevel[left[index]!] === 1)
}

/**
* Find a strictly increasing subsequence and the best length ending at each input index.
*
* @param values Axis indices in candidate order.
*/
export function increasingSubsequence(values: readonly number[]) {
const tails: number[] = []
const previous = new Int32Array(values.length).fill(-1)
Expand Down Expand Up @@ -100,13 +113,27 @@ export function increasingSubsequence(values: readonly number[]) {
return { lengths, indices: indices.reverse() }
}

/**
* Align an axis around unique exact matches, preserving coarse regions when attribution is uncertain.
*
* @param before Original axis items.
* @param after Replacement axis items.
* @param options Matching functions and the shared work budget consumed by gap solving.
*/
export function alignComparisonAxis<T>(before: readonly T[], after: readonly T[], options: Options<T>): 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)
}

/**
* Align columns using occurrence rank when repeated exact columns have equal counts.
*
* @param before Original columns.
* @param after Replacement columns.
* @param options Matching functions and the shared work budget consumed by gap solving.
*/
export function alignComparisonColumns<T>(before: readonly T[], after: readonly T[], options: Options<T>): readonly Region[] {
const beforeKeys = before.map(options.fingerprint)
const afterKeys = after.map(options.fingerprint)
Expand Down Expand Up @@ -245,6 +272,13 @@ interface AlignmentState {
signatures: readonly number[]
}

/**
* Resolve a gap to array-index pairs, or return a coarse reason for ambiguity or exhausted work.
*
* @param before Original items within this gap.
* @param after Replacement items within this gap.
* @param options Matching functions and mutable budget charged before solving.
*/
export function solveWeightedGap<T>(before: readonly T[], after: readonly T[], options: Options<T>): { steps: readonly Step[] } | { coarseReason: CoarseReason } {
const cellCharge = before.length * after.length
if (cellCharge > options.work.remainingCells) {
Expand Down
17 changes: 17 additions & 0 deletions src/comparison/comparisonDocumentIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ interface Mutable extends Omit<Location, 'children'> {

const minimalRootsCache = new WeakMap<readonly Location[], readonly Location[]>()

/**
* Index original document positions and parent/child paths. Looking up an absent path throws.
*
* @param doc Document whose nodes remain unchanged.
*/
export function createComparisonDocumentIndex(doc: Node): ComparisonDocumentIndex {
const byPath = new Map<string, Mutable>()
const locateChildren = (
Expand Down Expand Up @@ -70,6 +75,12 @@ export function createComparisonDocumentIndex(doc: Node): ComparisonDocumentInde
}
}

/**
* Find intersecting nodes and their ancestors in document order. Empty ranges include touching boundaries.
*
* @param range Range in original ProseMirror coordinates.
* @param roots Indexed subtrees to search.
*/
export function findComparisonNodes(range: Range, roots: readonly Location[]) {
const found = new Map<string, Location>()
const add = (location: Location) => found.set(pathKey(location.path), location)
Expand All @@ -89,6 +100,12 @@ export function findComparisonNodes(range: Range, roots: readonly Location[]) {
return [...found.values()].toSorted((a, b) => a.from - b.from || a.path.length - b.path.length)
}

/**
* Read a range without duplicating nested roots, using newlines for blocks and U+FFFC for leaves.
*
* @param range Original document range; an empty range returns an empty string.
* @param roots Indexed subtrees containing the range.
*/
export function comparisonRangeText(range: Range, roots: readonly Location[]) {
if (range.from === range.to) {
return ''
Expand Down
43 changes: 43 additions & 0 deletions src/comparison/comparisonNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@

import type { ComparisonEdit as Edit, ComparisonSide as Side } from './markdownComparisonTypes.ts'

/**
* Check whether every descriptor changes formatting alone.
*
* @param edit Semantic edit to classify.
*/
export function isPureFormatting(edit: Edit) {
return edit.descriptors.every(({ facets }) => facets.length === 1 && facets[0] === 'formatting')
}

/**
* Keep the current edit if visible, otherwise prefer the next visible edit, then the previous one.
*
* @param edits All edits in navigation order.
* @param activeIds Visible edit IDs; an empty list clears the selection.
* @param currentId Previously selected edit, if any.
*/
export function currentIdAfterFilter(
edits: readonly Edit[],
activeIds: readonly string[],
Expand Down Expand Up @@ -37,6 +49,13 @@ export function currentIdAfterFilter(
return edits.find(({ id }) => active.has(id))?.id ?? null
}

/**
* Move through visible edits with wraparound, returning null for an empty list.
*
* @param activeIds Visible edit IDs in navigation order.
* @param currentId Selected ID; an absent ID starts from the first edit.
* @param offset Signed number of edits to move.
*/
export function moveCurrentId(activeIds: readonly string[], currentId: string | null, offset: number) {
if (activeIds.length === 0) {
return null
Expand All @@ -46,21 +65,45 @@ export function moveCurrentId(activeIds: readonly string[], currentId: string |
return activeIds[next]!
}

/**
* Return a one-based visible ordinal, or zero when no visible edit is selected.
*
* @param activeIds Visible edit IDs in navigation order.
* @param currentId Selected ID, if any.
*/
export function currentOrdinal(activeIds: readonly string[], currentId: string | null) {
const index = currentId ? activeIds.indexOf(currentId) : -1
return index < 0 ? 0 : index + 1
}

/**
* Map arrow, Home and End keys to a side, returning null for other keys.
*
* @param key KeyboardEvent key value.
*/
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
}
/**
* Use immediate scrolling when the reader requests reduced motion.
*/
export function comparisonScrollBehavior(): ScrollBehavior {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
}

/**
* Center a change in its pane while preserving horizontal scroll and clamping to the scrollable range.
* Return false when the pane, scroller or target geometry is unavailable.
*
* @param pane Visible pane containing the change decorations.
* @param scroller Scroll container inside the pane.
* @param id Descriptor ID to locate.
* @param behavior Requested browser scroll behavior.
* @param fallbackRect Geometry for an undecorated range, such as an insertion boundary.
*/
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
Expand Down
10 changes: 10 additions & 0 deletions src/comparison/comparisonPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,22 @@ const marks: Record<MarkCode, readonly [number, Label]> = {
'inline-code': [101, () => t('text', 'Inline code')],
}

/**
* Choose the highest-priority signal, keeping the first on ties and returning undefined for an empty list.
*
* @param signals Signals attached to a descriptor.
*/
export function selectComparisonSignal(signals: readonly Signal[]): Signal | undefined {
return signals.reduce<Signal | undefined>((selected, signal) => (
!selected || signalPriority(signal) > signalPriority(selected) ? signal : selected
), undefined)
}

/**
* Localize attribute and mark signals; other signal kinds have no label here.
*
* @param signal Descriptor signal to describe.
*/
export function comparisonSignalLabel(signal: Signal) {
if (signal.type === 'attribute') {
return attributes[signal.attribute][1]()
Expand Down
19 changes: 19 additions & 0 deletions src/comparison/comparisonSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export interface ComparisonSection {
}
type Heading = ComparisonHeading

/**
* Collect nonempty top-level headings at their original document positions.
*
* @param doc Document to inspect.
*/
export function headingLocations(doc: Node): readonly Heading[] {
const headings: Heading[] = []
doc.forEach((node, from) => {
Expand All @@ -44,6 +49,12 @@ function nearestHeadingIndex(headings: readonly Heading[], position: number) {
}
return lower - 1
}
/**
* Return the heading at or before a position, or an empty title before the first heading.
*
* @param headings Headings ordered by document position.
* @param position Original ProseMirror position.
*/
export function nearestHeading(headings: readonly Heading[], position: number) {
return headings[nearestHeadingIndex(headings, position)]?.text ?? ''
}
Expand Down Expand Up @@ -128,6 +139,14 @@ function resolveSection(edit: ComparisonEdit, before: HeadingIndex, after: Headi
return side.keys[nearestHeadingIndex(side.headings, position)] ?? ''
}

/**
* Group consecutive edits under correlated headings, preserving edit order.
* Heading correlation groups changes; it does not establish unchanged-block correspondence.
*
* @param edits Edits in display order.
* @param beforeDocument Original Before document.
* @param afterDocument Original After document.
*/
export function buildComparisonSections(edits: readonly ComparisonEdit[], beforeDocument: Node, afterDocument: Node): readonly ComparisonSection[] {
const beforeHeadings = headingLocations(beforeDocument)
const afterHeadings = headingLocations(afterDocument)
Expand Down
6 changes: 6 additions & 0 deletions src/comparison/createComparisonEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ interface ComparisonEditorOptions {
schema?: Schema
}

/**
* Create a read-only embedded Markdown editor. The caller owns attachment and destruction.
*
* @param content Markdown snapshot; non-string input throws.
* @param options Accessibility, resource, link and optional shared-schema settings.
*/
export function createComparisonEditor(content: string, options: ComparisonEditorOptions = {}) {
if (typeof content !== 'string') {
throw new TypeError('Comparison content must be a string')
Expand Down
9 changes: 9 additions & 0 deletions src/comparison/hierarchicalMarkdownComparisonModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ const ABSENT_CELL_TOKEN = '\u0006'

const profileCache = new WeakMap<Node, readonly string[]>()

/**
* Build a recursively frozen model in original document coordinates without modifying either document.
* Schema normalization must preserve content and positions. Ambiguous regions stay coarse;
* exceeding the descriptor limit throws ComparisonModelLimitError.
*
* @param originalBefore Earlier document.
* @param originalAfter Later document, whose schema is used for comparison.
* @param options Optional descriptor budget.
*/
export function createHierarchicalMarkdownComparisonModel(originalBefore: Node, originalAfter: Node, options: ComparisonModelOptions = {}): Model {
const comparisonBefore = normalizeSchema(originalBefore, originalAfter)
const originalBeforeIndex = indexDocument(originalBefore)
Expand Down
30 changes: 30 additions & 0 deletions src/comparison/markdownComparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export class ComparisonProjectionError extends Error {
}
}

/**
* Check each snapshot against the rendered comparison character, line and line-length limits.
*
* @param before Earlier Markdown snapshot.
* @param after Later Markdown snapshot.
*/
export function exceedsRenderedComparisonLimit(before: string, after: string): boolean {
return [before, after].some((content) => {
if (content.length > LIMITS.maximumCharactersPerSnapshot) {
Expand All @@ -74,6 +80,15 @@ export function exceedsRenderedComparisonLimit(before: string, after: string): b
}

let pluginId = 0
/**
* Create an independently keyed decoration plugin for one unchanged comparison document.
* Projection errors propagate; document edits clear the decorations.
*
* @param descriptors Original-coordinate change descriptors.
* @param side Document side to decorate.
* @param markerLabel Localized accessible label for change markers.
* @param initialState Active and selected descriptor IDs; all descriptors start active by default.
*/
export function createComparisonDecorationPlugin(descriptors: readonly Descriptor[], side: Side, markerLabel: string, initialState: State = { activeIds: descriptors.map(({ id }) => id), currentIds: [] }) {
const key = new ProseMirrorPluginKey<PluginState>(`markdown-comparison-${side}-${pluginId++}`)
const plugin = new Plugin<PluginState>({
Expand Down Expand Up @@ -103,6 +118,13 @@ export function createComparisonDecorationPlugin(descriptors: readonly Descripto
return { key, plugin }
}

/**
* Dispatch a metadata-only transaction to update active and current decorations.
*
* @param editor Mounted editor receiving the update.
* @param key Key returned with its comparison plugin.
* @param state Active and selected descriptor IDs.
*/
export function setComparisonDecorationState(editor: Editor, key: ComparisonDecorationKey, state: State) {
editor.view.dispatch(editor.state.tr.setMeta(key, state))
}
Expand All @@ -127,6 +149,14 @@ function normalizeDecorationState(descriptors: readonly Descriptor[], state: Sta
}
}

/**
* Project nonempty descriptor ranges onto original document nodes without altering content.
* Empty ranges need no decoration; unprojectable ranges throw ComparisonProjectionError.
*
* @param doc Document to decorate.
* @param descriptors Change descriptors in original coordinates.
* @param side Descriptor side to project.
*/
export function prepareComparisonDecorations(doc: Node, descriptors: readonly Descriptor[], side: Side) {
const index = createComparisonDocumentIndex(doc)
return descriptors.flatMap((descriptor): Prepared[] => {
Expand Down
Loading
Loading