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-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 83cda97f25..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} 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 +477,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 +494,13 @@ 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': { + 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 +519,32 @@ 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, + ui?: EditorUi, + ): void { this.forCursor((cursor) => { switch (endpoint) { case 0: { let point = cursor.focus(); - point = 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 = 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 = this.skip(cursor.start, steps, unit); - const end = collapse ? start.clone() : 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 +590,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..b8d366c997 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -8,10 +8,44 @@ 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]; + +/** + * 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 + * 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-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/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index 987e35e09d..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({enabled: false}); + const debugPlugin = new DebugPlugin({enabled: debugEnabled}); return [cursorPlugin, toolbarPlugin, blocksPlugin, debugPlugin]; }, []); 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..bc74df002c --- /dev/null +++ b/src/json-crdt-peritext-ui/components/DebugLabel/index.tsx @@ -0,0 +1,48 @@ +// 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, + d: 'flex', + ai: 'center', + fz: '9px', + pd: '0 4px', + mr: '-1px', + bdrad: '10px', + bg: 'rgba(0,0,0)', + lh: '14px', + h: '14px', + col: 'white', + bd: '1px solid #fff', +}); + +const labelSecondClass = rule({ + ...theme.font.mono.bold, + d: 'flex', + fz: '8px', + mr: '2px -2px 2px 4px', + pd: '0 4px', + bdrad: '10px', + bg: 'rgba(255,255,255)', + lh: '10px', + h: '10px', + col: '#000', +}); + +export interface DebugLabelProps { + right?: React.ReactNode; + small?: boolean; + children?: React.ReactNode; +} + +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/dom/CursorController.ts b/src/json-crdt-peritext-ui/dom/CursorController.ts index c6b48c0ea4..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,45 +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 { - 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 { @@ -201,16 +162,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': @@ -219,6 +173,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/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 588372080f..924c0463b3 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -5,10 +5,16 @@ import {RichTextController} from './RichTextController'; 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 {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types'; +import type {Rect, UiLifeCycles} from '../dom/types'; import type {Log} from '../../json-crdt/log/Log'; +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; @@ -16,7 +22,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; @@ -57,12 +63,54 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.annals.stop(); } - /** ----------------------------------- {@link PeritextRenderingSurfaceApi} */ + /** ------------------------------------------------- {@link PeritextUiApi} */ public focus(): void { 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(char: number | ITimestampStruct): Rect | undefined { + const txt = this.opts.events.txt; + 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; + if (!textNode) return; + const range = document.createRange(); + range.selectNode(textNode); + 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(); + return rects[0]; + } + + public caretRect(): Rect | undefined { + return document.getElementById(this.cursor.caretId)?.getBoundingClientRect?.(); + } + /** ----------------------------------------------------- {@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..2d04db1976 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -19,10 +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; -} diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index dab2f26fc4..8360fa6d56 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -8,6 +8,10 @@ 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 {UiHandle} from './ui/UiHandle'; +import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; +import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; +import type {EditorUi} from '../../../json-crdt-extensions/peritext/editor/types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -25,6 +29,37 @@ export interface PeritextEventDefaultsOpts { */ 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; + 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(); + } + return nextPoint; + }, + }; public constructor( public readonly txt: Peritext, @@ -82,7 +117,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); } @@ -90,7 +125,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); } } } @@ -100,14 +135,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()) editor.move(len, unit ?? 'char'); + if (cursor.isCollapsed()) editor.move(len, unit ?? 'char', void 0, void 0, this.editorUi); else { if (len > 0) cursor.collapseToEnd(); else cursor.collapseToStart(); @@ -117,7 +152,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/defaults/ui/UiHandle.ts b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts new file mode 100644 index 0000000000..fb8c3ac5a3 --- /dev/null +++ b/src/json-crdt-peritext-ui/events/defaults/ui/UiHandle.ts @@ -0,0 +1,120 @@ +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'; +import type {PeritextUiApi, UiLineEdge, UiLineInfo} from './types'; + +export class UiHandle { + constructor( + public readonly txt: Peritext, + public readonly api: PeritextUiApi, + ) {} + + protected point(pos: number | Point): Point { + 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 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); + 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(); + const 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); + if (!startRect) return; + let curr = startPoint.clone(); + let currRect = startRect; + const prepareReturn = (): UiLineEdge => { + 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.getPointRect(next, right); + if (!nextRect) return prepareReturn(); + if (right ? nextRect.x < currRect.x : nextRect.x > currRect.x) return prepareReturn(); + curr = next; + 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]; + } + + 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/events/defaults/ui/types.ts b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts new file mode 100644 index 0000000000..bd983cf1a4 --- /dev/null +++ b/src/json-crdt-peritext-ui/events/defaults/ui/types.ts @@ -0,0 +1,37 @@ +import type {Point} from '../../../../json-crdt-extensions/peritext/rga/Point'; +import type {ITimestampStruct} from '../../../../json-crdt-patch'; +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 on the screen of a specific character. + * + * @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?(char: number | ITimestampStruct): Rect | undefined; + + /** + * Returns `true` if text at the given position has right-to-left direction. + */ + 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/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index f7e2011623..6beedaf65d 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -157,9 +157,24 @@ export interface CursorDetail { * 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. + * - `'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?: 'char' | 'word' | 'line' | 'block' | 'all'; + unit?: 'point' | 'char' | 'word' | 'line' | 'vert' | 'block' | 'all'; /** * Specifies which edge of the selection to move. If `'focus'`, the focus @@ -347,6 +362,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/cursor/RenderCaret.tsx b/src/json-crdt-peritext-ui/plugins/cursor/RenderCaret.tsx index 4d53fb5bce..16a17def8c 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: '2px', + h: '2px', + 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); @@ -61,6 +75,8 @@ export const RenderCaret: React.FC = ({italic, children}) => { 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/DebugPlugin.tsx b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx index 387ed0c9b9..79b4bf6228 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/DebugPlugin.tsx @@ -3,8 +3,9 @@ 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; +export interface DebugPluginOpts extends Pick {} export class DebugPlugin implements PeritextPlugin { constructor(protected readonly opts: DebugPluginOpts = {}) {} @@ -22,4 +23,8 @@ export class DebugPlugin implements PeritextPlugin { {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 cfc083cdc0..19e70a4f86 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderBlock.tsx @@ -1,12 +1,18 @@ // 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 {useSyncStore} from '../../react/hooks'; import type {BlockViewProps} from '../../react/BlockView'; -import {CommonSliceType} from '../../../json-crdt-extensions'; -const blockClass = drule({ - pos: 'relative', +const labelContainerClass = rule({ + pos: 'absolute', + top: '-8px', + left: '-4px', + us: 'none', + pe: 'none', }); export interface RenderBlockProps extends BlockViewProps { @@ -14,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; @@ -22,31 +29,9 @@ export const RenderBlock: React.FC = ({block, hash, children}) if (isRoot) return children; return ( -
-
- - {hash.toString(36)}{' '} - {block.path - .map((type) => (typeof type === 'number' ? `<${CommonSliceType[type] ?? type}>` : `<${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 new file mode 100644 index 0000000000..40b19cbdf0 --- /dev/null +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/CharOverlay.tsx @@ -0,0 +1,35 @@ +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); + // biome-ignore lint: lint/correctness/useExhaustiveDependencies + 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 ; +}; 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..3f2acb9023 --- /dev/null +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderCaret/index.tsx @@ -0,0 +1,181 @@ +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, type 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 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); + const prevLineCaretRef = React.useRef(null); + const nextLineCaretRef = React.useRef(null); + + const anchorLeft = point.anchor === Anchor.After; + + React.useEffect(() => { + 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]); + 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?.getPointRect?.(wordJumpLeftPoint, true)); + const wordJumpRightPoint = ctx!.peritext.editor.skip(point, 1, 'word'); + 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); + 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); + } + } + } + }); + + return ( + <> + + + + + + + + + + + + + + ); +}; diff --git a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx index 56747da426..90981dfeab 100644 --- a/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx +++ b/src/json-crdt-peritext-ui/plugins/debug/RenderInline.tsx @@ -1,16 +1,50 @@ // 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 {useSyncStore} from '../../react/hooks'; import type {InlineViewProps} from '../../react/InlineView'; + export interface RenderInlineProps extends InlineViewProps { children: React.ReactNode; } export const RenderInline: React.FC = (props) => { - const {children} = props; - const {enabled} = useDebugCtx(); + const {children, inline} = props; + const ctx = useDebugCtx(); + const enabled = useSyncStore(ctx.enabled); + + 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; + continue; + } + tag = SliceTypeName[tag as any] ?? tag + ''; + tag = '<' + tag + '>'; + tags.push(tag); + } if (!enabled) return children; - return {children}; + return ( + + {tags.length > 0 && ( + + {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 66afd28757..d47aa8b9cf 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,36 @@ 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); + // 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') { + enabled.next(enabledProp); + return () => {}; + } + enabled.next(enabledProp.value); + const unsubscribe = enabledProp.subscribe(() => { + enabled.next(enabledProp.value); + }); + return () => unsubscribe(); + }, [enabled, enabledProp]); const value = React.useMemo( () => ({ enabled, @@ -51,18 +74,33 @@ 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/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..a65e096de5 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..3e3b2701e6 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,19 @@ 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..5cb88109d0 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,22 @@ 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} */ diff --git a/src/json-crdt-peritext-ui/react/BlockView.tsx b/src/json-crdt-peritext-ui/react/BlockView.tsx index 830bd1e197..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 = ; @@ -50,7 +51,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/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index d2a23f701c..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, @@ -77,12 +78,15 @@ 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.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}); + const txt = state.peritext; + const uiHandle = new UiHandle(txt, newDom); + state.events.ui = uiHandle; + state.events.undo = newDom.annals; + newDom.start(); + state.dom = newDom; + setDom(newDom); + newDom.et.addEventListener('change', rerender); }, [peritext, state], ); 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) => {