From 7255553bbb0fb5b1b08cf89765bc2fb2939d1f15 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Loi=CC=88c=20Correnson?= <loic.correnson@cea.fr>
Date: Fri, 27 Oct 2023 13:50:01 +0200
Subject: [PATCH] [dome/richtext] selection listener

---
 ivette/src/dome/renderer/text/richtext.tsx | 59 ++++++++++++++++++++--
 ivette/src/sandbox/text.tsx                |  8 ++-
 2 files changed, 61 insertions(+), 6 deletions(-)

diff --git a/ivette/src/dome/renderer/text/richtext.tsx b/ivette/src/dome/renderer/text/richtext.tsx
index 69482245025..29786da13d6 100644
--- a/ivette/src/dome/renderer/text/richtext.tsx
+++ b/ivette/src/dome/renderer/text/richtext.tsx
@@ -34,6 +34,9 @@ export interface Range { offset: number; length: number }
 export interface Position { offset: number; line: number }
 export interface Selection extends Range { fromLine: number, toLine: number }
 
+export const empty : Range & Selection =
+  { offset: 0, length: 0, fromLine: 0, toLine: 0 };
+
 export function byDepth(a : Range, b : Range): number
 {
   return (a.length - b.length) || (b.offset - a.offset);
@@ -312,14 +315,46 @@ OnChange.pack(
     }
 ));
 
+/* -------------------------------------------------------------------------- */
+/* --- Selection Change Listener                                          --- */
+/* -------------------------------------------------------------------------- */
+
+export type SelectionCallback = (S: Selection) => void;
+
+const OnSelect = new Field<SelectionCallback|null>(null);
+
+OnSelect.pack(
+  CM.EditorView.updateListener.computeN(
+    [OnSelect.field],
+    (state) => {
+      const callback = state.field(OnSelect.field);
+      if (callback !== null)
+        return [
+          (updates: CM.ViewUpdate) => {
+            const oldSel = updates.startState.selection.main;
+            const newSel = updates.state.selection.main;
+            const doc = updates.state.doc;
+            if (!newSel.eq(oldSel)) {
+              const { from: offset, to: endOffset } = newSel;
+              const fromLine = doc.lineAt(offset).number;
+              const toLine = doc.lineAt(endOffset).number;
+              callback({
+                offset, length: endOffset - offset,
+                fromLine, toLine,
+              });
+            }
+        }];
+      return [];
+    }
+));
+
 /* -------------------------------------------------------------------------- */
 /* --- Editor View                                                        --- */
 /* -------------------------------------------------------------------------- */
 
 function createView(parent: Element): CM.EditorView {
   const extensions : CS.Extension[] = [
-    ReadOnly,
-    OnChange,
+    ReadOnly, OnChange, OnSelect,
   ];
   const state = CS.EditorState.create({ extensions });
   return new CM.EditorView({ state, parent });
@@ -333,6 +368,8 @@ export interface RichTextProps {
   text?: TextProxy;
   readOnly?: boolean;
   onChange?: Callback;
+  selection?: Range;
+  onSelection?: SelectionCallback;
   display?: boolean;
   visible?: boolean;
   className?: string;
@@ -352,10 +389,24 @@ export function TextView(props: RichTextProps) : JSX.Element {
     return undefined;
   }, [text, view]);
 
-  // ---- readOnly, onChange
-  const { readOnly = false, onChange = null } = props;
+  // ---- readOnly, onChange, onSelection
+  const {
+    readOnly = false, onChange = null,
+    onSelection: onSelect = 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]);
+
+  // ---- Selection
+  const { selection } = props;
+  React.useEffect(() => {
+    if (selection) {
+      const anchor = selection.offset;
+      const head = anchor + selection.length;
+      view?.dispatch({ scrollIntoView: true, selection: { anchor, head } });
+    }
+  }, [view, selection]);
 
   // ---- Mount & Unmount Editor
   const [nodeRef, setRef] = React.useState<Element | null>(null);
diff --git a/ivette/src/sandbox/text.tsx b/ivette/src/sandbox/text.tsx
index 68135a5d1c2..dad9ec8cfa2 100644
--- a/ivette/src/sandbox/text.tsx
+++ b/ivette/src/sandbox/text.tsx
@@ -30,7 +30,7 @@ import * as Dome from 'dome';
 import { ToolBar, Filler } from 'dome/frame/toolbars';
 import { Code } from 'dome/controls/labels';
 import { Button } from 'dome/controls/buttons';
-import { TextView, TextProxy, TextBuffer } from 'dome/text/richtext';
+import { TextView, TextProxy, TextBuffer, empty, } from 'dome/text/richtext';
 import { registerSandbox } from 'ivette';
 
 /* -------------------------------------------------------------------------- */
@@ -40,8 +40,9 @@ import { registerSandbox } from 'ivette';
 function UseText(): JSX.Element {
   const [prefix, setPrefix] = React.useState('');
   const [readOnly, flipReadOnly] = Dome.useFlipState(false);
-  const [useProxy, flipUseProxy] = Dome.useFlipState(true);
+  const [useProxy, flipUseProxy] = Dome.useFlipState(false);
   const [changes, setChanges] = React.useState(0);
+  const [s, onSelection] = React.useState(empty);
   const proxy = React.useMemo(() => new TextProxy(), []);
   const buffer = React.useMemo(() => new TextBuffer(), []);
   const text = useProxy ? proxy : buffer;
@@ -68,6 +69,8 @@ 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>{`"${prefix}" (${changes})`}</Code>
         <Button label="Push" onClick={push} />
@@ -77,6 +80,7 @@ function UseText(): JSX.Element {
         text={text}
         readOnly={readOnly}
         onChange={onChange}
+        onSelection={onSelection}
       />
     </>
   );
-- 
GitLab