diff --git a/ivette/src/dome/renderer/text/richtext.tsx b/ivette/src/dome/renderer/text/richtext.tsx index 7125fba4c235127eba94e1ff55db0c7d8fd16e8d..90713ed06b1e48dd80efb4b0c6bb8c2cb35f216b 100644 --- a/ivette/src/dome/renderer/text/richtext.tsx +++ b/ivette/src/dome/renderer/text/richtext.tsx @@ -268,21 +268,16 @@ class Extension { pack(ext : CS.Extension): void { this.extension.push(ext); } } -interface Comparator<A> { - (a: A, b: A): boolean; -} - class Field<A> extends Extension { readonly field : CS.StateField<A>; private readonly annot : CS.AnnotationType<A>; - constructor(init: A, compare ?: Comparator<A>) { + constructor(init: A) { super(); const annot = CS.Annotation.define<A>(); const field = CS.StateField.define<A>({ create: () => init, update: (fd: A, tr: CS.Transaction) => tr.annotation(annot) ?? fd, - compare, }); this.annot = annot; this.field = field; @@ -433,88 +428,6 @@ function isGutterDecoration(d : Decoration) : d is GutterDecoration return d.hasOwnProperty("line") && d.hasOwnProperty("gutter"); } -/* -------------------------------------------------------------------------- */ -/* --- Generic Builder --- */ -/* -------------------------------------------------------------------------- */ - -interface RangeValue<A> extends Range { value: A } - -class Builder<A extends CS.RangeValue> { - - private buffer : RangeValue<A>[] = []; - protected readonly doc : CS.Text; - - constructor(doc: CS.Text) { - this.doc = doc; - this.addSpec = this.addSpec.bind(this); - } - - addRange(offset: number, length: number, value: A): void { - if (offset < 0) return; - if (offset + length > this.doc.length) return; - this.buffer.push({ offset, length, value }); - } - - addSpec(spec : Decorations): void { - if (spec !== null) { - if (isDecoration(spec)) - this.addDecoration(spec); - else - spec.forEach(this.addSpec); - } - } - - finish(): CS.RangeSet<A> { - const { buffer } = this; - if (buffer.length === 0) return CS.RangeSet.empty; - const builder = new CS.RangeSetBuilder<A>(); - buffer.sort(byOffset).forEach((r) => - builder.add(r.offset, r.offset+r.length, r.value) - ); - return builder.finish(); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - protected addDecoration(_: Decoration): void {} - -} - -/* -------------------------------------------------------------------------- */ -/* --- Decoration Builder --- */ -/* -------------------------------------------------------------------------- */ - -class DecorationBuilder extends Builder<CM.Decoration> -{ - - addDecoration(spec: Decoration): void { - // ---- Mark Decoration - if (isMarkDecoration(spec)) { - const { offset, length, inclusive, className, title } = spec; - const attributes = title ? { title } : undefined; - const decoration = CM.Decoration.mark({ - 'class': className, - attributes, - inclusive, - }); - this.addRange(offset, length, decoration); - } - // ---- Line Decoration - if (isLineDecoration(spec)) { - const { line, className, title } = spec; - if (line < 1) return; - if (line > this.doc.lines) return; - const offset = this.doc.line(line).from; - const attributes = title ? { title } : undefined; - const decoration = CM.Decoration.line({ - 'class': className, - attributes, - }); - this.addRange(offset, 0, decoration); - } - } - -} - /* -------------------------------------------------------------------------- */ /* --- Gutter Builder --- */ /* -------------------------------------------------------------------------- */ @@ -539,6 +452,7 @@ class GutterMark extends CM.GutterMarker { } const GutterMarks : Map<string, GutterMark> = new Map(); +const GutterInit : GutterDecoration = { line: 0, gutter: '?' }; function gutterMark(spec: GutterDecoration) : CM.GutterMarker { const { gutter, className='', title='' } = spec; @@ -551,110 +465,155 @@ function gutterMark(spec: GutterDecoration) : CM.GutterMarker { return marker; } -class GutterBuilder extends Builder<CM.GutterMarker> -{ +/* -------------------------------------------------------------------------- */ +/* --- Decorations Builder --- */ +/* -------------------------------------------------------------------------- */ + +interface RangeValue<A> extends Range { value: A } + +function toRangeSet<A extends CS.RangeValue>( + ranges: RangeValue<A>[] +): CS.RangeSet<A> { + const buffer = new CS.RangeSetBuilder<A>(); + ranges.sort(byOffset).forEach((r) => + buffer.add(r.offset, r.offset + r.length, r.value) + ); + return buffer.finish(); +} + +class DecorationsBuilder { + + private ranges : RangeValue<CM.Decoration>[] = []; + private gutters : RangeValue<CM.GutterMarker>[] = []; + protected readonly doc : CS.Text; + + constructor(doc: CS.Text) { + this.doc = doc; + this.addSpec = this.addSpec.bind(this); + } - addDecoration(spec : Decoration): void { - // ---- Gutter Decoration - if (isGutterDecoration(spec)) { - const { line } = spec; - if (line < 1) return; - if (line > this.doc.lines) return; - const offset = this.doc.line(line).from; - const decoration = gutterMark(spec); - this.addRange(offset, 0, decoration); + addMark(spec: MarkDecoration): void { + const { offset, length, inclusive, className, title } = spec; + if (offset < 0) return; + if (offset + length > this.doc.length) return; + const attributes = title ? { title } : undefined; + const value = CM.Decoration.mark({ + 'class': className, + attributes, + inclusive, + }); + this.ranges.push({ offset, length, value }); + } + + addLine(spec: LineDecoration): void { + const { line, className, title } = spec; + if (line < 1) return; + if (line > this.doc.lines) return; + const offset = this.doc.line(line).from; + const attributes = title ? { title } : undefined; + const value = CM.Decoration.line({ + 'class': className, + attributes, + }); + this.ranges.push({ offset, length: 0, value }); + } + + addGutter(spec: GutterDecoration): void { + const { line } = spec; + if (line < 1) return; + if (line > this.doc.lines) return; + const offset = this.doc.line(line).from; + const value = gutterMark(spec); + this.gutters.push({ offset, length: 0, value }); + } + + addSpec(spec : Decorations): void { + if (spec !== null) { + if (isDecoration(spec)) { + if (isMarkDecoration(spec)) this.addMark(spec); + if (isLineDecoration(spec)) this.addLine(spec); + if (isGutterDecoration(spec)) this.addGutter(spec); + } else spec.forEach(this.addSpec); } } + getRanges(): CS.RangeSet<CM.Decoration> { + return toRangeSet(this.ranges); + } + + getGutters(): CS.RangeSet<CM.GutterMarker> { + return toRangeSet(this.gutters); + } + } /* -------------------------------------------------------------------------- */ -/* --- Decorators --- */ +/* --- Decorations --- */ /* -------------------------------------------------------------------------- */ -export type Decorator = Decorations | ((viewport: Selection) => Decorations); -export type Decorators = readonly Decorator[]; +interface Decorator { + spec : Decorations; + ranges : CM.DecorationSet; + gutters : CS.RangeSet<CM.GutterMarker>; +} -function compareDecorators(a : Decorators, b : Decorators): boolean +function compareDecorations(a : Decorations, b : Decorations): boolean { if (a === b) return true; + if (!Array.isArray(a)) return false; + if (!Array.isArray(b)) return false; const n = a.length; if (n !== b.length) return false; for (let k = 0; k < n; k++) if (a[k] !== b[k]) return false; return true; } -const Decorators = new Field<Decorators>([], compareDecorators); - -// --- Static Decorators - -function isStaticDecorator(d: Decorator): d is Decorations -{ - return typeof(d) !== 'function'; -} - -Decorators.pack( - CM.EditorView.decorations.compute( - [Decorators.field], - (state: CS.EditorState) => { - const decorators = - state.field(Decorators.field).filter(isStaticDecorator); - if (decorators.length === 0) return CS.RangeSet.empty; - const buffer = new DecorationBuilder(state.doc); - decorators.forEach(buffer.addSpec); - return buffer.finish(); +const DecoratorSpec = CS.Annotation.define<Decorations>(); + +const DecoratorState = CS.StateField.define<Decorator>({ + + create: () => ({ + spec: null, + ranges: CS.RangeSet.empty, + gutters: CS.RangeSet.empty, + }), + + update(value: Decorator, tr: CS.Transaction) { + const newSpec : Decorations = tr.annotation(DecoratorSpec) ?? null; + if (newSpec !== null && !compareDecorations(newSpec, value.spec)) { + const builder = new DecorationsBuilder(tr.newDoc); + builder.addSpec(newSpec); + return { + spec: newSpec, + ranges: builder.getRanges(), + gutters: builder.getGutters(), + }; } -)); - -// --- Dynamic Decorators + if (tr.docChanged) + return { + spec: value.spec, + ranges: value.ranges.map(tr.changes), + gutters: value.gutters.map(tr.changes), + }; + return value; + }, -type DynamicDecorator = ((viewport: Selection) => Decorations); +}); -function isDynamicDecorator(d: Decorator): d is DynamicDecorator -{ - return typeof(d) === 'function'; +function dispatchDecorations(view: View, spec: Decorations): void { + view?.dispatch({ annotations: DecoratorSpec.of(spec) }); } -Decorators.pack( - CM.EditorView.decorations.compute( - [Decorators.field], - (state: CS.EditorState) => { - const decorators = - state.field(Decorators.field).filter(isDynamicDecorator); - if (decorators.length === 0) return CS.RangeSet.empty; - return (view: CM.EditorView) => { - const doc = view.state.doc; - const buffer = new DecorationBuilder(doc); - view.visibleRanges.forEach((range) => - decorators.forEach((fn: DynamicDecorator) => - buffer.addSpec(fn(selection(doc, range))) - )); - return buffer.finish(); - }; - } -)); - -// --- Gutter Decorators - -const Gutters = CM.gutter({ - - markers(view : CM.EditorView): CS.RangeSet<CM.GutterMarker> { - const decorators = view.state.field(Decorators.field); - if (decorators.length === 0) return CS.RangeSet.empty; - const doc = view.state.doc; - const buffer = new GutterBuilder(doc); - decorators.forEach((spec: Decorator) => { - if (isStaticDecorator(spec)) - buffer.addSpec(spec); - else - view.visibleRanges.forEach((range) => - buffer.addSpec(spec(selection(doc, range))) - ); - }); - return buffer.finish(); - } - -}); +const Decorations: CS.Extension = [ + DecoratorState, + CM.EditorView.decorations.from(DecoratorState, ({ ranges }) => ranges), + CM.gutter({ + initialSpacer: () => gutterMark(GutterInit), + markers: (view) => view.state.field(DecoratorState).gutters, + lineMarkerChange: (update) => + update.transactions.some((tr) => !tr.annotation(DecoratorSpec)) + }), +]; /* -------------------------------------------------------------------------- */ /* --- Editor View --- */ @@ -662,7 +621,7 @@ const Gutters = CM.gutter({ function createView(parent: Element): CM.EditorView { const extensions : CS.Extension[] = [ - ReadOnly, OnChange, OnSelect, Decorators, Gutters + ReadOnly, OnChange, OnSelect, Decorations ]; const state = CS.EditorState.create({ extensions }); return new CM.EditorView({ state, parent }); @@ -678,7 +637,7 @@ export interface TextViewProps { onChange?: Callback; selection?: Range; onSelection?: SelectionCallback; - decorators?: Decorators; + decorations?: Decorations; display?: boolean; visible?: boolean; className?: string; @@ -702,12 +661,12 @@ export function TextView(props: TextViewProps) : JSX.Element { const { readOnly = false, onChange = null, onSelection: onSelect = null, - decorators: decors = [], + decorations: decors = null, } = props; React.useEffect(() => ReadOnly.dispatch(view, readOnly), [view, readOnly]); React.useEffect(() => OnChange.dispatch(view, onChange), [view, onChange]); React.useEffect(() => OnSelect.dispatch(view, onSelect), [view, onSelect]); - React.useEffect(() => Decorators.dispatch(view, decors), [view, decors]); + React.useEffect(() => dispatchDecorations(view, decors), [view, decors]); // ---- Selection const { selection } = props; diff --git a/ivette/src/sandbox/text.tsx b/ivette/src/sandbox/text.tsx index e4c5aaa294f7b0358995b68113f7e65557b99286..fbf585882e16ddb1bfb76b6b9decf8af9779f5c7 100644 --- a/ivette/src/sandbox/text.tsx +++ b/ivette/src/sandbox/text.tsx @@ -35,9 +35,7 @@ import { TextProxy, TextBuffer, empty, - Selection, Decoration, - Decorator, } from 'dome/text/richtext'; import { registerSandbox } from 'ivette'; @@ -49,7 +47,6 @@ function UseText(): JSX.Element { const [prefix, setPrefix] = React.useState(''); const [readOnly, flipReadOnly] = Dome.useFlipState(false); const [useProxy, flipUseProxy] = Dome.useFlipState(false); - const [dynamic, flipDynamic] = Dome.useFlipState(false); const [changes, setChanges] = React.useState(0); const [s, onSelection] = React.useState(empty); const proxy = React.useMemo(() => new TextProxy(), []); @@ -65,16 +62,7 @@ function UseText(): JSX.Element { text.append(`ADDED${n}\n`); }, [text]); const onChange = Dome.useDebounced(updatePrefix, 200); - const [decorations, setDecorations] = React.useState<Decoration[]>([]); - - const decorator = React.useMemo<Decorator>(() => { - if (!dynamic) return decorations; - return (_: Selection): Decoration[] => { - return decorations; - }; - }, [ dynamic, decorations ]); - const clearDecorations = React.useCallback(() => setDecorations([]), []); const addDecoration = React.useCallback(() => { @@ -114,16 +102,9 @@ function UseText(): JSX.Element { title={useProxy ? 'Use TextProxy' : 'Use TextBuffer (persistent)'} onClick={flipUseProxy} /> - <Button - icon={dynamic ? 'RELOAD' : 'PIN'} - title={dynamic ? 'Dynamic Decorations' : 'Static Decorations'} - onClick={flipDynamic} - /> <Code label={`Offset ${s.offset}-${s.offset + s.length}`} /> <Code label={`Line ${s.fromLine}-${s.toLine}`} /> - <Code - display={decorations.length > 0} - label={`${decorations.length} Decorations`} /> + <Code label={`Decorations ${decorations.length}`} /> <IconButton display={s.length === 0} icon="CIRC.INFO" @@ -157,7 +138,7 @@ function UseText(): JSX.Element { readOnly={readOnly} onChange={onChange} onSelection={onSelection} - decorators={[decorator]} + decorations={decorations} /> </> );