@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.
Files changed (69) hide show
  1. package/README.md +4 -4
  2. package/dist/canvas/dirty.d.ts +40 -0
  3. package/dist/canvas/dirty.js +100 -0
  4. package/dist/canvas/dirty.js.map +1 -0
  5. package/dist/canvas/events.d.ts +5 -0
  6. package/dist/canvas/events.js +30 -3
  7. package/dist/canvas/events.js.map +1 -1
  8. package/dist/canvas/index.d.ts +5 -1
  9. package/dist/canvas/index.js +4 -0
  10. package/dist/canvas/index.js.map +1 -1
  11. package/dist/canvas/layout.d.ts +12 -2
  12. package/dist/canvas/layout.js +593 -63
  13. package/dist/canvas/layout.js.map +1 -1
  14. package/dist/canvas/paint.d.ts +2 -0
  15. package/dist/canvas/paint.js +88 -11
  16. package/dist/canvas/paint.js.map +1 -1
  17. package/dist/canvas/renderer.d.ts +16 -0
  18. package/dist/canvas/renderer.js +154 -15
  19. package/dist/canvas/renderer.js.map +1 -1
  20. package/dist/canvas/scroll.d.ts +82 -0
  21. package/dist/canvas/scroll.js +639 -0
  22. package/dist/canvas/scroll.js.map +1 -0
  23. package/dist/canvas/selection.d.ts +63 -0
  24. package/dist/canvas/selection.js +466 -0
  25. package/dist/canvas/selection.js.map +1 -0
  26. package/dist/canvas/text.d.ts +6 -3
  27. package/dist/canvas/text.js +82 -43
  28. package/dist/canvas/text.js.map +1 -1
  29. package/dist/canvas/types.d.ts +18 -0
  30. package/dist/canvas/utils.d.ts +8 -2
  31. package/dist/canvas/utils.js +42 -16
  32. package/dist/canvas/utils.js.map +1 -1
  33. package/dist/canvas/virtualize.d.ts +25 -0
  34. package/dist/canvas/virtualize.js +65 -0
  35. package/dist/canvas/virtualize.js.map +1 -0
  36. package/dist/dom/applicators/index.js +0 -1
  37. package/dist/dom/applicators/index.js.map +1 -1
  38. package/dist/dom/applicators/margin.js +58 -17
  39. package/dist/dom/applicators/margin.js.map +1 -1
  40. package/dist/dom/applicators/padding.js +42 -17
  41. package/dist/dom/applicators/padding.js.map +1 -1
  42. package/dist/dom/components/icon.d.ts +9 -0
  43. package/dist/dom/components/icon.js +67 -0
  44. package/dist/dom/components/icon.js.map +1 -0
  45. package/dist/dom/components/index.js +2 -0
  46. package/dist/dom/components/index.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +19 -5
  48. package/dist/dom/renderer.js +147 -17
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/canvas/QUICKSTART.md +5 -5
  52. package/src/canvas/dirty.ts +116 -0
  53. package/src/canvas/events.ts +34 -3
  54. package/src/canvas/index.ts +5 -0
  55. package/src/canvas/layout.ts +653 -100
  56. package/src/canvas/paint.ts +109 -11
  57. package/src/canvas/renderer.ts +189 -16
  58. package/src/canvas/scroll.ts +729 -0
  59. package/src/canvas/selection.ts +560 -0
  60. package/src/canvas/text.ts +97 -54
  61. package/src/canvas/types.ts +26 -0
  62. package/src/canvas/utils.ts +47 -17
  63. package/src/canvas/virtualize.ts +81 -0
  64. package/src/dom/applicators/index.ts +1 -1
  65. package/src/dom/applicators/margin.ts +57 -11
  66. package/src/dom/applicators/padding.ts +45 -11
  67. package/src/dom/components/icon.ts +84 -0
  68. package/src/dom/components/index.ts +2 -0
  69. 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
+ }