@hypen-space/web 0.4.46 → 0.4.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/canvas/dirty.d.ts +40 -0
- package/dist/canvas/dirty.js +100 -0
- package/dist/canvas/dirty.js.map +1 -0
- package/dist/canvas/events.d.ts +5 -0
- package/dist/canvas/events.js +30 -3
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/index.d.ts +5 -1
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.d.ts +12 -2
- package/dist/canvas/layout.js +593 -63
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +2 -0
- package/dist/canvas/paint.js +88 -11
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +16 -0
- package/dist/canvas/renderer.js +154 -15
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +82 -0
- package/dist/canvas/scroll.js +639 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/selection.d.ts +63 -0
- package/dist/canvas/selection.js +466 -0
- package/dist/canvas/selection.js.map +1 -0
- package/dist/canvas/text.d.ts +6 -3
- package/dist/canvas/text.js +82 -43
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +18 -0
- package/dist/canvas/utils.d.ts +8 -2
- package/dist/canvas/utils.js +42 -16
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/virtualize.d.ts +25 -0
- package/dist/canvas/virtualize.js +65 -0
- package/dist/canvas/virtualize.js.map +1 -0
- package/dist/dom/applicators/index.js +0 -1
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/applicators/margin.js +58 -17
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +42 -17
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/components/icon.d.ts +9 -0
- package/dist/dom/components/icon.js +67 -0
- package/dist/dom/components/icon.js.map +1 -0
- package/dist/dom/components/index.js +2 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/renderer.d.ts +19 -5
- package/dist/dom/renderer.js +147 -17
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +4 -2
- package/src/canvas/QUICKSTART.md +5 -5
- package/src/canvas/dirty.ts +116 -0
- package/src/canvas/events.ts +34 -3
- package/src/canvas/index.ts +5 -0
- package/src/canvas/layout.ts +653 -100
- package/src/canvas/paint.ts +109 -11
- package/src/canvas/renderer.ts +189 -16
- package/src/canvas/scroll.ts +729 -0
- package/src/canvas/selection.ts +560 -0
- package/src/canvas/text.ts +97 -54
- package/src/canvas/types.ts +26 -0
- package/src/canvas/utils.ts +47 -17
- package/src/canvas/virtualize.ts +81 -0
- package/src/dom/applicators/index.ts +1 -1
- package/src/dom/applicators/margin.ts +57 -11
- package/src/dom/applicators/padding.ts +45 -11
- package/src/dom/components/icon.ts +84 -0
- package/src/dom/components/index.ts +2 -0
- package/src/dom/renderer.ts +155 -17
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Selection System
|
|
3
|
+
*
|
|
4
|
+
* Enables click-to-place-cursor, drag-to-select, double-click-word,
|
|
5
|
+
* triple-click-line, and Ctrl/Cmd+C copy for canvas-rendered text.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { VirtualNode, Point, FontStyle, TextMetrics } from "./types.js";
|
|
9
|
+
import { getScrollAwareBounds } from "./scroll.js";
|
|
10
|
+
import { measureText } from "./text.js";
|
|
11
|
+
import { createFontString } from "./utils.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A caret position within a text node. */
|
|
18
|
+
export interface TextPosition {
|
|
19
|
+
/** The VirtualNode (must be type "text") */
|
|
20
|
+
nodeId: string;
|
|
21
|
+
/** Index into the resolved text string */
|
|
22
|
+
offset: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Active selection range (anchor → focus, may be reversed). */
|
|
26
|
+
export interface TextSelection {
|
|
27
|
+
anchor: TextPosition;
|
|
28
|
+
focus: TextPosition;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolved geometry for painting a selection highlight on one line. */
|
|
32
|
+
export interface SelectionRect {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Selection state
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const HIGHLIGHT_COLOR = "rgba(59, 130, 246, 0.35)";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// SelectionManager
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export class SelectionManager {
|
|
50
|
+
private canvas: HTMLCanvasElement;
|
|
51
|
+
private rootNode: VirtualNode | null = null;
|
|
52
|
+
private requestRedraw: () => void;
|
|
53
|
+
|
|
54
|
+
/** Current selection (null = nothing selected). */
|
|
55
|
+
selection: TextSelection | null = null;
|
|
56
|
+
|
|
57
|
+
// Drag tracking
|
|
58
|
+
private dragging = false;
|
|
59
|
+
private clickCount = 0;
|
|
60
|
+
private lastClickTime = 0;
|
|
61
|
+
private lastClickNodeId = "";
|
|
62
|
+
|
|
63
|
+
// Bound handlers
|
|
64
|
+
private boundMouseDown!: (e: MouseEvent) => void;
|
|
65
|
+
private boundMouseMove!: (e: MouseEvent) => void;
|
|
66
|
+
private boundMouseUp!: (e: MouseEvent) => void;
|
|
67
|
+
private boundKeyDown!: (e: KeyboardEvent) => void;
|
|
68
|
+
|
|
69
|
+
constructor(canvas: HTMLCanvasElement, requestRedraw: () => void) {
|
|
70
|
+
this.canvas = canvas;
|
|
71
|
+
this.requestRedraw = requestRedraw;
|
|
72
|
+
this.setupListeners();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setRootNode(node: VirtualNode | null): void {
|
|
76
|
+
this.rootNode = node;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
// Event wiring
|
|
81
|
+
// -------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
private setupListeners(): void {
|
|
84
|
+
this.boundMouseDown = this.onMouseDown.bind(this);
|
|
85
|
+
this.boundMouseMove = this.onMouseMove.bind(this);
|
|
86
|
+
this.boundMouseUp = this.onMouseUp.bind(this);
|
|
87
|
+
this.boundKeyDown = this.onKeyDown.bind(this);
|
|
88
|
+
|
|
89
|
+
this.canvas.addEventListener("mousedown", this.boundMouseDown);
|
|
90
|
+
this.canvas.addEventListener("mousemove", this.boundMouseMove);
|
|
91
|
+
this.canvas.addEventListener("mouseup", this.boundMouseUp);
|
|
92
|
+
// keydown on window so it works even without canvas focus
|
|
93
|
+
(typeof window !== "undefined" ? window : this.canvas).addEventListener(
|
|
94
|
+
"keydown",
|
|
95
|
+
this.boundKeyDown as EventListener,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
// Mouse handlers
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
private canvasPoint(e: MouseEvent): Point {
|
|
104
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
105
|
+
const scaleX = this.canvas.width / rect.width;
|
|
106
|
+
const scaleY = this.canvas.height / rect.height;
|
|
107
|
+
return {
|
|
108
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
109
|
+
y: (e.clientY - rect.top) * scaleY,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private onMouseDown(e: MouseEvent): void {
|
|
114
|
+
if (e.button !== 0) return; // left-click only
|
|
115
|
+
const point = this.canvasPoint(e);
|
|
116
|
+
const hit = this.hitTestText(point);
|
|
117
|
+
|
|
118
|
+
if (!hit) {
|
|
119
|
+
if (this.selection) {
|
|
120
|
+
this.selection = null;
|
|
121
|
+
this.requestRedraw();
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Detect multi-click (double/triple)
|
|
127
|
+
const now = performance.now();
|
|
128
|
+
if (
|
|
129
|
+
now - this.lastClickTime < 400 &&
|
|
130
|
+
this.lastClickNodeId === hit.nodeId
|
|
131
|
+
) {
|
|
132
|
+
this.clickCount++;
|
|
133
|
+
} else {
|
|
134
|
+
this.clickCount = 1;
|
|
135
|
+
}
|
|
136
|
+
this.lastClickTime = now;
|
|
137
|
+
this.lastClickNodeId = hit.nodeId;
|
|
138
|
+
|
|
139
|
+
if (this.clickCount === 2) {
|
|
140
|
+
// Double-click: select word
|
|
141
|
+
this.selectWord(hit);
|
|
142
|
+
} else if (this.clickCount >= 3) {
|
|
143
|
+
// Triple-click: select entire text node
|
|
144
|
+
this.selectAll(hit.nodeId);
|
|
145
|
+
this.clickCount = 0;
|
|
146
|
+
} else {
|
|
147
|
+
// Single click: place caret, start drag
|
|
148
|
+
this.selection = { anchor: hit, focus: { ...hit } };
|
|
149
|
+
this.dragging = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.requestRedraw();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private onMouseMove(e: MouseEvent): void {
|
|
156
|
+
if (!this.dragging || !this.selection) return;
|
|
157
|
+
const point = this.canvasPoint(e);
|
|
158
|
+
const hit = this.hitTestText(point);
|
|
159
|
+
if (hit && hit.nodeId === this.selection.anchor.nodeId) {
|
|
160
|
+
this.selection.focus = hit;
|
|
161
|
+
this.requestRedraw();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private onMouseUp(_e: MouseEvent): void {
|
|
166
|
+
this.dragging = false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
// Keyboard (copy)
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
private onKeyDown(e: KeyboardEvent): void {
|
|
174
|
+
const isCopy =
|
|
175
|
+
(e.ctrlKey || e.metaKey) && e.key === "c";
|
|
176
|
+
const isSelectAll =
|
|
177
|
+
(e.ctrlKey || e.metaKey) && e.key === "a";
|
|
178
|
+
|
|
179
|
+
if (isCopy && this.selection) {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
const text = this.getSelectedText();
|
|
182
|
+
if (text && typeof navigator !== "undefined" && navigator.clipboard) {
|
|
183
|
+
navigator.clipboard.writeText(text).catch(() => {
|
|
184
|
+
// Fallback: execCommand (deprecated but works in more contexts)
|
|
185
|
+
fallbackCopy(text);
|
|
186
|
+
});
|
|
187
|
+
} else if (text) {
|
|
188
|
+
fallbackCopy(text);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (isSelectAll && this.selection) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
this.selectAll(this.selection.anchor.nodeId);
|
|
195
|
+
this.requestRedraw();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
// Selection logic
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
private selectWord(pos: TextPosition): void {
|
|
204
|
+
const node = this.findNodeById(pos.nodeId);
|
|
205
|
+
if (!node) return;
|
|
206
|
+
const text = resolveText(node);
|
|
207
|
+
const start = wordBoundaryBefore(text, pos.offset);
|
|
208
|
+
const end = wordBoundaryAfter(text, pos.offset);
|
|
209
|
+
this.selection = {
|
|
210
|
+
anchor: { nodeId: pos.nodeId, offset: start },
|
|
211
|
+
focus: { nodeId: pos.nodeId, offset: end },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private selectAll(nodeId: string): void {
|
|
216
|
+
const node = this.findNodeById(nodeId);
|
|
217
|
+
if (!node) return;
|
|
218
|
+
const text = resolveText(node);
|
|
219
|
+
this.selection = {
|
|
220
|
+
anchor: { nodeId, offset: 0 },
|
|
221
|
+
focus: { nodeId, offset: text.length },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getSelectedText(): string | null {
|
|
226
|
+
if (!this.selection) return null;
|
|
227
|
+
const node = this.findNodeById(this.selection.anchor.nodeId);
|
|
228
|
+
if (!node) return null;
|
|
229
|
+
const text = resolveText(node);
|
|
230
|
+
const [start, end] = normalizeRange(this.selection);
|
|
231
|
+
return text.slice(start, end);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// Hit testing — map canvas point → TextPosition
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Find which text node and character offset a canvas point maps to.
|
|
240
|
+
*/
|
|
241
|
+
private hitTestText(point: Point): TextPosition | null {
|
|
242
|
+
if (!this.rootNode) return null;
|
|
243
|
+
return this.hitTestTextNode(this.rootNode, point);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private hitTestTextNode(
|
|
247
|
+
node: VirtualNode,
|
|
248
|
+
point: Point,
|
|
249
|
+
): TextPosition | null {
|
|
250
|
+
if (!node.visible || !node.layout) return null;
|
|
251
|
+
|
|
252
|
+
// Check children first (front-to-back)
|
|
253
|
+
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
254
|
+
const result = this.hitTestTextNode(node.children[i], point);
|
|
255
|
+
if (result) return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Only text nodes are selectable
|
|
259
|
+
if (node.type !== "text") return null;
|
|
260
|
+
|
|
261
|
+
const bounds = getScrollAwareBounds(node);
|
|
262
|
+
if (!bounds) return null;
|
|
263
|
+
if (
|
|
264
|
+
point.x < bounds.x || point.x > bounds.x + bounds.width ||
|
|
265
|
+
point.y < bounds.y || point.y > bounds.y + bounds.height
|
|
266
|
+
) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Map point to character offset within this text node
|
|
271
|
+
const offset = pointToOffset(this.canvas, node, bounds, point);
|
|
272
|
+
return { nodeId: node.id, offset };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
// Painting — called from paint pipeline
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Paint selection highlight for a text node (called before text is drawn).
|
|
281
|
+
*/
|
|
282
|
+
paintSelection(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
283
|
+
if (!this.selection) return;
|
|
284
|
+
if (this.selection.anchor.nodeId !== node.id) return;
|
|
285
|
+
|
|
286
|
+
const [start, end] = normalizeRange(this.selection);
|
|
287
|
+
if (start === end) return; // caret only, no highlight
|
|
288
|
+
|
|
289
|
+
const rects = getSelectionRects(ctx, node, start, end);
|
|
290
|
+
|
|
291
|
+
ctx.save();
|
|
292
|
+
ctx.fillStyle = HIGHLIGHT_COLOR;
|
|
293
|
+
for (const r of rects) {
|
|
294
|
+
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
295
|
+
}
|
|
296
|
+
ctx.restore();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// Helpers
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
private findNodeById(id: string): VirtualNode | null {
|
|
304
|
+
if (!this.rootNode) return null;
|
|
305
|
+
return findById(this.rootNode, id);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
destroy(): void {
|
|
309
|
+
this.canvas.removeEventListener("mousedown", this.boundMouseDown);
|
|
310
|
+
this.canvas.removeEventListener("mousemove", this.boundMouseMove);
|
|
311
|
+
this.canvas.removeEventListener("mouseup", this.boundMouseUp);
|
|
312
|
+
(typeof window !== "undefined" ? window : this.canvas).removeEventListener(
|
|
313
|
+
"keydown",
|
|
314
|
+
this.boundKeyDown as EventListener,
|
|
315
|
+
);
|
|
316
|
+
this.rootNode = null;
|
|
317
|
+
this.selection = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Pure helpers
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function resolveText(node: VirtualNode): string {
|
|
326
|
+
const raw = node.props[0] || node.props.text || "";
|
|
327
|
+
let text = String(raw);
|
|
328
|
+
const tt = node.props.textTransform || "none";
|
|
329
|
+
if (tt === "uppercase") text = text.toUpperCase();
|
|
330
|
+
else if (tt === "lowercase") text = text.toLowerCase();
|
|
331
|
+
else if (tt === "capitalize") text = text.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
332
|
+
return text;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function normalizeRange(sel: TextSelection): [number, number] {
|
|
336
|
+
const a = sel.anchor.offset;
|
|
337
|
+
const b = sel.focus.offset;
|
|
338
|
+
return a <= b ? [a, b] : [b, a];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function wordBoundaryBefore(text: string, offset: number): number {
|
|
342
|
+
let i = Math.min(offset, text.length - 1);
|
|
343
|
+
// Skip non-word chars
|
|
344
|
+
while (i > 0 && !/\w/.test(text[i])) i--;
|
|
345
|
+
// Walk back through word chars
|
|
346
|
+
while (i > 0 && /\w/.test(text[i - 1])) i--;
|
|
347
|
+
return i;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function wordBoundaryAfter(text: string, offset: number): number {
|
|
351
|
+
let i = offset;
|
|
352
|
+
// Skip non-word chars
|
|
353
|
+
while (i < text.length && !/\w/.test(text[i])) i++;
|
|
354
|
+
// Walk forward through word chars
|
|
355
|
+
while (i < text.length && /\w/.test(text[i])) i++;
|
|
356
|
+
return i;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function findById(node: VirtualNode, id: string): VirtualNode | null {
|
|
360
|
+
if (node.id === id) return node;
|
|
361
|
+
for (const child of node.children) {
|
|
362
|
+
const found = findById(child, id);
|
|
363
|
+
if (found) return found;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Geometry: map a canvas point to a character offset
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Given a point inside a text node's bounds, return the character index
|
|
374
|
+
* that the point falls on.
|
|
375
|
+
*/
|
|
376
|
+
function pointToOffset(
|
|
377
|
+
canvas: HTMLCanvasElement,
|
|
378
|
+
node: VirtualNode,
|
|
379
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
380
|
+
point: Point,
|
|
381
|
+
): number {
|
|
382
|
+
const ctx = canvas.getContext("2d");
|
|
383
|
+
if (!ctx) return 0;
|
|
384
|
+
|
|
385
|
+
const props = node.props;
|
|
386
|
+
const text = resolveText(node);
|
|
387
|
+
const layout = node.layout!;
|
|
388
|
+
const fontSize = parseFloat(props.fontSize) || 16;
|
|
389
|
+
const fontWeight = props.fontWeight || "normal";
|
|
390
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
391
|
+
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
392
|
+
const textAlign = props.textAlign || "left";
|
|
393
|
+
|
|
394
|
+
const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
|
|
395
|
+
const font = createFontString(fontSize, fontWeight, fontFamily);
|
|
396
|
+
|
|
397
|
+
ctx.save();
|
|
398
|
+
ctx.font = font;
|
|
399
|
+
|
|
400
|
+
const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
|
|
401
|
+
const contentX = bounds.x + layout.contentX;
|
|
402
|
+
const contentY = bounds.y + layout.contentY;
|
|
403
|
+
|
|
404
|
+
// Find which line the point falls on
|
|
405
|
+
const relY = point.y - contentY;
|
|
406
|
+
let lineIndex = Math.floor(relY / metrics.lineHeight);
|
|
407
|
+
lineIndex = Math.max(0, Math.min(lineIndex, metrics.lines.length - 1));
|
|
408
|
+
|
|
409
|
+
// Calculate character offset before this line
|
|
410
|
+
let charsBefore = 0;
|
|
411
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
412
|
+
charsBefore += metrics.lines[i].length;
|
|
413
|
+
// Account for space/newline between lines (word wrap eats the space)
|
|
414
|
+
if (i < metrics.lines.length - 1) {
|
|
415
|
+
const nextLineStart = text.indexOf(metrics.lines[i + 1], charsBefore);
|
|
416
|
+
if (nextLineStart > charsBefore) charsBefore = nextLineStart;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const line = metrics.lines[lineIndex];
|
|
421
|
+
|
|
422
|
+
// Calculate line X offset (for text-align)
|
|
423
|
+
let lineX = contentX;
|
|
424
|
+
if (textAlign === "center") {
|
|
425
|
+
const lw = ctx.measureText(line).width;
|
|
426
|
+
lineX = contentX + (layout.contentWidth - lw) / 2;
|
|
427
|
+
} else if (textAlign === "right") {
|
|
428
|
+
const lw = ctx.measureText(line).width;
|
|
429
|
+
lineX = contentX + layout.contentWidth - lw;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Binary search for the character at point.x
|
|
433
|
+
const relX = point.x - lineX;
|
|
434
|
+
let best = 0;
|
|
435
|
+
for (let i = 0; i <= line.length; i++) {
|
|
436
|
+
const w = ctx.measureText(line.slice(0, i)).width;
|
|
437
|
+
if (w <= relX) {
|
|
438
|
+
best = i;
|
|
439
|
+
} else {
|
|
440
|
+
// Check if we're closer to this char or the previous
|
|
441
|
+
const prevW = ctx.measureText(line.slice(0, i - 1)).width;
|
|
442
|
+
if (relX - prevW < w - relX) {
|
|
443
|
+
best = i - 1;
|
|
444
|
+
} else {
|
|
445
|
+
best = i;
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
ctx.restore();
|
|
452
|
+
return charsBefore + best;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Geometry: compute highlight rectangles for a selection range
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Return a list of rectangles to paint as the selection highlight for
|
|
461
|
+
* characters [start, end) within a text node.
|
|
462
|
+
*/
|
|
463
|
+
function getSelectionRects(
|
|
464
|
+
ctx: CanvasRenderingContext2D,
|
|
465
|
+
node: VirtualNode,
|
|
466
|
+
start: number,
|
|
467
|
+
end: number,
|
|
468
|
+
): SelectionRect[] {
|
|
469
|
+
const props = node.props;
|
|
470
|
+
const layout = node.layout!;
|
|
471
|
+
const text = resolveText(node);
|
|
472
|
+
const fontSize = parseFloat(props.fontSize) || 16;
|
|
473
|
+
const fontWeight = props.fontWeight || "normal";
|
|
474
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
475
|
+
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
476
|
+
const textAlign = props.textAlign || "left";
|
|
477
|
+
|
|
478
|
+
const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
|
|
479
|
+
const font = createFontString(fontSize, fontWeight, fontFamily);
|
|
480
|
+
|
|
481
|
+
ctx.save();
|
|
482
|
+
ctx.font = font;
|
|
483
|
+
|
|
484
|
+
const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
|
|
485
|
+
const x = layout.x + layout.contentX;
|
|
486
|
+
const y = layout.y + layout.contentY;
|
|
487
|
+
|
|
488
|
+
const rects: SelectionRect[] = [];
|
|
489
|
+
|
|
490
|
+
// Map lines to character ranges
|
|
491
|
+
let charOffset = 0;
|
|
492
|
+
for (let i = 0; i < metrics.lines.length; i++) {
|
|
493
|
+
const line = metrics.lines[i];
|
|
494
|
+
const lineStart = charOffset;
|
|
495
|
+
const lineEnd = charOffset + line.length;
|
|
496
|
+
|
|
497
|
+
// Find actual position in original text for this line
|
|
498
|
+
if (i > 0) {
|
|
499
|
+
const idx = text.indexOf(line, charOffset);
|
|
500
|
+
if (idx > charOffset) charOffset = idx;
|
|
501
|
+
}
|
|
502
|
+
const actualLineStart = charOffset;
|
|
503
|
+
const actualLineEnd = charOffset + line.length;
|
|
504
|
+
|
|
505
|
+
// Does this line overlap with the selection?
|
|
506
|
+
if (actualLineEnd <= start || actualLineStart >= end) {
|
|
507
|
+
charOffset = actualLineEnd;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Clamp selection to this line
|
|
512
|
+
const selStart = Math.max(0, start - actualLineStart);
|
|
513
|
+
const selEnd = Math.min(line.length, end - actualLineStart);
|
|
514
|
+
|
|
515
|
+
// Calculate line X offset (text-align)
|
|
516
|
+
let lineX = x;
|
|
517
|
+
const lineWidth = ctx.measureText(line).width;
|
|
518
|
+
if (textAlign === "center") {
|
|
519
|
+
lineX = x + (layout.contentWidth - lineWidth) / 2;
|
|
520
|
+
} else if (textAlign === "right") {
|
|
521
|
+
lineX = x + layout.contentWidth - lineWidth;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Measure selection bounds within this line
|
|
525
|
+
const startX = ctx.measureText(line.slice(0, selStart)).width;
|
|
526
|
+
const endX = ctx.measureText(line.slice(0, selEnd)).width;
|
|
527
|
+
|
|
528
|
+
rects.push({
|
|
529
|
+
x: lineX + startX,
|
|
530
|
+
y: y + i * metrics.lineHeight,
|
|
531
|
+
width: endX - startX,
|
|
532
|
+
height: metrics.lineHeight,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
charOffset = actualLineEnd;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
ctx.restore();
|
|
539
|
+
return rects;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Clipboard fallback
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
function fallbackCopy(text: string): void {
|
|
547
|
+
if (typeof document === "undefined") return;
|
|
548
|
+
const textarea = document.createElement("textarea");
|
|
549
|
+
textarea.value = text;
|
|
550
|
+
textarea.style.position = "fixed";
|
|
551
|
+
textarea.style.left = "-9999px";
|
|
552
|
+
document.body.appendChild(textarea);
|
|
553
|
+
textarea.select();
|
|
554
|
+
try {
|
|
555
|
+
document.execCommand("copy");
|
|
556
|
+
} catch {
|
|
557
|
+
// ignore
|
|
558
|
+
}
|
|
559
|
+
document.body.removeChild(textarea);
|
|
560
|
+
}
|