2026-04-12 13:14:18 +02:00
|
|
|
import {
|
|
|
|
|
chipToken,
|
|
|
|
|
normalizeInlineWikilinkValue,
|
|
|
|
|
} from './inlineWikilinkTokens'
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
export interface InlineSelectionRange {
|
|
|
|
|
start: number
|
|
|
|
|
end: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SelectionBoundary {
|
|
|
|
|
container: Node
|
|
|
|
|
offset: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
export function serializeInlineNode(node: Node): string {
|
|
|
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
|
|
|
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node instanceof HTMLElement) {
|
|
|
|
|
if (node.dataset.chipTarget) {
|
|
|
|
|
return chipToken(node.dataset.chipTarget)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (node.tagName === 'BR') return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(node.childNodes).map(serializeInlineNode).join('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectionFallsOutsideEditor(
|
|
|
|
|
selection: Selection | null,
|
|
|
|
|
root: HTMLDivElement,
|
|
|
|
|
): boolean {
|
2026-04-13 13:26:38 +02:00
|
|
|
if (!selection || selection.rangeCount === 0) return true
|
|
|
|
|
const range = selection.getRangeAt(0)
|
|
|
|
|
return !root.contains(range.startContainer) || !root.contains(range.endContainer)
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
function serializedSelectionBoundary(
|
|
|
|
|
root: HTMLDivElement,
|
|
|
|
|
container: Node,
|
|
|
|
|
offset: number,
|
|
|
|
|
): number {
|
|
|
|
|
const range = document.createRange()
|
|
|
|
|
range.setStart(root, 0)
|
|
|
|
|
range.setEnd(container, offset)
|
|
|
|
|
return serializeInlineNode(range.cloneContents()).length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readSelectionRange(root: HTMLDivElement): InlineSelectionRange {
|
2026-04-12 13:15:51 +02:00
|
|
|
const currentSelection = window.getSelection()
|
2026-04-13 13:26:38 +02:00
|
|
|
if (!currentSelection || selectionFallsOutsideEditor(currentSelection, root)) {
|
|
|
|
|
const index = serializeInlineNode(root).length
|
|
|
|
|
return { start: index, end: index }
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
const range = currentSelection.getRangeAt(0)
|
|
|
|
|
return {
|
|
|
|
|
start: serializedSelectionBoundary(root, range.startContainer, range.startOffset),
|
|
|
|
|
end: serializedSelectionBoundary(root, range.endContainer, range.endOffset),
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
export function readSelectionIndex(root: HTMLDivElement): number {
|
|
|
|
|
return readSelectionRange(root).end
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
function boundaryAtEditorEnd(root: HTMLDivElement): SelectionBoundary {
|
|
|
|
|
return { container: root, offset: root.childNodes.length }
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
function boundaryForTextNode(
|
|
|
|
|
child: Node,
|
|
|
|
|
remaining: number,
|
|
|
|
|
): { boundary: SelectionBoundary | null; remaining: number } {
|
|
|
|
|
const textLength = normalizeInlineWikilinkValue(child.textContent ?? '').length
|
|
|
|
|
if (remaining <= textLength) {
|
|
|
|
|
return {
|
|
|
|
|
boundary: { container: child, offset: remaining },
|
|
|
|
|
remaining: 0,
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
return {
|
|
|
|
|
boundary: null,
|
|
|
|
|
remaining: remaining - textLength,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function boundaryForChip(
|
|
|
|
|
node: Node,
|
|
|
|
|
child: HTMLElement,
|
|
|
|
|
index: number,
|
|
|
|
|
remaining: number,
|
|
|
|
|
): { boundary: SelectionBoundary | null; remaining: number } {
|
2026-04-13 13:29:06 +02:00
|
|
|
const tokenLength = chipToken(child.dataset.chipTarget ?? '').length
|
2026-04-13 13:26:38 +02:00
|
|
|
if (remaining <= 0) {
|
|
|
|
|
return {
|
|
|
|
|
boundary: { container: node, offset: index },
|
|
|
|
|
remaining: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (remaining <= tokenLength) {
|
|
|
|
|
return {
|
|
|
|
|
boundary: { container: node, offset: index + 1 },
|
|
|
|
|
remaining: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
boundary: null,
|
|
|
|
|
remaining: remaining - tokenLength,
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
function findSelectionBoundary(
|
2026-04-12 13:14:18 +02:00
|
|
|
node: Node,
|
|
|
|
|
selectionIndex: number,
|
2026-04-13 13:26:38 +02:00
|
|
|
): SelectionBoundary | null {
|
|
|
|
|
let remaining = Math.max(0, selectionIndex)
|
|
|
|
|
const children = Array.from(node.childNodes)
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
for (const [index, child] of children.entries()) {
|
2026-04-12 13:14:18 +02:00
|
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
2026-04-13 13:26:38 +02:00
|
|
|
const textBoundary = boundaryForTextNode(child, remaining)
|
|
|
|
|
if (textBoundary.boundary) return textBoundary.boundary
|
|
|
|
|
remaining = textBoundary.remaining
|
2026-04-12 13:14:18 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(child instanceof HTMLElement)) continue
|
|
|
|
|
|
|
|
|
|
if (child.dataset.chipTarget) {
|
2026-04-13 13:26:38 +02:00
|
|
|
const chipBoundary = boundaryForChip(node, child, index, remaining)
|
|
|
|
|
if (chipBoundary.boundary) return chipBoundary.boundary
|
|
|
|
|
remaining = chipBoundary.remaining
|
2026-04-12 13:14:18 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
const childLength = serializeInlineNode(child).length
|
|
|
|
|
if (remaining <= childLength) {
|
|
|
|
|
return findSelectionBoundary(child, remaining)
|
|
|
|
|
}
|
|
|
|
|
remaining -= childLength
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
return null
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
export function applySelectionRange(
|
|
|
|
|
root: HTMLDivElement,
|
|
|
|
|
selectionRange: InlineSelectionRange,
|
2026-04-12 13:14:18 +02:00
|
|
|
) {
|
2026-04-13 13:26:38 +02:00
|
|
|
const selection = window.getSelection()
|
|
|
|
|
if (!selection) return
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
const range = document.createRange()
|
|
|
|
|
const serializedLength = serializeInlineNode(root).length
|
|
|
|
|
const start = Math.max(0, Math.min(selectionRange.start, selectionRange.end, serializedLength))
|
|
|
|
|
const end = Math.max(start, Math.min(Math.max(selectionRange.start, selectionRange.end), serializedLength))
|
|
|
|
|
const startBoundary = findSelectionBoundary(root, start) ?? boundaryAtEditorEnd(root)
|
|
|
|
|
const endBoundary = findSelectionBoundary(root, end) ?? boundaryAtEditorEnd(root)
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
range.setStart(startBoundary.container, startBoundary.offset)
|
|
|
|
|
range.setEnd(endBoundary.container, endBoundary.offset)
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
selection.removeAllRanges()
|
|
|
|
|
selection.addRange(range)
|
|
|
|
|
}
|
2026-04-12 13:14:18 +02:00
|
|
|
|
2026-04-13 13:26:38 +02:00
|
|
|
export function applySelectionIndex(
|
|
|
|
|
root: HTMLDivElement,
|
|
|
|
|
selectionIndex: number,
|
|
|
|
|
) {
|
|
|
|
|
applySelectionRange(root, { start: selectionIndex, end: selectionIndex })
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|