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