mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 19:09:19 +02:00
238 lines
6.6 KiB
TypeScript
238 lines
6.6 KiB
TypeScript
// expands modified textblocks in a merged document so that deleted
|
|
// text is injected as real content with a diffTextRemoved mark (and
|
|
// inserted text gets a diffTextAdded mark). this gives deleted text
|
|
// real ProseMirror positions, making it selectable and commentable.
|
|
//
|
|
// textblocks whose content includes non-text inline nodes (e.g.
|
|
// hard breaks) are left unexpanded — the DiffDecorations plugin
|
|
// handles them with the existing widget fallback.
|
|
|
|
import type { JSONContent } from "@tiptap/core"
|
|
import Text from "@tiptap/extension-text"
|
|
import { DiffStatus } from "./position-map"
|
|
import { extractTokensFromJSON, type DiffToken } from "./diff-tokens"
|
|
import { computeTokenDiff } from "./diff-ops"
|
|
import {
|
|
DIFF_TEXT_ADDED_MARK_NAME,
|
|
DIFF_TEXT_REMOVED_MARK_NAME,
|
|
} from "~/components/editor/mark-names"
|
|
import { MODIFIED_TEXT_CONTENT_TYPES } from "./config"
|
|
|
|
// extract character-level tokens preserving all marks (including
|
|
// comment marks that extractTokensFromJSON normally strips). used
|
|
// when building content for the new (modified) side so that active
|
|
// comments survive the expansion.
|
|
function extractTokensPreservingMarks(node: JSONContent): DiffToken[] {
|
|
const tokens: DiffToken[] = []
|
|
|
|
if (!node.content) {
|
|
return tokens
|
|
}
|
|
|
|
for (const child of node.content) {
|
|
if (child.type !== Text.name || !child.text) {
|
|
continue
|
|
}
|
|
|
|
const marks = child.marks ?? []
|
|
|
|
for (const char of child.text) {
|
|
tokens.push({ text: char, marks })
|
|
}
|
|
}
|
|
|
|
return tokens
|
|
}
|
|
|
|
// check whether a JSON node has only text children (or is empty).
|
|
function hasOnlyTextContent(node: JSONContent): boolean {
|
|
if (!node.content || node.content.length === 0) {
|
|
return true
|
|
}
|
|
|
|
return node.content.every((c) => c.type === Text.name)
|
|
}
|
|
|
|
// build JSON text nodes from diff tokens, grouping consecutive
|
|
// tokens with identical mark sets into single text nodes. when
|
|
// extraMark is provided it is appended to every token's marks.
|
|
function tokensToJSON(
|
|
tokens: DiffToken[],
|
|
extraMark?: JSONContent,
|
|
): JSONContent[] {
|
|
const result: JSONContent[] = []
|
|
let currentText = ""
|
|
let currentMarks: JSONContent[] = []
|
|
let currentKey = ""
|
|
|
|
function flush() {
|
|
if (currentText.length === 0) {
|
|
return
|
|
}
|
|
|
|
const node: JSONContent = { type: "text", text: currentText }
|
|
|
|
if (currentMarks.length > 0) {
|
|
node.marks = currentMarks as { type: string }[]
|
|
}
|
|
|
|
result.push(node)
|
|
currentText = ""
|
|
}
|
|
|
|
// consecutive tokens from the same source text node share the same
|
|
// marks array reference (assigned in extractTokensFromJSON /
|
|
// extractTokensPreservingMarks). we track the last reference with
|
|
// prevTokenMarks so we can skip both the spread+push copy and the
|
|
// jsonStableStringify serialization when the reference hasn't
|
|
// changed. for a 500-char run from one text node this reduces 500
|
|
// stringify calls and 500 array copies to 1 each.
|
|
//
|
|
// this is safe because extraMark is constant for the entire call —
|
|
// if token.marks is the same reference, the spread+push would
|
|
// produce an equivalent array and the same serialized key.
|
|
let prevTokenMarks: JSONContent[] | null = null
|
|
|
|
for (const token of tokens) {
|
|
if (token.marks !== prevTokenMarks) {
|
|
const marks = [...token.marks]
|
|
|
|
if (extraMark) {
|
|
marks.push(extraMark)
|
|
}
|
|
|
|
const key = marks.length > 0 ? jsonStableStringify(marks) : ""
|
|
prevTokenMarks = token.marks
|
|
|
|
if (key !== currentKey) {
|
|
flush()
|
|
currentMarks = marks
|
|
currentKey = key
|
|
}
|
|
}
|
|
|
|
currentText += token.text
|
|
}
|
|
|
|
flush()
|
|
return result
|
|
}
|
|
|
|
// try to expand a single modified textblock. returns the expanded
|
|
// node with merged content and oldNode cleared, or null if the node
|
|
// cannot be expanded (non-text inline content or atom/non-textblock
|
|
// nodes with no content).
|
|
function expandTextblock(node: JSONContent): JSONContent | null {
|
|
const oldNodeJSON = node.attrs?.oldNode as JSONContent | undefined
|
|
if (!oldNodeJSON) {
|
|
return null
|
|
}
|
|
|
|
// skip atom / leaf nodes that have no content — they are not
|
|
// textblocks and should keep their oldNode for component-level
|
|
// diffing (e.g. metricBlock).
|
|
const nodeEmpty = !node.content || node.content.length === 0
|
|
const oldEmpty = !oldNodeJSON.content || oldNodeJSON.content.length === 0
|
|
|
|
if (nodeEmpty && oldEmpty) {
|
|
return null
|
|
}
|
|
|
|
if (!hasOnlyTextContent(node) || !hasOnlyTextContent(oldNodeJSON)) {
|
|
return null
|
|
}
|
|
|
|
// stripped tokens for comparisons (comment marks removed)
|
|
const oldTokens = extractTokensFromJSON(oldNodeJSON)
|
|
const newTokensDiff = extractTokensFromJSON(node)
|
|
|
|
// full tokens for building the new-side content (preserves comments)
|
|
const newTokensFull = extractTokensPreservingMarks(node)
|
|
|
|
const ops = computeTokenDiff(oldTokens, newTokensDiff)
|
|
|
|
const mergedContent: JSONContent[] = []
|
|
let oldIdx = 0
|
|
let newIdx = 0
|
|
|
|
for (const op of ops) {
|
|
const len = op.tokens.length
|
|
|
|
if (op.type === "equal") {
|
|
// use full marks from the new side to preserve comment marks
|
|
mergedContent.push(
|
|
...tokensToJSON(newTokensFull.slice(newIdx, newIdx + len)),
|
|
)
|
|
oldIdx += len
|
|
newIdx += len
|
|
} else if (op.type === "insert") {
|
|
mergedContent.push(
|
|
...tokensToJSON(newTokensFull.slice(newIdx, newIdx + len), {
|
|
type: DIFF_TEXT_ADDED_MARK_NAME,
|
|
}),
|
|
)
|
|
newIdx += len
|
|
} else {
|
|
// deleted text uses old tokens (stripped of comment marks —
|
|
// comments from the published version should not appear)
|
|
mergedContent.push(
|
|
...tokensToJSON(oldTokens.slice(oldIdx, oldIdx + len), {
|
|
type: DIFF_TEXT_REMOVED_MARK_NAME,
|
|
}),
|
|
)
|
|
oldIdx += len
|
|
}
|
|
}
|
|
|
|
const expandedAttrs: Record<string, unknown> = {
|
|
...node.attrs,
|
|
oldNode: null,
|
|
}
|
|
|
|
if (node.type && MODIFIED_TEXT_CONTENT_TYPES.has(node.type)) {
|
|
expandedAttrs.modifiedTextContent = newTokensFull
|
|
.map((t) => t.text)
|
|
.join("")
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
attrs: expandedAttrs,
|
|
content: mergedContent.length > 0 ? mergedContent : undefined,
|
|
}
|
|
}
|
|
|
|
// recursively walk the tree and expand modified textblocks.
|
|
function expandNode(node: JSONContent): JSONContent {
|
|
if (node.attrs?.diffStatus === DiffStatus.Modified && node.attrs.oldNode) {
|
|
const expanded = expandTextblock(node)
|
|
|
|
if (expanded) {
|
|
return expanded
|
|
}
|
|
}
|
|
|
|
if (!node.content) {
|
|
return node
|
|
}
|
|
|
|
let changed = false
|
|
const newContent = node.content.map((child) => {
|
|
const result = expandNode(child)
|
|
|
|
if (result !== child) {
|
|
changed = true
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- changed is set inside the synchronous map callback, which TS narrowing does not track
|
|
return changed ? { ...node, content: newContent } : node
|
|
}
|
|
|
|
// expand inline diffs in all modified textblocks of a merged document.
|
|
export function expandInlineDiffs(doc: JSONContent): JSONContent {
|
|
return expandNode(doc)
|
|
}
|