From 2932ce155cd731fca916b1fc29728417d30bae76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Correnson?= <loic.correnson@cea.fr> Date: Thu, 9 Nov 2023 09:02:34 +0100 Subject: [PATCH] [dome/richtext] range proxy & viewport listener --- ivette/src/dome/renderer/text/richtext.tsx | 65 +++++++++++++++++++++- ivette/src/sandbox/text.tsx | 39 ++++++++----- 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/ivette/src/dome/renderer/text/richtext.tsx b/ivette/src/dome/renderer/text/richtext.tsx index 2f74cc04436..b42b85a6daf 100644 --- a/ivette/src/dome/renderer/text/richtext.tsx +++ b/ivette/src/dome/renderer/text/richtext.tsx @@ -115,6 +115,11 @@ function updateContents(view: CM.EditorView, newText: string): void { Methods of the class are no-ops when there is no associated view, and at most one component shall be associated with a given Text buffer at the same time. + <b>Warning:</n> do not access proxy's methods during React component + rendering since they would not be synchronized with further changes from + document or editor view. Rather, those methods shall be invoked from + React and event callbacks. + All methods are bound to `this`. */ export class TextProxy { @@ -123,6 +128,7 @@ export class TextProxy { protected proxy : View = null; constructor() { + this.range = this.range.bind(this); this.clear = this.clear.bind(this); this.append = this.append.bind(this); this.toString = this.toString.bind(this); @@ -135,21 +141,33 @@ export class TextProxy { // --- Public part + /** Full document range. Remark: empty documents still have 1 (empty) line. */ + range(): Selection { + const view = this.proxy; + if (view === null) return emptySelection; + const doc = view.state.doc; + return { offset: 0, length: doc.length, fromLine: 1, toLine: doc.lines }; + } + + /** Remove all text from document. */ clear(): void { const view = this.proxy; if (view) dispatchContents(view, CS.Text.empty); } + /** Full document contents. */ toString(): string { const view = this.proxy; return view ? view.state.doc.toString() : ''; } + /** Appends to end of document. */ append(data: string): void { const view = this.proxy; if (view) appendContents(view, data); } + /** Appends to end of document. */ setContents(data: string): void { const view = this.proxy; if (view) dispatchContents(view, data); @@ -181,13 +199,19 @@ function textOf(text: string): CS.Text { export class TextBuffer extends TextProxy { // --- Private part (we avoid unecessary conversions from/to text) - // --- Invariant: only one of proxy, text & contents holds data + // --- Invariant: only one of proxy, text or contents holds data private text = CS.Text.empty; private contents : string | undefined = undefined; private toText(): CS.Text { + // --- requires this.proxy is null const contents = this.contents; - return contents === undefined ? this.text : textOf(contents); + if (contents===undefined) return this.text; + const text = textOf(contents); + this.text = text; + this.contents = undefined; + // --- invariant established + return text; } /** @ignore */ @@ -210,6 +234,12 @@ export class TextBuffer extends TextProxy { // --- Public part + range(): Selection { + if (this.proxy) return super.range(); + const doc = this.toText(); + return { offset: 0, length: doc.length, fromLine: 1, toLine: doc.lines }; + } + clear(): void { const view = this.proxy; if (view) dispatchContents(view, CS.Text.empty); @@ -379,6 +409,30 @@ OnSelect.pack( } )); +/* -------------------------------------------------------------------------- */ +/* --- Viewport Change Listener --- */ +/* -------------------------------------------------------------------------- */ + +const Viewport = new Field<SelectionCallback|null>(null); + +Viewport.pack( + CM.EditorView.updateListener.computeN( + [Viewport.field], + (state) => { + const callback = state.field(Viewport.field); + if (callback !== null) + return [ + (updates: CM.ViewUpdate) => { + if (updates.viewportChanged) { + const sel = updates.view.viewport; + const doc = updates.state.doc; + callback(selection(doc, sel)); + } + }]; + return []; + } +)); + /* -------------------------------------------------------------------------- */ /* --- Decorations --- */ /* -------------------------------------------------------------------------- */ @@ -661,6 +715,7 @@ function createView(parent: Element): CM.EditorView { ReadOnly, OnChange, OnSelect, + Viewport, Decorations, ]; const state = CS.EditorState.create({ extensions }); @@ -676,6 +731,7 @@ export interface TextViewProps { readOnly?: boolean; onChange?: Callback; selection?: Range; + onViewport?: SelectionCallback; onSelection?: SelectionCallback; decorations?: Decorations; lineNumbers?: boolean; @@ -701,7 +757,9 @@ export function TextView(props: TextViewProps) : JSX.Element { // ---- readOnly, onChange, onSelection, lineNumbers const { - readOnly = false, onChange = null, + readOnly = false, + onChange = null, + onViewport: onReview = null, onSelection: onSelect = null, lineNumbers: lines, showCurrentLine: active, @@ -709,6 +767,7 @@ export function TextView(props: TextViewProps) : JSX.Element { 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(() => Viewport.dispatch(view, onReview), [view, onReview]); React.useEffect(() => LineNumbers.dispatch(view, lines), [view, lines]); React.useEffect(() => ActiveLine.dispatch(view, active), [view, active]); diff --git a/ivette/src/sandbox/text.tsx b/ivette/src/sandbox/text.tsx index 7ccce4e10d8..7d678b49712 100644 --- a/ivette/src/sandbox/text.tsx +++ b/ivette/src/sandbox/text.tsx @@ -44,28 +44,32 @@ import { registerSandbox } from 'ivette'; /* -------------------------------------------------------------------------- */ function UseText(): JSX.Element { - const [prefix, setPrefix] = React.useState(''); const [useLines, flipUseLines] = Dome.useFlipState(true); const [useCurrent, flipUseCurrent] = Dome.useFlipState(true); const [readOnly, flipReadOnly] = Dome.useFlipState(false); const [useProxy, flipUseProxy] = Dome.useFlipState(false); const [changed, setChanged] = React.useState(false); const [changes, setChanges] = React.useState(0); + const [length, setLength] = React.useState(0); + const [lines, setLines] = React.useState(1); const [s, onSelection] = React.useState(emptySelection); + const [v, onViewport] = React.useState(emptySelection); const proxy = React.useMemo(() => new TextProxy(), []); const buffer = React.useMemo(() => new TextBuffer(), []); const text = useProxy ? proxy : buffer; - const updatePrefix = React.useCallback( + const updateProxy = React.useCallback( () => { + const { length, toLine } = text.range(); setChanged(true); setChanges((n) => 1+n); - setPrefix(text.toString().substring(0, 20).trim()); + setLength(length); + setLines(toLine); }, [text]); const push = React.useCallback(() => { const n = Math.random(); text.append(`ADDED${n}\n`); }, [text]); - const onChange = Dome.useDebounced(updatePrefix, 200); + const onChange = Dome.useDebounced(updateProxy, 200); const [decorations, setDecorations] = React.useState<Decoration[]>([]); const inconsistent = decorations.length > 0 && changed; @@ -107,8 +111,8 @@ function UseText(): JSX.Element { }]); }, [decorations, s]); - const isLine = s.fromLine === s.toLine; - const isRange = s.length > 0; + const isLine = s.fromLine === s.toLine && s.toLine <= lines; + const isRange = s.length > 0 && s.offset + s.length <= length; return ( <> @@ -135,39 +139,36 @@ function UseText(): JSX.Element { title={useProxy ? 'Use TextProxy' : 'Use TextBuffer (persistent)'} onClick={flipUseProxy} /> - <Code label={`Offset ${s.offset}-${s.offset + s.length}`} /> - <Code label={`Line ${s.fromLine}-${s.toLine}`} /> + <Filler/> <Code icon={inconsistent ? 'WARNING' : undefined} title={inconsistent ? 'Iconsistent (modified text)' : undefined} - label={`Decorations ${decorations.length}`} + label={`Decorations: ${decorations.length}`} /> <IconButton - display={isLine} + enabled={isLine} icon="CIRC.INFO" title="Add Gutter Decoration" onClick={addGutterDecoration} /> <IconButton - display={isLine} + enabled={isLine} icon="CIRC.CHECK" title="Add Line Decoration" onClick={addLineDecoration} /> <IconButton - display={isRange} + enabled={isRange} icon="CIRC.PLUS" title="Add Decoration" onClick={addDecoration} /> <IconButton - display={decorations.length > 0} + enabled={decorations.length > 0} kind={inconsistent ? 'negative' : 'default'} icon="CIRC.CLOSE" title="Clear Decorations" onClick={clearDecorations} /> - <Filler /> - <Code>{`"${prefix}" (${changes})`}</Code> <Button label="Push" onClick={push} /> <Button label="Clear" kind='negative' onClick={clearText} /> </ToolBar> @@ -176,10 +177,18 @@ function UseText(): JSX.Element { readOnly={readOnly} onChange={onChange} onSelection={onSelection} + onViewport={onViewport} decorations={decorations} lineNumbers={useLines} showCurrentLine={useCurrent} /> + <ToolBar> + <Code label={`Offset ${s.offset}-${s.offset + s.length} / ${length}`} /> + <Code label={`Line ${s.fromLine}-${s.toLine} / ${lines}`} /> + <Code label={`View ${v.fromLine}-${v.toLine}`} /> + <Filler /> + <Code>{`Changes: ${changes}`}</Code> + </ToolBar> </> ); } -- GitLab