From f7def3459fb95e0bc2eaf905ae4a212941715a68 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 26 Mar 2025 11:36:44 +0100 Subject: [PATCH 01/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20pass=20through=20caret=20Point=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/react/BlockView.tsx | 4 ++-- src/json-crdt-peritext-ui/react/InlineView.tsx | 4 +++- src/json-crdt-peritext-ui/react/cursor/CaretView.tsx | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-peritext-ui/react/BlockView.tsx b/src/json-crdt-peritext-ui/react/BlockView.tsx index 830bd1e197..cd9735b8a3 100644 --- a/src/json-crdt-peritext-ui/react/BlockView.tsx +++ b/src/json-crdt-peritext-ui/react/BlockView.tsx @@ -32,7 +32,7 @@ export const BlockView: React.FC = React.memo( const key = cursorStart.start.key() + '-a'; let element: React.ReactNode; if (cursorStart.isStartFocused()) { - if (cursorStart.isCollapsed()) element = ; + if (cursorStart.isCollapsed()) element = ; else { const isItalic = italic instanceof InlineAttrEnd || italic instanceof InlineAttrPassing; element = ; @@ -50,7 +50,7 @@ export const BlockView: React.FC = React.memo( const key = cursorEnd.end.key() + '-b'; let element: React.ReactNode; if (cursorEnd.isEndFocused()) { - if (cursorEnd.isCollapsed()) element = ; + if (cursorEnd.isCollapsed()) element = ; else element = ( = (props) => { className: CssClass.Inline, ref: (span: HTMLSpanElement | null) => { ref.current = span as HTMLSpanElement; - if (span) (span as any)[ElementAttr.InlineOffset] = inline; + if (span) { + (span as any)[ElementAttr.InlineOffset] = inline; + } }, }; for (const map of plugins) attr = map.text?.(attr, inline, ctx) ?? attr; diff --git a/src/json-crdt-peritext-ui/react/cursor/CaretView.tsx b/src/json-crdt-peritext-ui/react/cursor/CaretView.tsx index a837536900..c0984d8d9c 100644 --- a/src/json-crdt-peritext-ui/react/cursor/CaretView.tsx +++ b/src/json-crdt-peritext-ui/react/cursor/CaretView.tsx @@ -2,9 +2,11 @@ import * as React from 'react'; import {usePeritext} from '../context'; import {Caret} from './Caret'; +import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; export interface CaretViewProps { italic?: boolean; + point: Point; } export const CaretView: React.FC = (props) => { From e4107c175e72da7002f18fc083f574cb82076f79 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 26 Mar 2025 13:15:36 +0100 Subject: [PATCH 02/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20highlight=20characters=20immediately=20adjacent?= =?UTF-8?q?=20to=20caret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 11 ++- .../__demos__/components/App.tsx | 2 +- .../dom/CursorController.ts | 1 + .../dom/DomController.ts | 43 +++++++++- src/json-crdt-peritext-ui/dom/types.ts | 25 ++++++ .../plugins/debug/DebugPlugin.tsx | 3 + .../plugins/debug/RenderCaret.tsx | 85 +++++++++++++++++++ 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index a812e62615..97547aa555 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -8,7 +8,7 @@ import {Slices} from './slice/Slices'; import {LocalSlices} from './slice/LocalSlices'; import {Overlay} from './overlay/Overlay'; import {Chars} from './constants'; -import {interval} from '../../json-crdt-patch/clock'; +import {interval, tick} from '../../json-crdt-patch/clock'; import {Model, type StrApi} from '../../json-crdt/model'; import {CONST, updateNum} from '../../json-hash'; import {SESSION} from '../../json-crdt-patch/constants'; @@ -22,6 +22,7 @@ import type {MarkerSlice} from './slice/MarkerSlice'; import type {SliceSchema, SliceType} from './slice/types'; import type {SchemaToJsonNode} from '../../json-crdt/schema/types'; import type {AbstractRga} from '../../json-crdt/nodes/rga'; +import type {ChunkSlice} from './util/ChunkSlice'; const EXTRA_SLICES_SCHEMA = s.vec(s.arr([])); @@ -183,6 +184,14 @@ export class Peritext implements Printable { return Range.from(this.str, p1, p2); } + public rangeFromChunkSlice(slice: ChunkSlice): Range { + const startId = slice.off === 0 ? slice.chunk.id : tick(slice.chunk.id, slice.off); + const endId = tick(slice.chunk.id, slice.off + slice.len - 1); + const start = this.point(startId, Anchor.Before); + const end = this.point(endId, Anchor.After); + return this.range(start, end); + } + /** * Creates a range from two points, the points have to be in the correct * order. diff --git a/src/json-crdt-peritext-ui/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index 987e35e09d..6c61eaede3 100644 --- a/src/json-crdt-peritext-ui/__demos__/components/App.tsx +++ b/src/json-crdt-peritext-ui/__demos__/components/App.tsx @@ -39,7 +39,7 @@ export const App: React.FC = () => { const cursorPlugin = new CursorPlugin(); const toolbarPlugin = new ToolbarPlugin(); const blocksPlugin = new BlocksPlugin(); - const debugPlugin = new DebugPlugin({enabled: false}); + const debugPlugin = new DebugPlugin({enabled: true}); return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin]; }, []); diff --git a/src/json-crdt-peritext-ui/dom/CursorController.ts b/src/json-crdt-peritext-ui/dom/CursorController.ts index c6b48c0ea4..4898d7ed48 100644 --- a/src/json-crdt-peritext-ui/dom/CursorController.ts +++ b/src/json-crdt-peritext-ui/dom/CursorController.ts @@ -74,6 +74,7 @@ export class CursorController implements UiLifeCycles, Printable { * object, see: https://www.bennadel.com/blog/4310-detecting-rendered-line-breaks-in-a-text-node-in-javascript.htm */ public getNextLinePos(direction: 1 | -1 = 1): number | undefined { + // TODO: this works only for the main cursor, make it work for all cursors. const rect = this.caretRect(); if (!rect) return; const {x, y, width, height} = rect; diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 588372080f..97f11b8b9d 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -5,10 +5,14 @@ import {RichTextController} from './RichTextController'; import {KeyController} from './KeyController'; import {CompositionController} from './CompositionController'; import {AnnalsController} from './annals/AnnalsController'; +import {ElementAttr} from '../constants'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; -import type {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types'; +import type {PeritextRenderingSurfaceApi, Rect, UiLifeCycles} from '../dom/types'; import type {Log} from '../../json-crdt/log/Log'; +import type {Point} from '../../json-crdt-extensions/peritext/rga/Point'; +import type {Inline} from '../../json-crdt-extensions'; +import type {Range} from '../../json-crdt-extensions/peritext/rga/Range'; export interface DomControllerOpts { source: HTMLElement; @@ -63,6 +67,43 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.opts.source.focus(); } + protected getSpans() { + return this.opts.source.querySelectorAll('.jsonjoy-peritext-inline'); + } + + protected findSpanContaining(range: Range): [span: HTMLSpanElement, inline: Inline] | undefined { + const spans = this.getSpans(); + const length = spans.length; + for (let i = 0; i < length; i++) { + const span = spans[i] as HTMLSpanElement; + const inline = (span as any)[ElementAttr.InlineOffset] as Inline | undefined; + if (inline) { + const contains = inline.contains(range); + if (contains) return [span, inline]; + } + } + return; + } + + public getCharRect(pos: number | Point, right = true): Rect | undefined { + const txt = this.opts.events.txt; + const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const char = right ? point.rightChar() : point.leftChar(); + if (!char) return; + const charRange = txt.rangeFromChunkSlice(char); + const [span, inline] = this.findSpanContaining(charRange) || []; + if (!span || !inline) return; + const textNode = span.firstChild as Text; + if (!textNode) return; + const range = document.createRange(); + range.selectNode(textNode); + const offset = right ? 0 : textNode.length - 1; + range.setStart(textNode, offset); + range.setEnd(textNode, offset + 1); + const rects = range.getClientRects(); + return rects[0]; + } + /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { diff --git a/src/json-crdt-peritext-ui/dom/types.ts b/src/json-crdt-peritext-ui/dom/types.ts index d4cbb76fc0..964ecd9773 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -1,3 +1,5 @@ +import type {Point} from "../../json-crdt-extensions/peritext/rga/Point"; + /** * @todo Unify this with {@link UiLifeCycles}, join interfaces. * @todo Rename this to something like "disposable", as it does not have to be @@ -25,4 +27,27 @@ export interface PeritextRenderingSurfaceApi { * Focuses the rendering surface, so that it can receive keyboard input. */ focus(): void; + + // /** + // * Returns the bounding rectangle of the rendering surface. + // */ + // getBoundingClientRect(): Rect; + + // // /** + // // * Returns the bounding rectangle of the line at a given position. + // // */ + // // getLineRect(line: number): Rect; + + /** + * Finds the position of the character at the given position (between + * characters). The first position has index of 0. + * + * @param pos The index of the character in the text. + * @param right Whether to find the location of character after the given + * {@link Point} or before, defaults to `true`. + * @returns The bounding rectangle of the character at the given index. + */ + getCharRect(pos: number | Point, right?: boolean): Rect | undefined; + // TIP: find soft line break by iterating over the characters and checking for the `x` value of the bounding rectangle. Take into account text direction. + // TODO: Need to be able to detect text direction of the current character. } diff --git a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx index 387ed0c9b9..e2c49238eb 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx @@ -3,6 +3,7 @@ import {RenderInline} from './RenderInline'; import {RenderBlock} from './RenderBlock'; import {RenderPeritext, type RenderPeritextProps} from './RenderPeritext'; import type {PeritextPlugin} from '../../react/types'; +import {RenderCaret} from './RenderCaret'; export type DebugPluginOpts = Pick; @@ -22,4 +23,6 @@ export class DebugPlugin implements PeritextPlugin { {children} ); + + public readonly caret: PeritextPlugin['caret'] = (props, children) => {children}; } diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx new file mode 100644 index 0000000000..fb0ccdbeb4 --- /dev/null +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import {rule} from 'nano-theme'; +import {useDebugCtx} from './context'; +import useWindowSize from 'react-use/lib/useWindowSize'; +import useWindowScroll from 'react-use/lib/useWindowScroll'; +import type {CaretViewProps} from '../../react/cursor/CaretView'; + +const blockClass = rule({ + pos: 'relative', + pe: 'none', + us: 'none', + w: '0px', + h: '100%', + va: 'bottom', +}); + +export interface RenderCaretProps extends CaretViewProps { + children: React.ReactNode; +} + +export const RenderCaret: React.FC = (props) => { + const {children} = props; + const {enabled, ctx} = useDebugCtx(); + + if (!enabled || !ctx?.dom) return children; + + return ; +}; + +const RenderDebugCaret: React.FC = (props) => { + return ( + + {props.children} + + + ); +}; + +const characterOverlayStyles: React.CSSProperties = { + position: 'fixed', + display: 'inline-block', + top: -100, + left: -100, + width: 0, + height: 0, + backgroundColor: 'rgba(0, 0, 255, 0.1)', + outline: '1px dashed blue', +}; + +const DebugOverlay: React.FC = ({point}) => { + useWindowSize(); + useWindowScroll(); + const {ctx} = useDebugCtx(); + const leftCharRef = React.useRef(null); + const rightCharRef = React.useRef(null); + + React.useEffect(() => { + const leftCharRect = ctx!.dom!.getCharRect(point, false); + const leftCharSpan = leftCharRef.current; + if (leftCharRect && leftCharSpan) { + leftCharSpan.style.top = leftCharRect.y + 'px'; + leftCharSpan.style.left = leftCharRect.x + 'px'; + leftCharSpan.style.width = leftCharRect.width + 'px'; + leftCharSpan.style.height = leftCharRect.height + 'px'; + } + const rightCharRect = ctx!.dom!.getCharRect(point, true); + const rightCharSpan = rightCharRef.current; + if (rightCharRect && rightCharSpan) { + rightCharSpan.style.top = rightCharRect.y + 'px'; + rightCharSpan.style.left = rightCharRect.x + 'px'; + rightCharSpan.style.width = rightCharRect.width + 'px'; + rightCharSpan.style.height = rightCharRect.height + 'px'; + } + }); + + return ( + <> + {/* Render outline around the previous character before the caret. */} + + + {/* Render outline around the next character after the caret. */} + + + ); +}; From b9c547b014f8394280dd2094bc03e15d896db6c7 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 26 Mar 2025 13:24:16 +0100 Subject: [PATCH 03/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20display=20of=20caret=20adjacent=20char?= =?UTF-8?q?acters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderCaret.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index fb0ccdbeb4..3ad5deb42f 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -39,10 +39,7 @@ const RenderDebugCaret: React.FC = (props) => { const characterOverlayStyles: React.CSSProperties = { position: 'fixed', display: 'inline-block', - top: -100, - left: -100, - width: 0, - height: 0, + visibility: 'hidden', backgroundColor: 'rgba(0, 0, 255, 0.1)', outline: '1px dashed blue', }; @@ -55,21 +52,31 @@ const DebugOverlay: React.FC = ({point}) => { const rightCharRef = React.useRef(null); React.useEffect(() => { - const leftCharRect = ctx!.dom!.getCharRect(point, false); const leftCharSpan = leftCharRef.current; - if (leftCharRect && leftCharSpan) { - leftCharSpan.style.top = leftCharRect.y + 'px'; - leftCharSpan.style.left = leftCharRect.x + 'px'; - leftCharSpan.style.width = leftCharRect.width + 'px'; - leftCharSpan.style.height = leftCharRect.height + 'px'; + if (leftCharSpan) { + const leftCharRect = ctx!.dom!.getCharRect(point, false); + if (leftCharRect) { + leftCharSpan.style.top = leftCharRect.y + 'px'; + leftCharSpan.style.left = leftCharRect.x + 'px'; + leftCharSpan.style.width = leftCharRect.width + 'px'; + leftCharSpan.style.height = leftCharRect.height + 'px'; + leftCharSpan.style.visibility = 'visible'; + } else { + leftCharSpan.style.visibility = 'hidden'; + } } - const rightCharRect = ctx!.dom!.getCharRect(point, true); const rightCharSpan = rightCharRef.current; - if (rightCharRect && rightCharSpan) { - rightCharSpan.style.top = rightCharRect.y + 'px'; - rightCharSpan.style.left = rightCharRect.x + 'px'; - rightCharSpan.style.width = rightCharRect.width + 'px'; - rightCharSpan.style.height = rightCharRect.height + 'px'; + if (rightCharSpan) { + const rightCharRect = ctx!.dom!.getCharRect(point, true); + if (rightCharRect) { + rightCharSpan.style.top = rightCharRect.y + 'px'; + rightCharSpan.style.left = rightCharRect.x + 'px'; + rightCharSpan.style.width = rightCharRect.width + 'px'; + rightCharSpan.style.height = rightCharRect.height + 'px'; + rightCharSpan.style.visibility = 'visible'; + } else { + rightCharSpan.style.visibility = 'hidden'; + } } }); From 946900fae8a2d63c5cb6b816d84d5a42b9cd6681 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 26 Mar 2025 21:34:34 +0100 Subject: [PATCH 04/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20right=20EOL=20indicator=20to=20debug=20mod?= =?UTF-8?q?e=20and=20fix=20char=20pos=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 21 ++++++++++++++++- .../plugins/debug/RenderCaret.tsx | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 97f11b8b9d..1613035e6a 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -97,13 +97,32 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering if (!textNode) return; const range = document.createRange(); range.selectNode(textNode); - const offset = right ? 0 : textNode.length - 1; + const offset = charRange.start.viewPos() - inline.start.viewPos(); range.setStart(textNode, offset); range.setEnd(textNode, offset + 1); const rects = range.getClientRects(); return rects[0]; } + public getLineEnd(pos: number | Point): [point: Point, rect: Rect] | undefined { + const txt = this.opts.events.txt; + const right = true; + const startPoint = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const startRect = this.getCharRect(startPoint, right); + if (!startRect) return; + let curr = startPoint.clone(); + let currRect = startRect; + while (true) { + const next = curr.copy(p => p.step(1)); + if (!next) return [curr, currRect]; + const nextRect = this.getCharRect(next, right); + if (!nextRect) return [curr, currRect]; + if (nextRect.x < currRect.x) return [curr, currRect]; + curr = next; + currRect = nextRect; + } + } + /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index 3ad5deb42f..7b0768a5a0 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -44,12 +44,18 @@ const characterOverlayStyles: React.CSSProperties = { outline: '1px dashed blue', }; +const eolCharacterOverlayStyles: React.CSSProperties = { + ...characterOverlayStyles, + outline: '1px dotted blue', +}; + const DebugOverlay: React.FC = ({point}) => { useWindowSize(); useWindowScroll(); const {ctx} = useDebugCtx(); const leftCharRef = React.useRef(null); const rightCharRef = React.useRef(null); + const rightLineEndCharRef = React.useRef(null); React.useEffect(() => { const leftCharSpan = leftCharRef.current; @@ -78,6 +84,20 @@ const DebugOverlay: React.FC = ({point}) => { rightCharSpan.style.visibility = 'hidden'; } } + const rightLineEndCharSpan = rightLineEndCharRef.current; + if (rightLineEndCharSpan) { + const lineEnd = ctx!.dom!.getLineEnd(point); + if (lineEnd) { + const [, rect] = lineEnd; + rightLineEndCharSpan.style.top = rect.y + 'px'; + rightLineEndCharSpan.style.left = rect.x + 'px'; + rightLineEndCharSpan.style.width = rect.width + 'px'; + rightLineEndCharSpan.style.height = rect.height + 'px'; + rightLineEndCharSpan.style.visibility = 'visible'; + } else { + rightLineEndCharSpan.style.visibility = 'hidden'; + } + } }); return ( @@ -87,6 +107,9 @@ const DebugOverlay: React.FC = ({point}) => { {/* Render outline around the next character after the caret. */} + + {/* Render outline around the end of the line. */} + ); }; From a1b7f984faf1062a0bdd32d103e2280cbcc7625d Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 26 Mar 2025 21:42:30 +0100 Subject: [PATCH 05/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20render=20soft=20line=20beginning=20in=20debug=20?= =?UTF-8?q?mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 7 +++---- .../plugins/debug/RenderCaret.tsx | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 1613035e6a..d6c4ee2b70 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -104,20 +104,19 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering return rects[0]; } - public getLineEnd(pos: number | Point): [point: Point, rect: Rect] | undefined { + public getLineEnd(pos: number | Point, right = true): [point: Point, rect: Rect] | undefined { const txt = this.opts.events.txt; - const right = true; const startPoint = typeof pos === 'number' ? txt.pointAt(pos) : pos; const startRect = this.getCharRect(startPoint, right); if (!startRect) return; let curr = startPoint.clone(); let currRect = startRect; while (true) { - const next = curr.copy(p => p.step(1)); + const next = curr.copy(p => p.step(right ? 1 : -1)); if (!next) return [curr, currRect]; const nextRect = this.getCharRect(next, right); if (!nextRect) return [curr, currRect]; - if (nextRect.x < currRect.x) return [curr, currRect]; + if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return [curr, currRect]; curr = next; currRect = nextRect; } diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index 7b0768a5a0..c9ea5f9d0c 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -55,6 +55,7 @@ const DebugOverlay: React.FC = ({point}) => { const {ctx} = useDebugCtx(); const leftCharRef = React.useRef(null); const rightCharRef = React.useRef(null); + const leftLineEndCharRef = React.useRef(null); const rightLineEndCharRef = React.useRef(null); React.useEffect(() => { @@ -86,7 +87,7 @@ const DebugOverlay: React.FC = ({point}) => { } const rightLineEndCharSpan = rightLineEndCharRef.current; if (rightLineEndCharSpan) { - const lineEnd = ctx!.dom!.getLineEnd(point); + const lineEnd = ctx!.dom!.getLineEnd(point, true); if (lineEnd) { const [, rect] = lineEnd; rightLineEndCharSpan.style.top = rect.y + 'px'; @@ -98,6 +99,20 @@ const DebugOverlay: React.FC = ({point}) => { rightLineEndCharSpan.style.visibility = 'hidden'; } } + const leftLineEndCharSpan = leftLineEndCharRef.current; + if (leftLineEndCharSpan) { + const lineEnd = ctx!.dom!.getLineEnd(point, false); + if (lineEnd) { + const [, rect] = lineEnd; + leftLineEndCharSpan.style.top = rect.y + 'px'; + leftLineEndCharSpan.style.left = rect.x + 'px'; + leftLineEndCharSpan.style.width = rect.width + 'px'; + leftLineEndCharSpan.style.height = rect.height + 'px'; + leftLineEndCharSpan.style.visibility = 'visible'; + } else { + leftLineEndCharSpan.style.visibility = 'hidden'; + } + } }); return ( @@ -108,6 +123,9 @@ const DebugOverlay: React.FC = ({point}) => { {/* Render outline around the next character after the caret. */} + {/* Render outline around the beginning of the line. */} + + {/* Render outline around the end of the line. */} From 290317887b84cc3746af1a3723083440664c8e47 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 26 Mar 2025 23:13:12 +0100 Subject: [PATCH 06/32] =?UTF-8?q?fix(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=90=9B=20correctly=20format=20slice=20type=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx index cfc083cdc0..fd91d6f3b3 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import {drule} from 'nano-theme'; import {useDebugCtx} from './context'; +import {formatType} from '../../../json-crdt-extensions/peritext/slice/util'; import type {BlockViewProps} from '../../react/BlockView'; -import {CommonSliceType} from '../../../json-crdt-extensions'; const blockClass = drule({ pos: 'relative', @@ -44,7 +44,7 @@ export const RenderBlock: React.FC = ({block, hash, children}) > {hash.toString(36)}{' '} {block.path - .map((type) => (typeof type === 'number' ? `<${CommonSliceType[type] ?? type}>` : `<${type}>`)) + .map((type) => formatType(type)) .join('.')} From b0660fa7b171b1f787c9c3832d8eb6435937ca02 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 09:59:47 +0100 Subject: [PATCH 07/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ability=20to=20move=20caret=20to=20soft=20?= =?UTF-8?q?line=20wrap=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 10 +++++----- src/json-crdt-peritext-ui/dom/DomController.ts | 18 ++++++++++++++---- src/json-crdt-peritext-ui/dom/types.ts | 4 +++- .../events/defaults/PeritextEventDefaults.ts | 12 ++++++++++-- .../react/PeritextView.tsx | 1 + 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 83cda97f25..ed81fd8e91 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -507,26 +507,26 @@ export class Editor implements Printable { * @param endpoint 0 for "focus", 1 for "anchor", 2 for both. * @param collapse Whether to collapse the range to a single point. */ - public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true): void { + public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true, skipLine?: (point: Point, steps: number) => (Point | undefined)): void { this.forCursor((cursor) => { switch (endpoint) { case 0: { let point = cursor.focus(); - point = this.skip(point, steps, unit); + point = (unit === 'line' ? skipLine?.(point, steps) : void 0) ?? this.skip(point, steps, unit); if (collapse) cursor.set(point); else cursor.setEndpoint(point, 0); break; } case 1: { let point = cursor.anchor(); - point = this.skip(point, steps, unit); + point = (unit === 'line' ? skipLine?.(point, steps) : void 0) ?? this.skip(point, steps, unit); if (collapse) cursor.set(point); else cursor.setEndpoint(point, 1); break; } case 2: { - const start = this.skip(cursor.start, steps, unit); - const end = collapse ? start.clone() : this.skip(cursor.end, steps, unit); + const start = (unit === 'line' ? skipLine?.(cursor.start, steps) : void 0) ?? this.skip(cursor.start, steps, unit); + const end = collapse ? start.clone() : ((unit === 'line' ? skipLine?.(cursor.end, steps) : void 0) ?? this.skip(cursor.end, steps, unit)); cursor.set(start, end); break; } diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index d6c4ee2b70..93bae164c9 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -97,7 +97,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering if (!textNode) return; const range = document.createRange(); range.selectNode(textNode); - const offset = charRange.start.viewPos() - inline.start.viewPos(); + const offset = Math.max(0, Math.min(textNode.length - 1, charRange.start.viewPos() - inline.start.viewPos())); range.setStart(textNode, offset); range.setEnd(textNode, offset + 1); const rects = range.getClientRects(); @@ -111,12 +111,22 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering if (!startRect) return; let curr = startPoint.clone(); let currRect = startRect; + const prepareReturn = (): [point: Point, rect: Rect] => { + if (right) { + curr.step(1); + curr.refAfter(); + } else { + curr.step(-1); + curr.refBefore(); + } + return [curr, currRect]; + }; while (true) { const next = curr.copy(p => p.step(right ? 1 : -1)); - if (!next) return [curr, currRect]; + if (!next) return prepareReturn(); const nextRect = this.getCharRect(next, right); - if (!nextRect) return [curr, currRect]; - if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return [curr, currRect]; + if (!nextRect) return prepareReturn(); + if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return prepareReturn(); curr = next; currRect = nextRect; } diff --git a/src/json-crdt-peritext-ui/dom/types.ts b/src/json-crdt-peritext-ui/dom/types.ts index 964ecd9773..ff1492157f 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -48,6 +48,8 @@ export interface PeritextRenderingSurfaceApi { * @returns The bounding rectangle of the character at the given index. */ getCharRect(pos: number | Point, right?: boolean): Rect | undefined; - // TIP: find soft line break by iterating over the characters and checking for the `x` value of the bounding rectangle. Take into account text direction. + + getLineEnd(pos: number | Point, right?: boolean): [point: Point, rect: Rect] | undefined; + // TODO: Need to be able to detect text direction of the current character. } diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index dab2f26fc4..48e07e0175 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -8,6 +8,7 @@ import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/E import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; import type {UndoCollector} from '../../types'; +import type {PeritextRenderingSurfaceApi} from '../../dom/types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -22,9 +23,10 @@ export interface PeritextEventDefaultsOpts { * {@link PeritextEventTarget} to provide default behavior for each event type. * If `event.preventDefault()` is called on a Peritext event, the default handler * will not be executed. - */ +*/ export class PeritextEventDefaults implements PeritextEventHandlerMap { public undo?: UndoCollector; + public surface?: PeritextRenderingSurfaceApi; public constructor( public readonly txt: Peritext, @@ -107,7 +109,13 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { // If `len` is specified. if (len) { const cursor = editor.cursor; - if (cursor.isCollapsed()) editor.move(len, unit ?? 'char'); + if (cursor.isCollapsed()) { + const surface = this.surface; + editor.move(len, unit ?? 'char', void 0, void 0, surface ? (point, steps) => { + const res = surface.getLineEnd(point, steps > 0); + return res ? res[0] : void 0; + } : void 0); + } else { if (len > 0) cursor.collapseToEnd(); else cursor.collapseToStart(); diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index d2a23f701c..a748dedb89 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -78,6 +78,7 @@ export const PeritextView: React.FC = React.memo((props) => { } if (dom && dom.opts.source === el) return; const ctrl = new DomController({source: el, events: state.events, log: state.log}); + state.events.surface = ctrl; state.events.undo = ctrl.annals; ctrl.start(); state.dom = ctrl; From 06ae980c79614f45100f18a0b730ea5a2af130f9 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 10:02:55 +0100 Subject: [PATCH 08/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20display=20of=20block=20debug=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DebugLabel/index.tsx | 40 +++++++++++++++++++ .../plugins/debug/RenderBlock.tsx | 37 ++++++----------- 2 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 src/json-crdt-peritext-ui/components/DebugLabel/index.tsx diff --git a/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx new file mode 100644 index 0000000000..b353b21248 --- /dev/null +++ b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx @@ -0,0 +1,40 @@ +// biome-ignore lint: React is used for JSX +import * as React from 'react'; +import {rule, theme} from 'nano-theme'; + +const labelClass = rule({ + ...theme.font.mono.bold, + fz: '9px', + pd: '2px 2px 2px 5px', + bdrad: '8px', + bg: 'rgba(0,0,0)', + lh: '8px', + col: 'white', + d: 'inline-block', +}); + +const labelSecondClass = rule({ + ...theme.font.mono.bold, + fz: '9px', + mr: '0 0 0 4px', + pd: '2px 4px', + bdrad: '8px', + bg: 'rgba(255,255,255)', + lh: '8px', + col: '#000', + d: 'inline-block', +}); + +export interface DebugLabelProps { + right?: React.ReactNode; + children?: React.ReactNode; +} + +export const DebugLabel: React.FC = ({right, children}) => { + return ( + + {children} + {!!right && {right}} + + ); +}; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx index fd91d6f3b3..cf8cbd777b 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -1,12 +1,17 @@ // biome-ignore lint: React is used for JSX import * as React from 'react'; -import {drule} from 'nano-theme'; +import {rule} from 'nano-theme'; import {useDebugCtx} from './context'; import {formatType} from '../../../json-crdt-extensions/peritext/slice/util'; +import {DebugLabel} from '../../components/DebugLabel'; import type {BlockViewProps} from '../../react/BlockView'; -const blockClass = drule({ - pos: 'relative', +const labelContainerClass = rule({ + pos: 'absolute', + top: '-24px', + left: 0, + us: 'none', + pe: 'none', }); export interface RenderBlockProps extends BlockViewProps { @@ -22,31 +27,13 @@ export const RenderBlock: React.FC = ({block, hash, children}) if (isRoot) return children; return ( -
-
- - {hash.toString(36)}{' '} +
+
e.preventDefault()}> + {block.path .map((type) => formatType(type)) .join('.')} - +
{children}
From f0b5e0e49ecb1dcd7f38751a0735ef21a33a7acd Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 10:37:47 +0100 Subject: [PATCH 09/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20show=20debug=20labels=20over=20inline=20formatti?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderInline.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 56747da426..7ae13c76ca 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -1,16 +1,44 @@ // biome-ignore lint: React is used for JSX import * as React from 'react'; import {useDebugCtx} from './context'; +import {DebugLabel} from '../../components/DebugLabel'; +import {SliceTypeName} from '../../../json-crdt-extensions'; +import {SliceTypeCon} from '../../../json-crdt-extensions/peritext/slice/constants'; import type {InlineViewProps} from '../../react/InlineView'; + export interface RenderInlineProps extends InlineViewProps { children: React.ReactNode; } export const RenderInline: React.FC = (props) => { - const {children} = props; + const {children, inline} = props; const {enabled} = useDebugCtx(); + const keys: (number | string)[] = Object.keys(inline.attr()); + const tags: string[] = []; + const length = keys.length; + let hasCursor = false; + for (let i = 0; i < length; i++) { + let tag: string | number = keys[i]; + if (typeof tag === 'string' && Number(tag) + '' === tag) tag = Number(tag); + if (tag === SliceTypeCon.Cursor) hasCursor = true; + tag = SliceTypeName[tag as any] ?? (tag + ''); + tag = '<' + tag + '>'; + tags.push(tag); + } + if (!enabled) return children; - return {children}; + return ( + + {tags.length > 0 && ( + + + {tags.join(', ')} + + + )} + {children} + + ); }; From 2b3387ed6e183dad01d6a6c23421576a766355d6 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 10:43:41 +0100 Subject: [PATCH 10/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20debug=20label=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DebugLabel/index.tsx | 25 +++++++++++-------- .../plugins/debug/RenderBlock.tsx | 4 +-- .../plugins/debug/RenderInline.tsx | 2 +- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx index b353b21248..e36d263355 100644 --- a/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx +++ b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx @@ -4,25 +4,30 @@ import {rule, theme} from 'nano-theme'; const labelClass = rule({ ...theme.font.mono.bold, + d: 'flex', + fz: '9px', - pd: '2px 2px 2px 5px', - bdrad: '8px', + pd: '0 4px', + mr: '-1px', + bdrad: '10px', bg: 'rgba(0,0,0)', - lh: '8px', + lh: '14px', + h: '14px', col: 'white', - d: 'inline-block', + bd: '1px solid #fff', }); const labelSecondClass = rule({ ...theme.font.mono.bold, - fz: '9px', - mr: '0 0 0 4px', - pd: '2px 4px', - bdrad: '8px', + d: 'inline-block', + fz: '8px', + mr: '2px -2px 2px 4px', + pd: '0 4px', + bdrad: '10px', bg: 'rgba(255,255,255)', - lh: '8px', + lh: '10px', + h: '10px', col: '#000', - d: 'inline-block', }); export interface DebugLabelProps { diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx index cf8cbd777b..68027ae27d 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -8,8 +8,8 @@ import type {BlockViewProps} from '../../react/BlockView'; const labelContainerClass = rule({ pos: 'absolute', - top: '-24px', - left: 0, + top: '-8px', + left: '-4px', us: 'none', pe: 'none', }); diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 7ae13c76ca..5eec90fb97 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -32,7 +32,7 @@ export const RenderInline: React.FC = (props) => { return ( {tags.length > 0 && ( - + {tags.join(', ')} From fe6d0b1ba2ee7e77f3126bfecf21c25efb060d06 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 11:00:48 +0100 Subject: [PATCH 11/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20display=20of=20debug=20labels,=20add?= =?UTF-8?q?=20small=20label=20ability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DebugLabel/index.tsx | 11 +++++++---- .../plugins/debug/RenderInline.tsx | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx index e36d263355..bc74df002c 100644 --- a/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx +++ b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx @@ -5,7 +5,7 @@ import {rule, theme} from 'nano-theme'; const labelClass = rule({ ...theme.font.mono.bold, d: 'flex', - + ai: 'center', fz: '9px', pd: '0 4px', mr: '-1px', @@ -19,7 +19,7 @@ const labelClass = rule({ const labelSecondClass = rule({ ...theme.font.mono.bold, - d: 'inline-block', + d: 'flex', fz: '8px', mr: '2px -2px 2px 4px', pd: '0 4px', @@ -32,12 +32,15 @@ const labelSecondClass = rule({ export interface DebugLabelProps { right?: React.ReactNode; + small?: boolean; children?: React.ReactNode; } -export const DebugLabel: React.FC = ({right, children}) => { +export const DebugLabel: React.FC = ({right, small, children}) => { + const style = small ? {fontSize: '7px', lineHeight: '10px', height: '10px', padding: '0 2px'} : void 0; + return ( - + {children} {!!right && {right}} diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 5eec90fb97..7c4839b467 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -32,8 +32,8 @@ export const RenderInline: React.FC = (props) => { return ( {tags.length > 0 && ( - - + + {tags.join(', ')} From d574fb0cd5b6c109bc6c0b144c5b2c5b09033b34 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 11:04:11 +0100 Subject: [PATCH 12/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20hide=20cursor=20inline=20debug=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 7c4839b467..6fa77183d8 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -21,7 +21,10 @@ export const RenderInline: React.FC = (props) => { for (let i = 0; i < length; i++) { let tag: string | number = keys[i]; if (typeof tag === 'string' && Number(tag) + '' === tag) tag = Number(tag); - if (tag === SliceTypeCon.Cursor) hasCursor = true; + if (tag === SliceTypeCon.Cursor) { + hasCursor = true; + continue; + } tag = SliceTypeName[tag as any] ?? (tag + ''); tag = '<' + tag + '>'; tags.push(tag); From b17c4ba00a8d0e3d8b3a0e7e4dd0eb2d2ae8fd7c Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 12:21:49 +0100 Subject: [PATCH 13/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ability=20to=20move=20point-by-point=20usi?= =?UTF-8?q?ng=20keyboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/dom/CursorController.ts | 1 + src/json-crdt-peritext-ui/events/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-peritext-ui/dom/CursorController.ts b/src/json-crdt-peritext-ui/dom/CursorController.ts index 4898d7ed48..c97fa97586 100644 --- a/src/json-crdt-peritext-ui/dom/CursorController.ts +++ b/src/json-crdt-peritext-ui/dom/CursorController.ts @@ -220,6 +220,7 @@ export class CursorController implements UiLifeCycles, Printable { event.preventDefault(); if (event.shiftKey) et.move(direction, unit(event) || 'char', 'focus'); else if (event.metaKey) et.move(direction, 'line'); + else if (event.altKey && event.ctrlKey) et.move(direction, 'point'); else if (event.altKey || event.ctrlKey) et.move(direction, 'word'); else et.move(direction); break; diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index f7e2011623..8be4ca461a 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -159,7 +159,7 @@ export interface CursorDetail { * * Defaults to `'char'`. */ - unit?: 'char' | 'word' | 'line' | 'block' | 'all'; + unit?: 'point' | 'char' | 'word' | 'line' | 'block' | 'all'; /** * Specifies which edge of the selection to move. If `'focus'`, the focus From 346a41a53ae16c6c0ee1a184e0dfc70d95cbd023 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 12:44:44 +0100 Subject: [PATCH 14/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20render=20caret=20anchor=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/cursor/RenderCaret.tsx | 19 ++++++++++- .../plugins/debug/RenderCaret.tsx | 33 ++++++++++++------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx index 4d53fb5bce..c83dbe7218 100644 --- a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx @@ -7,6 +7,7 @@ import {DefaultRendererColors} from './constants'; import {CommonSliceType} from '../../../json-crdt-extensions'; import {useCursorPlugin} from './context'; import {CaretScore} from '../../components/CaretScore'; +import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; import type {CaretViewProps} from '../../react/cursor/CaretView'; const height = 1.5; @@ -48,11 +49,24 @@ const innerClass2 = rule({ 'mix-blend-mode': 'hard-light', }); +const dotClass = rule({ + pos: 'absolute', + pe: 'none', + us: 'none', + w: '4px', + h: '4px', + bdrad: '50%', + z: 9999, + top: '-8px', + left: '4px', + bg: DefaultRendererColors.InactiveCursor, +}); + export interface RenderCaretProps extends CaretViewProps { children: React.ReactNode; } -export const RenderCaret: React.FC = ({italic, children}) => { +export const RenderCaret: React.FC = ({italic, point, children}) => { const ctx = usePeritext(); const pending = useSyncStore(ctx.peritext.editor.pending); const [show, setShow] = React.useState(true); @@ -60,6 +74,8 @@ export const RenderCaret: React.FC = ({italic, children}) => { const {dom} = usePeritext(); const focus = useSyncStoreOpt(dom?.cursor.focus) || false; const plugin = useCursorPlugin(); + + const anchorForward = point.anchor === Anchor.Before; const score = plugin.score.value; const delta = plugin.scoreDelta.value; @@ -88,6 +104,7 @@ export const RenderCaret: React.FC = ({italic, children}) => { }} /> )} + {anchorForward && } {/* Two carets overlay, so that they look good, both, on white and black backgrounds. */} diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index c9ea5f9d0c..6b8ae235d9 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -3,6 +3,7 @@ import {rule} from 'nano-theme'; import {useDebugCtx} from './context'; import useWindowSize from 'react-use/lib/useWindowSize'; import useWindowScroll from 'react-use/lib/useWindowScroll'; +import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; import type {CaretViewProps} from '../../react/cursor/CaretView'; const blockClass = rule({ @@ -58,31 +59,39 @@ const DebugOverlay: React.FC = ({point}) => { const leftLineEndCharRef = React.useRef(null); const rightLineEndCharRef = React.useRef(null); + const anchorLeft = point.anchor === Anchor.After; + React.useEffect(() => { const leftCharSpan = leftCharRef.current; if (leftCharSpan) { const leftCharRect = ctx!.dom!.getCharRect(point, false); + const style = leftCharSpan.style; if (leftCharRect) { - leftCharSpan.style.top = leftCharRect.y + 'px'; - leftCharSpan.style.left = leftCharRect.x + 'px'; - leftCharSpan.style.width = leftCharRect.width + 'px'; - leftCharSpan.style.height = leftCharRect.height + 'px'; - leftCharSpan.style.visibility = 'visible'; + style.top = leftCharRect.y + 'px'; + style.left = leftCharRect.x + 'px'; + style.width = leftCharRect.width + 'px'; + style.height = leftCharRect.height + 'px'; + style.outlineStyle = anchorLeft ? 'solid' : 'dashed'; + style.backgroundColor = anchorLeft ? 'rgba(0,0,255,.2)' : 'rgba(0,0,255,.1)'; + style.visibility = 'visible'; } else { - leftCharSpan.style.visibility = 'hidden'; + style.visibility = 'hidden'; } } const rightCharSpan = rightCharRef.current; if (rightCharSpan) { const rightCharRect = ctx!.dom!.getCharRect(point, true); + const style = rightCharSpan.style; if (rightCharRect) { - rightCharSpan.style.top = rightCharRect.y + 'px'; - rightCharSpan.style.left = rightCharRect.x + 'px'; - rightCharSpan.style.width = rightCharRect.width + 'px'; - rightCharSpan.style.height = rightCharRect.height + 'px'; - rightCharSpan.style.visibility = 'visible'; + style.top = rightCharRect.y + 'px'; + style.left = rightCharRect.x + 'px'; + style.width = rightCharRect.width + 'px'; + style.height = rightCharRect.height + 'px'; + style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; + style.backgroundColor = anchorLeft ? 'rgba(0,0,255,.1)' : 'rgba(0,0,255,.2)'; + style.visibility = 'visible'; } else { - rightCharSpan.style.visibility = 'hidden'; + style.visibility = 'hidden'; } } const rightLineEndCharSpan = rightLineEndCharRef.current; From 5c19869578fc053eb2d4ac8b862a7b6f6301c414 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 15:08:47 +0100 Subject: [PATCH 15/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20make=20debug=20plugin=20enabled=20state=20reacti?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__demos__/components/App.tsx | 2 +- .../plugins/cursor/RenderCaret.tsx | 4 +- .../plugins/debug/DebugPlugin.tsx | 2 +- .../plugins/debug/RenderBlock.tsx | 4 +- .../plugins/debug/RenderCaret.tsx | 6 +- .../plugins/debug/RenderInline.tsx | 4 +- .../plugins/debug/RenderPeritext.tsx | 56 ++++++++++++++----- .../plugins/debug/context.ts | 2 +- .../react/PeritextView.tsx | 14 ++--- 9 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/json-crdt-peritext-ui/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index 6c61eaede3..d37ee8e300 100644 --- a/src/json-crdt-peritext-ui/__demos__/components/App.tsx +++ b/src/json-crdt-peritext-ui/__demos__/components/App.tsx @@ -39,7 +39,7 @@ export const App: React.FC = () => { const cursorPlugin = new CursorPlugin(); const toolbarPlugin = new ToolbarPlugin(); const blocksPlugin = new BlocksPlugin(); - const debugPlugin = new DebugPlugin({enabled: true}); + const debugPlugin = new DebugPlugin(); return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin]; }, []); diff --git a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx index c83dbe7218..9bc8d2ffca 100644 --- a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx @@ -53,8 +53,8 @@ const dotClass = rule({ pos: 'absolute', pe: 'none', us: 'none', - w: '4px', - h: '4px', + w: '2px', + h: '2px', bdrad: '50%', z: 9999, top: '-8px', diff --git a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx index e2c49238eb..4d008e0ecb 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx @@ -5,7 +5,7 @@ import {RenderPeritext, type RenderPeritextProps} from './RenderPeritext'; import type {PeritextPlugin} from '../../react/types'; import {RenderCaret} from './RenderCaret'; -export type DebugPluginOpts = Pick; +export interface DebugPluginOpts extends Pick {} export class DebugPlugin implements PeritextPlugin { constructor(protected readonly opts: DebugPluginOpts = {}) {} diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx index 68027ae27d..67d7934a20 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -4,6 +4,7 @@ import {rule} from 'nano-theme'; import {useDebugCtx} from './context'; import {formatType} from '../../../json-crdt-extensions/peritext/slice/util'; import {DebugLabel} from '../../components/DebugLabel'; +import {useSyncStore} from '../../react/hooks'; import type {BlockViewProps} from '../../react/BlockView'; const labelContainerClass = rule({ @@ -19,7 +20,8 @@ export interface RenderBlockProps extends BlockViewProps { } export const RenderBlock: React.FC = ({block, hash, children}) => { - const {enabled} = useDebugCtx(); + const ctx = useDebugCtx(); + const enabled = useSyncStore(ctx.enabled); if (!enabled) return children; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index 6b8ae235d9..ba2ef789cb 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -4,6 +4,7 @@ import {useDebugCtx} from './context'; import useWindowSize from 'react-use/lib/useWindowSize'; import useWindowScroll from 'react-use/lib/useWindowScroll'; import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; +import {useSyncStore} from '../../react/hooks'; import type {CaretViewProps} from '../../react/cursor/CaretView'; const blockClass = rule({ @@ -21,9 +22,10 @@ export interface RenderCaretProps extends CaretViewProps { export const RenderCaret: React.FC = (props) => { const {children} = props; - const {enabled, ctx} = useDebugCtx(); + const ctx = useDebugCtx(); + const enabled = useSyncStore(ctx.enabled); - if (!enabled || !ctx?.dom) return children; + if (!enabled || !ctx.ctx?.dom) return children; return ; }; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 6fa77183d8..acf17df771 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -4,6 +4,7 @@ import {useDebugCtx} from './context'; import {DebugLabel} from '../../components/DebugLabel'; import {SliceTypeName} from '../../../json-crdt-extensions'; import {SliceTypeCon} from '../../../json-crdt-extensions/peritext/slice/constants'; +import {useSyncStore} from '../../react/hooks'; import type {InlineViewProps} from '../../react/InlineView'; export interface RenderInlineProps extends InlineViewProps { @@ -12,7 +13,8 @@ export interface RenderInlineProps extends InlineViewProps { export const RenderInline: React.FC = (props) => { const {children, inline} = props; - const {enabled} = useDebugCtx(); + const ctx = useDebugCtx(); + const enabled = useSyncStore(ctx.enabled); const keys: (number | string)[] = Object.keys(inline.attr()); const tags: string[] = []; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx index 66afd28757..5c7c718044 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx @@ -5,6 +5,7 @@ import {Button} from '../../components/Button'; import {Console} from './Console'; import {ValueSyncStore} from '../../../util/events/sync-store'; import type {PeritextSurfaceState, PeritextViewProps} from '../../react'; +import {useSyncStore} from '../../react/hooks'; const blockClass = rule({ pos: 'relative', @@ -27,14 +28,27 @@ const childrenDebugClass = rule({ }); export interface RenderPeritextProps extends PeritextViewProps { - enabled?: boolean; + enabled?: boolean | ValueSyncStore; + button?: boolean; children?: React.ReactNode; ctx?: PeritextSurfaceState; } -export const RenderPeritext: React.FC = ({enabled: enabledProp = true, ctx, children}) => { +export const RenderPeritext: React.FC = ({enabled: enabledProp = false, ctx, button, children}) => { const theme = useTheme(); - const [enabled, setEnabled] = React.useState(enabledProp); + const enabled = React.useMemo(() => new ValueSyncStore(true), []); + useSyncStore(enabled); + React.useEffect(() => { + if (typeof enabledProp === 'boolean') { + enabled.next(enabledProp); + return () => {}; + } + enabled.next(enabledProp.value); + const unsubscribe = enabledProp.subscribe(() => { + enabled.next(enabledProp.value); + }); + return () => unsubscribe(); + }, [enabledProp]); const value = React.useMemo( () => ({ enabled, @@ -51,18 +65,30 @@ export const RenderPeritext: React.FC = ({enabled: enabledP return ( -
-
- -
-
{children}
- {enabled && } +
{ + switch (event.key) { + case 'D': { + if (event.ctrlKey) { + event.preventDefault(); + enabled.next(!enabled.getSnapshot()); + } + break; + } + } + }}> + {!!button && ( +
+ +
+ )} +
{children}
+ {enabled.getSnapshot() && }
); diff --git a/src/json-crdt-peritext-ui/plugins/debug/context.ts b/src/json-crdt-peritext-ui/plugins/debug/context.ts index 2dba266b5c..3091ce1105 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/context.ts +++ b/src/json-crdt-peritext-ui/plugins/debug/context.ts @@ -3,7 +3,7 @@ import type {ValueSyncStore} from '../../../util/events/sync-store'; import type {PeritextSurfaceState} from '../../react/state'; export interface DebugRenderersContextValue { - enabled: boolean; + enabled: ValueSyncStore; flags: { dom: ValueSyncStore; editor: ValueSyncStore; diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index a748dedb89..c023cdf38c 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -77,13 +77,13 @@ export const PeritextView: React.FC = React.memo((props) => { return; } if (dom && dom.opts.source === el) return; - const ctrl = new DomController({source: el, events: state.events, log: state.log}); - state.events.surface = ctrl; - state.events.undo = ctrl.annals; - ctrl.start(); - state.dom = ctrl; - setDom(ctrl); - ctrl.et.addEventListener('change', rerender); + const newDom = new DomController({source: el, events: state.events, log: state.log}); + state.events.surface = newDom; + state.events.undo = newDom.annals; + newDom.start(); + state.dom = newDom; + setDom(newDom); + newDom.et.addEventListener('change', rerender); }, [peritext, state], ); From 1c1ab58360cdcb48849bc176db1d4c021a0c6d8b Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 15:26:02 +0100 Subject: [PATCH 16/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20display=20debug=20button=20in=20top=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__demos__/components/App.tsx | 6 ++++-- .../plugins/debug/RenderPeritext.tsx | 2 +- .../plugins/toolbar/RenderPeritext.tsx | 8 +++++--- .../plugins/toolbar/ToolbarPlugin.ts | 9 ++++++++- .../plugins/toolbar/TopToolbar/index.tsx | 18 +++++++++++++++--- .../plugins/toolbar/state/index.tsx | 5 +++-- 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/json-crdt-peritext-ui/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index d37ee8e300..1583d97ce3 100644 --- a/src/json-crdt-peritext-ui/__demos__/components/App.tsx +++ b/src/json-crdt-peritext-ui/__demos__/components/App.tsx @@ -7,6 +7,7 @@ import {CursorPlugin} from '../../plugins/cursor'; import {ToolbarPlugin} from '../../plugins/toolbar'; import {DebugPlugin} from '../../plugins/debug'; import {BlocksPlugin} from '../../plugins/blocks'; +import {ValueSyncStore} from '../../../util/events/sync-store'; const markdown = 'The German __automotive sector__ is in the process of *cutting ' + @@ -36,10 +37,11 @@ export const App: React.FC = () => { }, [model]); const plugins = React.useMemo(() => { + const debugEnabled = new ValueSyncStore(false); const cursorPlugin = new CursorPlugin(); - const toolbarPlugin = new ToolbarPlugin(); + const toolbarPlugin = new ToolbarPlugin({debug: debugEnabled}); const blocksPlugin = new BlocksPlugin(); - const debugPlugin = new DebugPlugin(); + const debugPlugin = new DebugPlugin({enabled: debugEnabled}); return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin]; }, []); diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx index 5c7c718044..20f70fb7c1 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx @@ -36,7 +36,7 @@ export interface RenderPeritextProps extends PeritextViewProps { export const RenderPeritext: React.FC = ({enabled: enabledProp = false, ctx, button, children}) => { const theme = useTheme(); - const enabled = React.useMemo(() => new ValueSyncStore(true), []); + const enabled = React.useMemo(() => typeof enabledProp === 'boolean' ? new ValueSyncStore(enabledProp) : enabledProp, []); useSyncStore(enabled); React.useEffect(() => { if (typeof enabledProp === 'boolean') { diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/RenderPeritext.tsx index aaf93ec379..97c00ea41c 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/RenderPeritext.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/RenderPeritext.tsx @@ -3,16 +3,18 @@ import {Chrome} from './Chrome'; import {context, type ToolbarPluginContextValue} from './context'; import {ToolbarState} from './state'; import type {PeritextSurfaceState, PeritextViewProps} from '../../react'; +import type {ToolbarPluginOpts} from './ToolbarPlugin'; export interface RenderPeritextProps extends PeritextViewProps { surface: PeritextSurfaceState; + opts: ToolbarPluginOpts; children?: React.ReactNode; } -export const RenderPeritext: React.FC = ({surface, children}) => { +export const RenderPeritext: React.FC = ({surface, opts, children}) => { const value: ToolbarPluginContextValue = React.useMemo( - () => ({surface, toolbar: new ToolbarState(surface)}), - [surface], + () => ({surface, toolbar: new ToolbarState(surface, opts)}), + [surface, opts], ); const toolbar = value.toolbar; diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/ToolbarPlugin.ts b/src/json-crdt-peritext-ui/plugins/toolbar/ToolbarPlugin.ts index 8d1043b77b..50aee4e726 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/ToolbarPlugin.ts +++ b/src/json-crdt-peritext-ui/plugins/toolbar/ToolbarPlugin.ts @@ -6,10 +6,17 @@ import {RenderBlock} from './RenderBlock'; import {RenderCaret} from './RenderCaret'; import {RenderFocus} from './RenderFocus'; import type {PeritextPlugin} from '../../react/types'; +import type {ValueSyncStore} from '../../../util/events/sync-store'; const h = React.createElement; +export interface ToolbarPluginOpts { + debug?: ValueSyncStore; +} + export class ToolbarPlugin implements PeritextPlugin { + constructor (public readonly opts: ToolbarPluginOpts = {}) {} + public readonly text: PeritextPlugin['text'] = text; public readonly inline: PeritextPlugin['inline'] = (props, children) => h(RenderInline, props as any, children); @@ -17,7 +24,7 @@ export class ToolbarPlugin implements PeritextPlugin { public readonly block: PeritextPlugin['block'] = (props, children) => h(RenderBlock, props as any, children); public readonly peritext: PeritextPlugin['peritext'] = (props, children, surface) => - h(RenderPeritext, {...props, children, surface}); + h(RenderPeritext, {...props, children, surface, opts: this.opts}); public readonly caret: PeritextPlugin['caret'] = (props, children) => h(RenderCaret, props, children); public readonly focus: PeritextPlugin['focus'] = (props, children) => h(RenderFocus, props, children); diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx index c44dc8774b..fe92a5890b 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx @@ -3,8 +3,9 @@ import * as React from 'react'; import {Button} from '../../../components/Button'; import {CommonSliceType} from '../../../../json-crdt-extensions'; import {ButtonGroup} from '../../../components/ButtonGroup'; -import {useSyncStore} from '../../../react/hooks'; +import {useSyncStore, useSyncStoreOpt} from '../../../react/hooks'; import {ButtonSeparator} from '../../../components/ButtonSeparator'; +import {useToolbarPlugin} from '../context'; import type {PeritextSurfaceState} from '../../../react'; export interface TopToolbarProps { @@ -12,9 +13,11 @@ export interface TopToolbarProps { } export const TopToolbar: React.FC = ({ctx}) => { + const {toolbar} = useToolbarPlugin()!; const peritext = ctx.peritext; const editor = peritext.editor; const pending = useSyncStore(editor.pending); + const isDebugMode = useSyncStoreOpt(toolbar.opts.debug); if (!ctx.dom) return null; @@ -30,8 +33,8 @@ export const TopToolbar: React.FC = ({ctx}) => { ); - const button = (name: React.ReactNode, onClick: React.MouseEventHandler) => ( - ); @@ -85,6 +88,15 @@ export const TopToolbar: React.FC = ({ctx}) => { {button('Redo', () => { ctx.dom?.annals.redo(); })} + {!!toolbar.opts.debug && ( + <> + + {button('Debug', () => { + const debug = toolbar.opts.debug!; + debug!.next(!debug.value); + }, !!isDebugMode)} + + )} ); }; diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx index 003e4b31bb..c2db87cb3d 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx @@ -5,18 +5,19 @@ import {ValueSyncStore} from '../../../../util/events/sync-store'; import {secondBrain} from './menus'; import {Code} from 'nice-ui/lib/1-inline/Code'; import {FontStyleButton} from 'nice-ui/lib/2-inline-block/FontStyleButton'; +import {CommonSliceType} from '../../../../json-crdt-extensions'; import type {UiLifeCyclesRender} from '../../../dom/types'; import type {BufferDetail, PeritextEventDetailMap} from '../../../events/types'; import type {PeritextSurfaceState} from '../../../react'; import type {MenuItem} from '../types'; -import {CommonSliceType} from '../../../../json-crdt-extensions'; +import type {ToolbarPluginOpts} from '../ToolbarPlugin'; export class ToolbarState implements UiLifeCyclesRender { public lastEvent: PeritextEventDetailMap['change']['ev'] | undefined = void 0; public lastEventTs: number = 0; public readonly showInlineToolbar = new ValueSyncStore<[show: boolean, time: number]>([false, 0]); - constructor(public readonly surface: PeritextSurfaceState) {} + constructor(public readonly surface: PeritextSurfaceState, public readonly opts: ToolbarPluginOpts) {} /** ------------------------------------------- {@link UiLifeCyclesRender} */ From 558f171f785c4ef6dcfe460a13eb58e7d734346c Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 27 Mar 2025 18:46:43 +0100 Subject: [PATCH 17/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20display=20word=20just=20locations=20in=20debug?= =?UTF-8?q?=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderCaret.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index ba2ef789cb..5c68efcc27 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -52,6 +52,12 @@ const eolCharacterOverlayStyles: React.CSSProperties = { outline: '1px dotted blue', }; +const eowCharacterOverlayStyles: React.CSSProperties = { + ...characterOverlayStyles, + backgroundColor: 'rgba(127,127,127,.1)', + outline: '0', +}; + const DebugOverlay: React.FC = ({point}) => { useWindowSize(); useWindowScroll(); @@ -60,6 +66,8 @@ const DebugOverlay: React.FC = ({point}) => { const rightCharRef = React.useRef(null); const leftLineEndCharRef = React.useRef(null); const rightLineEndCharRef = React.useRef(null); + const wordSkipLeftCharRef = React.useRef(null); + const wordSkipRightCharRef = React.useRef(null); const anchorLeft = point.anchor === Anchor.After; @@ -124,6 +132,42 @@ const DebugOverlay: React.FC = ({point}) => { leftLineEndCharSpan.style.visibility = 'hidden'; } } + const wordSkipRightCharSpan = wordSkipRightCharRef.current; + if (wordSkipRightCharSpan) { + const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); + if (wordJumpRightPoint) { + const rect = ctx!.dom!.getCharRect(wordJumpRightPoint, false); + const style = wordSkipRightCharSpan.style; + if (rect) { + style.top = rect.y + 'px'; + style.left = rect.x + 'px'; + style.width = rect.width + 'px'; + style.height = rect.height + 'px'; + style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; + style.visibility = 'visible'; + } else { + style.visibility = 'hidden'; + } + } + } + const wordSkipLeftCharSpan = wordSkipLeftCharRef.current; + if (wordSkipLeftCharSpan) { + const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); + if (wordJumpLeftPoint) { + const rect = ctx!.dom!.getCharRect(wordJumpLeftPoint, true); + const style = wordSkipLeftCharSpan.style; + if (rect) { + style.top = rect.y + 'px'; + style.left = rect.x + 'px'; + style.width = rect.width + 'px'; + style.height = rect.height + 'px'; + style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; + style.visibility = 'visible'; + } else { + style.visibility = 'hidden'; + } + } + } }); return ( @@ -139,6 +183,9 @@ const DebugOverlay: React.FC = ({point}) => { {/* Render outline around the end of the line. */} + + + ); }; From 834f7dcc2ed219fdbef378e984f96b9c4348d643 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 28 Mar 2025 15:34:14 +0100 Subject: [PATCH 18/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20introduce=20concept=20of=20rendering=20surface?= =?UTF-8?q?=20API=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 35 ++-------------- src/json-crdt-peritext-ui/dom/types.ts | 34 --------------- .../events/defaults/PeritextEventDefaults.ts | 10 ++--- .../events/defaults/ui/UiHandle.ts | 41 +++++++++++++++++++ .../events/defaults/ui/types.ts | 34 +++++++++++++++ src/json-crdt-peritext-ui/events/types.ts | 4 ++ .../plugins/debug/RenderCaret.tsx | 8 ++-- .../react/PeritextView.tsx | 5 ++- 8 files changed, 96 insertions(+), 75 deletions(-) create mode 100644 src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts create mode 100644 src/json-crdt-peritext-ui/events/defaults/ui/types.ts diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 93bae164c9..1bd65e69a2 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -8,11 +8,12 @@ import {AnnalsController} from './annals/AnnalsController'; import {ElementAttr} from '../constants'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; -import type {PeritextRenderingSurfaceApi, Rect, UiLifeCycles} from '../dom/types'; +import type {Rect, UiLifeCycles} from '../dom/types'; import type {Log} from '../../json-crdt/log/Log'; import type {Point} from '../../json-crdt-extensions/peritext/rga/Point'; import type {Inline} from '../../json-crdt-extensions'; import type {Range} from '../../json-crdt-extensions/peritext/rga/Range'; +import type {PeritextUiApi} from '../events/defaults/ui/types'; export interface DomControllerOpts { source: HTMLElement; @@ -20,7 +21,7 @@ export interface DomControllerOpts { log: Log; } -export class DomController implements UiLifeCycles, Printable, PeritextRenderingSurfaceApi { +export class DomController implements UiLifeCycles, Printable, PeritextUiApi { public readonly et: PeritextEventTarget; public readonly keys: KeyController; public readonly comp: CompositionController; @@ -61,7 +62,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.annals.stop(); } - /** ----------------------------------- {@link PeritextRenderingSurfaceApi} */ + /** ------------------------------------------------- {@link PeritextUiApi} */ public focus(): void { this.opts.source.focus(); @@ -104,34 +105,6 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering return rects[0]; } - public getLineEnd(pos: number | Point, right = true): [point: Point, rect: Rect] | undefined { - const txt = this.opts.events.txt; - const startPoint = typeof pos === 'number' ? txt.pointAt(pos) : pos; - const startRect = this.getCharRect(startPoint, right); - if (!startRect) return; - let curr = startPoint.clone(); - let currRect = startRect; - const prepareReturn = (): [point: Point, rect: Rect] => { - if (right) { - curr.step(1); - curr.refAfter(); - } else { - curr.step(-1); - curr.refBefore(); - } - return [curr, currRect]; - }; - while (true) { - const next = curr.copy(p => p.step(right ? 1 : -1)); - if (!next) return prepareReturn(); - const nextRect = this.getCharRect(next, right); - if (!nextRect) return prepareReturn(); - if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return prepareReturn(); - curr = next; - currRect = nextRect; - } - } - /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { diff --git a/src/json-crdt-peritext-ui/dom/types.ts b/src/json-crdt-peritext-ui/dom/types.ts index ff1492157f..2d04db1976 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -1,5 +1,3 @@ -import type {Point} from "../../json-crdt-extensions/peritext/rga/Point"; - /** * @todo Unify this with {@link UiLifeCycles}, join interfaces. * @todo Rename this to something like "disposable", as it does not have to be @@ -21,35 +19,3 @@ export interface UiLifeCyclesRender { } export type Rect = Pick; - -export interface PeritextRenderingSurfaceApi { - /** - * Focuses the rendering surface, so that it can receive keyboard input. - */ - focus(): void; - - // /** - // * Returns the bounding rectangle of the rendering surface. - // */ - // getBoundingClientRect(): Rect; - - // // /** - // // * Returns the bounding rectangle of the line at a given position. - // // */ - // // getLineRect(line: number): Rect; - - /** - * Finds the position of the character at the given position (between - * characters). The first position has index of 0. - * - * @param pos The index of the character in the text. - * @param right Whether to find the location of character after the given - * {@link Point} or before, defaults to `true`. - * @returns The bounding rectangle of the character at the given index. - */ - getCharRect(pos: number | Point, right?: boolean): Rect | undefined; - - getLineEnd(pos: number | Point, right?: boolean): [point: Point, rect: Rect] | undefined; - - // TODO: Need to be able to detect text direction of the current character. -} diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 48e07e0175..ca9208b926 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -8,7 +8,7 @@ import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/E import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; import type {UndoCollector} from '../../types'; -import type {PeritextRenderingSurfaceApi} from '../../dom/types'; +import type {UiHandle} from './ui/UiHandle'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -26,7 +26,7 @@ export interface PeritextEventDefaultsOpts { */ export class PeritextEventDefaults implements PeritextEventHandlerMap { public undo?: UndoCollector; - public surface?: PeritextRenderingSurfaceApi; + public ui?: UiHandle; public constructor( public readonly txt: Peritext, @@ -110,9 +110,9 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { if (len) { const cursor = editor.cursor; if (cursor.isCollapsed()) { - const surface = this.surface; - editor.move(len, unit ?? 'char', void 0, void 0, surface ? (point, steps) => { - const res = surface.getLineEnd(point, steps > 0); + const ui = this.ui; + editor.move(len, unit ?? 'char', void 0, void 0, ui ? (point, steps) => { + const res = ui.getLineEnd(point, steps > 0); return res ? res[0] : void 0; } : void 0); } diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts new file mode 100644 index 0000000000..147731bf9c --- /dev/null +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -0,0 +1,41 @@ +import type {Peritext} from '../../../../json-crdt-extensions'; +import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; +import type {Rect} from '../../../dom/types'; +import type {PeritextUiApi} from './types'; + +export class UiHandle { + constructor( + public readonly txt: Peritext, + public readonly api: PeritextUiApi, + ) {} + + public getLineEnd(pos: number | Point, right = true): [point: Point, rect: Rect] | undefined { + const api = this.api; + if (!api.getCharRect) return; + const txt = this.txt; + const startPoint = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const startRect = api.getCharRect(startPoint, right); + if (!startRect) return; + let curr = startPoint.clone(); + let currRect = startRect; + const prepareReturn = (): [point: Point, rect: Rect] => { + if (right) { + curr.step(1); + curr.refAfter(); + } else { + curr.step(-1); + curr.refBefore(); + } + return [curr, currRect]; + }; + while (true) { + const next = curr.copy(p => p.step(right ? 1 : -1)); + if (!next) return prepareReturn(); + const nextRect = api.getCharRect(next, right); + if (!nextRect) return prepareReturn(); + if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return prepareReturn(); + curr = next; + currRect = nextRect; + } + } +} diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts new file mode 100644 index 0000000000..3c83ff6868 --- /dev/null +++ b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts @@ -0,0 +1,34 @@ +import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; +import type {Rect} from '../../../dom/types'; + +/** + * API which a rendering surface (UI) must implement to be used by the event + * system. + */ +export interface PeritextUiApi { + /** + * Focuses the rendering surface, so that it can receive keyboard input. + */ + focus?(): void; + + /** + * Blurs the rendering surface, so that it cannot receive keyboard input. + */ + blur?(): void; + + /** + * Finds the position of the character at the given position (between + * characters). The first position has index of 0. + * + * @param pos The index of the character in the text. + * @param fwd Whether to find the location of the next character after the + * given {@link Point} or before, defaults to `true`. + * @returns The bounding rectangle of the character at the given index. + */ + getCharRect?(pos: number | Point, fwd?: boolean): Rect | undefined; + + /** + * Returns `true` if text at the given position has right-to-left direction. + */ + isRTL?(pos: number | Point): boolean; +} diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index 8be4ca461a..72bbb397a2 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -347,6 +347,10 @@ export interface AnnalsDetail { */ export type Position = EditorPosition; +/** + * A map of all Peritext rendering surface event types and their corresponding + * detail types. + */ export type PeritextEventDetailMap = { change: ChangeDetail; insert: InsertDetail; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index 5c68efcc27..caa4461b1e 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -106,7 +106,7 @@ const DebugOverlay: React.FC = ({point}) => { } const rightLineEndCharSpan = rightLineEndCharRef.current; if (rightLineEndCharSpan) { - const lineEnd = ctx!.dom!.getLineEnd(point, true); + const lineEnd = ctx!.events.ui?.getLineEnd(point, true); if (lineEnd) { const [, rect] = lineEnd; rightLineEndCharSpan.style.top = rect.y + 'px'; @@ -120,7 +120,7 @@ const DebugOverlay: React.FC = ({point}) => { } const leftLineEndCharSpan = leftLineEndCharRef.current; if (leftLineEndCharSpan) { - const lineEnd = ctx!.dom!.getLineEnd(point, false); + const lineEnd = ctx!.events.ui?.getLineEnd(point, false); if (lineEnd) { const [, rect] = lineEnd; leftLineEndCharSpan.style.top = rect.y + 'px'; @@ -136,7 +136,7 @@ const DebugOverlay: React.FC = ({point}) => { if (wordSkipRightCharSpan) { const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); if (wordJumpRightPoint) { - const rect = ctx!.dom!.getCharRect(wordJumpRightPoint, false); + const rect = ctx!.events.ui?.api?.getCharRect?.(wordJumpRightPoint, false); const style = wordSkipRightCharSpan.style; if (rect) { style.top = rect.y + 'px'; @@ -154,7 +154,7 @@ const DebugOverlay: React.FC = ({point}) => { if (wordSkipLeftCharSpan) { const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); if (wordJumpLeftPoint) { - const rect = ctx!.dom!.getCharRect(wordJumpLeftPoint, true); + const rect = ctx!.events.ui?.api?.getCharRect?.(wordJumpLeftPoint, true); const style = wordSkipLeftCharSpan.style; if (rect) { style.top = rect.y + 'px'; diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index c023cdf38c..c3af5a8eac 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -10,6 +10,7 @@ import {PeritextSurfaceState} from './state'; import {create} from '../events'; import type {Peritext} from '../../json-crdt-extensions/peritext/Peritext'; import type {PeritextPlugin} from './types'; +import {UiHandle} from '../events/defaults/ui/UiHandle'; put('.' + CssClass.Editor, { out: 0, @@ -78,7 +79,9 @@ export const PeritextView: React.FC = React.memo((props) => { } if (dom && dom.opts.source === el) return; const newDom = new DomController({source: el, events: state.events, log: state.log}); - state.events.surface = newDom; + const txt = state.peritext; + const uiHandle = new UiHandle(txt, newDom); + state.events.ui = uiHandle; state.events.undo = newDom.annals; newDom.start(); state.dom = newDom; From 93a31ba938b7fbec9b3a64fbd20840fedef8dedf Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 28 Mar 2025 17:25:10 +0100 Subject: [PATCH 19/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20utility=20for=20retrieving=20line=20?= =?UTF-8?q?bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/ui/UiHandle.ts | 25 ++++++++++--- .../events/defaults/ui/types.ts | 11 ++++++ .../plugins/debug/RenderCaret.tsx | 37 ++++++++----------- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index 147731bf9c..585d0ba6dc 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -1,7 +1,6 @@ import type {Peritext} from '../../../../json-crdt-extensions'; import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; -import type {Rect} from '../../../dom/types'; -import type {PeritextUiApi} from './types'; +import type {PeritextUiApi, UiLineEdge, UiLineInfo} from './types'; export class UiHandle { constructor( @@ -9,16 +8,19 @@ export class UiHandle { public readonly api: PeritextUiApi, ) {} - public getLineEnd(pos: number | Point, right = true): [point: Point, rect: Rect] | undefined { + protected point(pos: number | Point): Point { + return typeof pos === 'number' ? this.txt.pointAt(pos) : pos; + } + + public getLineEnd(pos: number | Point, right = true): UiLineEdge | undefined { const api = this.api; if (!api.getCharRect) return; - const txt = this.txt; - const startPoint = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const startPoint = this.point(pos); const startRect = api.getCharRect(startPoint, right); if (!startRect) return; let curr = startPoint.clone(); let currRect = startRect; - const prepareReturn = (): [point: Point, rect: Rect] => { + const prepareReturn = (): UiLineEdge => { if (right) { curr.step(1); curr.refAfter(); @@ -38,4 +40,15 @@ export class UiHandle { currRect = nextRect; } } + + public getLineInfo(pos: number | Point): UiLineInfo | undefined { + const txt = this.txt; + const point = this.point(pos); + const isEndOfText = point.viewPos() === txt.strApi().length(); + if (isEndOfText) return; + const left = this.getLineEnd(point, false); + const right = this.getLineEnd(point, true); + if (!left || !right) return; + return [left, right]; + } } diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts index 3c83ff6868..f20d9ee02f 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts @@ -32,3 +32,14 @@ export interface PeritextUiApi { */ isRTL?(pos: number | Point): boolean; } + +export type UiLineEdge = [ + point: Point, + rect: Rect, +]; + +export type UiLineInfo = [ + left: UiLineEdge, + right: UiLineEdge, +]; + diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx index caa4461b1e..8435f955a1 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx @@ -105,31 +105,26 @@ const DebugOverlay: React.FC = ({point}) => { } } const rightLineEndCharSpan = rightLineEndCharRef.current; - if (rightLineEndCharSpan) { - const lineEnd = ctx!.events.ui?.getLineEnd(point, true); - if (lineEnd) { - const [, rect] = lineEnd; - rightLineEndCharSpan.style.top = rect.y + 'px'; - rightLineEndCharSpan.style.left = rect.x + 'px'; - rightLineEndCharSpan.style.width = rect.width + 'px'; - rightLineEndCharSpan.style.height = rect.height + 'px'; - rightLineEndCharSpan.style.visibility = 'visible'; - } else { - rightLineEndCharSpan.style.visibility = 'hidden'; - } - } const leftLineEndCharSpan = leftLineEndCharRef.current; - if (leftLineEndCharSpan) { - const lineEnd = ctx!.events.ui?.getLineEnd(point, false); - if (lineEnd) { - const [, rect] = lineEnd; - leftLineEndCharSpan.style.top = rect.y + 'px'; - leftLineEndCharSpan.style.left = rect.x + 'px'; - leftLineEndCharSpan.style.width = rect.width + 'px'; - leftLineEndCharSpan.style.height = rect.height + 'px'; + if (rightLineEndCharSpan && leftLineEndCharSpan) { + const lineInfo = ctx!.events.ui?.getLineInfo(point); + if (lineInfo) { + const [left, right] = lineInfo; + const [, rectLeft] = left; + leftLineEndCharSpan.style.top = rectLeft.y + 'px'; + leftLineEndCharSpan.style.left = rectLeft.x + 'px'; + leftLineEndCharSpan.style.width = rectLeft.width + 'px'; + leftLineEndCharSpan.style.height = rectLeft.height + 'px'; leftLineEndCharSpan.style.visibility = 'visible'; + const [, rectRight] = right; + rightLineEndCharSpan.style.top = rectRight.y + 'px'; + rightLineEndCharSpan.style.left = rectRight.x + 'px'; + rightLineEndCharSpan.style.width = rectRight.width + 'px'; + rightLineEndCharSpan.style.height = rectRight.height + 'px'; + rightLineEndCharSpan.style.visibility = 'visible'; } else { leftLineEndCharSpan.style.visibility = 'hidden'; + rightLineEndCharSpan.style.visibility = 'hidden'; } } const wordSkipRightCharSpan = wordSkipRightCharRef.current; From 6eb56a83676edbd599d31b6ce64b8efc14d1702c Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 28 Mar 2025 18:26:02 +0100 Subject: [PATCH 20/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20create=20=20helper=20for=20debuggin?= =?UTF-8?q?g=20overlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderCaret/CharOverlay.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx new file mode 100644 index 0000000000..0167928d37 --- /dev/null +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import useWindowSize from 'react-use/lib/useWindowSize'; +import useWindowScroll from 'react-use/lib/useWindowScroll'; +import type {Rect} from '../../../dom/types'; + +export type SetRect = (rect?: Rect) => void; + +export interface CharOverlayProps extends React.HTMLAttributes { + rectRef: React.RefObject; +} + +export const CharOverlay: React.FC = ({rectRef, ...rest}) => { + useWindowSize(); + useWindowScroll(); + const ref = React.useRef(null); + React.useEffect(() => { + (rectRef as any).current = (rect?: Rect) => { + const span = ref.current; + if (!span) return; + const style = span.style; + if (rect) { + style.top = rect.y + 'px'; + style.left = rect.x + 'px'; + style.width = rect.width + 'px'; + style.height = rect.height + 'px'; + style.visibility = 'visible'; + } else { + style.visibility = 'hidden'; + } + }; + }, []); + + return ( + + ); +}; From c73013eb84e06d51a542f3d3435404eb332c5515 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 28 Mar 2025 18:33:07 +0100 Subject: [PATCH 21/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20use=20the=20new=20=20in=20debug=20h?= =?UTF-8?q?ighlighting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderCaret.tsx | 186 ------------------ .../plugins/debug/RenderCaret/index.tsx | 103 ++++++++++ 2 files changed, 103 insertions(+), 186 deletions(-) delete mode 100644 src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx create mode 100644 src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx deleted file mode 100644 index 8435f955a1..0000000000 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import * as React from 'react'; -import {rule} from 'nano-theme'; -import {useDebugCtx} from './context'; -import useWindowSize from 'react-use/lib/useWindowSize'; -import useWindowScroll from 'react-use/lib/useWindowScroll'; -import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; -import {useSyncStore} from '../../react/hooks'; -import type {CaretViewProps} from '../../react/cursor/CaretView'; - -const blockClass = rule({ - pos: 'relative', - pe: 'none', - us: 'none', - w: '0px', - h: '100%', - va: 'bottom', -}); - -export interface RenderCaretProps extends CaretViewProps { - children: React.ReactNode; -} - -export const RenderCaret: React.FC = (props) => { - const {children} = props; - const ctx = useDebugCtx(); - const enabled = useSyncStore(ctx.enabled); - - if (!enabled || !ctx.ctx?.dom) return children; - - return ; -}; - -const RenderDebugCaret: React.FC = (props) => { - return ( - - {props.children} - - - ); -}; - -const characterOverlayStyles: React.CSSProperties = { - position: 'fixed', - display: 'inline-block', - visibility: 'hidden', - backgroundColor: 'rgba(0, 0, 255, 0.1)', - outline: '1px dashed blue', -}; - -const eolCharacterOverlayStyles: React.CSSProperties = { - ...characterOverlayStyles, - outline: '1px dotted blue', -}; - -const eowCharacterOverlayStyles: React.CSSProperties = { - ...characterOverlayStyles, - backgroundColor: 'rgba(127,127,127,.1)', - outline: '0', -}; - -const DebugOverlay: React.FC = ({point}) => { - useWindowSize(); - useWindowScroll(); - const {ctx} = useDebugCtx(); - const leftCharRef = React.useRef(null); - const rightCharRef = React.useRef(null); - const leftLineEndCharRef = React.useRef(null); - const rightLineEndCharRef = React.useRef(null); - const wordSkipLeftCharRef = React.useRef(null); - const wordSkipRightCharRef = React.useRef(null); - - const anchorLeft = point.anchor === Anchor.After; - - React.useEffect(() => { - const leftCharSpan = leftCharRef.current; - if (leftCharSpan) { - const leftCharRect = ctx!.dom!.getCharRect(point, false); - const style = leftCharSpan.style; - if (leftCharRect) { - style.top = leftCharRect.y + 'px'; - style.left = leftCharRect.x + 'px'; - style.width = leftCharRect.width + 'px'; - style.height = leftCharRect.height + 'px'; - style.outlineStyle = anchorLeft ? 'solid' : 'dashed'; - style.backgroundColor = anchorLeft ? 'rgba(0,0,255,.2)' : 'rgba(0,0,255,.1)'; - style.visibility = 'visible'; - } else { - style.visibility = 'hidden'; - } - } - const rightCharSpan = rightCharRef.current; - if (rightCharSpan) { - const rightCharRect = ctx!.dom!.getCharRect(point, true); - const style = rightCharSpan.style; - if (rightCharRect) { - style.top = rightCharRect.y + 'px'; - style.left = rightCharRect.x + 'px'; - style.width = rightCharRect.width + 'px'; - style.height = rightCharRect.height + 'px'; - style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; - style.backgroundColor = anchorLeft ? 'rgba(0,0,255,.1)' : 'rgba(0,0,255,.2)'; - style.visibility = 'visible'; - } else { - style.visibility = 'hidden'; - } - } - const rightLineEndCharSpan = rightLineEndCharRef.current; - const leftLineEndCharSpan = leftLineEndCharRef.current; - if (rightLineEndCharSpan && leftLineEndCharSpan) { - const lineInfo = ctx!.events.ui?.getLineInfo(point); - if (lineInfo) { - const [left, right] = lineInfo; - const [, rectLeft] = left; - leftLineEndCharSpan.style.top = rectLeft.y + 'px'; - leftLineEndCharSpan.style.left = rectLeft.x + 'px'; - leftLineEndCharSpan.style.width = rectLeft.width + 'px'; - leftLineEndCharSpan.style.height = rectLeft.height + 'px'; - leftLineEndCharSpan.style.visibility = 'visible'; - const [, rectRight] = right; - rightLineEndCharSpan.style.top = rectRight.y + 'px'; - rightLineEndCharSpan.style.left = rectRight.x + 'px'; - rightLineEndCharSpan.style.width = rectRight.width + 'px'; - rightLineEndCharSpan.style.height = rectRight.height + 'px'; - rightLineEndCharSpan.style.visibility = 'visible'; - } else { - leftLineEndCharSpan.style.visibility = 'hidden'; - rightLineEndCharSpan.style.visibility = 'hidden'; - } - } - const wordSkipRightCharSpan = wordSkipRightCharRef.current; - if (wordSkipRightCharSpan) { - const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); - if (wordJumpRightPoint) { - const rect = ctx!.events.ui?.api?.getCharRect?.(wordJumpRightPoint, false); - const style = wordSkipRightCharSpan.style; - if (rect) { - style.top = rect.y + 'px'; - style.left = rect.x + 'px'; - style.width = rect.width + 'px'; - style.height = rect.height + 'px'; - style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; - style.visibility = 'visible'; - } else { - style.visibility = 'hidden'; - } - } - } - const wordSkipLeftCharSpan = wordSkipLeftCharRef.current; - if (wordSkipLeftCharSpan) { - const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); - if (wordJumpLeftPoint) { - const rect = ctx!.events.ui?.api?.getCharRect?.(wordJumpLeftPoint, true); - const style = wordSkipLeftCharSpan.style; - if (rect) { - style.top = rect.y + 'px'; - style.left = rect.x + 'px'; - style.width = rect.width + 'px'; - style.height = rect.height + 'px'; - style.outlineStyle = anchorLeft ? 'dashed' : 'solid'; - style.visibility = 'visible'; - } else { - style.visibility = 'hidden'; - } - } - } - }); - - return ( - <> - {/* Render outline around the previous character before the caret. */} - - - {/* Render outline around the next character after the caret. */} - - - {/* Render outline around the beginning of the line. */} - - - {/* Render outline around the end of the line. */} - - - - - - ); -}; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx new file mode 100644 index 0000000000..cf37982eef --- /dev/null +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import {rule} from 'nano-theme'; +import {useDebugCtx} from '../context'; +import {Anchor} from '../../../../json-crdt-extensions/peritext/rga/constants'; +import {useSyncStore} from '../../../react/hooks'; +import type {CaretViewProps} from '../../../react/cursor/CaretView'; +import {CharOverlay, SetRect} from './CharOverlay'; + +const blockClass = rule({ + pos: 'relative', + pe: 'none', + us: 'none', + w: '0px', + h: '100%', + va: 'bottom', +}); + +export interface RenderCaretProps extends CaretViewProps { + children: React.ReactNode; +} + +export const RenderCaret: React.FC = (props) => { + const {children} = props; + const ctx = useDebugCtx(); + const enabled = useSyncStore(ctx.enabled); + + if (!enabled || !ctx.ctx?.dom) return children; + + return ; +}; + +const RenderDebugCaret: React.FC = (props) => { + return ( + + {props.children} + + + ); +}; + +const characterOverlayStyles: React.CSSProperties = { + position: 'fixed', + display: 'inline-block', + visibility: 'hidden', + backgroundColor: 'rgba(0, 0, 255, 0.1)', + outline: '1px dashed blue', +}; + +const eolCharacterOverlayStyles: React.CSSProperties = { + ...characterOverlayStyles, + outline: '1px dotted blue', +}; + +const eowCharacterOverlayStyles: React.CSSProperties = { + ...characterOverlayStyles, + backgroundColor: 'rgba(127,127,127,.1)', + outline: '0', +}; + +const DebugOverlay: React.FC = ({point}) => { + const {ctx} = useDebugCtx(); + const leftCharRef = React.useRef(null); + const rightCharRef = React.useRef(null); + const leftLineEndCharRef = React.useRef(null); + const rightLineEndCharRef = React.useRef(null); + const wordSkipLeftCharRef = React.useRef(null); + const wordSkipRightCharRef = React.useRef(null); + + const anchorLeft = point.anchor === Anchor.After; + + React.useEffect(() => { + leftCharRef.current?.(ctx!.dom!.getCharRect(point, false)); + rightCharRef.current?.(ctx!.dom!.getCharRect(point, true)); + const lineInfo = ctx!.events.ui?.getLineInfo(point); + leftLineEndCharRef.current?.(lineInfo?.[0][1]); + rightLineEndCharRef.current?.(lineInfo?.[1][1]); + const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); + if (wordJumpLeftPoint) + wordSkipLeftCharRef.current?.(ctx!.events.ui?.api?.getCharRect?.(wordJumpLeftPoint, true)); + const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); + if (wordJumpRightPoint) + wordSkipRightCharRef.current?.(ctx!.events.ui?.api?.getCharRect?.(wordJumpRightPoint, false)); + }); + + return ( + <> + + + + + + + + ); +}; From 3f8a636c08340bf4e0508998a643cfc970761e38 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 29 Mar 2025 01:04:00 +0100 Subject: [PATCH 22/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20show=20previous=20and=20next=20line=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/rga/Point.ts | 12 ++++++++++++ .../events/defaults/ui/UiHandle.ts | 14 ++++++++++++++ .../plugins/debug/RenderCaret/index.tsx | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/json-crdt-extensions/peritext/rga/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts index a74b9704d9..71e21365b6 100644 --- a/src/json-crdt-extensions/peritext/rga/Point.ts +++ b/src/json-crdt-extensions/peritext/rga/Point.ts @@ -150,6 +150,18 @@ export class Point implements Pick, Printable { return this.anchor === Anchor.Before ? pos : pos + 1; } + /** + * @returns Returns `true` if the point is at the very start of the string, i.e. + * there are no visible characters before it. + */ + public isStart(): boolean { + const chunk = this.chunk(); + if (!chunk) return true; + if (!chunk.del && chunk.id.time < this.id.time) return false; + const l = chunk.l; + return l ? !l.len : true; + } + /** * Goes to the next visible character in the string. The `move` parameter * specifies how many characters to move the cursor by. If the cursor reaches diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index 585d0ba6dc..44db5dd458 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -51,4 +51,18 @@ export class UiHandle { if (!left || !right) return; return [left, right]; } + + public getPrevLineInfo(line: UiLineInfo): UiLineInfo | undefined { + const [[left]] = line; + if (left.isStart()) return; + const point = left.copy(p => p.step(-1)); + return this.getLineInfo(point); + } + + public getNextLineInfo(line: UiLineInfo): UiLineInfo | undefined { + const [, [right]] = line; + if (right.viewPos() >= this.txt.str.length()) return; + const point = right.copy(p => p.step(1)); + return this.getLineInfo(point); + } } diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index cf37982eef..8e5afcbe19 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -63,6 +63,10 @@ const DebugOverlay: React.FC = ({point}) => { const rightCharRef = React.useRef(null); const leftLineEndCharRef = React.useRef(null); const rightLineEndCharRef = React.useRef(null); + const leftPrevLineEndCharRef = React.useRef(null); + const rightPrevLineEndCharRef = React.useRef(null); + const leftNextLineEndCharRef = React.useRef(null); + const rightNextLineEndCharRef = React.useRef(null); const wordSkipLeftCharRef = React.useRef(null); const wordSkipRightCharRef = React.useRef(null); @@ -74,6 +78,14 @@ const DebugOverlay: React.FC = ({point}) => { const lineInfo = ctx!.events.ui?.getLineInfo(point); leftLineEndCharRef.current?.(lineInfo?.[0][1]); rightLineEndCharRef.current?.(lineInfo?.[1][1]); + if (lineInfo) { + const prevLineInfo = ctx!.events.ui?.getPrevLineInfo(lineInfo); + const nextLineInfo = ctx!.events.ui?.getNextLineInfo(lineInfo); + leftPrevLineEndCharRef.current?.(prevLineInfo?.[0][1]); + rightPrevLineEndCharRef.current?.(prevLineInfo?.[1][1]); + leftNextLineEndCharRef.current?.(nextLineInfo?.[0][1]); + rightNextLineEndCharRef.current?.(nextLineInfo?.[1][1]); + } const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); if (wordJumpLeftPoint) wordSkipLeftCharRef.current?.(ctx!.events.ui?.api?.getCharRect?.(wordJumpLeftPoint, true)); @@ -96,6 +108,10 @@ const DebugOverlay: React.FC = ({point}) => { }} /> + + + + From a46ac8abee3b764d0e5002ba6335e51fc7a55697 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 29 Mar 2025 16:38:14 +0100 Subject: [PATCH 23/32] =?UTF-8?q?refactor(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=92=A1=20simplify=20DOM=20body=20API=20interface,=20just?= =?UTF-8?q?=20find=20char=20by=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 14 +++++----- .../events/defaults/ui/UiHandle.ts | 27 ++++++++++++++++--- .../events/defaults/ui/types.ts | 11 ++++---- .../plugins/debug/RenderCaret/index.tsx | 8 +++--- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 1bd65e69a2..6f14bc6f37 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -6,11 +6,12 @@ import {KeyController} from './KeyController'; import {CompositionController} from './CompositionController'; import {AnnalsController} from './annals/AnnalsController'; import {ElementAttr} from '../constants'; +import {Anchor} from '../../json-crdt-extensions/peritext/rga/constants'; +import type {ITimestampStruct} from '../../json-crdt-patch'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; import type {Rect, UiLifeCycles} from '../dom/types'; import type {Log} from '../../json-crdt/log/Log'; -import type {Point} from '../../json-crdt-extensions/peritext/rga/Point'; import type {Inline} from '../../json-crdt-extensions'; import type {Range} from '../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextUiApi} from '../events/defaults/ui/types'; @@ -86,12 +87,13 @@ export class DomController implements UiLifeCycles, Printable, PeritextUiApi { return; } - public getCharRect(pos: number | Point, right = true): Rect | undefined { + public getCharRect(char: number | ITimestampStruct): Rect | undefined { const txt = this.opts.events.txt; - const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; - const char = right ? point.rightChar() : point.leftChar(); - if (!char) return; - const charRange = txt.rangeFromChunkSlice(char); + const id = typeof char === 'number' ? txt.str.find(char) : char; + if (!id) return; + const start = txt.point(id, Anchor.Before); + const end = txt.point(id, Anchor.After); + const charRange = txt.range(start, end); const [span, inline] = this.findSpanContaining(charRange) || []; if (!span || !inline) return; const textNode = span.firstChild as Text; diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index 44db5dd458..911abd6fa8 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -1,5 +1,7 @@ +import {tick} from '../../../../json-crdt-patch'; import type {Peritext} from '../../../../json-crdt-extensions'; import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; +import type {Rect} from '../../../dom/types'; import type {PeritextUiApi, UiLineEdge, UiLineInfo} from './types'; export class UiHandle { @@ -12,11 +14,28 @@ export class UiHandle { return typeof pos === 'number' ? this.txt.pointAt(pos) : pos; } + /** + * Finds the position of the character at the given point (position between + * characters). The first position has index of 0. Have to specify the + * direction of the search, forward or backward. + * + * @param point The index of the character in the text, or a {@link Point}. + * @param fwd Whether to find the location of the next character after the + * given {@link Point} or before, defaults to `true`. + * @returns The bounding rectangle of the character at the given index. + */ + public getPointRect(pos: number | Point, right = true): Rect | undefined { + const txt = this.txt; + const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const char = right ? point.rightChar() : point.leftChar(); + if (!char) return; + const id = tick(char.chunk.id, char.off); + return this.api.getCharRect?.(id); + } + public getLineEnd(pos: number | Point, right = true): UiLineEdge | undefined { - const api = this.api; - if (!api.getCharRect) return; const startPoint = this.point(pos); - const startRect = api.getCharRect(startPoint, right); + const startRect = this.getPointRect(startPoint, right); if (!startRect) return; let curr = startPoint.clone(); let currRect = startRect; @@ -33,7 +52,7 @@ export class UiHandle { while (true) { const next = curr.copy(p => p.step(right ? 1 : -1)); if (!next) return prepareReturn(); - const nextRect = api.getCharRect(next, right); + const nextRect = this.getPointRect(next, right); if (!nextRect) return prepareReturn(); if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return prepareReturn(); curr = next; diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts index f20d9ee02f..f4bc0e1d3f 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts @@ -1,4 +1,5 @@ import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; +import type {ITimestampStruct} from '../../../../json-crdt-patch'; import type {Rect} from '../../../dom/types'; /** @@ -17,15 +18,13 @@ export interface PeritextUiApi { blur?(): void; /** - * Finds the position of the character at the given position (between - * characters). The first position has index of 0. + * Finds the position on the screen of a specific character. * - * @param pos The index of the character in the text. - * @param fwd Whether to find the location of the next character after the - * given {@link Point} or before, defaults to `true`. + * @param char The index of the character in the text, or a the ID + * {@link ITimestampStruct} of the character. * @returns The bounding rectangle of the character at the given index. */ - getCharRect?(pos: number | Point, fwd?: boolean): Rect | undefined; + getCharRect?(char: number | ITimestampStruct): Rect | undefined; /** * Returns `true` if text at the given position has right-to-left direction. diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index 8e5afcbe19..892b22b3e2 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -73,8 +73,8 @@ const DebugOverlay: React.FC = ({point}) => { const anchorLeft = point.anchor === Anchor.After; React.useEffect(() => { - leftCharRef.current?.(ctx!.dom!.getCharRect(point, false)); - rightCharRef.current?.(ctx!.dom!.getCharRect(point, true)); + leftCharRef.current?.(ctx!.events.ui?.getPointRect(point, false)); + rightCharRef.current?.(ctx!.events.ui?.getPointRect(point, true)); const lineInfo = ctx!.events.ui?.getLineInfo(point); leftLineEndCharRef.current?.(lineInfo?.[0][1]); rightLineEndCharRef.current?.(lineInfo?.[1][1]); @@ -88,10 +88,10 @@ const DebugOverlay: React.FC = ({point}) => { } const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); if (wordJumpLeftPoint) - wordSkipLeftCharRef.current?.(ctx!.events.ui?.api?.getCharRect?.(wordJumpLeftPoint, true)); + wordSkipLeftCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpLeftPoint, true)); const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); if (wordJumpRightPoint) - wordSkipRightCharRef.current?.(ctx!.events.ui?.api?.getCharRect?.(wordJumpRightPoint, false)); + wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); }); return ( From 92717370bdb7c5af3d5fb791296a3b9fe8bf90d3 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 29 Mar 2025 19:17:48 +0100 Subject: [PATCH 24/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ability=20to=20compute=20point=20x=20coord?= =?UTF-8?q?inate=20on=20the=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/ui/UiHandle.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index 911abd6fa8..2b5ff22c7e 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -1,4 +1,5 @@ import {tick} from '../../../../json-crdt-patch'; +import {Anchor} from '../../../../json-crdt-extensions/peritext/rga/constants'; import type {Peritext} from '../../../../json-crdt-extensions'; import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; import type {Rect} from '../../../dom/types'; @@ -33,6 +34,15 @@ export class UiHandle { return this.api.getCharRect?.(id); } + public getPointX(pos: number | Point, right = true): [x: number, rect: Rect] | undefined { + const txt = this.txt; + const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; + const rect = this.getPointRect(point, right); + if (!rect) return; + const x = point.anchor === Anchor.Before ? rect.x : rect.x + rect.width; + return [x, rect]; + } + public getLineEnd(pos: number | Point, right = true): UiLineEdge | undefined { const startPoint = this.point(pos); const startRect = this.getPointRect(startPoint, right); From becd4817881b05a775bd1a5110983f1d7f5064fa Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 29 Mar 2025 20:14:08 +0100 Subject: [PATCH 25/32] =?UTF-8?q?chore(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=A4=96=20log=20cursor=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index 892b22b3e2..651c874581 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -92,6 +92,10 @@ const DebugOverlay: React.FC = ({point}) => { const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); if (wordJumpRightPoint) wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); + const pos = ctx!.events.ui?.getPointX(point); + if (pos) { + console.log(pos[0]); + } }); return ( From 87edfe7740236bfed58e44ddaf8b9fcff4694e1b Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 30 Mar 2025 17:22:16 +0200 Subject: [PATCH 26/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20compute=20previous=20line=20caret=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/ui/UiHandle.ts | 27 +++++++++++++++++-- .../plugins/debug/RenderCaret/index.tsx | 13 ++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index 2b5ff22c7e..f1da55a78f 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -34,15 +34,38 @@ export class UiHandle { return this.api.getCharRect?.(id); } - public getPointX(pos: number | Point, right = true): [x: number, rect: Rect] | undefined { + public pointX(pos: number | Point): [x: number, rect: Rect] | undefined { const txt = this.txt; const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; - const rect = this.getPointRect(point, right); + const rect = this.getPointRect(point, point.anchor === Anchor.Before ? true : false); if (!rect) return; const x = point.anchor === Anchor.Before ? rect.x : rect.x + rect.width; return [x, rect]; } + public findPointAtRelX(relX: number, line: UiLineInfo): Point { + const lineRect = line[0][1]; + const lineX = lineRect.x; + let point = line[0][0].clone(); + let curr = point; + let bestDiff = 1e9; + const max = line[1][0].viewPos() - line[0][0].viewPos(); + if (!this.api.getCharRect) return point; + for (let i = 0; i < max; i++) { + const pointX = this.pointX(curr); + if (!pointX) break; + const [x] = pointX; + const currRelX = x - lineX; + const diff = Math.abs(currRelX - relX); + if (diff <= bestDiff) { + bestDiff = diff; + point = curr.clone(); + } else break; + curr.step(1); + } + return point; + } + public getLineEnd(pos: number | Point, right = true): UiLineEdge | undefined { const startPoint = this.point(pos); const startRect = this.getPointRect(startPoint, right); diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index 651c874581..f8b2b7a7fe 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -92,9 +92,16 @@ const DebugOverlay: React.FC = ({point}) => { const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); if (wordJumpRightPoint) wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); - const pos = ctx!.events.ui?.getPointX(point); - if (pos) { - console.log(pos[0]); + const pos = ctx!.events.ui?.pointX(point); + const currLine = ctx!.events.ui?.getLineInfo(point); + if (pos && currLine) { + const lineEdgeX = currLine[0][1].x; + const relX = pos[0] - lineEdgeX; + const prevLine = ctx!.events.ui?.getPrevLineInfo(currLine); + if (prevLine) { + const prevLinePoint = ctx!.events.ui?.findPointAtRelX(relX, prevLine); + console.log(prevLinePoint + ''); + } } }); From d0dad611f9d73512ef8a7851d470e31f090a4f9d Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 30 Mar 2025 17:31:08 +0200 Subject: [PATCH 27/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20render=20prev=20and=20next=20line=20caret=20dest?= =?UTF-8?q?ination=20in=20debug=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/debug/RenderCaret/index.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index f8b2b7a7fe..2978583fc1 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -69,6 +69,8 @@ const DebugOverlay: React.FC = ({point}) => { const rightNextLineEndCharRef = React.useRef(null); const wordSkipLeftCharRef = React.useRef(null); const wordSkipRightCharRef = React.useRef(null); + const prevLineCaretRef = React.useRef(null); + const nextLineCaretRef = React.useRef(null); const anchorLeft = point.anchor === Anchor.After; @@ -93,14 +95,30 @@ const DebugOverlay: React.FC = ({point}) => { if (wordJumpRightPoint) wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); const pos = ctx!.events.ui?.pointX(point); + const currLine = ctx!.events.ui?.getLineInfo(point); if (pos && currLine) { const lineEdgeX = currLine[0][1].x; const relX = pos[0] - lineEdgeX; const prevLine = ctx!.events.ui?.getPrevLineInfo(currLine); + const nextLine = ctx!.events.ui?.getNextLineInfo(currLine); if (prevLine) { const prevLinePoint = ctx!.events.ui?.findPointAtRelX(relX, prevLine); - console.log(prevLinePoint + ''); + if (point.anchor === Anchor.Before) prevLinePoint?.refBefore(); + else prevLinePoint?.refAfter(); + if (prevLinePoint) { + const rect = ctx!.events.ui?.api.getCharRect?.(prevLinePoint.id); + if (rect) prevLineCaretRef.current?.(rect); + } + } + if (nextLine) { + const prevLinePoint = ctx!.events.ui?.findPointAtRelX(relX, nextLine); + if (point.anchor === Anchor.Before) prevLinePoint?.refBefore(); + else prevLinePoint?.refAfter(); + if (prevLinePoint) { + const rect = ctx!.events.ui?.api.getCharRect?.(prevLinePoint.id); + if (rect) nextLineCaretRef.current?.(rect); + } } } }); @@ -125,6 +143,8 @@ const DebugOverlay: React.FC = ({point}) => { + + ); }; From 9d9d4f69012bbea6da819c919a11af4cc8aafb70 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 31 Mar 2025 13:19:08 +0200 Subject: [PATCH 28/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20'line-vert'=20cursor=20movement=20unit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/events/types.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index 72bbb397a2..ac89c7b962 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -156,10 +156,25 @@ export interface CursorDetail { * one word in the direction specified by `len`. If `'line'`, the cursor * will be moved to the beginning or end of line, in direction specified * by `len`. - * - * Defaults to `'char'`. + * + * - `'point'`: Moves by one Peritext anchor point. Each character has two + * anchor points, one from each side of the character. + * - `'char'`: Moves by one character. Skips one visible character. + * - `'word'`: Moves by one word. Skips all visible characters until the end + * of a word. + * - `'line'`: Moves to the beginning or end of line. If UI API is provided, + * the line end is determined by a visual line wrap. + * - `'line-vertical'`: Moves cursor up or down by one line, works if UI + * API is provided. Determines the best position in the target by finding + * the position which has the closest relative offset from the beginning + * of the line. + * - `'block'`: Moves to the beginning or end of block, i.e. paragraph, + * blockequote, etc. + * - `'all'`: Moves to the beginning or end of the document. + * + * @default 'char' */ - unit?: 'point' | 'char' | 'word' | 'line' | 'block' | 'all'; + unit?: 'point' | 'char' | 'word' | 'line' | 'line-vert' | 'block' | 'all'; /** * Specifies which edge of the selection to move. If `'focus'`, the focus From 779b8918da277cb44a89c26f1662d5ede3196afa Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 31 Mar 2025 14:17:08 +0200 Subject: [PATCH 29/32] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20EditorApi=20injectable=20for=20Edito?= =?UTF-8?q?r=20cursor=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 36 +++++++------- .../peritext/editor/types.ts | 31 +++++++++++- .../events/defaults/PeritextEventDefaults.ts | 48 ++++++++++++++----- src/json-crdt-peritext-ui/events/types.ts | 10 ++-- 4 files changed, 92 insertions(+), 33 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index ed81fd8e91..4d5e43f47e 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -21,7 +21,7 @@ import type {Peritext} from '../Peritext'; import type {ChunkSlice} from '../util/ChunkSlice'; import type {MarkerSlice} from '../slice/MarkerSlice'; import type {SliceRegistry} from '../registry/SliceRegistry'; -import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice} from './types'; +import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice, EditorUi} from './types'; /** * For inline boolean ("Overwrite") slices, both range endpoints should be @@ -468,7 +468,7 @@ export class Editor implements Printable { * @param unit The unit of move per step: "char", "word", "line", etc. * @returns The destination point after the move. */ - public skip(point: Point, steps: number, unit: TextRangeUnit): Point { + public skip(point: Point, steps: number, unit: TextRangeUnit, ui?: EditorUi): Point { if (!steps) return point; switch (unit) { case 'point': { @@ -485,10 +485,14 @@ export class Editor implements Printable { 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); + 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': { + if (!ui?.vert) return point; + 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); @@ -507,26 +511,26 @@ export class Editor implements Printable { * @param endpoint 0 for "focus", 1 for "anchor", 2 for both. * @param collapse Whether to collapse the range to a single point. */ - public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true, skipLine?: (point: Point, steps: number) => (Point | undefined)): void { + public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true, ui?: EditorUi): void { this.forCursor((cursor) => { switch (endpoint) { case 0: { let point = cursor.focus(); - point = (unit === 'line' ? skipLine?.(point, steps) : void 0) ?? this.skip(point, steps, unit); + 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 = (unit === 'line' ? skipLine?.(point, steps) : void 0) ?? this.skip(point, steps, unit); + point = this.skip(point, steps, unit, ui); if (collapse) cursor.set(point); else cursor.setEndpoint(point, 1); break; } case 2: { - const start = (unit === 'line' ? skipLine?.(cursor.start, steps) : void 0) ?? this.skip(cursor.start, steps, unit); - const end = collapse ? start.clone() : ((unit === 'line' ? skipLine?.(cursor.end, steps) : void 0) ?? this.skip(cursor.end, steps, unit)); + 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; } @@ -572,24 +576,24 @@ export class Editor implements Printable { * @param unit Unit of the range expansion. * @returns Range which contains the specified unit. */ - public range(point: Point, unit: TextRangeUnit): Range | undefined { + public range(point: Point, unit: TextRangeUnit, ui?: EditorUi): Range | undefined { if (unit === 'word') return this.rangeWord(point); - const point1 = this.skip(point, -1, unit); - const point2 = this.skip(point, 1, unit); + const point1 = this.skip(point, -1, unit, ui); + const point2 = this.skip(point, 1, unit, ui); return this.txt.range(point1, point2); } - public select(unit: TextRangeUnit): void { + public select(unit: TextRangeUnit, ui?: EditorUi): void { this.forCursor((cursor) => { - const range = this.range(cursor.start, unit); + const range = this.range(cursor.start, unit, ui); if (range) cursor.set(range.start, range.end, CursorAnchor.Start); else this.delCursors; }); } - public selectAt(at: Position, unit: TextRangeUnit | ''): void { + public selectAt(at: Position, unit: TextRangeUnit | '', ui?: EditorUi): void { this.cursor.set(this.point(at)); - if (unit) this.select(unit); + if (unit) this.select(unit, ui); } // --------------------------------------------------------------- formatting diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index 63bba807ca..d0c4b2cfe9 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -8,10 +8,39 @@ export type CharIterator = UndefIterator>; export type CharPredicate = (char: T) => boolean; export type Position = number | [at: number, anchor: 0 | 1] | Point; -export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'block' | 'all'; +export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'vert' | 'block' | 'all'; export type ViewRange = [text: string, textPosition: number, slices: ViewSlice[]]; export type ViewSlice = [header: number, x1: number, x2: number, type: SliceType, data?: unknown]; export type ViewStyle = [behavior: SliceBehavior, type: SliceType, data?: unknown]; + +export interface EditorUi { + /** + * Visually skips to the end or beginning of the line. Visually as in, it will + * respect the visual line breaks created by word wrapping. + * + * Skips just one line, regardless of the magnitude of the `steps` parameter. + * + * @param point The point from which to start skipping. + * @param steps The direction to skip. Positive for forward, negative for + * backward. Does not respect the magnitude of the steps, always performs + * one step. + * @returns The point after skipping the specified number of lines, or + * undefined if no such point exists. + */ + eol?(point: Point, steps: number): (Point | undefined); + + /** + * Used when user presses "ArrowUp" or "ArrowDown" keys. It will skip to the + * position in the next visual line, while trying to preserve the horizontal + * offset from the beginning of the line. + * + * @param point The point from which to start skipping. + * @param steps Number of lines to skip. + * @returns The point after skipping the specified number of lines, or + * undefined if no such point exists. + */ + vert?(point: Point, steps: number): Point | undefined; +} diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index ca9208b926..003fbc4108 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -9,6 +9,9 @@ import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; import type {UndoCollector} from '../../types'; import type {UiHandle} from './ui/UiHandle'; +import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; +import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; +import {EditorUi} from '../../../json-crdt-extensions/peritext/editor/types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -28,6 +31,35 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { public undo?: UndoCollector; public ui?: UiHandle; + protected editorUi: EditorUi = { + eol: (point: Point, steps: number): (Point | undefined) => { + const ui = this.ui; + if (!ui) return; + const res = ui.getLineEnd(point, steps > 0); + return res ? res[0] : void 0; + }, + vert: (point: Point, steps: number): Point | undefined => { + const ui = this.ui; + if (!ui) return; + const pos = ui.pointX(point); + if (!pos) return; + const currLine = ui.getLineInfo(point); + if (!currLine) return; + const lineEdgeX = currLine[0][1].x; + const relX = pos[0] - lineEdgeX; + let iterations = Math.abs(steps); + let nextPoint = point; + for (let i = 0; i < iterations; i++) { + const nextLine = steps > 0 ? ui.getNextLineInfo(currLine) : ui.getPrevLineInfo(currLine); + if (!nextLine) break; + nextPoint = ui.findPointAtRelX(relX, nextLine); + if (!nextPoint) break; + if (point.anchor === Anchor.Before) nextPoint.refBefore(); else nextPoint.refAfter(); + } + return point; + }, + }; + public constructor( public readonly txt: Peritext, public readonly et: PeritextEventTarget, @@ -84,7 +116,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { default: { // Select a range from the "at" position to the specified length. if (!!len && typeof len === 'number') { - const point2 = editor.skip(point, len, unit ?? 'char'); + const point2 = editor.skip(point, len, unit ?? 'char', this.editorUi); const range = txt.rangeFromPoints(point, point2); // Sorted range. editor.cursor.set(range.start, range.end, len < 0 ? CursorAnchor.End : CursorAnchor.Start); } @@ -92,7 +124,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { else { point.refAfter(); editor.cursor.set(point); - if (unit) editor.select(unit); + if (unit) editor.select(unit, this.editorUi); } } } @@ -102,20 +134,14 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { // If `edge` is specified. const isSpecificEdgeSelected = edge === 'focus' || edge === 'anchor'; if (isSpecificEdgeSelected) { - editor.move(len ?? 0, unit ?? 'char', edge === 'focus' ? 0 : 1, false); + editor.move(len ?? 0, unit ?? 'char', edge === 'focus' ? 0 : 1, false, this.editorUi); return; } // If `len` is specified. if (len) { const cursor = editor.cursor; - if (cursor.isCollapsed()) { - const ui = this.ui; - editor.move(len, unit ?? 'char', void 0, void 0, ui ? (point, steps) => { - const res = ui.getLineEnd(point, steps > 0); - return res ? res[0] : void 0; - } : void 0); - } + if (cursor.isCollapsed()) editor.move(len, unit ?? 'char', void 0, void 0, this.editorUi); else { if (len > 0) cursor.collapseToEnd(); else cursor.collapseToStart(); @@ -125,7 +151,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { // If `unit` is specified. if (unit) { - editor.select(unit); + editor.select(unit, this.editorUi); return; } }; diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index ac89c7b962..ae3d7fc2d0 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -164,17 +164,17 @@ export interface CursorDetail { * of a word. * - `'line'`: Moves to the beginning or end of line. If UI API is provided, * the line end is determined by a visual line wrap. - * - `'line-vertical'`: Moves cursor up or down by one line, works if UI - * API is provided. Determines the best position in the target by finding - * the position which has the closest relative offset from the beginning - * of the line. + * - `'vert'`: Moves cursor up or down by one line, works if UI + * API is provided. Determines the best position in the target line by + * finding the position which has the closest relative offset from the + * beginning of the line. * - `'block'`: Moves to the beginning or end of block, i.e. paragraph, * blockequote, etc. * - `'all'`: Moves to the beginning or end of the document. * * @default 'char' */ - unit?: 'point' | 'char' | 'word' | 'line' | 'line-vert' | 'block' | 'all'; + unit?: 'point' | 'char' | 'word' | 'line' | 'vert' | 'block' | 'all'; /** * Specifies which edge of the selection to move. If `'focus'`, the focus From 966c0175e23191b706f929dd9c099a041772001a Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 31 Mar 2025 15:06:05 +0200 Subject: [PATCH 30/32] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20wire=20in=20vertical=20cursor=20movement=20using?= =?UTF-8?q?=20keyboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/editor/Editor.ts | 3 +-- src/json-crdt-extensions/peritext/editor/types.ts | 5 +++++ src/json-crdt-peritext-ui/dom/CursorController.ts | 11 ++--------- .../events/defaults/PeritextEventDefaults.ts | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 4d5e43f47e..1f68138bbc 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -490,8 +490,7 @@ export class Editor implements Printable { return point; } case 'vert': { - if (!ui?.vert) return point; - return ui.vert(point, steps) || point; + return ui?.vert?.(point, steps) || point; } case 'block': { if (steps > 0) for (let i = 0; i < steps; i++) point = this.eob(point); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index d0c4b2cfe9..b2acf26559 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -16,6 +16,11 @@ export type ViewSlice = [header: number, x1: number, x2: number, type: SliceType export type ViewStyle = [behavior: SliceBehavior, type: SliceType, data?: unknown]; +/** + * UI API which can be injected during various methods of the editor. Used to + * perform editor function while taking into account the visual representation + * of the document, such as word wrapping. + */ export interface EditorUi { /** * Visually skips to the end or beginning of the line. Visually as in, it will diff --git a/src/json-crdt-peritext-ui/dom/CursorController.ts b/src/json-crdt-peritext-ui/dom/CursorController.ts index c97fa97586..e76afaae38 100644 --- a/src/json-crdt-peritext-ui/dom/CursorController.ts +++ b/src/json-crdt-peritext-ui/dom/CursorController.ts @@ -202,16 +202,9 @@ export class CursorController implements UiLifeCycles, Printable { switch (key) { case 'ArrowUp': case 'ArrowDown': { + event.preventDefault(); const direction = key === 'ArrowUp' ? -1 : 1; - const at = this.getNextLinePos(direction); - if (at !== undefined) { - event.preventDefault(); - if (event.shiftKey) { - et.cursor({at, edge: 'focus'}); - } else { - et.cursor({at}); - } - } + et.move(direction, 'vert', event.shiftKey ? 'focus' : 'both'); break; } case 'ArrowLeft': diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 003fbc4108..d52e58b72f 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -56,7 +56,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { if (!nextPoint) break; if (point.anchor === Anchor.Before) nextPoint.refBefore(); else nextPoint.refAfter(); } - return point; + return nextPoint; }, }; From 30c6e14029050981e7ee5f5d4b51d4649bb8b67d Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 31 Mar 2025 15:23:09 +0200 Subject: [PATCH 31/32] =?UTF-8?q?chore(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=A4=96=20remove=20unused=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/CursorController.ts | 42 +------------------ .../dom/DomController.ts | 4 ++ 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/CursorController.ts b/src/json-crdt-peritext-ui/dom/CursorController.ts index e76afaae38..2357de5f2e 100644 --- a/src/json-crdt-peritext-ui/dom/CursorController.ts +++ b/src/json-crdt-peritext-ui/dom/CursorController.ts @@ -5,7 +5,7 @@ import {ValueSyncStore} from '../../util/events/sync-store'; import type {Printable} from 'tree-dump'; import type {KeyController} from './KeyController'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; -import type {Rect, UiLifeCycles} from './types'; +import type {UiLifeCycles} from './types'; import type {Peritext} from '../../json-crdt-extensions/peritext'; import type {Inline} from '../../json-crdt-extensions/peritext/block/Inline'; @@ -55,46 +55,6 @@ export class CursorController implements UiLifeCycles, Printable { return -1; } - public caretRect(): Rect | undefined { - const el = document.getElementById(this.caretId); - if (!el) return; - const rect = el.getBoundingClientRect(); - return rect; - } - - /** - * Find text position at similar x coordinate on the next line. - * - * @param direction 1 for next line, -1 for previous line. - * @returns The position at similar x coordinate on the next line, or - * undefined if not found. - * - * @todo Implement similar functionality for finding soft line breaks (end - * and start of lines). Or use `.getClientRects()` trick with `Range` - * object, see: https://www.bennadel.com/blog/4310-detecting-rendered-line-breaks-in-a-text-node-in-javascript.htm - */ - public getNextLinePos(direction: 1 | -1 = 1): number | undefined { - // TODO: this works only for the main cursor, make it work for all cursors. - const rect = this.caretRect(); - if (!rect) return; - const {x, y, width, height} = rect; - const halfWidth = width / 2; - const halfHeight = height / 2; - const currentPos = this.opts.txt.editor.cursor.focus().viewPos(); - const caretPos = this.posAtPoint(x + halfWidth, y + halfHeight); - if (currentPos !== caretPos) return; - for (let i = 1; i < 16; i++) { - const dy = i * direction * halfHeight; - const pos = this.posAtPoint(x + halfWidth, y + dy); - if (pos !== -1 && pos !== caretPos) { - if (direction < 0) { - if (pos < caretPos) return pos; - } else if (pos > caretPos) return pos; - } - } - return undefined; - } - /** -------------------------------------------------- {@link UiLifeCycles} */ public start(): void { diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 6f14bc6f37..924c0463b3 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -107,6 +107,10 @@ export class DomController implements UiLifeCycles, Printable, PeritextUiApi { return rects[0]; } + public caretRect(): Rect | undefined { + return document.getElementById(this.cursor.caretId)?.getBoundingClientRect?.(); + } + /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { From 61f348b933b1eadbeeda6dc088083604625a9c40 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 31 Mar 2025 15:26:02 +0200 Subject: [PATCH 32/32] =?UTF-8?q?style:=20=F0=9F=92=84=20fix=20linter=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 19 ++++- .../peritext/editor/types.ts | 4 +- .../events/defaults/PeritextEventDefaults.ts | 11 +-- .../events/defaults/ui/UiHandle.ts | 10 +-- .../events/defaults/ui/types.ts | 11 +-- src/json-crdt-peritext-ui/events/types.ts | 4 +- .../plugins/cursor/RenderCaret.tsx | 2 +- .../plugins/debug/DebugPlugin.tsx | 4 +- .../plugins/debug/RenderBlock.tsx | 8 +- .../plugins/debug/RenderCaret/CharOverlay.tsx | 5 +- .../plugins/debug/RenderCaret/index.tsx | 79 +++++++++++++------ .../plugins/debug/RenderInline.tsx | 11 +-- .../plugins/debug/RenderPeritext.tsx | 36 ++++++--- .../plugins/toolbar/ToolbarPlugin.ts | 2 +- .../plugins/toolbar/TopToolbar/index.tsx | 12 ++- .../plugins/toolbar/state/index.tsx | 5 +- src/json-crdt-peritext-ui/react/BlockView.tsx | 3 +- 17 files changed, 142 insertions(+), 84 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 1f68138bbc..3c6c4b3ea7 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -21,7 +21,16 @@ import type {Peritext} from '../Peritext'; import type {ChunkSlice} from '../util/ChunkSlice'; import type {MarkerSlice} from '../slice/MarkerSlice'; import type {SliceRegistry} from '../registry/SliceRegistry'; -import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewStyle, ViewRange, ViewSlice, EditorUi} from './types'; +import type { + CharIterator, + CharPredicate, + Position, + TextRangeUnit, + ViewStyle, + ViewRange, + ViewSlice, + EditorUi, +} from './types'; /** * For inline boolean ("Overwrite") slices, both range endpoints should be @@ -510,7 +519,13 @@ export class Editor implements Printable { * @param endpoint 0 for "focus", 1 for "anchor", 2 for both. * @param collapse Whether to collapse the range to a single point. */ - public move(steps: number, unit: TextRangeUnit, endpoint: 0 | 1 | 2 = 0, collapse: boolean = true, ui?: EditorUi): void { + public move( + steps: number, + unit: TextRangeUnit, + endpoint: 0 | 1 | 2 = 0, + collapse: boolean = true, + ui?: EditorUi, + ): void { this.forCursor((cursor) => { switch (endpoint) { case 0: { diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index b2acf26559..b8d366c997 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -25,7 +25,7 @@ export interface EditorUi { /** * Visually skips to the end or beginning of the line. Visually as in, it will * respect the visual line breaks created by word wrapping. - * + * * Skips just one line, regardless of the magnitude of the `steps` parameter. * * @param point The point from which to start skipping. @@ -35,7 +35,7 @@ export interface EditorUi { * @returns The point after skipping the specified number of lines, or * undefined if no such point exists. */ - eol?(point: Point, steps: number): (Point | undefined); + eol?(point: Point, steps: number): Point | undefined; /** * Used when user presses "ArrowUp" or "ArrowDown" keys. It will skip to the diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index d52e58b72f..8360fa6d56 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -11,7 +11,7 @@ import type {UndoCollector} from '../../types'; import type {UiHandle} from './ui/UiHandle'; import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; -import {EditorUi} from '../../../json-crdt-extensions/peritext/editor/types'; +import type {EditorUi} from '../../../json-crdt-extensions/peritext/editor/types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -26,13 +26,13 @@ export interface PeritextEventDefaultsOpts { * {@link PeritextEventTarget} to provide default behavior for each event type. * If `event.preventDefault()` is called on a Peritext event, the default handler * will not be executed. -*/ + */ export class PeritextEventDefaults implements PeritextEventHandlerMap { public undo?: UndoCollector; public ui?: UiHandle; protected editorUi: EditorUi = { - eol: (point: Point, steps: number): (Point | undefined) => { + eol: (point: Point, steps: number): Point | undefined => { const ui = this.ui; if (!ui) return; const res = ui.getLineEnd(point, steps > 0); @@ -47,14 +47,15 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { if (!currLine) return; const lineEdgeX = currLine[0][1].x; const relX = pos[0] - lineEdgeX; - let iterations = Math.abs(steps); + const iterations = Math.abs(steps); let nextPoint = point; for (let i = 0; i < iterations; i++) { const nextLine = steps > 0 ? ui.getNextLineInfo(currLine) : ui.getPrevLineInfo(currLine); if (!nextLine) break; nextPoint = ui.findPointAtRelX(relX, nextLine); if (!nextPoint) break; - if (point.anchor === Anchor.Before) nextPoint.refBefore(); else nextPoint.refAfter(); + if (point.anchor === Anchor.Before) nextPoint.refBefore(); + else nextPoint.refAfter(); } return nextPoint; }, diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts index f1da55a78f..fb8c3ac5a3 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -37,7 +37,7 @@ export class UiHandle { public pointX(pos: number | Point): [x: number, rect: Rect] | undefined { const txt = this.txt; const point = typeof pos === 'number' ? txt.pointAt(pos) : pos; - const rect = this.getPointRect(point, point.anchor === Anchor.Before ? true : false); + const rect = this.getPointRect(point, point.anchor === Anchor.Before); if (!rect) return; const x = point.anchor === Anchor.Before ? rect.x : rect.x + rect.width; return [x, rect]; @@ -47,7 +47,7 @@ export class UiHandle { const lineRect = line[0][1]; const lineX = lineRect.x; let point = line[0][0].clone(); - let curr = point; + const curr = point; let bestDiff = 1e9; const max = line[1][0].viewPos() - line[0][0].viewPos(); if (!this.api.getCharRect) return point; @@ -83,7 +83,7 @@ export class UiHandle { return [curr, currRect]; }; while (true) { - const next = curr.copy(p => p.step(right ? 1 : -1)); + const next = curr.copy((p) => p.step(right ? 1 : -1)); if (!next) return prepareReturn(); const nextRect = this.getPointRect(next, right); if (!nextRect) return prepareReturn(); @@ -107,14 +107,14 @@ export class UiHandle { public getPrevLineInfo(line: UiLineInfo): UiLineInfo | undefined { const [[left]] = line; if (left.isStart()) return; - const point = left.copy(p => p.step(-1)); + const point = left.copy((p) => p.step(-1)); return this.getLineInfo(point); } public getNextLineInfo(line: UiLineInfo): UiLineInfo | undefined { const [, [right]] = line; if (right.viewPos() >= this.txt.str.length()) return; - const point = right.copy(p => p.step(1)); + const point = right.copy((p) => p.step(1)); return this.getLineInfo(point); } } diff --git a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts index f4bc0e1d3f..bd983cf1a4 100644 --- a/src/json-crdt-peritext-ui/events/defaults/ui/types.ts +++ b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts @@ -32,13 +32,6 @@ export interface PeritextUiApi { isRTL?(pos: number | Point): boolean; } -export type UiLineEdge = [ - point: Point, - rect: Rect, -]; - -export type UiLineInfo = [ - left: UiLineEdge, - right: UiLineEdge, -]; +export type UiLineEdge = [point: Point, rect: Rect]; +export type UiLineInfo = [left: UiLineEdge, right: UiLineEdge]; diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index ae3d7fc2d0..6beedaf65d 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -156,7 +156,7 @@ export interface CursorDetail { * one word in the direction specified by `len`. If `'line'`, the cursor * will be moved to the beginning or end of line, in direction specified * by `len`. - * + * * - `'point'`: Moves by one Peritext anchor point. Each character has two * anchor points, one from each side of the character. * - `'char'`: Moves by one character. Skips one visible character. @@ -171,7 +171,7 @@ export interface CursorDetail { * - `'block'`: Moves to the beginning or end of block, i.e. paragraph, * blockequote, etc. * - `'all'`: Moves to the beginning or end of the document. - * + * * @default 'char' */ unit?: 'point' | 'char' | 'word' | 'line' | 'vert' | 'block' | 'all'; diff --git a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx index 9bc8d2ffca..16a17def8c 100644 --- a/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx +++ b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx @@ -74,7 +74,7 @@ export const RenderCaret: React.FC = ({italic, point, children const {dom} = usePeritext(); const focus = useSyncStoreOpt(dom?.cursor.focus) || false; const plugin = useCursorPlugin(); - + const anchorForward = point.anchor === Anchor.Before; const score = plugin.score.value; diff --git a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx index 4d008e0ecb..79b4bf6228 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx @@ -24,5 +24,7 @@ export class DebugPlugin implements PeritextPlugin { ); - public readonly caret: PeritextPlugin['caret'] = (props, children) => {children}; + public readonly caret: PeritextPlugin['caret'] = (props, children) => ( + {children} + ); } diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx index 67d7934a20..19e70a4f86 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -30,12 +30,8 @@ export const RenderBlock: React.FC = ({block, hash, children}) return (
-
e.preventDefault()}> - - {block.path - .map((type) => formatType(type)) - .join('.')} - +
e.preventDefault()}> + {block.path.map((type) => formatType(type)).join('.')}
{children}
diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx index 0167928d37..40b19cbdf0 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx @@ -13,6 +13,7 @@ export const CharOverlay: React.FC = ({rectRef, ...rest}) => { useWindowSize(); useWindowScroll(); const ref = React.useRef(null); + // biome-ignore lint: lint/correctness/useExhaustiveDependencies React.useEffect(() => { (rectRef as any).current = (rect?: Rect) => { const span = ref.current; @@ -30,7 +31,5 @@ export const CharOverlay: React.FC = ({rectRef, ...rest}) => { }; }, []); - return ( - - ); + return ; }; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx index 2978583fc1..3f2acb9023 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -4,7 +4,7 @@ import {useDebugCtx} from '../context'; import {Anchor} from '../../../../json-crdt-extensions/peritext/rga/constants'; import {useSyncStore} from '../../../react/hooks'; import type {CaretViewProps} from '../../../react/cursor/CaretView'; -import {CharOverlay, SetRect} from './CharOverlay'; +import {CharOverlay, type SetRect} from './CharOverlay'; const blockClass = rule({ pos: 'relative', @@ -89,11 +89,9 @@ const DebugOverlay: React.FC = ({point}) => { rightNextLineEndCharRef.current?.(nextLineInfo?.[1][1]); } const wordJumpLeftPoint = ctx!.peritext.editor.skip(point, -1, 'word'); - if (wordJumpLeftPoint) - wordSkipLeftCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpLeftPoint, true)); + if (wordJumpLeftPoint) wordSkipLeftCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpLeftPoint, true)); const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); - if (wordJumpRightPoint) - wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); + if (wordJumpRightPoint) wordSkipRightCharRef.current?.(ctx!.events.ui?.getPointRect?.(wordJumpRightPoint, false)); const pos = ctx!.events.ui?.pointX(point); const currLine = ctx!.events.ui?.getLineInfo(point); @@ -125,26 +123,59 @@ const DebugOverlay: React.FC = ({point}) => { return ( <> - - + + - - - - - - - - - + + + + + + + + + ); }; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index acf17df771..90981dfeab 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -27,7 +27,7 @@ export const RenderInline: React.FC = (props) => { hasCursor = true; continue; } - tag = SliceTypeName[tag as any] ?? (tag + ''); + tag = SliceTypeName[tag as any] ?? tag + ''; tag = '<' + tag + '>'; tags.push(tag); } @@ -37,10 +37,11 @@ export const RenderInline: React.FC = (props) => { return ( {tags.length > 0 && ( - - - {tags.join(', ')} - + + {tags.join(', ')} )} {children} diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx index 20f70fb7c1..d47aa8b9cf 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderPeritext.tsx @@ -34,9 +34,18 @@ export interface RenderPeritextProps extends PeritextViewProps { ctx?: PeritextSurfaceState; } -export const RenderPeritext: React.FC = ({enabled: enabledProp = false, ctx, button, children}) => { +export const RenderPeritext: React.FC = ({ + enabled: enabledProp = false, + ctx, + button, + children, +}) => { const theme = useTheme(); - const enabled = React.useMemo(() => typeof enabledProp === 'boolean' ? new ValueSyncStore(enabledProp) : enabledProp, []); + // biome-ignore lint: lint/correctness/useExhaustiveDependencies + const enabled = React.useMemo( + () => (typeof enabledProp === 'boolean' ? new ValueSyncStore(enabledProp) : enabledProp), + [], + ); useSyncStore(enabled); React.useEffect(() => { if (typeof enabledProp === 'boolean') { @@ -48,7 +57,7 @@ export const RenderPeritext: React.FC = ({enabled: enabledP enabled.next(enabledProp.value); }); return () => unsubscribe(); - }, [enabledProp]); + }, [enabled, enabledProp]); const value = React.useMemo( () => ({ enabled, @@ -65,17 +74,20 @@ export const RenderPeritext: React.FC = ({enabled: enabledP return ( -
{ - switch (event.key) { - case 'D': { - if (event.ctrlKey) { - event.preventDefault(); - enabled.next(!enabled.getSnapshot()); +
{ + switch (event.key) { + case 'D': { + if (event.ctrlKey) { + event.preventDefault(); + enabled.next(!enabled.getSnapshot()); + } + break; } - break; } - } - }}> + }} + > {!!button && (
= ({ctx}) => { {!!toolbar.opts.debug && ( <> - {button('Debug', () => { - const debug = toolbar.opts.debug!; - debug!.next(!debug.value); - }, !!isDebugMode)} + {button( + 'Debug', + () => { + const debug = toolbar.opts.debug!; + debug!.next(!debug.value); + }, + !!isDebugMode, + )} )} diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx index c2db87cb3d..5cb88109d0 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/state/index.tsx @@ -17,7 +17,10 @@ export class ToolbarState implements UiLifeCyclesRender { public lastEventTs: number = 0; public readonly showInlineToolbar = new ValueSyncStore<[show: boolean, time: number]>([false, 0]); - constructor(public readonly surface: PeritextSurfaceState, public readonly opts: ToolbarPluginOpts) {} + constructor( + public readonly surface: PeritextSurfaceState, + public readonly opts: ToolbarPluginOpts, + ) {} /** ------------------------------------------- {@link UiLifeCyclesRender} */ diff --git a/src/json-crdt-peritext-ui/react/BlockView.tsx b/src/json-crdt-peritext-ui/react/BlockView.tsx index cd9735b8a3..15d3425b35 100644 --- a/src/json-crdt-peritext-ui/react/BlockView.tsx +++ b/src/json-crdt-peritext-ui/react/BlockView.tsx @@ -32,7 +32,8 @@ export const BlockView: React.FC = React.memo( const key = cursorStart.start.key() + '-a'; let element: React.ReactNode; if (cursorStart.isStartFocused()) { - if (cursorStart.isCollapsed()) element = ; + if (cursorStart.isCollapsed()) + element = ; else { const isItalic = italic instanceof InlineAttrEnd || italic instanceof InlineAttrPassing; element = ;