@pierre/diffs
Version:
1,626 lines • 57.4 kB
JavaScript
import { createSegmenter, endsWithLineBreak, getGraphemeSegmenter } from "./utils.js";
import { applyDocumentChangeToLineAnnotations } from "./lineAnnotations.js";
//#region src/editor/selection.ts
const DirectionBackward = -1;
const DirectionNone = 0;
const DirectionForward = 1;
const AUTO_SURROUND_CLOSE_CHARS = /* @__PURE__ */ new Map([
["'", "'"],
["\"", "\""],
["`", "`"],
["{", "}"],
["[", "]"],
["<", ">"],
["(", ")"]
]);
const AUTO_SURROUND_QUOTE_CHARS = /* @__PURE__ */ new Set([
"'",
"\"",
"`"
]);
const AUTO_SURROUND_BRACKET_CHARS = /* @__PURE__ */ new Set([
"{",
"[",
"(",
"<"
]);
/**
* Converts a selection from a web selection to an editor selection.
*/
function convertSelection(range, direction = 0) {
const start = boundaryToPosition(range.startContainer, range.startOffset);
const end = boundaryToPosition(range.endContainer, range.endOffset);
if (start === null || end === null) return;
return {
start,
end,
direction
};
}
/**
* Resolves the indent edits for a selection.
*/
function resolveIndentEdits(textDocument, selection, tabSize, outdent) {
if (textDocument === void 0) return [[], selection];
const { start, end } = selection;
const edits = [];
let newSelection = { ...selection };
const blockIndent = start.line !== end.line;
let endLine = end.line;
if (start.line < end.line && end.character === 0) endLine--;
for (let line = start.line; line <= endLine; line++) {
const lineText = textDocument.getLineText(line);
if (lineText === void 0) continue;
if (blockIndent && lineText.trim().length === 0) continue;
const indentUnit = lineText.startsWith(" ") ? " " : " ".repeat(tabSize);
let deleteLength = 0;
let newText = indentUnit;
if (outdent) {
if (lineText.startsWith(" ")) deleteLength = 1;
else if (lineText.startsWith(" ")) {
const leadingSpacesLength = lineText.length - lineText.trimStart().length;
deleteLength = Math.min(indentUnit.length, leadingSpacesLength);
}
if (deleteLength === 0) continue;
newText = "";
}
edits.push({
range: {
start: {
line,
character: 0
},
end: {
line,
character: deleteLength
}
},
newText
});
const delta = newText.length - deleteLength;
if (line === start.line) newSelection = {
...newSelection,
start: {
...start,
character: Math.max(0, start.character + delta)
}
};
if (line === end.line) newSelection = {
...newSelection,
end: {
...end,
character: Math.max(0, end.character + delta)
}
};
}
return [edits, newSelection];
}
/**
* Maps the cursor move to all selections.
*/
function mapCursorMove(textDocument, selections, shortcut, options = {}) {
const lineCount = textDocument.lineCount;
return selections.map((selection) => {
let { line, character } = shortcut === "up" || shortcut === "left" ? selection.start : selection.end;
if (shortcut === "textStart" || shortcut === "start" || shortcut === "end") {
const caret = getCaretPosition(selection);
line = caret.line;
character = caret.character;
const softLine = getSoftLineInfo(textDocument, line, character, options);
if (shortcut === "textStart") {
const softLineText = textDocument.getLineText(line).slice(softLine.start, softLine.end);
const indent = softLine.start + getLeadingSpaces(softLineText);
character = character === indent ? softLine.start : indent;
} else character = shortcut === "start" ? softLine.start : softLine.end;
} else if (shortcut === "up") {
const moved = moveBySoftLine(textDocument, line, character, -1, options);
if (moved !== void 0) {
line = moved.line;
character = moved.character;
} else if (line > 0) line = options.resolveRenderableLine == null ? line - 1 : options.resolveRenderableLine(line - 1, "up") ?? line;
} else if (shortcut === "down") {
const moved = moveBySoftLine(textDocument, line, character, 1, options);
if (moved !== void 0) {
line = moved.line;
character = moved.character;
} else {
const maxLine = Math.max(lineCount - 1, 0);
if (line < maxLine) line = options.resolveRenderableLine == null ? line + 1 : Math.min(options.resolveRenderableLine(line + 1, "down") ?? line, maxLine);
}
} else if (isCollapsedSelection(selection)) {
const lineLength = textDocument.getLineLength(line);
character = Math.min(character, lineLength);
if (shortcut === "left") {
if (character > 0) character = stepCharacterByGrapheme(textDocument, line, character, false);
else if (line > 0) {
const targetLine = options.resolveRenderableLine == null ? line - 1 : options.resolveRenderableLine(line - 1, "up");
if (targetLine !== void 0) {
line = targetLine;
character = textDocument.getLineLength(line);
}
}
} else if (character < lineLength) character = stepCharacterByGrapheme(textDocument, line, character, true);
else if (line < lineCount - 1) {
const targetLine = options.resolveRenderableLine == null ? line + 1 : options.resolveRenderableLine(line + 1, "down");
if (targetLine !== void 0) {
line = Math.min(targetLine, lineCount - 1);
character = 0;
}
}
}
const pos = {
line,
character
};
return {
start: pos,
end: pos,
direction: 0
};
});
}
function moveBySoftLine(textDocument, line, character, direction, options) {
if (options.getSoftLineOffsets === void 0) return;
const current = getSoftLineInfo(textDocument, line, character, options);
const targetIndex = current.index + direction;
let targetLine = line;
let target;
if (targetIndex >= 0 && targetIndex < current.count) target = getSoftLineInfoAtIndex(textDocument, targetLine, targetIndex, options);
else {
const nextLine = line + direction;
if (nextLine < 0 || nextLine >= textDocument.lineCount) return {
line,
character
};
const resolvedLine = options.resolveRenderableLine == null ? nextLine : options.resolveRenderableLine(nextLine, direction < 0 ? "up" : "down");
if (resolvedLine === void 0) return {
line,
character
};
targetLine = Math.min(resolvedLine, textDocument.lineCount - 1);
const targetCount = getSoftLineCount(targetLine, options);
target = getSoftLineInfoAtIndex(textDocument, targetLine, direction < 0 ? targetCount - 1 : 0, options);
}
const column = Math.max(0, character - current.start);
const targetCharacter = target.start + column;
const landedCharacter = target.index === target.count - 1 ? targetCharacter : Math.min(targetCharacter, target.end);
const targetLineText = textDocument.getLineText(targetLine);
return {
line: targetLine,
character: landedCharacter > targetLineText.length ? landedCharacter : snapCharacterToGraphemeBoundary(targetLineText, landedCharacter)
};
}
function snapCharacterToGraphemeBoundary(lineText, character) {
if (character <= 0 || character >= lineText.length) return character;
const segmenter = getGraphemeSegmenter();
if (segmenter !== void 0) {
for (const segment of segmenter.segment(lineText)) {
if (character <= segment.index) return character;
const segmentEnd = segment.index + segment.segment.length;
if (character < segmentEnd) return segmentEnd;
}
return character;
}
let segmentStart = 0;
for (const codePoint of lineText) {
if (character <= segmentStart) return character;
const segmentEnd = segmentStart + codePoint.length;
if (character < segmentEnd) return segmentEnd;
segmentStart = segmentEnd;
}
return character;
}
function getSoftLineInfo(textDocument, line, character, options) {
const lineLength = textDocument.getLineLength(line);
const offsets = options.getSoftLineOffsets?.(line);
if (offsets === void 0 || offsets.length < 2) return {
start: 0,
end: lineLength,
index: 0,
count: 1
};
for (let index = 0; index + 1 < offsets.length; index++) {
const softLine = getSoftLineInfoAtIndex(textDocument, line, index, options);
if (character >= softLine.start && character <= softLine.end) return softLine;
}
return getSoftLineInfoAtIndex(textDocument, line, character < (offsets[0] ?? 0) ? 0 : offsets.length - 2, options);
}
function getSoftLineCount(line, options) {
const offsets = options.getSoftLineOffsets?.(line);
return offsets === void 0 || offsets.length < 2 ? 1 : offsets.length - 1;
}
function getSoftLineInfoAtIndex(textDocument, line, index, options) {
const lineLength = textDocument.getLineLength(line);
const offsets = options.getSoftLineOffsets?.(line);
if (offsets === void 0 || offsets.length < 2) return {
start: 0,
end: lineLength,
index: 0,
count: 1
};
const count = offsets.length - 1;
const boundedIndex = Math.max(0, Math.min(index, count - 1));
const start = Math.max(0, Math.min(lineLength, offsets[boundedIndex] ?? 0));
return {
start,
end: Math.max(start, Math.min(lineLength, offsets[boundedIndex + 1] ?? lineLength)),
index: boundedIndex,
count
};
}
/**
* Same as mapCursorMove, but with shift key pressed.
*/
function mapSelectionShift(textDocument, selections, shortcut, options = {}) {
return selections.map((selection) => {
const focusPosition = selection.direction === -1 ? selection.start : selection.end;
const [movedFocusSelection] = mapCursorMove(textDocument, [{
start: focusPosition,
end: focusPosition,
direction: 0
}], shortcut, options);
return createSelectionFrom(selection, movedFocusSelection);
});
}
/**
* Applies a text change to the given text document
*/
function applyTextChangeToSelections(textDocument, selections, edit, lineAnnotations, tabSize = 2, undoBoundary = false) {
if (selections[selections.length - 1] === void 0) return { nextSelections: [] };
const selectionPositions = [];
for (const selection of selections) selectionPositions.push(selection.start, selection.end);
const selectionOffsets = selectionPositions.map((position) => textDocument.offsetAt(position));
const primaryStartOffset = selectionOffsets[(selections.length - 1) * 2];
const primaryEndOffset = selectionOffsets[(selections.length - 1) * 2 + 1];
const ordered = [];
let isAlreadyOrdered = true;
for (let index = 0; index < selections.length; index++) {
const entry = {
index,
start: selectionOffsets[index * 2],
end: selectionOffsets[index * 2 + 1]
};
const previous = ordered[ordered.length - 1];
if (previous !== void 0 && (entry.start < previous.start || entry.start === previous.start && entry.end < previous.end)) isAlreadyOrdered = false;
ordered.push(entry);
}
if (!isAlreadyOrdered) ordered.sort((a, b) => {
const startOrder = a.start - b.start;
if (startOrder !== 0) return startOrder;
const endOrder = a.end - b.end;
if (endOrder !== 0) return endOrder;
return a.index - b.index;
});
const adjustedChange = normalizeLeadingIndentForChange(textDocument, edit, tabSize);
const edits = [];
const nextSelectionOffsets = Array.from({ length: selections.length });
let offsetDelta = 0;
let mergedGroup;
const finalizeMergedGroup = () => {
if (mergedGroup === void 0) return;
const perGroupChange = normalizeLeadingIndentForChange(textDocument, {
start: mergedGroup.start,
end: mergedGroup.end,
text: adjustedChange.text
}, tabSize);
const newText = expandSingleNewlineInsert(textDocument, perGroupChange.text, perGroupChange.start);
edits.push({
start: perGroupChange.start,
end: perGroupChange.end,
text: newText
});
const nextOffsets = [mergedGroup.start + offsetDelta + newText.length, mergedGroup.start + offsetDelta + newText.length];
for (const index of mergedGroup.indices) nextSelectionOffsets[index] = nextOffsets;
offsetDelta += newText.length - (perGroupChange.end - perGroupChange.start);
mergedGroup = void 0;
};
for (const entry of ordered) {
const startOffset = Math.max(0, entry.start + (adjustedChange.start - primaryStartOffset));
const endOffset = Math.max(startOffset, entry.end + (adjustedChange.end - primaryEndOffset));
if (mergedGroup !== void 0 && startOffset < mergedGroup.end) {
mergedGroup.end = Math.max(mergedGroup.end, endOffset);
mergedGroup.indices.push(entry.index);
continue;
}
finalizeMergedGroup();
mergedGroup = {
start: startOffset,
end: endOffset,
indices: [entry.index]
};
}
finalizeMergedGroup();
const change = textDocument.applyResolvedEdits(edits, true, selections, void 0, undoBoundary);
const nextSelections = createSelectionsFromOffsetPairs(textDocument, nextSelectionOffsets.map((offsets) => {
if (offsets === void 0) throw new Error("Missing next selection offsets");
return offsets;
}));
textDocument.setLastUndoSelectionsAfter(nextSelections);
if (change !== void 0 && lineAnnotations !== void 0) {
const nextLineAnnotations = applyDocumentChangeToLineAnnotations(change, lineAnnotations);
if (nextLineAnnotations !== void 0) textDocument.setLastUndoLineAnnotations(lineAnnotations, nextLineAnnotations);
}
return {
nextSelections,
change
};
}
/**
* Returns the next anchor/focus offsets after replacing a selection range.
* When the inserted text still contains the original selection (auto-surround),
* the inner range is reselected to match VS Code/CodeMirror behavior.
*/
function getAutoSurroundPreservedOffset(originalText, newText) {
if (newText.length !== originalText.length + 2) return;
const closeChar = AUTO_SURROUND_CLOSE_CHARS.get(newText[0]);
if (closeChar === void 0 || newText[newText.length - 1] !== closeChar || newText.slice(1, -1) !== originalText) return;
return 1;
}
function getNextSelectionOffsetPairAfterReplace(textDocument, entry, offsetDelta, newText) {
const insertStart = entry.start + offsetDelta;
const insertEnd = insertStart + newText.length;
if (entry.end - entry.start > 0) {
const originalText = textDocument.getTextSlice(entry.start, entry.end);
const preservedOffset = getAutoSurroundPreservedOffset(originalText, newText) ?? newText.indexOf(originalText);
if (preservedOffset !== -1 && preservedOffset + originalText.length <= newText.length) {
const rangeStart = insertStart + preservedOffset;
return [rangeStart, rangeStart + originalText.length];
}
}
return [insertEnd, insertEnd];
}
/**
* Applies text replacements to multiple selections. Texts pair by selection
* index unless they are explicitly marked as document ordered.
*/
function applyTextReplaceToSelections(textDocument, selections, texts, lineAnnotations, undoBoundary = false, textOrder = "selection") {
if (selections.length !== texts.length) throw new Error("Selection text replacements must match the selection count");
const selectionPositions = [];
for (const selection of selections) selectionPositions.push(selection.start, selection.end);
const selectionOffsets = selectionPositions.map((position) => textDocument.offsetAt(position));
const ordered = [];
let isAlreadyOrdered = true;
for (let index = 0; index < selections.length; index++) {
const entry = {
index,
start: selectionOffsets[index * 2],
end: selectionOffsets[index * 2 + 1]
};
const previous = ordered[ordered.length - 1];
if (previous !== void 0 && (entry.start < previous.start || entry.start === previous.start && entry.end < previous.end)) isAlreadyOrdered = false;
ordered.push(entry);
}
if (!isAlreadyOrdered) ordered.sort((a, b) => {
const startOrder = a.start - b.start;
if (startOrder !== 0) return startOrder;
const endOrder = a.end - b.end;
if (endOrder !== 0) return endOrder;
return a.index - b.index;
});
const allDeletes = texts.every((text) => text === "");
let edits;
const nextSelectionOffsetPairs = Array.from({ length: selections.length });
if (allDeletes) {
edits = [];
let hasEffect = false;
for (const entry of ordered) {
nextSelectionOffsetPairs[entry.index] = [entry.end, entry.end];
if (entry.start >= entry.end) continue;
hasEffect = true;
const last = edits[edits.length - 1];
if (last !== void 0 && entry.start < last.end) edits[edits.length - 1] = {
start: last.start,
end: Math.max(last.end, entry.end),
text: ""
};
else edits.push({
start: entry.start,
end: entry.end,
text: ""
});
}
if (!hasEffect) return { nextSelections: selections };
for (const entry of ordered) {
const caret = entry.end;
let delta = 0;
let next = caret;
for (const edit of edits) {
if (caret <= edit.start) break;
if (caret >= edit.end) {
delta -= edit.end - edit.start;
continue;
}
next = edit.start + delta;
break;
}
if (next === caret) next += delta;
nextSelectionOffsetPairs[entry.index] = [next, next];
}
} else {
edits = [];
let offsetDelta = 0;
let previousEditEnd = -1;
for (let index = 0; index < ordered.length; index++) {
const entry = ordered[index];
if (entry.start < previousEditEnd) throw new Error("Overlapping multi-selection edits are not supported");
previousEditEnd = entry.end;
const newText = expandSingleNewlineInsert(textDocument, texts[textOrder === "document" ? index : entry.index], entry.start);
edits.push({
start: entry.start,
end: entry.end,
text: newText
});
nextSelectionOffsetPairs[entry.index] = getNextSelectionOffsetPairAfterReplace(textDocument, entry, offsetDelta, newText);
offsetDelta += newText.length - (entry.end - entry.start);
}
}
const change = textDocument.applyResolvedEdits(edits, true, selections, void 0, undoBoundary);
const nextSelections = createSelectionsFromOffsetPairs(textDocument, nextSelectionOffsetPairs.map((offsets) => {
if (offsets === void 0) throw new Error("Missing next selection offsets");
return offsets;
}));
textDocument.setLastUndoSelectionsAfter(nextSelections);
if (change !== void 0 && lineAnnotations !== void 0) {
const nextLineAnnotations = applyDocumentChangeToLineAnnotations(change, lineAnnotations);
if (nextLineAnnotations !== void 0) textDocument.setLastUndoLineAnnotations(lineAnnotations, nextLineAnnotations);
}
return {
nextSelections,
change
};
}
function shouldAutoSurroundChar(autoSurround, char) {
if (autoSurround === "never") return false;
if (autoSurround === "brackets") return AUTO_SURROUND_BRACKET_CHARS.has(char);
if (autoSurround === "quotes") return AUTO_SURROUND_QUOTE_CHARS.has(char);
return true;
}
/**
* Returns per-selection replacement text when typing a surround character over
* non-collapsed selections, matching VS Code auto-surround behavior.
*/
function getAutoSurroundReplacementTexts(textDocument, selections, char, autoSurround) {
if (char.length !== 1 || selections.length === 0) return;
const closeChar = AUTO_SURROUND_CLOSE_CHARS.get(char);
if (closeChar === void 0 || !shouldAutoSurroundChar(autoSurround, char)) return;
const replacements = [];
for (const selection of selections) {
if (isCollapsedSelection(selection)) return;
replacements.push(char + textDocument.getText(selection) + closeChar);
}
return replacements;
}
/**
* Swaps the two characters adjacent to a collapsed selection, matching browser
* insertTranspose (Ctrl+T) behavior.
*/
function applyTransposeToSelections(textDocument, selections, lineAnnotations) {
const edits = [];
const nextOffsetPairs = [];
for (const selection of selections) {
const [anchor, focus] = getSelectionAnchorAndFocusOffsets(textDocument, selection);
if (!isCollapsedSelection(selection)) {
nextOffsetPairs.push([anchor, focus]);
continue;
}
const { line, character } = selection.start;
const offset = anchor;
const lineText = textDocument.getLineText(line);
const lineLength = lineText.length;
const lineStart = offset - character;
const graphemeStarts = getLineGraphemeStarts(lineText);
let edit;
if (character > 0 && character < lineLength) {
const before = findClusterBreak(lineText, character, false, graphemeStarts);
const after = findClusterBreak(lineText, character, true, graphemeStarts);
edit = {
start: lineStart + before,
end: lineStart + after,
text: lineText.slice(character, after) + lineText.slice(before, character)
};
nextOffsetPairs.push([lineStart + after, lineStart + after]);
} else if (character === lineLength && graphemeStarts.length >= 2) {
const lastStart = graphemeStarts[graphemeStarts.length - 1];
const secondLastStart = graphemeStarts[graphemeStarts.length - 2];
edit = {
start: lineStart + secondLastStart,
end: offset,
text: lineText.slice(lastStart, lineLength) + lineText.slice(secondLastStart, lastStart)
};
nextOffsetPairs.push([offset, offset]);
} else if (character === 0 && line > 0 && lineLength > 0) {
const prevLine = line - 1;
const prevLineText = textDocument.getLineText(prevLine);
const prevLength = prevLineText.length;
const prevEnd = textDocument.offsetAt({
line: prevLine,
character: prevLength
});
const prevGraphemeStart = prevLength > 0 ? findClusterBreak(prevLineText, prevLength, false, getLineGraphemeStarts(prevLineText)) : prevLength;
const firstEnd = findClusterBreak(lineText, 0, true, graphemeStarts);
const prevStart = prevEnd - (prevLength - prevGraphemeStart);
const newText = lineText.slice(0, firstEnd) + textDocument.getTextSlice(prevEnd, offset) + prevLineText.slice(prevGraphemeStart, prevLength);
edit = {
start: prevStart,
end: offset + firstEnd,
text: newText
};
const caret = prevStart + newText.length;
nextOffsetPairs.push([caret, caret]);
} else {
nextOffsetPairs.push([anchor, focus]);
continue;
}
edits.push(edit);
}
if (edits.length === 0) return { nextSelections: selections };
edits.sort((a, b) => a.start - b.start);
for (let index = 1; index < edits.length; index++) if (edits[index].start < edits[index - 1].end) throw new Error("Overlapping multi-selection edits are not supported");
const change = textDocument.applyResolvedEdits(edits, true, selections);
const nextSelections = createSelectionsFromOffsetPairs(textDocument, nextOffsetPairs);
textDocument.setLastUndoSelectionsAfter(nextSelections);
if (change !== void 0 && lineAnnotations !== void 0) {
const nextLineAnnotations = applyDocumentChangeToLineAnnotations(change, lineAnnotations);
if (nextLineAnnotations !== void 0) textDocument.setLastUndoLineAnnotations(lineAnnotations, nextLineAnnotations);
}
return {
nextSelections,
change
};
}
/**
* Deletes from each selection to the end of its line, including the line break
* when the caret is already at the end of a non-final line. Non-collapsed
* selections delete their selected text instead.
*/
function applyDeleteHardLineForwardToSelections(textDocument, selections, lineAnnotations) {
const deleteSelections = selections.map((selection) => {
const range = resolveDeleteHardLineForwardRange(textDocument, selection);
return {
start: range.start,
end: range.end,
direction: 0
};
});
return applyTextReplaceToSelections(textDocument, deleteSelections, deleteSelections.map(() => ""), lineAnnotations);
}
/**
* Deletes from each selection back to the start of its soft (visual) line.
* Non-collapsed selections delete their selected text instead.
*/
function applyDeleteSoftLineBackwardToSelections(textDocument, selections, getSoftLineStart, lineAnnotations) {
const deleteSelections = selections.map((selection) => {
if (!isCollapsedSelection(selection)) return {
start: selection.start,
end: selection.end,
direction: 0
};
const caret = getCaretPosition(selection);
const { line, character } = caret;
const softLineStart = getSoftLineStart?.(line, character) ?? 0;
if (character > softLineStart) return {
start: {
line,
character: softLineStart
},
end: {
line,
character
},
direction: 0
};
if (line === 0) return {
start: caret,
end: caret,
direction: 0
};
const prevLineLength = textDocument.getLineLength(line - 1);
return {
start: {
line: line - 1,
character: prevLineLength
},
end: {
line,
character: 0
},
direction: 0
};
});
return applyTextReplaceToSelections(textDocument, deleteSelections, deleteSelections.map(() => ""), lineAnnotations);
}
/**
* Deletes the word or separator group immediately before each selection.
* Non-collapsed selections delete their selected text instead.
*/
function applyDeleteWordBackwardToSelections(textDocument, selections, lineAnnotations) {
const deleteSelections = selections.map((selection) => {
const [start, end] = resolveDeleteWordBackwardRange(textDocument, selection);
return {
start,
end,
direction: 0
};
});
return applyTextReplaceToSelections(textDocument, deleteSelections, deleteSelections.map(() => ""), lineAnnotations);
}
/**
* Resolves the document range deleted by Backspace or Delete at a collapsed
* caret. Non-collapsed selections delete their selected text instead.
*/
function resolveDeleteCharacterRange(textDocument, selection, forward) {
if (!isCollapsedSelection(selection)) return [selection.start, selection.end];
const caret = getCaretPosition(selection);
let { line, character } = caret;
const lineLength = textDocument.getLineLength(line);
const lineCount = textDocument.lineCount;
character = Math.min(character, lineLength);
if (forward) {
if (character < lineLength) return [{
line,
character
}, {
line,
character: stepCharacterByGrapheme(textDocument, line, character, true)
}];
if (line < lineCount - 1) return [{
line,
character: lineLength
}, {
line: line + 1,
character: 0
}];
return [caret, caret];
}
if (character > 0) return [{
line,
character: stepCharacterByGrapheme(textDocument, line, character, false)
}, {
line,
character
}];
if (line > 0) {
const prevLineLength = textDocument.getLineLength(line - 1);
return [{
line: line - 1,
character: prevLineLength
}, {
line,
character: 0
}];
}
return [caret, caret];
}
/**
* Deletes one grapheme (or selected text) at each selection.
*/
function applyDeleteCharacterToSelections(textDocument, selections, forward, lineAnnotations, tabSize = 2) {
const deleteSelections = selections.map((selection) => {
let [start, end] = resolveDeleteCharacterRange(textDocument, selection, forward);
if (!forward && isCollapsedSelection(selection)) {
const normalized = normalizeLeadingIndentForChange(textDocument, {
start: textDocument.offsetAt(start),
end: textDocument.offsetAt(end),
text: ""
}, tabSize);
start = textDocument.positionAt(normalized.start);
end = textDocument.positionAt(normalized.end);
}
return {
start,
end,
direction: 0
};
});
return applyTextReplaceToSelections(textDocument, deleteSelections, deleteSelections.map(() => ""), lineAnnotations);
}
/**
* Checks if a selection is collapsed.
*/
function isCollapsedSelection(selection) {
return selection.start.line === selection.end.line && selection.start.character === selection.end.character;
}
/**
* Returns the caret (focus) position for a selection.
*/
function getCaretPosition(selection) {
const { start, end, direction } = selection;
return direction === -1 ? start : end;
}
/**
* Checks if a line is editable.
*/
function isLineEditable(lineType) {
return lineType === "context" || lineType === "context-expanded" || lineType === "change-addition";
}
/**
* Checks whether selections `a` and `b` intersect.
*/
function selectionIntersects(a, b) {
const aCollapsed = isCollapsedSelection(a);
const bCollapsed = isCollapsedSelection(b);
if (aCollapsed && bCollapsed) return comparePosition(a.start, b.start) === 0;
if (aCollapsed) return comparePosition(b.start, a.start) <= 0 && comparePosition(a.start, b.end) <= 0;
if (bCollapsed) return comparePosition(a.start, b.start) <= 0 && comparePosition(b.start, a.end) <= 0;
return comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0;
}
/**
* Compares two positions.
*/
function comparePosition(a, b) {
if (a.line !== b.line) return a.line - b.line;
return a.character - b.character;
}
/**
* Creates a selection from anchor and focus offsets.
*/
function createSelectionFromAnchorAndFocusOffsets(textDocument, anchorOffset, focusOffset) {
const direction = anchorOffset === focusOffset ? 0 : anchorOffset < focusOffset ? 1 : -1;
const start = Math.min(anchorOffset, focusOffset);
const end = Math.max(anchorOffset, focusOffset);
return {
start: textDocument.positionAt(start),
end: textDocument.positionAt(end),
direction
};
}
/**
* Maps a single offset from the pre-edit document into the post-edit document.
* `edits` are resolved edits in pre-edit offsets, sorted ascending and
* non-overlapping. An offset at or after an edit's start shifts to the end of
* that edit's replacement (right gravity), so text inserted at the caret pushes
* the caret past it; an offset strictly before an edit is only shifted by the
* net length change of the edits that precede it.
*/
function remapOffsetThroughEdits(offset, edits) {
let delta = 0;
for (const edit of edits) {
if (offset < edit.start) break;
if (offset >= edit.end) delta += edit.text.length - (edit.end - edit.start);
else return edit.start + delta + edit.text.length;
}
return offset + delta;
}
/**
* Re-anchors selections after a batch of text edits has been applied, so the
* caret keeps pointing at the same logical location in the changed buffer.
*
* `selectionOffsets` (one `[start, end]` pair per selection) and `edits` are
* measured in the PRE-edit document; the returned selections are built from
* `textDocument`, which must already reflect the applied edits. Selection
* direction is preserved by remapping each edge and re-deriving anchor/focus.
*/
function remapSelectionsAfterEdits(textDocument, selections, selectionOffsets, edits) {
return selections.map((selection, index) => {
const [startOffset, endOffset] = selectionOffsets[index];
const nextStart = remapOffsetThroughEdits(startOffset, edits);
const nextEnd = remapOffsetThroughEdits(endOffset, edits);
return createSelectionFromAnchorAndFocusOffsets(textDocument, selection.direction === -1 ? nextEnd : nextStart, selection.direction === -1 ? nextStart : nextEnd);
});
}
/**
* Creates a selection from a anchor and focus selection.
*/
function createSelectionFrom(anchorSelection, focusSelection) {
const anchor = anchorSelection.direction === -1 ? anchorSelection.end : anchorSelection.start;
const currentStartOrder = comparePosition(anchor, focusSelection.start);
const currentEndOrder = comparePosition(anchor, focusSelection.end);
let focus = focusSelection.end;
if (currentStartOrder <= 0) focus = focusSelection.end;
else if (currentEndOrder >= 0) focus = focusSelection.start;
else focus = currentStartOrder === 0 ? focusSelection.end : focusSelection.start;
const anchorVsFocus = comparePosition(anchor, focus);
return {
start: anchorVsFocus <= 0 ? anchor : focus,
end: anchorVsFocus <= 0 ? focus : anchor,
direction: anchorVsFocus === 0 ? 0 : anchorVsFocus < 0 ? 1 : -1
};
}
/**
* Extends or shrinks the selection `original` using the endpoints of `target`, \
* matching contenteditable shift + click extend behavior.
*/
function extendSelection(original, target) {
const leftExtended = comparePosition(target.start, original.start) < 0;
const rightExtended = comparePosition(target.end, original.end) > 0;
if (leftExtended && !rightExtended) return {
start: target.start,
end: original.end,
direction: -1
};
if (rightExtended && !leftExtended) return {
start: original.start,
end: target.end,
direction: 1
};
if (original.direction === -1) return {
start: target.start,
end: original.end,
direction: comparePosition(target.start, original.end) === 0 ? 0 : -1
};
return {
start: original.start,
end: target.end,
direction: comparePosition(original.start, target.end) === 0 ? 0 : 1
};
}
/**
* Extends multiple selections.
*/
function extendSelections(selections, target) {
return mergeOverlappingSelections(selections.map((selection) => {
return extendSelection(selection, target);
}));
}
/**
* Merges overlapping selections.
*/
function mergeOverlappingSelections(selections) {
if (selections.length <= 1) return selections;
const ordered = selections.map((selection, index) => ({
index,
selection
})).sort((a, b) => {
const startOrder = comparePosition(a.selection.start, b.selection.start);
if (startOrder !== 0) return startOrder;
const endOrder = comparePosition(a.selection.end, b.selection.end);
return endOrder !== 0 ? endOrder : a.index - b.index;
});
const merged = [];
let current = ordered[0];
for (const entry of ordered.slice(1)) {
if (selectionIntersects(current.selection, entry.selection)) {
const latest = entry.index > current.index ? entry : current;
const start = comparePosition(entry.selection.start, current.selection.start) < 0 ? entry.selection.start : current.selection.start;
const end = comparePosition(entry.selection.end, current.selection.end) > 0 ? entry.selection.end : current.selection.end;
let direction = latest.selection.direction;
if (direction === 0 && comparePosition(start, end) !== 0) direction = comparePosition(latest.selection.start, start) === 0 ? -1 : 1;
current = {
index: latest.index,
selection: {
direction,
end,
start
}
};
continue;
}
merged.push(current);
current = entry;
}
merged.push(current);
return merged.sort((a, b) => a.index - b.index).map(({ selection }) => selection);
}
/**
* Converts selections into merged line blocks for line-based commands.
*/
function getSelectedLineBlocks(selections) {
const blocks = selections.map((selection) => {
let endLine = selection.end.line;
if (selection.end.character === 0 && comparePosition(selection.start, selection.end) !== 0) endLine--;
return {
startLine: selection.start.line,
endLine: Math.max(selection.start.line, endLine)
};
}).sort((a, b) => {
const startOrder = a.startLine - b.startLine;
return startOrder !== 0 ? startOrder : a.endLine - b.endLine;
});
const merged = [];
for (const block of blocks) {
const previous = merged.at(-1);
if (previous !== void 0 && block.startLine <= previous.endLine + 1) previous.endLine = Math.max(previous.endLine, block.endLine);
else merged.push({ ...block });
}
return merged;
}
/**
* Moves a selection's line positions after its lines are shifted, clamping to
* the target document bounds.
*/
function shiftSelectionLines(selection, direction, lineCount, getLineLength) {
const shiftPosition = (position) => {
const line = position.line + direction;
if (line >= lineCount) {
const lastLine = Math.max(0, lineCount - 1);
return {
line: lastLine,
character: getLineLength(lastLine)
};
}
if (line < 0) return {
line: 0,
character: 0
};
return {
line,
character: position.character
};
};
return {
start: shiftPosition(selection.start),
end: shiftPosition(selection.end),
direction: selection.direction
};
}
/**
* Finds the next matching word and updates the selections.
*/
function findNextMatch(textDocument, selections) {
if (selections.length === 0) return;
const normalizedSelections = selections.map((selection) => isCollapsedSelection(selection) ? expandCollapsedSelectionToWord(textDocument, selection) : selection);
const texts = normalizedSelections.map((s) => textDocument.getText(s));
const needle = texts[0];
if (needle.length === 0 || texts.some((t) => t !== needle)) return;
const occupied = normalizedSelections.map((s) => [textDocument.offsetAt(s.start), textDocument.offsetAt(s.end)]);
const nextOffset = textDocument.findNextNonOverlappingSubstring(needle, occupied);
if (nextOffset === void 0) return normalizedSelections.some((selection, index) => {
const original = selections[index];
return comparePosition(selection.start, original.start) !== 0 || comparePosition(selection.end, original.end) !== 0 || selection.direction !== original.direction;
}) ? normalizedSelections : void 0;
const added = createSelectionFromAnchorAndFocusOffsets(textDocument, nextOffset, nextOffset + needle.length);
return [...normalizedSelections, added];
}
/**
* Get the full selection of the document.
*/
function getDocumentFullSelection(textDocument) {
const lastLine = textDocument.lineCount - 1;
return {
start: {
line: 0,
character: 0
},
end: {
line: lastLine,
character: textDocument.getLineLength(lastLine)
},
direction: 1
};
}
/**
* Get the boundary selection of the document.
*/
function getDocumentBoundarySelection(textDocument, atEnd, trimmedEndNewLine) {
let line = 0;
if (atEnd) {
const lastLine = textDocument.lineCount - 1;
line = trimmedEndNewLine === true && lastLine > 0 && textDocument.getLineLength(lastLine) === 0 ? lastLine - 1 : lastLine;
}
const character = atEnd ? textDocument.getLineLength(line) : 0;
const start = {
line,
character
};
return {
start,
end: start,
direction: 1
};
}
/** Resolves the document offset range one selection contributes to a copy. */
function resolveClipboardRegion(textDocument, selection) {
if (isCollapsedSelection(selection)) {
const line = selection.start.line;
return {
start: textDocument.offsetAt({
line,
character: 0
}),
end: line < textDocument.lineCount - 1 ? textDocument.offsetAt({
line: line + 1,
character: 0
}) : textDocument.offsetAt({
line,
character: textDocument.getLineLength(line)
})
};
}
const start = textDocument.offsetAt(selection.start);
const end = textDocument.offsetAt(selection.end);
return start <= end ? {
start,
end
} : {
start: end,
end: start
};
}
/**
* Resolves the document offset range each selection contributes to the
* clipboard, ordered by position. A collapsed selection contributes its whole
* logical line including the trailing line break; the final line has no
* trailing break to include. A ranged selection contributes the selected text.
*/
function resolveClipboardRegions(textDocument, selections) {
return selections.map((selection) => resolveClipboardRegion(textDocument, selection)).sort((a, b) => {
const startOrder = a.start - b.start;
return startOrder !== 0 ? startOrder : a.end - b.end;
});
}
/**
* Gets the text contributed by each selection in document order, preserving
* the pairing needed to paste the values into another set of selections.
*/
function getSelectionClipboardTexts(textDocument, selections) {
return resolveClipboardRegions(textDocument, selections).map(({ start, end }) => textDocument.getTextSlice(start, end));
}
/**
* Get the clipboard text of the selections for the given text document. Used by
* both copy and cut so the two stay in sync. Overlapping regions (e.g. several
* carets on one line) are merged so the same text is never emitted twice, and a
* line-ending separator is inserted only between regions that aren't already
* contiguous in the document.
*/
function getSelectionText(textDocument, selections) {
const regions = resolveClipboardRegions(textDocument, selections);
const eol = textDocument.eol;
let result = "";
let prevEnd = -1;
for (const region of regions) {
if (region.end <= region.start) continue;
if (region.start <= prevEnd) {
if (region.end > prevEnd) {
result += textDocument.getTextSlice(prevEnd, region.end);
prevEnd = region.end;
}
continue;
}
if (result.length > 0 && !endsWithLineBreak(result)) result += eol;
result += textDocument.getTextSlice(region.start, region.end);
prevEnd = region.end;
}
return result;
}
function resolveSelectionCutEdit(textDocument, selection) {
if (isCollapsedSelection(selection)) return resolveCollapsedSelectionCutEdit(textDocument, selection);
const [start, end] = comparePosition(selection.start, selection.end) <= 0 ? [selection.start, selection.end] : [selection.end, selection.start];
return {
start: textDocument.offsetAt(start),
end: textDocument.offsetAt(end),
text: ""
};
}
function resolveCollapsedSelectionCutEdit(textDocument, selection) {
const line = selection.start.line;
const lineStart = textDocument.offsetAt({
line,
character: 0
});
const lineEnd = textDocument.offsetAt({
line,
character: textDocument.getLineLength(line)
});
if (line < textDocument.lineCount - 1) return {
start: lineStart,
end: textDocument.offsetAt({
line: line + 1,
character: 0
}),
text: ""
};
if (line > 0) return {
start: textDocument.offsetAt({
line: line - 1,
character: textDocument.getLineLength(line - 1)
}),
end: lineEnd,
text: ""
};
return {
start: lineStart,
end: lineEnd,
text: ""
};
}
function mergeCutEdits(orderedCuts) {
const edits = [];
for (const { edit } of orderedCuts) {
if (edit.start >= edit.end) continue;
const last = edits.at(-1);
if (last !== void 0 && edit.start <= last.end) edits[edits.length - 1] = {
start: last.start,
end: Math.max(last.end, edit.end),
text: ""
};
else edits.push(edit);
}
return edits;
}
function mapCutSelectionOffsets(orderedCuts, edits) {
const nextOffsets = Array.from({ length: orderedCuts.length });
let editIndex = 0;
let offsetDelta = 0;
for (const cut of orderedCuts) {
while (editIndex < edits.length && cut.edit.start > edits[editIndex].end) {
const edit = edits[editIndex];
offsetDelta -= edit.end - edit.start;
editIndex++;
}
const edit = edits[editIndex];
if (edit !== void 0 && cut.edit.start >= edit.start && cut.edit.start <= edit.end) nextOffsets[cut.index] = edit.start + offsetDelta;
else nextOffsets[cut.index] = cut.edit.start + offsetDelta;
}
return nextOffsets;
}
function resolveSelectionCut(textDocument, selections) {
const orderedCuts = [...selections.map((selection, index) => ({
index,
edit: resolveSelectionCutEdit(textDocument, selection)
}))].sort((a, b) => {
const startOrder = a.edit.start - b.edit.start;
if (startOrder !== 0) return startOrder;
const endOrder = a.edit.end - b.edit.end;
if (endOrder !== 0) return endOrder;
return a.index - b.index;
});
const edits = mergeCutEdits(orderedCuts);
return {
text: getSelectionText(textDocument, selections),
edits,
nextSelectionOffsets: mapCutSelectionOffsets(orderedCuts, edits)
};
}
/**
* Get the anchor node and offset for a selection.
*/
function getSelectionAnchor(lineElement, character) {
const ch = Math.max(0, character);
const tokens = collectTokens(lineElement);
let last = null;
for (const token of tokens) {
last = token;
const base = getCharacterIndex(token);
if (ch <= base + (token.textContent?.length ?? 0)) {
const anchor = textAt(token, ch < base ? 0 : ch - base);
if (anchor !== null) return anchor;
}
}
if (last !== null) {
const anchor = textAt(last, last.textContent?.length ?? 0);
if (anchor !== null) return anchor;
return [last, 0];
}
let textOffset = 0;
let lastTextNode = null;
for (const child of lineElement.childNodes) {
if (child.nodeType === 1 && child.tagName === "BR") return [child, 0];
if (child.nodeType !== 3) continue;
lastTextNode = child;
const len = getTextOffset(lastTextNode.textContent, lastTextNode.textContent?.length ?? 0);
if (ch <= textOffset + len) return [lastTextNode, getTextOffset(lastTextNode.textContent, ch - textOffset)];
textOffset += len;
}
if (lastTextNode !== null) return [lastTextNode, getTextOffset(lastTextNode.textContent, lastTextNode.textContent?.length ?? 0)];
return [lineElement, 0];
}
/**
* Expands a zero-width selection to the word-like segment that contains the caret.
*/
function expandCollapsedSelectionToWord(textDocument, selection) {
const { line, character } = selection.start;
const lineText = textDocument.getLineText(line);
const span = expandCollapsedLineWord(lineText, Math.max(0, Math.min(character, lineText.length)));
if (span === void 0) return selection;
return {
start: {
line,
character: span.start
},
end: {
line,
character: span.end
},
direction: 1
};
}
function expandCollapsedLineWord(lineText, character) {
const segmenter = createSegmenter({ granularity: "word" });
if (segmenter !== void 0) {
for (const seg of segmenter.segment(lineText)) {
if (seg.isWordLike !== true) continue;
const lo = seg.index;
const hi = lo + seg.segment.length;
if (character >= lo && character <= hi) return {
start: lo,
end: hi
};
}
return;
}
const wordRe = /[\p{Alphabetic}\p{Number}_]+/gu;
let match;
while ((match = wordRe.exec(lineText)) !== null) {
const lo = match.index;
const hi = lo + match[0].length;
if (character >= lo && character <= hi) return {
start: lo,
end: hi
};
}
}
function resolveDeleteWordBackwardRange(textDocument, selection) {
if (!isCollapsedSelection(selection)) return [selection.start, selection.end];
const caret = getCaretPosition(selection);
const { line, character: head } = caret;
if (head === 0) {
if (line === 0) return [caret, caret];
const prevLineLength = textDocument.getLineLength(line - 1);
return [{
line: line - 1,
character: prevLineLength
}, {
line,
character: 0
}];
}
const lineText = textDocument.getLineText(line);
const graphemeStarts = getLineGraphemeStarts(lineText);
let pos = head;
let match;
while (pos > 0) {
const prev = findClusterBreak(lineText, pos, false, graphemeStarts);
const nextChar = lineText.slice(prev, pos);
const nextMatch = !/\S/.test(nextChar) ? 0 : /\p{Alphabetic}|\p{Number}|_/u.test(nextChar) ? 1 : 2;
if (match !== void 0 && nextMatch !== match) break;
if (nextMatch !== 0 || pos !== head) match = nextMatch;
pos = prev;
}
return [{
line,
character: pos
}, {
line,
character: head
}];
}
function findClusterBreak(text, pos, forward, graphemeStarts) {
if (forward) {
for (const start of graphemeStarts) if (start > pos) return start;
return text.length;
}
for (let i = graphemeStarts.length - 1; i >= 0; i--) {
const start = graphemeStarts[i];
if (start < pos) return start;
}
return 0;
}
function getLineGraphemeStarts(lineText) {
const graphemeStarts = [0];
const segmenter = getGraphemeSegmenter();
if (segmenter !== void 0) {
for (const segment of segmenter.segment(lineText)) if (segment.index > 0) graphemeStarts.push(segment.index);
return graphemeStarts;
}
let index = 0;
for (const codePoint of lineText) {
if (index > 0) graphemeStarts.push(index);
index += codePoint.length;
}
return graphemeStarts;
}
function stepCharacterByGrapheme(textDocument, line, character, forward) {
const lineLength = textDocument.getLineLength(line);
if (forward) {
if (character >= lineLength) return lineLength;
const lineStart = textDocument.offsetAt({
line,
character: 0
});
const suffix = textDocument.getTextSlice(lineStart + character, lineStart + lineLength);
const segmenter = getGraphemeSegmenter();
if (segmenter !== void 0) {
for (const segment of segmenter.segment(suffix)) return character + segment.segment.length;
return lineLength;
}
for (const codePoint of suffix) return character + codePoint.length;
return lineLength;
}
if (character <= 0) return 0;
const lineStart = textDocument.offsetAt({
line,
character: 0
});
const prefix = textDocument.getTextSlice(lineStart, lineStart + character);
let prevStart = 0;
const segmenter = getGraphemeSegmenter();
if (segmenter !== void 0) {
for (const segment of segmenter.segment(prefix)) prevStart = segment.index;
return prevStart;
}
let index = 0;
for (const codePoint of prefix) {
prevStart = index;
index += codePoint.length;
}
return prevStart;
}
function getSelectionAnchorAndFocusOffsets(textDocument, selection) {
const isBackward = selection.direction === -1;
return [textDocument.offsetAt(isBackward ? selection.end : selection.start), textDocument.offsetAt(getCaretPosition(selection))];
}
function resolveDeleteHardLineForwardRange(textDocument, selection) {
if (!isCollapsedSelection(selection)) return {
start: selection.start,
end: selection.end
};
const { line, character } = selection.start;
const lineLength = textDocument.getLineText(line).length;
if (character < lineLength) return {
start: {
line,
character
},
end: {
line,
character: lineLength
}
};
if (line < textDocument.lineCount - 1) return {
start: {
line,
character
},
end: {
line: line + 1,
character: 0
}
};
return {
start: {
line,
character
},
end: {
line,
character
}
};
}
function expandSingleNewlineInsert(textDocument, insertText, insertStartOffset) {
if (insertText !== "\n" && insertText !== "\r" && insertText !== "\r\n") return insertText;
const line = textDocument.positionAt(insertStartOffset).line;
const lineText = textDocument.getLineText(line);
const indentLen = getLeadingSpaces(lineText);
if (indentLen === 0) return insertText;
return insertText + lineText.slice(0, indentLen);
}
function getLeadingSpaces(text) {
let indent = 0;
for (; indent < text.length; indent++) {
const c = text.charCodeAt(indent);
if (c !== 32 && c !== 9) break;
}
return indent;
}
function createSelectionsFromOffsetPairs(textDocument, offsetPairs) {
const normalizedOffsets = [];
for (const [anchorOffset, focusOffset] of offsetPairs) normalizedOffsets.push(Math.min(anchorOffset, focusOffset), Math.max(anchorOffset, focusOffset));
const positions = textDocument.positionsAt(normalizedOffsets);
return offsetPairs.map(([anchorOffset, focusOffset], index) => {
const direction = anchorOffset === focusOffset ? 0 : anchorOffset < focusOffset ? 1 : -1;
return {
start: positions[index * 2],
end: positions[index * 2 + 1],
direction
};
});
}
function normalizeLeadingIndentForChange(textDocument, change, tabSize) {
if (change.text !== "" || change.start !== change.end - 1) return change;
const caretPosition = textDocument.positionAt(change.end);
if (caretPosition.character === 0) return change;
const lineText = textDocument.getLineText(caretPosition.line);
const leadingText = lineText.slice(0, caretPosition.character);
if (/[^ \t]/.test(leadingText)) return change;
if (lineText[caretPosition.character - 1] === " ") return change;
const softTabStart = Math.max(0, caretPosition.character - tabSize);
const softTabText = lineText.slice(softTabStart, caretPosition.character);
if (softTabText.length === tabSize && /^ +$/.test(softTabText)) return {
...change,
start: change.end - softTabText.length
};
return change;
}
function boundaryToPosition(node, offset) {
let lineEl = node.nodeType === 1 ? node : node.parentElement;
while (lineEl !== null && getLineIndex(lineEl) === void 0) lineEl = lineEl.parentElement;
if (lineEl === null) return null;
const line = getLineIndex(lineEl);
if (line === void 0) return null;
if (node.nodeType === 3) {
if (node.parentElement === null) return null;
if (findTokenSpan(node.parentElement) !== null) return {
line,
character: getLineChildEnd(node, offset)
};
return {
line,
character: offsetBefore(lineEl, node) + getTextOffset(node.textContent, offset)
};
}
if (node.nodeType === 1) {
const el = node;
if (el.tagName === "DIV") {
let character = 0;
for (let i = 0; i < offset; i++) character = getLineChildEnd(el.childNodes[i]);
return {
line,
character
};
}
if (el.tagName === "BR") return {
line,
character: 0
};
if (el.tagName === "SPAN") {
if (offset < el.childNodes.length) {
const next = el.childNodes[offset];
if (next?.nodeType === 1) {
const nextBase = getCharacterIndex(next);
if (nextBase !== void 0) return {
line,
character: nextBase
};
const token = findTokenSpan(next);
const tokenBase = token === null ? void 0 : getCharacterIndex(token);
if (tokenBase !== void 0) return {
line,
character: tokenBase
};
}
}
return {
line,
character: offset > 0 ? getLineChildEnd(el.childNodes[offset - 1]) : offsetBefore(lineEl, el)
};
}
return {
line,
character: offsetBefore(lineEl, el)
};
}
return null;
}
function collectTokens(line) {
const tokens = [];
for (const child of line.childNodes) {
if (child.nodeType !== 1) continue;
const el = child;
if (el.tagName !== "SPAN") continue;
if (getCharacterIndex(el) !== void 0) {
tokens.push(el);
continue;
}
for (const nested of el.childNodes) if (nested.nodeType === 1 && getCharacterIndex(nested) !== void 0) tokens.push(nested);
}
return tokens;
}
function textAt(token, offset) {
let remaining = Math.max(0, offset);
const stack = [{
container: token,
index: 0
}];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
if (frame.index >= frame.container.childNodes.length) {
stack.pop();
continue;
}
const walkNode = frame.container.childNodes[frame.index];
frame.index++;
if (walkNode.nodeType === 3) {
const len = getTextOffset(walkNode.textContent, walkNode.textContent?.length ?? 0);
if (remaining <= len) return [walkNode, remaining];
remaining -= len;
} else if (walkNode.nodeType === 1) stack.push({
container: walkNode,
index: 0
});
}
return null;
}
function textLengthBefore(root, target) {
let before = 0;
const stack = [{
container: root,
index: 0
}];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
if (frame.index >= frame.container.childNodes.length) {
stack.pop();
continue;
}
const walkNode = frame.container.childNodes[frame.index];
if (walkNode === target) return before;
frame.index++;
if (walkNode.nodeType === 3) before += getTextOffset(walkNode.textContent, walkNode.textContent?.length ?? 0);
else if (walkNode.nodeType === 1) stack.push({
container: walkNode,
index: 0
});
}
return before;
}
function isInside(token, node) {
let current = node;
while (current !== null) {
if (current === token) return true;
current = current.parentElement;
}
return false;
}
function offsetBefore(line, node) {
if (node.parentElement === line) {
let offset = 0;
const index = Array.prototype.indexOf.call(line.childNodes, node);
for (let i = 0; i < index; i++) offset = getLineChildEnd(line.childNodes[i]);
return offset;
}
for (const token of collectTokens(line)) if (isInside(token, node)) return getCharacterIndex(token) + (node.nodeType === 3 ? textLengthBefore(token, node) : 0);
let offset = 0;
let target = node.nodeType === 1 ? node : node.parentElement;
while (target !== null && target.parentElement !== null) {
if (getLineIndex(target.parentElement) !== void 0) break;
const parent = target.parentElement;
const index = Array.prototype.indexOf.call(parent.childNodes, target);
for (let i = 0; i < index; i++) offset = getLineChildEnd(parent.childNodes[i]);
target = parent;
}
return offset;
}
function findTokenSpan(el) {
let current = el;
while (current !== null) {
if (getLineIndex(current) !== void 0) return null;
if (getCharacterIndex(current) !== void 0) return current;
current = current.parentElement;
}
return null;
}
function getLineChildEnd(child, textOffsetInChild) {
if (child === void 0) return 0;
if (child.nodeType === 3) {
const parent = child.parentElement;
if (parent === null) return 0;
const token = findTokenSpan(parent);
if (token === null) return 0;
const base = getCharacterIndex(token);
if (base === void 0) return 0;
const length = textOffsetInChild === void 0 ? getTextOffset(child.textContent, child.textContent?.length ?? 0) : getTextOffset(child.textContent, textOffsetInChild);
return base + textLengthBefore(token, child) + length;
}
if (child.nodeType !== 1) return 0;
const el = child;
if (el.tagName !== "SPAN" && el.tagName !== "BR") return 0;
const base = getCharacterIndex(el);
if (base !== void 0) return base + (el.textContent?.length ?? 0);
let end = 0;
for (const token of el.childNodes) end = Math.max(end, getLineChildEnd(token));
return end;
}
function getLineIndex(el) {
const { line, lineType } = el.dataset;
if (line !== void 0 && lineType !== "change-deletion") {
const lineNumber = parseInt(line, 10);
if (!Number.isNaN(lineNumber)) return lineNumber - 1;
}
}
function getCharacterIndex(el) {
const { char } = el.dataset;
if (char !== void 0) {
const charIndex = parseInt(char, 10);
if (!Number.isNaN(charIndex)) return charIndex;
}
}
function getTextOffset(text, offset) {
const value = text ?? "";
const lineBreakIndex = value.search(/[\r\n]/);
return Math.min(offset, lineBreakIndex === -1 ? value.length : lineBreakIndex);
}
//#endregion
export { DirectionBackward, DirectionForward, DirectionNone, applyDeleteCharacterToSelections, applyDeleteHardLineForwardToSelections, applyDeleteSoftLineBackwardToSelections, applyDeleteWordBackwardToSelections, applyTextChangeToSelections, applyTextReplaceToSelections, applyTransposeToSelections, comparePosition, convertSelection, createSelectionFrom, createSelectionFromAnchorAndFocusOffsets, expandCollapsedSelectionToWord, extendSelection, extendSelections, findNextMatch, getAutoSurroundReplacementTexts, getCaretPosition, getDocumentBoundarySelection, getDocumentFullSelection, getSelectedLineBlocks, getSelectionAnchor, getSelectionClipboardTexts, getSelectionText, isCollapsedSelection, isLineEditable, mapCursorMove, mapSelectionShift, mergeOverlappingSelections, remapOffsetThroughEdits, remapSelectionsAfterEdits, resolveDeleteCharacterRange, resolveIndentEdits, resolveSelectionCut, selectionIntersects, shiftSelectionLines, snapCharacterToGraphemeBoundary };
//# sourceMappingURL=selection.js.map