pdf-tei-editor / app /src /modules /codemirror /codemirror-utils.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
22 kB
/**
* @import {SyntaxNode} from '@lezer/common'
* @import {ViewUpdate} from '@codemirror/view'
* @import {Text} from '@codemirror/state'
* @import {Diagnostic} from '@codemirror/lint'
*/
/**
* @typedef {Diagnostic & {line?: number, column?: number}} ExtendedDiagnostic
*/
/**
* @typedef {Object.<string, any>} ReferenceMap
* A recursive map structure that can contain references to other parts of itself.
* Used for resolving deduplicated autocomplete data with "#x" and "#Mx" references.
* Values can be strings, numbers, booleans, arrays, or other ReferenceMap objects.
*/
import { EditorView, ViewPlugin } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
/**
* Links CodeMirror's syntax tree nodes representing XML elements with their corresponding DOM
* elements parsed by DOMParser by traversing both trees in lockstep and storing cross-references
* in two Maps. Processing instructions, comments, and text nodes between elements are skipped
* transparently on both sides.
*
* Algorithm: walks both trees with a pair of sibling cursors; at each element pair it validates
* the tag names match and recurses into their first element child. Per-element memory is O(1) —
* no intermediate arrays of children are materialised.
*
* @param {EditorView} view The CodeMirror EditorView instance.
* @param {SyntaxNode} syntaxNode The root syntax node of the CodeMirror XML editor's syntax tree.
* @param {Element|Document} domNode The (root) DOM element parsed by DOMParser.
* @throws {Error} If the root nodes are not Document/DOCUMENT_NODE, if a root element cannot be
* found on either side, if tag names mismatch, or if the two trees have a different number of
* element children at any level.
* @returns {{syntaxToDom: Map<number, Node>, domToSyntax: Map<Node, number> }} Two Maps linking
* syntax tree node positions to DOM nodes and vice versa.
*/
export function linkSyntaxTreeWithDOM(view, syntaxNode, domNode) {
/** @type {Map<number, Node>} */
const syntaxToDom = new Map();
/** @type {Map<Node, number>} */
const domToSyntax = new Map();
/**
* @param {SyntaxNode} node
* @returns {string}
*/
const getText = node => view.state.doc.sliceString(node.from, node.to);
/**
* Advance along a syntax-node sibling chain until the first Element is found.
* @param {SyntaxNode | null} node
* @returns {SyntaxNode | null}
*/
function findFirstSyntaxElement(node) {
while (node) {
if (node.name === "Element") return node;
node = node.nextSibling;
}
return null;
}
/**
* Advance along a DOM sibling chain until the first Element is found.
* @param {Node | null} node
* @returns {Element | null}
*/
function findFirstDomElement(node) {
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) return /** @type {Element} */ (node);
node = node.nextSibling;
}
return null;
}
/**
* Resolve the TagName syntax node under an Element. Lezer's XML grammar shapes Elements as
* `Element -> (OpenTag | SelfClosingTag) ...`, where the start tag's children are `<`, TagName,
* attributes..., `>`. Returns the TagName node or null when the grammar shape is broken (e.g.
* during partial parsing of malformed input).
* @param {SyntaxNode} element
* @returns {SyntaxNode | null}
*/
function resolveTagNameNode(element) {
const tag = element.firstChild?.firstChild?.nextSibling;
return tag && tag.name === "TagName" ? tag : null;
}
/**
* Link one element pair and recurse into their element children using parallel sibling cursors.
* Side-effect only: writes into the outer syntaxToDom / domToSyntax maps.
* @param {SyntaxNode} syntaxElement
* @param {Element} domElement
*/
function linkPair(syntaxElement, domElement) {
const syntaxTagNode = resolveTagNameNode(syntaxElement);
if (!syntaxTagNode) {
throw new Error(`Expected a TagName child node in syntax tree. Found: ${getText(syntaxElement)}`);
}
const syntaxTagName = getText(syntaxTagNode);
const domTagName = domElement.tagName;
if (syntaxTagName !== domTagName) {
throw new Error(`Tag mismatch: Syntax tree has ${syntaxTagName}, DOM has ${domTagName}`);
}
// Store references. The syntax tree is regenerated on each lookup, so we key by position.
syntaxToDom.set(syntaxElement.from, domElement);
domToSyntax.set(domElement, syntaxElement.from);
// Two-cursor walk: advance both sides element-by-element without building arrays.
let syntaxChild = findFirstSyntaxElement(syntaxElement.firstChild);
let domChild = findFirstDomElement(domElement.firstChild);
while (syntaxChild && domChild) {
linkPair(syntaxChild, domChild);
syntaxChild = findFirstSyntaxElement(syntaxChild.nextSibling);
domChild = findFirstDomElement(domChild.nextSibling);
}
// One side has more element children than the other.
if (syntaxChild && !domChild) {
const extras = [];
for (let n = syntaxChild; n; n = findFirstSyntaxElement(n.nextSibling)) {
extras.push(getText(n));
}
throw new Error(`Syntax tree has more child elements than the DOM tree: ${extras.join(', ')}`);
}
if (domChild && !syntaxChild) {
const extras = [];
for (let n = domChild; n; n = findFirstDomElement(n.nextSibling)) {
extras.push(n.tagName);
}
throw new Error(`DOM tree has more child elements than the syntax tree: ${extras.join(', ')}`);
}
}
if (syntaxNode.name !== "Document" || domNode.nodeType !== Node.DOCUMENT_NODE) {
throw new Error("Invalid arguments. The root syntax node must be the top Document node and the DOM node must be a document. Received: " +
`syntaxNode: ${syntaxNode.name}, domNode: ${Object.keys(Node)[domNode.nodeType - 1]}`);
}
// Locate the root Element on each side, skipping PIs, comments, and whitespace text nodes.
const syntaxRoot = findFirstSyntaxElement(syntaxNode.firstChild);
const domRoot = findFirstDomElement(domNode.firstChild);
if (!syntaxRoot || !domRoot) {
console.warn("Could not find root elements in one or both trees");
return { syntaxToDom, domToSyntax };
}
linkPair(syntaxRoot, domRoot);
return { syntaxToDom, domToSyntax };
}
/**
* Function to install the selection change listener
* @param {function} onSelectionChange
*/
export function selectionChangeListener(onSelectionChange) {
return ViewPlugin.fromClass(
class {
/**
* @param {EditorView} _view
*/
constructor(_view) {
/** @type {function} */
this.onSelectionChange = onSelectionChange;
}
/**
* @param {ViewUpdate} update
*/
update(update) {
if (update.selectionSet) {
const selection = update.state.selection;
const ranges = selection.ranges;
// Convert ranges to plain JavaScript objects if needed, or pass them directly
/**
* @param {any} range
*/
const selectionInfo = ranges.map(range => {
return {
view: update.view,
from: range.from,
to: range.to,
empty: range.empty
}
});
this.onSelectionChange(selectionInfo, update); // Pass the current selectionInfo and the update object to the callback
}
}
destroy() { }
}
);
}
/**
* Resolves a simple XPath-like expression against a CodeMirror 6 syntax tree
* to find the position of the target node.
*
* The XPath only supports direct and indexed children (e.g., "/TEI/standOff/listBibl/biblStruct[8]/monogr").
* TODO this can be replaced with xmlEditor::
*
* @param {EditorView} view The CodeMirror 6 EditorView
* @param {string} xpath The XPath-like expression to resolve.
* @returns {{from: number, to: number}|null} The `from` and `to` positions of the matching node, or null if not found.
*/
export function resolveXPath(view, xpath) {
const tree = syntaxTree(view.state);
const doc = view.state.doc;
const pathSegments = xpath.split("/").filter(segment => segment !== "");
let cursor = tree.topNode.cursor();
let foundNode = null;
/**
* @param {SyntaxNode} node
* @param {number|null} [length=null]
* @returns {string}
*/
function text(node, length = null) {
return doc.sliceString(node.from, length ? Math.min(node.from + length, node.to, doc.length) : node.to);
}
// function debugNode(node, textLength=10) {
// return node ? `(${node.name}: "${text(node, textLength)}")`: "(null)";
// }
for (const segment of pathSegments) {
let index = 0;
let tagName = segment;
const match = segment.match(/^(.*?)\[(\d+)\]$/);
if (match) {
tagName = match[1];
index = parseInt(match[2], 10) - 1;
if (isNaN(index) || index < 0) {
console.error(`Invalid child index in ${segment}`);
return null;
}
}
let childIndex = 0;
let found = false;
//console.log("Next segment:" , tagName, index)
// move to first child of current cursor
if (!cursor.firstChild()) {
console.log("cursor has no children")
return null;
}
do {
//console.log('Current cursor node: ', debugNode(cursor))
if (cursor.name == "Element") {
const element = cursor.node;
//console.log(' - cursor[1][1]: ', debugNode(element.firstChild?.firstChild))
//console.log(' - cursor[1][2]: ', debugNode(element.firstChild?.firstChild?.nextSibling))
let tagNameNode = element.firstChild?.firstChild?.nextSibling;
if (tagNameNode && tagNameNode.name === "TagName" && text(tagNameNode) === tagName) {
if (childIndex === index) {
found = true;
foundNode = element;
break;
}
childIndex++;
}
}
} while (cursor.nextSibling());
if (!found || !foundNode) {
return null; // No matching node found at this level
}
cursor = foundNode.cursor(); // move the cursor for the next level
}
if (foundNode) {
return { from: foundNode.from, to: foundNode.to };
} else {
return null;
}
}
/**
* Parses a DOMParser `parsererror` element into a diagnostic object, handling the different
* error formats across browsers (Chrome/Blink, Firefox/Gecko, and unknown engines).
* @param {Node} errorNode The parsererror DOM node returned by DOMParser
* @param {Text} doc The CodeMirror document, used to resolve character positions from line/column
* @returns {ExtendedDiagnostic}
* @throws {Error} if error node has no text content
*/
export function parseXmlError(errorNode, doc) {
const severity = /** @type {"error"} */ ("error")
// Use full textContent to cover both Firefox (text node child) and Chrome (element children)
const textContent = errorNode.textContent;
if (!textContent) {
throw new Error("Error node has no text content");
}
// Chrome/Blink format: "error on line X at column Y: message"
const chromeMatch = textContent.match(/error on line (\d+) at column (\d+):\s*(.+)/);
if (chromeMatch) {
const line = parseInt(chromeMatch[1], 10);
const column = parseInt(chromeMatch[2], 10);
const message = chromeMatch[3].trim();
let { from, to } = doc.line(line);
from = from + column - 1;
return /** @type {ExtendedDiagnostic} */ ({ message, severity, line, column, from, to });
}
// Firefox/Gecko format: message on first line, position info on third line with two numbers
const [message, _, location] = textContent.split("\n")
const matches = location?.match(/\d+/g);
if (matches && matches.length >= 2) {
const line = parseInt(matches[0], 10);
const column = parseInt(matches[1], 10);
let { from, to } = doc.line(line);
from = from + column - 1;
return /** @type {ExtendedDiagnostic} */ ({ message, severity, line, column, from, to });
}
// Unknown browser format: still return a diagnostic so invalid XML mode is triggered
console.warn(`Unknown parsererror format, cannot extract line/column. Raw content: "${textContent.slice(0, 200)}"`)
return /** @type {ExtendedDiagnostic} */ ({ message: message || "XML parse error", severity, from: 0, to: 0 })
}
/**
* Checks if an object has the Extension interface (not really doing that currently)
* @param {any} extension
* @returns {boolean}
*/
export function isExtension(extension){
return extension && typeof extension == "object"
}
/**
* Given an XML string, figures out whether the XML uses tabs or spaces for indentation,
* and, if spaces, calculates the number of spaces per indentation level with some heuristic.
* It's more robust against tabs in content or mixed indentation.
*
* @param {string} xmlString The XML string to analyze.
* @param {string} [defaultIndentation=" "] The default indentation to return if the XML cannot be reliably analyzed.
* Defaults to two spaces.
* @returns {string} '\t' if the majority of indents are tabs, or a number of space characters (2, 4, etc.) if spaces are used.
* If the indentation cannot be reliably determined, it returns the default indentation.
*/
export function detectXmlIndentation(xmlString, defaultIndentation = " ") {
const lines = xmlString.split('\n');
let tabIndentedLines = 0;
let spaceIndentedLines = 0;
/** @type {number[]} */
const spaceIndentations = [];
for (const line of lines) {
const match = line.match(/^(\s*)/);
if (match) {
const indentation = match[1];
if (indentation.length > 0) {
if (indentation.includes('\t')) {
tabIndentedLines++;
} else if (indentation.includes(' ')) {
spaceIndentedLines++;
if (!spaceIndentations.includes(indentation.length)) {
spaceIndentations.push(indentation.length);
}
}
}
}
}
// Determine if the majority of indented lines use tabs
if (tabIndentedLines > spaceIndentedLines) {
return '\t';
}
// If the majority is not tabs, proceed with space-based indentation logic
if (spaceIndentations.length > 0) {
spaceIndentations.sort((a, b) => a - b);
if (spaceIndentations.length === 1) {
return " ".repeat(spaceIndentations[0]);
}
// Heuristic: Find the greatest common divisor (GCD) of the indentation differences.
const differences = [];
for (let i = 1; i < spaceIndentations.length; i++) {
const diff = spaceIndentations[i] - spaceIndentations[i-1];
if(diff > 0){
differences.push(diff);
}
}
if(differences.length > 0) {
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
let result = differences[0];
for (let i = 1; i < differences.length; i++) {
result = gcd(result, differences[i]);
}
// If the GCD is a common indentation number (2 or 4), it's a strong candidate.
if (result === 2 || result === 4) {
return " ".repeat(result);
}
}
// As a fallback, find the smallest indentation unit.
if(spaceIndentations[0] > 1) {
return " ".repeat(spaceIndentations[0]);
}
}
return defaultIndentation; // Default value if indentation cannot be reliably determined
}
/**
* Creates a CodeMirror autocomplete data structure from a compressed version sent
* by the server, which uses "#x" references which are reused at different places. This
* includes "#Mx" references which are macros containing references. Returns the resolved version
* with the string references replaced by the actual object references.
*
* @param {ReferenceMap} data Map to be resolved
* @returns {ReferenceMap} Resolved map
*/
export function resolveDeduplicated(data) {
// Create a copy to avoid modifying the original
const resolved = JSON.parse(JSON.stringify(data));
// Extract and resolve reference definitions (keys starting with #)
/** @type {ReferenceMap} */
const refs = {};
Object.keys(resolved).forEach(key => {
if (key.startsWith('#')) {
const resolvedObj = /** @type {ReferenceMap} */ (resolved);
const refsObj = /** @type {ReferenceMap} */ (refs);
refsObj[key] = resolvedObj[key];
delete resolvedObj[key];
}
});
// Pre-resolve all references to create shared objects
/** @type {ReferenceMap} */
const resolvedRefs = {};
// First pass: resolve simple references and macros
Object.keys(refs).forEach(refId => {
const refsTyped = /** @type {ReferenceMap} */ (refs);
if (refId.startsWith('#M')) {
// Macro reference - resolve to composite pattern
const macroContent = refsTyped[refId];
if (typeof macroContent === 'string' && macroContent.includes(' ')) {
const refIds = macroContent.split(' ').filter(id => id.startsWith('#'));
resolvedRefs[refId] = mergeReferences(refIds, refs);
} else {
resolvedRefs[refId] = refsTyped[refId];
}
} else {
// Simple reference - use as-is (will be shared)
resolvedRefs[refId] = refsTyped[refId];
}
});
/**
* Recursive function to resolve references using shared objects
* @param {any} obj
* @returns {any}
*/
function resolveRefs(obj) {
if (typeof obj === 'string' && obj.includes('#')) {
if (obj.startsWith('#') && !obj.includes(' ')) {
// Simple reference - return shared object and recursively resolve its contents
const resolved = /** @type {ReferenceMap} */ (resolvedRefs)[obj];
if (resolved) {
return resolveRefs(resolved); // Recursively resolve the contents
}
return obj;
} else if (obj.includes(' ')) {
// Composite reference like "#1 #23 #44"
const refIds = obj.split(' ').filter(id => id.startsWith('#'));
return mergeReferences(refIds, resolvedRefs);
}
return obj;
} else if (Array.isArray(obj)) {
return obj.map(resolveRefs);
} else if (obj && typeof obj === 'object') {
/** @type {ReferenceMap} */
const result = {};
Object.keys(obj).forEach(key => {
/** @type {ReferenceMap} */ (result)[key] = resolveRefs(/** @type {ReferenceMap} */ (obj)[key]);
});
return result;
}
return obj;
}
/**
* Function to merge multiple references into a single object/array
* @param {string[]} refIds
* @param {ReferenceMap} refSource
* @returns {any}
*/
function mergeReferences(refIds, refSource) {
/**
* @param {string} id
*/
const resolved = refIds.map(id => /** @type {ReferenceMap} */ (refSource)[id]).filter(Boolean);
if (resolved.length === 0) return null;
if (resolved.length === 1) return resolved[0]; // Share the single object
// Determine merge strategy based on types
const firstType = Array.isArray(resolved[0]) ? 'array' : typeof resolved[0];
/**
* @param {any} r
*/
if (firstType === 'object' && resolved.every(r => typeof r === 'object' && !Array.isArray(r))) {
// Merge objects - create new object but reference shared values where possible
const merged = {};
/**
* @param {ReferenceMap} obj
*/
resolved.forEach(obj => {
Object.keys(obj).forEach(key => {
if (key === 'doc' && /** @type {ReferenceMap} */ (merged)[key]) {
// Merge documentation fields by concatenating with separator
/** @type {ReferenceMap} */ (merged)[key] = /** @type {ReferenceMap} */ (merged)[key] + ' | ' + /** @type {ReferenceMap} */ (obj)[key];
} else {
/** @type {ReferenceMap} */ (merged)[key] = /** @type {ReferenceMap} */ (obj)[key]; // This shares the value reference
}
});
});
return merged;
/**
* @param {any} r
*/
} else if (firstType === 'array' && resolved.every(r => Array.isArray(r))) {
// Concatenate arrays and deduplicate
/** @type {any[]} */
const concatenated = /** @type {any[]} */ ([].concat(.../** @type {any[]} */ (resolved)));
const seen = new Set();
return concatenated.filter(item => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
} else {
// Mixed types - return as array
return resolved;
}
}
/**
* Helper function to deduplicate arrays, preserving order
* @param {any} arr
* @returns {any}
*/
function deduplicateArray(arr) {
if (!Array.isArray(arr)) return arr;
const seen = new Set();
return arr.filter(item => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
}
/**
* Helper function to recursively deduplicate values arrays in objects
* @param {any} obj
* @returns {any}
*/
function deduplicateValues(obj) {
if (Array.isArray(obj)) {
return obj.map(deduplicateValues);
} else if (obj && typeof obj === 'object') {
const result = {};
Object.keys(obj).forEach(key => {
if (key === 'values' && Array.isArray(/** @type {ReferenceMap} */ (obj)[key])) {
// Deduplicate values arrays specifically
/** @type {ReferenceMap} */ (result)[key] = deduplicateArray(/** @type {ReferenceMap} */ (obj)[key]);
} else {
/** @type {ReferenceMap} */ (result)[key] = deduplicateValues(/** @type {ReferenceMap} */ (obj)[key]);
}
});
return result;
}
return obj;
}
// Resolve all references in the main data
Object.keys(resolved).forEach(key => {
/** @type {ReferenceMap} */ (resolved)[key] = resolveRefs(/** @type {ReferenceMap} */ (resolved)[key]);
});
// Deduplicate all values arrays in the resolved data
const finalResolved = deduplicateValues(resolved);
return finalResolved;
}