openpencil/packages/core/src/text-editor.ts
Danila Poyarkov f90e1400b7 P2P collaboration: cursors, follow mode, cleanup
- Broadcast cursor position from canvas mouse move via awareness
- Broadcast selection changes via reactive watcher on selectedIds
- Follow mode: click peer avatar to track their viewport (pan + zoom)
- Zoom broadcast via watcher on store.state.zoom, not just mouse move
- Figma-style cursor arrows: colored fill, white border, name pill
- Stale cursor cleanup: removeAwarenessStates on peer leave
- MQTT signaling (replace Nostr), STUN + TURN ICE servers
- Collab constants extracted to src/constants.ts
- crypto.getRandomValues() replaces Math.random() everywhere
- Provide/inject for collab composable (COLLAB_KEY)
- Update README (collab section, tech stack), CHANGELOG, AGENTS.md
- AGENTS.md: release process, CI workflows, documentation guidelines
2026-03-01 18:11:07 +03:00

350 lines
9.2 KiB
TypeScript

import type { SkiaRenderer } from './renderer'
import type { SceneNode } from './scene-graph'
import type { Rect } from './types'
import type { CanvasKit, Paragraph } from 'canvaskit-wasm'
export interface TextCaret {
x: number
y0: number
y1: number
}
export interface TextEditorState {
nodeId: string
text: string
cursor: number
selectionAnchor: number | null
paragraph: Paragraph | null
}
export class TextEditor {
private ck: CanvasKit
private renderer: SkiaRenderer | null = null
private _state: TextEditorState | null = null
caretVisible = true
constructor(ck: CanvasKit) {
this.ck = ck
}
private prepareMove(extend: boolean): TextEditorState | null {
const s = this._state
if (!s) return null
if (extend && s.selectionAnchor === null) s.selectionAnchor = s.cursor
if (!extend) s.selectionAnchor = null
return s
}
get state(): TextEditorState | null {
return this._state
}
get isActive(): boolean {
return this._state !== null
}
get nodeId(): string | null {
return this._state?.nodeId ?? null
}
setRenderer(renderer: SkiaRenderer | null): void {
this.renderer = renderer
}
start(node: SceneNode): void {
this._state = {
nodeId: node.id,
text: node.text,
cursor: node.text.length,
selectionAnchor: null,
paragraph: null
}
this.rebuildParagraph(node)
}
stop(): { nodeId: string; text: string } | null {
if (!this._state) return null
const result = { nodeId: this._state.nodeId, text: this._state.text }
this._state.paragraph?.delete()
this._state = null
return result
}
rebuildParagraph(node: SceneNode): void {
const s = this._state
if (!s || !this.renderer) return
s.paragraph?.delete()
s.paragraph = this.renderer.buildParagraph(node)
}
hasSelection(): boolean {
const s = this._state
return s !== null && s.selectionAnchor !== null && s.selectionAnchor !== s.cursor
}
getSelectionRange(): [number, number] | null {
const s = this._state
if (!s || s.selectionAnchor === null || s.selectionAnchor === s.cursor) return null
const lo = Math.min(s.cursor, s.selectionAnchor)
const hi = Math.max(s.cursor, s.selectionAnchor)
return [lo, hi]
}
getSelectedText(): string {
const range = this.getSelectionRange()
if (!range || !this._state) return ''
return this._state.text.slice(range[0], range[1])
}
selectAll(): void {
const s = this._state
if (!s) return
s.selectionAnchor = 0
s.cursor = s.text.length
}
selectWord(pos: number): void {
const s = this._state
if (!s) return
const text = s.text
let start = pos
let end = pos
while (start > 0 && !isWordBoundary(text[start - 1])) start--
while (end < text.length && !isWordBoundary(text[end])) end++
s.selectionAnchor = start
s.cursor = end
}
setCursorAt(x: number, y: number, extend = false): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
if (extend) {
if (s.selectionAnchor === null) s.selectionAnchor = s.cursor
} else {
s.selectionAnchor = null
}
s.cursor = pos
}
selectLine(pos: number): void {
const s = this._state
if (!s?.paragraph) return
const lineNum = s.paragraph.getLineNumberAt(pos)
if (lineNum < 0) return
const metrics = s.paragraph.getLineMetricsAt(lineNum)
if (!metrics) return
s.selectionAnchor = metrics.startIndex
s.cursor = metrics.endExcludingWhitespaces
}
selectWordAt(x: number, y: number): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
this.selectWord(pos)
}
selectLineAt(x: number, y: number): void {
const s = this._state
if (!s?.paragraph) return
const pos = s.paragraph.getGlyphPositionAtCoordinate(x, y).pos
this.selectLine(pos)
}
insert(text: string, node: SceneNode): void {
const s = this._state
if (!s) return
const range = this.getSelectionRange()
if (range) {
s.text = s.text.slice(0, range[0]) + text + s.text.slice(range[1])
s.cursor = range[0] + text.length
} else {
s.text = s.text.slice(0, s.cursor) + text + s.text.slice(s.cursor)
s.cursor += text.length
}
s.selectionAnchor = null
this.rebuildParagraph(node)
}
backspace(node: SceneNode): void {
const s = this._state
if (!s) return
const range = this.getSelectionRange()
if (range) {
s.text = s.text.slice(0, range[0]) + s.text.slice(range[1])
s.cursor = range[0]
} else if (s.cursor > 0) {
s.text = s.text.slice(0, s.cursor - 1) + s.text.slice(s.cursor)
s.cursor--
}
s.selectionAnchor = null
this.rebuildParagraph(node)
}
delete(node: SceneNode): void {
const s = this._state
if (!s) return
const range = this.getSelectionRange()
if (range) {
s.text = s.text.slice(0, range[0]) + s.text.slice(range[1])
s.cursor = range[0]
} else if (s.cursor < s.text.length) {
s.text = s.text.slice(0, s.cursor) + s.text.slice(s.cursor + 1)
}
s.selectionAnchor = null
this.rebuildParagraph(node)
}
moveLeft(extend = false): void {
const s = this._state
if (!s) return
if (!extend && this.hasSelection()) {
const range = this.getSelectionRange()
if (range) s.cursor = range[0]
s.selectionAnchor = null
return
}
this.prepareMove(extend)
if (s.cursor > 0) s.cursor--
}
moveRight(extend = false): void {
const s = this._state
if (!s) return
if (!extend && this.hasSelection()) {
const range = this.getSelectionRange()
if (range) s.cursor = range[1]
s.selectionAnchor = null
return
}
this.prepareMove(extend)
if (s.cursor < s.text.length) s.cursor++
}
moveUp(extend = false): void {
const s = this._state
if (!s?.paragraph) return
this.prepareMove(extend)
const caret = this.getCaretRect()
if (!caret) return
const fontSize = s.paragraph.getLineMetrics()[0]?.height ?? 14
s.cursor = s.paragraph.getGlyphPositionAtCoordinate(caret.x, caret.y0 - fontSize / 2).pos
}
moveDown(extend = false): void {
const s = this._state
if (!s?.paragraph) return
this.prepareMove(extend)
const caret = this.getCaretRect()
if (!caret) return
const fontSize = s.paragraph.getLineMetrics()[0]?.height ?? 14
s.cursor = s.paragraph.getGlyphPositionAtCoordinate(caret.x, caret.y1 + fontSize / 2).pos
}
moveToLineStart(extend = false): void {
const s = this._state
if (!s?.paragraph) return
this.prepareMove(extend)
const lineNum = s.paragraph.getLineNumberAt(s.cursor)
if (lineNum < 0) return
const metrics = s.paragraph.getLineMetricsAt(lineNum)
if (metrics) s.cursor = metrics.startIndex
}
moveToLineEnd(extend = false): void {
const s = this._state
if (!s?.paragraph) return
this.prepareMove(extend)
const lineNum = s.paragraph.getLineNumberAt(s.cursor)
if (lineNum < 0) return
const metrics = s.paragraph.getLineMetricsAt(lineNum)
if (metrics) s.cursor = metrics.endExcludingWhitespaces
}
moveWordLeft(extend = false): void {
const s = this.prepareMove(extend)
if (!s) return
let pos = s.cursor
while (pos > 0 && isWordBoundary(s.text[pos - 1])) pos--
while (pos > 0 && !isWordBoundary(s.text[pos - 1])) pos--
s.cursor = pos
}
moveWordRight(extend = false): void {
const s = this.prepareMove(extend)
if (!s) return
let pos = s.cursor
while (pos < s.text.length && !isWordBoundary(s.text[pos])) pos++
while (pos < s.text.length && isWordBoundary(s.text[pos])) pos++
s.cursor = pos
}
getCaretRect(): TextCaret | null {
const s = this._state
if (!s?.paragraph) return null
const text = s.text
const cursor = s.cursor
if (text.length === 0) {
const metrics = s.paragraph.getLineMetrics()
if (metrics.length === 0) return null
const line = metrics[0]
return { x: line.left, y0: 0, y1: line.height }
}
let lo: number
let hi: number
let useRight = false
if (cursor === 0) {
lo = 0
hi = 1
} else if (cursor >= text.length) {
lo = text.length - 1
hi = text.length
useRight = true
} else {
lo = cursor
hi = cursor + 1
}
const rects = s.paragraph.getRectsForRange(
lo,
hi,
this.ck.RectHeightStyle.Max,
this.ck.RectWidthStyle.Tight
)
if (rects.length === 0) return null
const [left, top, right, bottom] = rects[0].rect
return {
x: useRight ? right : left,
y0: top,
y1: bottom
}
}
getSelectionRects(): Rect[] {
const s = this._state
if (!s?.paragraph) return []
const range = this.getSelectionRange()
if (!range) return []
const rects = s.paragraph.getRectsForRange(
range[0],
range[1],
this.ck.RectHeightStyle.Max,
this.ck.RectWidthStyle.Tight
)
return rects.map((r) => {
const [left, top, right, bottom] = r.rect
return { x: left, y: top, width: right - left, height: bottom - top }
})
}
}
function isWordBoundary(ch: string): boolean {
return /\s|[.,;:!?()[\]{}"'`<>/\\|@#$%^&*~+=\-_]/.test(ch)
}