UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

1,148 lines (1,147 loc) 44.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Editor = void 0; const Cursor_1 = require("./Cursor"); const constants_1 = require("../rga/constants"); const util_1 = require("../slice/util"); const EditorSlices_1 = require("./EditorSlices"); const util_2 = require("sonic-forest/lib/util"); const printTree_1 = require("tree-dump/lib/printTree"); const SliceRegistry_1 = require("../registry/SliceRegistry"); const PersistedSlice_1 = require("../slice/PersistedSlice"); const stringify_1 = require("../../../json-text/stringify"); const slice_1 = require("../slice"); const util_3 = require("./util"); const sync_store_1 = require("../../../util/events/sync-store"); const MarkerOverlayPoint_1 = require("../overlay/MarkerOverlayPoint"); const iterator_1 = require("../../../util/iterator"); const json_crdt_patch_1 = require("../../../json-crdt-patch"); const constants_2 = require("../slice/constants"); /** * For inline boolean ("Overwrite") slices, both range endpoints should be * attached to {@link Anchor.Before} as per the Peritext paper. This way, say * bold text, automatically extends to include the next character typed as * user types. * * @param range The range to be adjusted. */ const makeRangeExtendable = (range) => { if (range.end.anchor !== constants_1.Anchor.Before || range.start.anchor !== constants_1.Anchor.Before) { const start = range.start.clone(); const end = range.end.clone(); start.refBefore(); end.refBefore(); range.set(start, end); } }; class Editor { constructor(txt) { this.txt = txt; /** * Formatting basic inline formatting which will be applied to the next * inserted text. This is a temporary store for formatting which is not yet * applied to the text, but will be if the cursor is not moved. This is used * for {@link SliceStacking.One} formatting which is set as "pending" when * user toggles it while cursor is caret. */ this.pending = new sync_store_1.ValueSyncStore(void 0); this.saved = new EditorSlices_1.EditorSlices(txt, txt.savedSlices); this.extra = new EditorSlices_1.EditorSlices(txt, txt.extraSlices); this.local = new EditorSlices_1.EditorSlices(txt, txt.localSlices); } getRegistry() { let registry = this.registry; if (!registry) this.registry = registry = SliceRegistry_1.SliceRegistry.withCommon(); return registry; } text() { return this.txt.strApi().view(); } // ------------------------------------------------------------------ cursors addCursor(range = this.txt.rangeAt(0), anchor = constants_2.CursorAnchor.Start) { const cursor = this.txt.localSlices.ins(range, constants_2.SliceStacking.Cursor, anchor, undefined, Cursor_1.Cursor); return cursor; } /** * Cursor is the the current user selection. It can be a caret or a range. If * range is collapsed to a single point, it is a *caret*. * * Returns the first cursor in the text and removes all other cursors. If * there is no cursor, creates one and inserts it at the start of the text. * To work with multiple cursors, use `.cursors()` method. */ get cursor() { let cursor; for (let i, iterator = this.cursors0(); (i = iterator());) if (!cursor) cursor = i; else this.local.del(i); return cursor ?? this.addCursor(); } cursors0() { const iterator = this.txt.localSlices.iterator0(); return () => { while (true) { const slice = iterator(); if (slice instanceof Cursor_1.Cursor) return slice; if (!slice) return; } }; } mainCursor() { return this.cursors0()(); } cursors() { return new iterator_1.UndefEndIter(this.cursors0()); } forCursor(callback) { for (let cursor, i = this.cursors0(); (cursor = i());) callback(cursor); } /** * @returns Returns `true` if there is at least one cursor in the document. */ hasCursor() { return !!this.cursors0()(); } /** * Returns the first cursor in the document, if any. * * @returns Returns the first cursor in the document, or `undefined` if there * are no cursors. */ getCursor() { return this.hasCursor() ? this.cursor : void 0; } /** * @returns Returns the exact number of cursors in the document. */ cursorCount() { let cnt = 0; for (const i = this.cursors0(); i();) cnt++; return cnt; } /** * Returns relative count of cursors (cardinality). * * @returns 0 if there are no cursors, 1 if there is exactly one cursor, 2 if * there are more than one cursor. */ cursorCard() { const i = this.cursors0(); return !i() ? 0 : !i() ? 1 : 2; } delCursor(cursor) { this.local.del(cursor); } delCursors() { for (let cursor, i = this.cursors0(); (cursor = i());) this.delCursor(cursor); } /** * Ensures there is no range selection. If user has selected a range, * the contents is removed and the cursor is set at the start of the range * as caret. */ collapseCursor(cursor) { this.delRange(cursor); cursor.collapseToStart(); } collapseCursors() { for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) this.collapseCursor(cursor); } // ------------------------------------------------------------- text editing /** * Ensures there is exactly one cursor. If the cursor is a range, contents * inside the range is deleted and cursor is collapsed to a single point. * * @returns A single cursor collapsed to a single point. */ caret() { const cursor = this.cursor; if (!cursor.isCollapsed()) this.delRange(cursor); return cursor; } /** * Insert inline text at current cursor position. If cursor selects a range, * the range is removed and the text is inserted at the start of the range. */ insert0(range, text) { if (!text) return; if (!range.isCollapsed()) this.delRange(range); const after = range.start.clone(); after.refAfter(); const txt = this.txt; const textId = txt.ins(after.id, text); const span = new json_crdt_patch_1.Timespan(textId.sid, textId.time, text.length); const shift = text.length - 1; const point = txt.point(shift ? (0, json_crdt_patch_1.tick)(textId, shift) : textId, constants_1.Anchor.After); if (range instanceof Cursor_1.Cursor) range.set(point, point, constants_2.CursorAnchor.Start); else range.set(point); return span; } /** * Inserts text at the cursor positions and collapses cursors, if necessary. * Then applies any pending inline formatting to the inserted text. */ insert(text, ranges) { const spans = []; if (!ranges) { if (!this.hasCursor()) this.addCursor(); ranges = this.cursors(); } if (!ranges) return []; for (const range of ranges) { const span = this.insert0(range, text); if (span) spans.push(span); const pending = this.pending.value; if (pending?.size) { this.pending.next(void 0); const start = range.start.clone(); start.step(-text.length); const toggleRange = this.txt.range(start, range.end.clone()); for (const [type, data] of pending) this.toggleRangeExclFmt(toggleRange, type, data); } } return spans; } /** * Deletes the previous character at current cursor positions. If cursors * select a range, deletes the whole range. */ del(step = -1) { this.delete(step, 'char'); } delRange(range) { const txt = this.txt; const overlay = txt.overlay; const contained = overlay.findContained(range); for (const slice of contained) if (slice instanceof PersistedSlice_1.PersistedSlice && slice.stacking !== constants_2.SliceStacking.Cursor) slice.del(); txt.delStr(range); } /** * Deletes one or more units of text in all cursors. If cursor is a range, * deletes the whole range. * * @param step Number of units to delete. * @param unit A unit of deletion: "char", "word", "line". */ delete(step, unit) { const txt = this.txt; for (let i = this.cursors0(), cursor = i(); cursor; cursor = i()) { if (!cursor.isCollapsed()) { this.collapseCursor(cursor); return; } let point1 = cursor.start.clone(); let point2 = point1.clone(); if (step > 0) point2 = this.skip(point2, step, unit); else if (step < 0) point1 = this.skip(point1, step, unit); else if (step === 0) { point1 = this.skip(point1, -1, unit); point2 = this.skip(point2, 1, unit); } const range = txt.range(point1, point2); this.delRange(range); point1.refAfter(); cursor.set(point1); } } // ----------------------------------------------------------------- movement /** * Returns an iterator through visible text, one `step`, one character at a * time, starting from a given {@link Point}. * * @param start The starting point. * @param step Number of visible characters to skip. * @returns The next visible character iterator. */ walk(start, step = 1) { let point = start.clone(); return () => { if (!point) return; const char = step > 0 ? point.rightChar() : point.leftChar(); if (!char) return (point = undefined); const end = point.step(step); if (end) point = undefined; return char; }; } /** * Returns a forward iterator through visible text, one character at a time, * starting from a given {@link Point}. * * @param start The starting point. * @param chunk Chunk to start from. * @returns The next visible character iterator. */ fwd(start) { return this.walk(start, 1); } /** * Returns a backward iterator through visible text, one character at a time, * starting from a given {@link Point}. * * @param start The starting point. * @param chunk Chunk to start from. * @returns The previous visible character iterator. */ bwd(start) { return this.walk(start, -1); } /** * Skips a word in an arbitrary direction. A word is defined by the `predicate` * function, which returns `true` if the character is part of the word. * * @param iterator Character iterator. * @param predicate Predicate function to match characters, returns `true` if * the character is part of the word. * @param firstLetterFound Whether the first letter has already been found. If * not, will skip any characters until the first letter, which is matched * by the `predicate` is found. * @returns Point after the last skipped character. */ skipWord(iterator, predicate, firstLetterFound) { let next; let prev; while ((next = iterator())) { const char = next.view()[0]; if (firstLetterFound) { if (!predicate(char)) break; } else if (predicate(char)) firstLetterFound = true; prev = next; } if (!prev) return; return this.txt.point(prev.id(), constants_1.Anchor.After); } /** * Hard skips line, skips to the next "\n" newline character. * * @param iterator Character iterator. * @returns Point after the last skipped character. */ skipLine(iterator) { let next; let prev; while ((next = iterator())) { const char = next.view()[0]; if (char === '\n') break; prev = next; } if (!prev) return; return this.txt.point(prev.id(), constants_1.Anchor.After); } /** * End of word iterator (eow). Skips a word forward. A word is defined by the * `predicate` function, which returns `true` if the character is part of the * word. * * @param point Point from which to start skipping. * @param predicate Character class to skip. * @param firstLetterFound Whether the first letter has already been found. If * not, will skip any characters until the first letter, which is * matched by the `predicate` is found. * @returns Point after the last character skipped. */ eow(point, predicate = util_3.isLetter, firstLetterFound = false) { return this.skipWord(this.fwd(point), predicate, firstLetterFound) || point; } /** * Beginning of word iterator (bow). Skips a word backward. A word is defined * by the `predicate` function, which returns `true` if the character is part * of the word. * * @param point Point from which to start skipping. * @param predicate Character class to skip. * @param firstLetterFound Whether the first letter has already been found. If * not, will skip any characters until the first letter, which is * matched by the `predicate` is found. * @returns Point after the last character skipped. */ bow(point, predicate = util_3.isLetter, firstLetterFound = false) { const bwd = this.bwd(point); const endPoint = this.skipWord(bwd, predicate, firstLetterFound); if (endPoint) endPoint.anchor = constants_1.Anchor.Before; return endPoint || point; } /** Find end of line, starting from given point. */ eol(point) { return this.skipLine(this.fwd(point)) || this.end(); } /** Find beginning of line, starting from given point. */ bol(point) { const bwd = this.bwd(point); const endPoint = this.skipLine(bwd); if (endPoint) endPoint.anchor = constants_1.Anchor.Before; return endPoint || this.start(); } /** * Find end of block, starting from given point. Overlay should be refreshed * before calling this method. */ eob(point) { const txt = this.txt; const overlay = txt.overlay; point = point.clone(); point.halfstep(1); if (point.isAbsEnd()) return point; let overlayPoint = overlay.getOrNextHigher(point); if (!overlayPoint) return this.end(); if (point.cmp(overlayPoint) === 0) overlayPoint = (0, util_2.next)(overlayPoint); while (!(overlayPoint instanceof MarkerOverlayPoint_1.MarkerOverlayPoint) && overlayPoint) overlayPoint = (0, util_2.next)(overlayPoint); if (overlayPoint instanceof MarkerOverlayPoint_1.MarkerOverlayPoint) { const point = overlayPoint.clone(); point.refAfter(); return point; } else return this.end(); } /** * Find beginning of block, starting from given point. Overlay should be * refreshed before calling this method. */ bob(point) { const overlay = this.txt.overlay; point = point.clone(); point.halfstep(-1); if (point.isAbsStart()) return point; let overlayPoint = overlay.getOrNextLower(point); if (!overlayPoint) return this.start(); while (!(overlayPoint instanceof MarkerOverlayPoint_1.MarkerOverlayPoint) && overlayPoint) overlayPoint = (0, util_2.prev)(overlayPoint); if (overlayPoint instanceof MarkerOverlayPoint_1.MarkerOverlayPoint) { const point = overlayPoint.clone(); point.refBefore(); return point; } else return this.start(); } /** * Move a point given number of steps in a specified direction. The unit of * one move step is defined by the `unit` parameter. * * @param point The point to start from. * @param steps Number of steps to move. Negative number moves backward. * @param unit The unit of move per step: "char", "word", "line", etc. * @returns The destination point after the move. */ skip(point, steps, unit, ui) { if (!steps) return point; switch (unit) { case 'point': { const p = point.clone(); return p.halfstep(steps), p; } case 'char': { const p = point.clone(); return p.step(steps), p; } case 'word': { if (steps > 0) for (let i = 0; i < steps; i++) point = this.eow(point); else for (let i = 0; i < -steps; i++) point = this.bow(point); return point; } case 'line': { if (steps > 0) for (let i = 0; i < steps; i++) point = this.eol(point); else for (let i = 0; i < -steps; i++) point = this.bol(point); return point; } case 'vline': { if (steps > 0) for (let i = 0; i < steps; i++) point = ui?.eol?.(point, 1) ?? this.eol(point); else for (let i = 0; i < -steps; i++) point = ui?.eol?.(point, -1) ?? this.bol(point); return point; } case 'vert': { return ui?.vert?.(point, steps) || point; } case 'block': { if (steps > 0) for (let i = 0; i < steps; i++) point = this.eob(point); else for (let i = 0; i < -steps; i++) point = this.bob(point); return point; } case 'all': return steps > 0 ? this.end() : this.start(); } } /** * Move all cursors given number of units. * * @param steps Number of steps to move. * @param unit The unit of move per step: "char", "word", "line". * @param endpoint 0 for "focus", 1 for "anchor", 2 for both. * @param collapse Whether to collapse the range to a single point. */ move(steps, unit, endpoint = 0, collapse = true, ui) { this.forCursor((cursor) => { switch (endpoint) { case 0: { let point = cursor.focus(); point = this.skip(point, steps, unit, ui); if (collapse) cursor.set(point); else cursor.setEndpoint(point, 0); break; } case 1: { let point = cursor.anchor(); point = this.skip(point, steps, unit, ui); if (collapse) cursor.set(point); else cursor.setEndpoint(point, 1); break; } case 2: { const start = this.skip(cursor.start, steps, unit, ui); const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit, ui); cursor.set(start, end); break; } } }); } // ---------------------------------------------------------------- selection /** * Leaves only the first cursor, and sets it selection to the whole text. * * @returns Returns `true` if the selection was successful. */ selectAll() { const range = this.txt.rangeAll(); if (!range) return false; this.cursor.setRange(range); return true; } /** * Selects a word by extending the selection to the left and right of the point. * * @param point Point to the right of which is the starting character of the word. * @returns Range which contains the word. */ rangeWord(point) { const char = point.rightChar() || point.leftChar(); if (!char) return; const c = String(char.view())[0]; const predicate = (0, util_3.isLetter)(c) ? util_3.isLetter : (0, util_3.isWhitespace)(c) ? util_3.isWhitespace : util_3.isPunctuation; const start = this.bow(point, predicate, true); const end = this.eow(point, predicate, true); return this.txt.range(start, end); } /** * Returns a range by expanding the selection to the left and right of the * given point. * * @param point Point from which to start range expansion. * @param unit Unit of the range expansion. * @returns Range which contains the specified unit. */ range(point, unit, ui) { if (unit === 'word') return this.rangeWord(point); const point1 = this.skip(point, -1, unit, ui); const point2 = this.skip(point, 1, unit, ui); return this.txt.range(point1, point2); } select(unit, ui) { this.forCursor((cursor) => { const range = this.range(cursor.start, unit, ui); if (range) cursor.set(range.start, range.end, constants_2.CursorAnchor.Start); else this.delCursors; }); } selectAt(at, unit, ui) { this.cursor.set(this.pos2point(at)); if (unit) this.select(unit, ui); } // --------------------------------------------------------------- formatting eraseFormatting(store = this.saved, selection = this.cursors()) { const overlay = this.txt.overlay; for (const range of selection) { overlay.refresh(); const contained = overlay.findContained(range); for (const slice of contained) { if (slice instanceof PersistedSlice_1.PersistedSlice) { switch (slice.stacking) { case constants_2.SliceStacking.One: case constants_2.SliceStacking.Many: case constants_2.SliceStacking.Erase: slice.del(); } } } overlay.refresh(); const overlapping = overlay.findOverlapping(range); for (const slice of overlapping) { switch (slice.stacking) { case constants_2.SliceStacking.One: case constants_2.SliceStacking.Many: { store.insErase(slice.type); } } } } } clearFormatting(store = this.saved, selection = this.cursors()) { const overlay = this.txt.overlay; overlay.refresh(); for (const range of selection) { const overlapping = overlay.findOverlapping(range); for (const slice of overlapping) store.del(slice.id); } } // -------------------------------------------------------- inline formatting toggleRangeExclFmt(range, type, data, store = this.saved) { if (range.isCollapsed()) throw new Error('Range is collapsed'); const txt = this.txt; const overlay = txt.overlay; const [complete] = overlay.stat(range, 1e6); const needToRemoveFormatting = complete.has(type); makeRangeExtendable(range); const contained = overlay.findContained(range); for (const slice of contained) if (slice instanceof PersistedSlice_1.PersistedSlice && slice.type === type) slice.del(); if (needToRemoveFormatting) { overlay.refresh(); const [complete2, partial2] = overlay.stat(range, 1e6); const needsErase = complete2.has(type) || partial2.has(type); if (needsErase) store.slices.insErase(range, type); } else { if (range.start.isAbs()) { const start = txt.pointStart(); if (!start) return; if (start.cmpSpatial(range.end) >= 0) return; range.start = start; } if (range.end.isAbs()) { const end = txt.pointEnd(); if (!end) return; if (end.cmpSpatial(range.start) <= 0) return; range.end = end; } store.slices.insOne(range, type, data); } } toggleExclFmt(type, data, store = this.saved, selection = this.cursors()) { // TODO: handle mutually exclusive slices (<sub>, <sub>) this.txt.overlay.refresh(); SELECTION: for (const range of selection) { if (range.isCollapsed()) { const pending = this.pending.value ?? new Map(); if (pending.has(type)) pending.delete(type); else pending.set(type, data); this.pending.next(pending); continue SELECTION; } this.toggleRangeExclFmt(range, type, data, store); } } // --------------------------------------------------------- block formatting /** * Returns block split marker of the block inside which the point is located. * * @param point The point to get the marker at. * @returns The split marker at the point, if any. */ getMarker(point) { return this.txt.overlay.getOrNextLowerMarker(point)?.marker; } /** * Returns the block type at the given point. Block type is a nested array of * tags, e.g. `['p']`, `['blockquote', 'p']`, `['ul', 'li', 'p']`, etc. * * @param point The point to get the block type at. * @returns Current block type at the point. */ getBlockType(point) { const marker = this.getMarker(point); if (!marker) return [[0 /* SliceTypeCon.p */]]; let steps = marker?.type ?? [0 /* SliceTypeCon.p */]; if (!Array.isArray(steps)) steps = [steps]; return [steps, marker]; } /** * Insert a block split at the start of the document. The start of the * document is defined as immediately after all deleted characters starting * from the beginning of the document, or as the ABS start of the document if * there are no deleted characters. * * @param type The type of the marker. * @returns The inserted marker slice. */ insStartMarker(type) { const txt = this.txt; const start = txt.pointStart() ?? txt.pointAbsStart(); start.refAfter(); return this.txt.savedSlices.insMarkerAfter(start.id, type); } /** * Find the block split marker which contains the point and sets the block * type of the marker. If there is no block split marker at the point, a new * block split marker is inserted at the beginning of the document with the * specified block type. * * @param point The point at which to set the block type. * @param type The new block type. * @returns The marker slice at the point, or a new marker slice if there is none. */ setBlockType(point, type) { const marker = this.getMarker(point); if (marker) { marker.update({ type }); return marker; } return this.insStartMarker(type); } getContainerPath(steps) { const registry = this.getRegistry(); const length = steps.length; for (let i = length - 1; i >= 0; i--) { const step = steps[i]; const tag = Array.isArray(step) ? step[0] : step; const isContainer = registry.isContainer(tag); if (isContainer) return steps.slice(0, i + 1); } return []; } getDeepestCommonContainer(steps1, steps2) { const length1 = steps1.length; const length2 = steps2.length; const min = Math.min(length1, length2); for (let i = 0; i < min; i++) { const step1 = steps1[i]; const step2 = steps2[i]; const tag1 = Array.isArray(step1) ? step1[0] : step1; const tag2 = Array.isArray(step2) ? step2[0] : step2; const disc1 = Array.isArray(step1) ? step1[1] : 0; const disc2 = Array.isArray(step2) ? step2[1] : 0; if (tag1 !== tag2 || disc1 !== disc2) return i - 1; if (!this.getRegistry().isContainer(tag1)) return i - 1; } return min; } /** * @param at Point at which split block split happens. * @param slices The slices set to use. * @returns True if a marker was inserted, false if it was updated. */ splitAt(at, slices = this.saved) { const [type, marker] = this.getBlockType(at); const prevMarker = marker ? this.getMarker(marker.start.copy((p) => p.halfstep(-1))) : void 0; if (marker && prevMarker) { const rangeFromMarker = this.txt.range(marker.start, at); const noLeadingText = rangeFromMarker.length() <= 1; if (noLeadingText) { const markerSteps = marker.typeSteps(); const prevMarkerSteps = prevMarker.typeSteps(); if (markerSteps.length > 1) { const areMarkerTypesEqual = (0, util_3.stepsEqual)(markerSteps, prevMarkerSteps); if (areMarkerTypesEqual) { const i = this.getDeepestCommonContainer(markerSteps, prevMarkerSteps); if (i >= 0) { const newType = [...markerSteps]; const step = newType[i]; const tag = Array.isArray(step) ? step[0] : step; const disc = Array.isArray(step) ? step[1] : 0; newType[i] = [tag, (disc + 1) % 8]; marker.update({ type: newType }); return false; } } } } } const containerPath = this.getContainerPath(type); const newType = containerPath.concat([slice_1.CommonSliceType.p]); slices.insMarker(newType); return true; } split(type, data, selection = this.cursors(), slices = this.saved) { if (type === void 0) { for (const range of selection) { this.collapseCursor(range); const didInsertMarker = this.splitAt(range.start, slices); if (didInsertMarker && range instanceof Cursor_1.Cursor) range.move(1); } } else { for (const range of selection) { this.collapseCursor(range); if (type === void 0) { // TODO: detect current block type type = slice_1.CommonSliceType.p; } slices.insMarker(type, data); if (range instanceof Cursor_1.Cursor) range.move(1); } } } setStartMarker(type, data, slices = this.saved) { const after = this.txt.pointStart() ?? this.txt.pointAbsStart(); after.refAfter(); if (Array.isArray(type) && type.length === 1) type = type[0]; return slices.slices.insMarkerAfter(after.id, type, data); } tglMarkerAt(point, type, data, slices = this.saved, def = 0 /* SliceTypeCon.p */) { const overlay = this.txt.overlay; const markerPoint = overlay.getOrNextLowerMarker(point); if (markerPoint) { const marker = markerPoint.marker; const tag = marker.tag(); if (!Array.isArray(type)) type = [type]; const typeTag = type[type.length - 1]; if (tag === typeTag) type = [...type.slice(0, -1), def]; if (Array.isArray(type) && type.length === 1) type = type[0]; marker.update({ type }); } else this.setStartMarker(type, data, slices); } updMarkerAt(point, type, data, slices = this.saved) { const overlay = this.txt.overlay; const markerPoint = overlay.getOrNextLowerMarker(point); if (markerPoint) { const marker = markerPoint.marker; if (Array.isArray(type) && type.length === 1) type = type[0]; marker.update({ type }); } else this.setStartMarker(type, data, slices); } /** * Toggle the type of a block split between the slice type and the default * (paragraph) block type. * * @param type Slice type to toggle. * @param data Custom data of the slice. */ tglMarker(type, data, selection = this.cursors(), slices = this.saved, def = 0 /* SliceTypeCon.p */) { for (const range of selection) this.tglMarkerAt(range.start, type, data, slices, def); } /** * Update the type of a block split at all cursor positions. * * @param type Slice type to set. * @param data Custom data of the slice. * @param slices The slices set to use, if new marker is inserted at the start * of the document. */ updMarker(type, data, selection = this.cursors(), slices = this.saved) { for (const range of selection) this.updMarkerAt(range.start, type, data, slices); } delMarker(selection = this.cursors()) { const markerPoints = new Set(); for (const range of selection) { const markerPoint = this.txt.overlay.getOrNextLowerMarker(range.start); if (markerPoint) markerPoints.add(markerPoint); } for (const markerPoint of markerPoints) { const boundary = markerPoint.marker.boundary(); this.delRange(boundary); } } // ---------------------------------------------------------- export / import export(range) { const r = range.range(); r.start.refBefore(); r.end.refAfter(); const text = r.text(); const offset = r.start.viewPos(); const viewSlices = []; const view = [text, offset, viewSlices]; const txt = this.txt; const overlay = txt.overlay; const slices = overlay.findOverlapping(r); for (const slice of slices) { const isSavedSlice = slice.id.sid === txt.model.clock.sid; if (!isSavedSlice) continue; const stacking = slice.stacking; switch (stacking) { case constants_2.SliceStacking.One: case constants_2.SliceStacking.Many: case constants_2.SliceStacking.Erase: case constants_2.SliceStacking.Marker: { const { stacking, type, start, end } = slice; const header = (stacking << constants_2.SliceHeaderShift.Stacking) + (start.anchor << constants_2.SliceHeaderShift.X1Anchor) + (end.anchor << constants_2.SliceHeaderShift.X2Anchor); const viewSlice = [header, start.viewPos(), end.viewPos(), type]; const data = slice.data(); if (data !== void 0) viewSlice.push(data); viewSlices.push(viewSlice); } } } return view; } /** * "Copy formatting-only", copies inline formatting applied to the selected * range. * * @param range Range copy formatting from, normally a single visible character. * @returns A list of serializable inline formatting applied to the selected range. */ exportStyle(range) { const formatting = []; const txt = this.txt; const overlay = txt.overlay; const slices = overlay.findOverlapping(range); for (const slice of slices) { const isSavedSlice = slice.id.sid === txt.model.clock.sid; if (!isSavedSlice) continue; if (!slice.contains(range)) continue; const stacking = slice.stacking; switch (stacking) { case constants_2.SliceStacking.One: case constants_2.SliceStacking.Many: case constants_2.SliceStacking.Erase: { const sliceFormatting = [stacking, slice.type]; const data = slice.data(); if (data !== void 0) sliceFormatting.push(data); formatting.push(sliceFormatting); } } } return formatting; } import(pos, view) { let [text, offset, slices] = view; const txt = this.txt; let removeFirstMarker = false; const firstSlice = slices[0]; if (firstSlice) { const [header, x1, , type] = firstSlice; const stacking = (header & constants_2.SliceHeaderMask.Stacking) >>> constants_2.SliceHeaderShift.Stacking; const isBlockSplitMarker = stacking === constants_2.SliceStacking.Marker; if (isBlockSplitMarker) { const markerStartsAtZero = x1 - offset === 0; if (markerStartsAtZero) { const point = txt.pointAt(pos); const markerBefore = txt.overlay.getOrNextLowerMarker(point); if (markerBefore) { if (markerBefore.type() === type) removeFirstMarker = true; } else { if (type === slice_1.CommonSliceType.p) removeFirstMarker = true; } } } } if (removeFirstMarker) { text = text.slice(1); offset += 1; slices = slices.slice(1); } const length = slices.length; const splits = []; const annotations = []; const texts = []; let curr = 0; for (let i = 0; i < length; i++) { const slice = slices[i]; const [header, x1] = slice; const stacking = (header & constants_2.SliceHeaderMask.Stacking) >>> constants_2.SliceHeaderShift.Stacking; const isBlockSplitMarker = stacking === constants_2.SliceStacking.Marker; if (isBlockSplitMarker) { const end = x1 - offset; texts.push(text.slice(curr, end)); curr = end + 1; splits.push(slice); } else annotations.push(slice); } const lastText = text.slice(curr); const splitLength = splits.length; curr = pos; for (let i = 0; i < splitLength; i++) { const str = texts[i]; const split = splits[i]; if (str) { txt.insAt(curr, str); curr += str.length; } if (split) { const [, , , type, data] = split; const after = txt.pointAt(curr); after.refAfter(); txt.savedSlices.insMarkerAfter(after.id, type, data); curr += 1; } } if (lastText) { txt.insAt(curr, lastText); curr += lastText.length; } const annotationsLength = annotations.length; for (let i = 0; i < annotationsLength; i++) { const slice = annotations[i]; const [header, x1, x2, type, data] = slice; const anchor1 = (header & constants_2.SliceHeaderMask.X1Anchor) >>> constants_2.SliceHeaderShift.X1Anchor; const anchor2 = (header & constants_2.SliceHeaderMask.X2Anchor) >>> constants_2.SliceHeaderShift.X2Anchor; const stacking = (header & constants_2.SliceHeaderMask.Stacking) >>> constants_2.SliceHeaderShift.Stacking; const x1Src = x1 - offset; const x2Src = x2 - offset; const x1Capped = Math.max(0, x1Src); const x2Capped = Math.min(text.length, x2Src); const x1Dest = x1Capped + pos; const annotationLength = x2Capped - x1Capped; const range = txt.rangeAt(x1Dest, annotationLength); if (!!x1Dest && anchor1 === constants_1.Anchor.After) range.start.refAfter(); // else range.start.refBefore(); if (anchor2 === constants_1.Anchor.Before) range.end.refBefore(); // else range.end.refAfter(); if (range.end.isAbs()) range.end.refAfter(); txt.savedSlices.ins(range, stacking, type, data); } return curr - pos; } importStyle(range, formatting) { const txt = this.txt; const length = formatting.length; for (let i = 0; i < length; i++) { const [stacking, type, data] = formatting[i]; txt.savedSlices.ins(range, stacking, type, data); } } // ------------------------------------------------------------------ various pos2point(at) { const txt = this.txt; return typeof at === 'number' ? txt.pointAt(at) : Array.isArray(at) ? txt.pointAt(at[0], at[1]) : at; } sel2range(at) { if (!Array.isArray(at)) return [at, constants_2.CursorAnchor.End]; const [pos1, pos2] = at; const p1 = this.pos2point(pos1); const txt = this.txt; if (pos2 === undefined) { p1.refAfter(); return [txt.range(p1), constants_2.CursorAnchor.End]; } const p2 = this.pos2point(pos2); const range = txt.rangeFromPoints(p1, p2); const anchor = range.start === p1 ? constants_2.CursorAnchor.Start : constants_2.CursorAnchor.End; return [range, anchor]; } end() { const txt = this.txt; return txt.pointEnd() ?? txt.pointAbsEnd(); } start() { const txt = this.txt; return txt.pointStart() ?? txt.pointAbsStart(); } /** ----------------------------------------------------- {@link Printable} */ toString(tab = '') { const pending = this.pending.value; const pendingFormatted = {}; if (pending) for (const [type, data] of pending) pendingFormatted[(0, util_1.formatType)(type)] = data; return ('Editor' + (0, printTree_1.printTree)(tab, [ (tab) => 'cursors' + (0, printTree_1.printTree)(tab, [...this.cursors()].map((cursor) => (tab) => cursor.toString(tab))), (tab) => this.getRegistry().toString(tab), pending ? () => `pending ${(0, stringify_1.stringify)(pendingFormatted)}` : null, ])); } } exports.Editor = Editor;