@tio-canvas/canvas 0.0.0-stage → 0.1.0

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/bin/tio-canvas.mjs +12 -0
  4. package/client/arrange.js +41 -0
  5. package/client/arrow-geometry.js +56 -0
  6. package/client/arrow-selection.js +68 -0
  7. package/client/arrows.js +205 -0
  8. package/client/bridge.js +74 -0
  9. package/client/canvas.css +733 -0
  10. package/client/canvas.js +1009 -0
  11. package/client/corners.js +22 -0
  12. package/client/device-gate.js +15 -0
  13. package/client/fullscreen.js +118 -0
  14. package/client/geometry.js +79 -0
  15. package/client/history.js +37 -0
  16. package/client/hover-preview.js +35 -0
  17. package/client/index.html +112 -0
  18. package/client/layer-order.js +20 -0
  19. package/client/layers.js +154 -0
  20. package/client/menu-position.js +36 -0
  21. package/client/onboarding.js +302 -0
  22. package/client/page-picker.js +76 -0
  23. package/client/platform.js +16 -0
  24. package/client/range-selection.js +54 -0
  25. package/client/registry.js +31 -0
  26. package/client/screen-loader.js +67 -0
  27. package/client/screens.js +7 -0
  28. package/client/select-field.js +191 -0
  29. package/client/shortcuts.js +158 -0
  30. package/client/smart-handles.js +28 -0
  31. package/client/smart-selection.js +52 -0
  32. package/client/spacing.js +45 -0
  33. package/client/text-format.js +49 -0
  34. package/client/text.js +466 -0
  35. package/client/tio-logo.png +0 -0
  36. package/client/tooltip.js +128 -0
  37. package/client/touch-preview.js +112 -0
  38. package/docs/exploration-canvas.md +315 -0
  39. package/package.json +31 -4
  40. package/src/build.js +33 -0
  41. package/src/index.js +3 -0
  42. package/src/interface-kit-bundle.js +103 -0
  43. package/src/interface-kit-entry.js +16 -0
  44. package/src/project.js +29 -0
  45. package/src/server.js +70 -0
  46. package/src/thumbnails.js +95 -0
package/client/text.js ADDED
@@ -0,0 +1,466 @@
1
+ import { TEXT_SIZES, TEXT_LINE_HEIGHT, sanitizeHtml, plainToHtml, readTexts, rubberBand } from './text-format.js';
2
+ import { intersects, bounds, snapRect } from './geometry.js';
3
+
4
+ const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
5
+ const DIRECTIONS = [['nw', 0, 0], ['n', 50, 0], ['ne', 100, 0], ['e', 100, 50], ['se', 100, 100], ['s', 50, 100], ['sw', 0, 100], ['w', 0, 50]];
6
+
7
+ // Text objects live in the world like screens. The element is the text; the
8
+ // selection frame and its handles are separate, in screen space, so a pull past
9
+ // a limit can stretch the frame while the words stay exactly as they are.
10
+ export function createTexts({ viewport, world, camera, extent, saved, checkpoint, commit, clearScreens, screenSelectionCount, rank, schedule, stopCanvas, setTool, snapTargets, drawGuides, companions, duplicateCompanions, discardCompanions }) {
11
+ let texts = readTexts(saved), tool = 'cursor', drag = null, editing = null, editBefore = null, settleTimer = 0, lastPress = null, selDrag = null;
12
+ const chosen = new Set(), elements = new Map();
13
+ const layer = document.createElement('div'); layer.id = 'text-layer'; world.append(layer);
14
+ const nodes = document.createElement('div'); nodes.id = 'text-nodes'; viewport.append(nodes);
15
+ const frame = document.createElement('div'); frame.className = 'text-frame'; frame.hidden = true; nodes.append(frame);
16
+ const outlines = document.createElement('div'); outlines.className = 'text-outlines'; nodes.append(outlines);
17
+ for (const [direction, x, y] of DIRECTIONS) {
18
+ const handle = document.createElement('button'); handle.type = 'button'; handle.className = 'handle'; handle.dataset.textHandle = direction;
19
+ handle.setAttribute('aria-label', `Resize text ${direction}`); handle.style.cssText = `left:${x}%;top:${y}%;cursor:${direction}-resize`;
20
+ if (direction.length === 1) handle.dataset.side = x === 50 ? 'horizontal' : 'vertical';
21
+ frame.append(handle);
22
+ }
23
+ const toolbar = document.querySelector('#text-inspector');
24
+ const sizeField = toolbar.querySelector('select[data-text-field="size"]');
25
+ const buttons = { bold: toolbar.querySelector('#text-bold'), italic: toolbar.querySelector('#text-italic') };
26
+ const textButton = document.querySelector('#tool-text');
27
+
28
+ const pointAt = event => ({ x: (event.clientX - camera.x) / camera.scale, y: (event.clientY - camera.y) / camera.scale });
29
+ const selected = () => texts.filter(t => chosen.has(t.id));
30
+ const find = id => texts.find(t => t.id === id);
31
+ const elementOf = text => elements.get(text.id);
32
+ const minWidth = text => TEXT_SIZES[text.size] * 1.5;
33
+ const minHeight = text => TEXT_SIZES[text.size] * TEXT_LINE_HEIGHT;
34
+
35
+ function choose(text, toggle = false) {
36
+ if (!toggle) { clearScreens(); chosen.clear(); }
37
+ if (text) { if (toggle && chosen.has(text.id)) chosen.delete(text.id); else chosen.add(text.id); }
38
+ schedule();
39
+ }
40
+
41
+ // ---- Elements -----------------------------------------------------------
42
+ function ensure(text) {
43
+ let el = elements.get(text.id);
44
+ if (el) return el;
45
+ el = document.createElement('div'); el.className = 'text-object'; el.dataset.textId = text.id; el.tabIndex = 0;
46
+ el.addEventListener('focus', () => { const t = find(el.dataset.textId); if (t && !editing && el.matches(':focus-visible') && !chosen.has(t.id)) choose(t); });
47
+ el.addEventListener('keydown', event => {
48
+ const t = find(el.dataset.textId); if (!t) return;
49
+ if (editing === t) {
50
+ event.stopPropagation();
51
+ if (event.key === 'Escape') { event.preventDefault(); finishEdit(); el.focus({ preventScroll: true }); }
52
+ else if (hotkey(event)) event.preventDefault();
53
+ } else if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); event.stopPropagation(); startEdit(t); }
54
+ });
55
+ el.addEventListener('mousedown', event => {
56
+ if (editing !== find(el.dataset.textId) || event.button !== 0) return;
57
+ if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return;
58
+ const c = caretAt(el, event.clientX, event.clientY); if (!c) return;
59
+ event.preventDefault(); el.focus({ preventScroll: true });
60
+ const sel = getSelection();
61
+ sel.setBaseAndExtent(c.node, c.offset, c.node, c.offset);
62
+ if (event.detail === 2) { sel.modify('move', 'backward', 'word'); sel.modify('extend', 'forward', 'word'); }
63
+ else if (event.detail >= 3) sel.selectAllChildren(el);
64
+ else { selDrag = { anchor: c, focus: c, outside: false }; document.body.dataset.textSelect = ''; }
65
+ });
66
+ el.addEventListener('input', () => { const t = find(el.dataset.textId); if (t && editing === t) { t.fit = true; t.html = el.innerHTML; el.dataset.html = t.html; schedule(); } });
67
+ el.addEventListener('paste', event => {
68
+ if (editing !== find(el.dataset.textId)) return;
69
+ event.preventDefault();
70
+ document.execCommand('insertHTML', false, plainToHtml(event.clipboardData?.getData('text/plain') ?? ''));
71
+ });
72
+ el.addEventListener('focusout', event => {
73
+ const t = find(el.dataset.textId);
74
+ if (editing !== t || toolbar.contains(event.relatedTarget)) return;
75
+ finishEdit();
76
+ });
77
+ layer.append(el); elements.set(text.id, el);
78
+ return el;
79
+ }
80
+ function setHtml(text, el, html) { el.innerHTML = html; el.dataset.html = html; el.setAttribute('aria-label', `Text: ${el.textContent.slice(0, 60) || 'empty'}`); }
81
+ function paint(text) {
82
+ const el = ensure(text), order = rank(text.id);
83
+ const key = `${text.x}|${text.y}|${text.width}|${text.fit ? 'a' : text.height}|${text.size}|${order}`;
84
+ if (el.dataset.key !== key) {
85
+ Object.assign(el.style, { left: `${text.x}px`, top: `${text.y}px`, width: `${text.width}px`, height: text.fit ? 'auto' : `${text.height}px`, fontSize: `${TEXT_SIZES[text.size]}px`, zIndex: String(order) });
86
+ el.dataset.key = key;
87
+ }
88
+ if (editing !== text && el.dataset.html !== text.html) setHtml(text, el, text.html);
89
+ el.toggleAttribute('data-editing', editing === text);
90
+ return el;
91
+ }
92
+ // Read a measurement with temporary inline styles, then put the styles back.
93
+ function probe(el, css, read) {
94
+ const kept = {}; for (const name of Object.keys(css)) { kept[name] = el.style[name]; el.style[name] = css[name]; }
95
+ const value = read(); for (const name of Object.keys(kept)) el.style[name] = kept[name];
96
+ return value;
97
+ }
98
+ const worldSize = (el, axis) => Math.ceil(el.getBoundingClientRect()[axis] / camera.scale - .01);
99
+ const maxWidth = text => probe(paint(text), { width: 'max-content', height: 'auto' }, () => worldSize(elementOf(text), 'width'));
100
+ const contentHeight = (text, width) => probe(paint(text), { width: `${width}px`, height: 'auto' }, () => worldSize(elementOf(text), 'height'));
101
+ // A text is never wider or taller than the words inside it.
102
+ function fitToContent(text) {
103
+ const max = maxWidth(text);
104
+ text.width = text.fitWidth ? Math.min(max, extent.width - text.x) : Math.min(text.width, max);
105
+ if (text.width >= max) text.fitWidth = true;
106
+ const height = contentHeight(text, text.width);
107
+ if (text.fit || text.height >= height) { text.fit = true; text.height = height; } else text.height = Math.max(minHeight(text), text.height);
108
+ }
109
+
110
+ // ---- Editing ------------------------------------------------------------
111
+ function startEdit(text, before = editBefore ?? checkpoint()) {
112
+ if (!text || editing === text) return;
113
+ if (editing) finishEdit();
114
+ stopCanvas(); choose(text);
115
+ editing = text; editBefore = before;
116
+ const el = paint(text); el.contentEditable = 'true'; el.spellcheck = false; el.setAttribute('role', 'textbox'); el.setAttribute('aria-multiline', 'true');
117
+ el.focus({ preventScroll: true });
118
+ const range = document.createRange(); range.selectNodeContents(el); range.collapse(false);
119
+ const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range);
120
+ schedule();
121
+ }
122
+ function finishEdit() {
123
+ const text = editing; if (!text) return;
124
+ editing = null;
125
+ const el = elementOf(text);
126
+ el.removeAttribute('contenteditable'); el.removeAttribute('role'); el.removeAttribute('aria-multiline');
127
+ if (el.contains(getSelection().anchorNode)) getSelection().removeAllRanges();
128
+ text.html = sanitizeHtml(el.innerHTML); setHtml(text, el, text.html);
129
+ if (!el.textContent.trim()) { texts = texts.filter(t => t !== text); chosen.delete(text.id); el.remove(); elements.delete(text.id); }
130
+ else fitToContent(text);
131
+ const before = editBefore; editBefore = null;
132
+ if (before) commit(before);
133
+ schedule();
134
+ }
135
+
136
+ // ---- Formatting ---------------------------------------------------------
137
+ const rangeIn = el => { const s = getSelection(); return s.rangeCount && el.contains(s.getRangeAt(0).commonAncestorContainer) ? s.getRangeAt(0).cloneRange() : null; };
138
+ const offsetOf = (root, node, offset) => { const r = document.createRange(); r.setStart(root, 0); r.setEnd(node, offset); return r.toString().length; };
139
+ function pointAtOffset(root, offset) {
140
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node, left = offset;
141
+ while ((node = walker.nextNode())) { if (left <= node.data.length) return [node, left]; left -= node.data.length; }
142
+ return [root, root.childNodes.length];
143
+ }
144
+ // With a range selected the format applies to it; with none, to the whole text.
145
+ function applyFormat(text, command) {
146
+ const el = paint(text), live = editing === text;
147
+ if (!live) { el.contentEditable = 'true'; el.focus({ preventScroll: true }); }
148
+ const selection = getSelection();
149
+ let range = rangeIn(el), caret = null;
150
+ const ranged = !!range && !range.collapsed;
151
+ if (!ranged) { caret = range ? offsetOf(el, range.endContainer, range.endOffset) : null; range = document.createRange(); range.selectNodeContents(el); }
152
+ selection.removeAllRanges(); selection.addRange(range);
153
+ document.execCommand('styleWithCSS', false, false);
154
+ document.execCommand(command);
155
+ if (!ranged) {
156
+ if (live && caret !== null) { const [node, offset] = pointAtOffset(el, caret); const r = document.createRange(); r.setStart(node, offset); r.collapse(true); selection.removeAllRanges(); selection.addRange(r); }
157
+ else selection.removeAllRanges();
158
+ }
159
+ if (live) { text.html = el.innerHTML; el.dataset.html = text.html; }
160
+ else { el.removeAttribute('contenteditable'); el.blur(); text.html = sanitizeHtml(el.innerHTML); setHtml(text, el, text.html); fitToContent(text); }
161
+ }
162
+ function formatSelected(command) {
163
+ const targets = editing ? [editing] : selected(); if (!targets.length) return;
164
+ const before = editing ? null : checkpoint();
165
+ for (const text of targets) applyFormat(text, command);
166
+ if (before) commit(before);
167
+ schedule();
168
+ }
169
+ // Is every word in range (or in the whole text) inside the given tag?
170
+ function covered(text, selector) {
171
+ const el = elementOf(text); if (!el) return false;
172
+ const range = editing === text ? rangeIn(el) : null, partial = range && !range.collapsed;
173
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let node, any = false;
174
+ while ((node = walker.nextNode())) {
175
+ if (!node.data.trim() || (partial && !range.intersectsNode(node))) continue;
176
+ any = true;
177
+ const owner = node.parentElement.closest(selector);
178
+ if (!owner || !el.contains(owner)) return false;
179
+ }
180
+ return any;
181
+ }
182
+ // Cmd/Ctrl+B and I. With words selected they format those; otherwise the whole text.
183
+ function hotkey(event) {
184
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return false;
185
+ const name = { b: 'bold', i: 'italic' }[event.key.toLowerCase()];
186
+ if (!name) return false;
187
+ formatSelected(name);
188
+ return true;
189
+ }
190
+ toolbar.addEventListener('submit', event => event.preventDefault());
191
+ // The editor keeps its focus and selection while a format button is pressed.
192
+ toolbar.addEventListener('mousedown', event => { if (event.target.closest('[data-text-format]')) event.preventDefault(); });
193
+ buttons.bold.addEventListener('click', () => formatSelected('bold'));
194
+ buttons.italic.addEventListener('click', () => formatSelected('italic'));
195
+ let syncing = false;
196
+ sizeField.addEventListener('change', () => {
197
+ if (syncing || !sizeField.value) return;
198
+ const targets = selected(); if (!targets.length) return;
199
+ const before = editing ? null : checkpoint();
200
+ for (const text of targets) { text.size = sizeField.value; fitToContent(text); }
201
+ if (before) commit(before);
202
+ schedule();
203
+ });
204
+ // Selecting words by dragging is done here, not by the browser: the browser drops the
205
+ // selection the moment the pointer leaves the text. The anchor is where the press
206
+ // landed and the other end follows the pointer, nearest point on the text when it is outside.
207
+ function caretAt(el, x, y) {
208
+ const p = document.caretPositionFromPoint?.(x, y);
209
+ const c = p ? { node: p.offsetNode, offset: p.offset } : (() => { const r = document.caretRangeFromPoint?.(x, y); return r ? { node: r.startContainer, offset: r.startOffset } : null; })();
210
+ return c && el.contains(c.node) ? c : null;
211
+ }
212
+ const paintSelection = d => getSelection().setBaseAndExtent(d.anchor.node, d.anchor.offset, d.focus.node, d.focus.offset);
213
+ window.addEventListener('pointermove', event => {
214
+ if (!selDrag || !editing) return;
215
+ const el = elementOf(editing), r = el.getBoundingClientRect();
216
+ // Outside the text the pointer is read as its nearest point on the text, so moving
217
+ // above, below or beside it keeps steering the selection instead of dropping it.
218
+ const x = clamp(event.clientX, r.left + 1, r.right - 1), y = clamp(event.clientY, r.top + 1, r.bottom - 1);
219
+ const c = caretAt(el, x, y);
220
+ if (c && (c.node !== selDrag.focus.node || c.offset !== selDrag.focus.offset)) { selDrag.focus = c; paintSelection(selDrag); }
221
+ });
222
+ const endSelDrag = () => { selDrag = null; delete document.body.dataset.textSelect; };
223
+ window.addEventListener('pointerup', endSelDrag); window.addEventListener('pointercancel', endSelDrag);
224
+ document.addEventListener('selectionchange', () => { if (editing) schedule(); });
225
+
226
+ // ---- Pointer ------------------------------------------------------------
227
+ function begin(event, text, kind, part) {
228
+ if (drag || event.button !== 0) return;
229
+ stopCanvas(); event.preventDefault(); event.stopPropagation(); viewport.focus({ preventScroll: true });
230
+ clearTimeout(settleTimer); delete frame.dataset.settling;
231
+ const before = kind === 'create' ? editBefore : checkpoint(), origin = pointAt(event);
232
+ drag = { id: event.pointerId, kind, part, text, before, origin, moved: false, over: { w: 0, h: 0 },
233
+ originals: selected().map(t => ({ text: t, x: t.x, y: t.y })), companions: kind === 'move' ? companions().map(model => ({ model, x: model.x, y: model.y })) : [], base: { x: text.x, y: text.y, width: text.width, height: text.height, fit: text.fit, fitWidth: text.fitWidth }, maxW: kind === 'resize' ? maxWidth(text) : 0 };
234
+ document.body.dataset.textDrag = ''; viewport.setPointerCapture(event.pointerId); schedule();
235
+ }
236
+ viewport.addEventListener('pointerdown', event => {
237
+ if (document.body.hasAttribute('data-fullscreen') || document.body.hasAttribute('data-space') || event.button !== 0 || event.target.closest('#text-inspector')) return;
238
+ if (editing && !elementOf(editing).contains(event.target) && !event.target.closest('.icon-options')) finishEdit();
239
+ const handle = event.target.closest('[data-text-handle]');
240
+ if (handle && selected().length === 1) { begin(event, selected()[0], 'resize', handle.dataset.textHandle); return; }
241
+ const hit = event.target.closest('.text-object'), text = hit && find(hit.dataset.textId);
242
+ if (tool === 'text') {
243
+ if (text) { event.preventDefault(); event.stopPropagation(); setTool('cursor'); startEdit(text); return; }
244
+ if (texts.length >= 500) { event.preventDefault(); event.stopPropagation(); document.querySelector('#canvas-status').textContent = 'The canvas supports up to 500 texts.'; return; }
245
+ editBefore = checkpoint();
246
+ const point = pointAt(event);
247
+ const created = { id: crypto.randomUUID(), x: Math.round(clamp(point.x, 0, extent.width - 8)), y: Math.round(clamp(point.y, 0, extent.height - 8)), width: 8, height: 0, fit: true, fitWidth: false, size: 'md', html: '' };
248
+ texts.push(created); clearScreens(); chosen.clear(); chosen.add(created.id); paint(created);
249
+ begin(event, created, 'create'); return;
250
+ }
251
+ if (tool !== 'cursor' || !text) return;
252
+ if (editing === text) return;
253
+ // Two quick presses on the same text edit it. Cancelling pointerdown below also
254
+ // cancels the browser's own dblclick in some engines, so the pair is counted here.
255
+ const now = performance.now(), again = lastPress && lastPress.id === text.id && now - lastPress.time < 400 && Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) < 6;
256
+ lastPress = again || event.shiftKey ? null : { id: text.id, time: now, x: event.clientX, y: event.clientY };
257
+ if (again) { event.preventDefault(); event.stopPropagation(); startEdit(text); return; }
258
+ if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); choose(text, true); return; }
259
+ if (!chosen.has(text.id)) choose(text);
260
+ begin(event, text, 'move');
261
+ }, true);
262
+ function move(event) {
263
+ if (!drag || event.pointerId !== drag.id) return;
264
+ drag.last = event;
265
+ const { kind, text, origin, base } = drag, point = pointAt(event), dx = point.x - origin.x, dy = point.y - origin.y;
266
+ if (!drag.moved && kind !== 'create' && Math.hypot(dx, dy) * camera.scale < 3) return;
267
+ drag.moved = true;
268
+ if (kind === 'move') {
269
+ // Same snapping and axis lock as a screen: edges and centres align with the
270
+ // other objects, equal gaps are offered, and Shift keeps to one axis.
271
+ // Option/Alt copies the selection and carries on with the copies, as with screens.
272
+ drag.duplicate ||= event.altKey === true;
273
+ if (drag.duplicate && !drag.duplicated && !drag.denied) duplicateDrag();
274
+ const rects = [...drag.originals.map(({ text: t, x, y }) => ({ x, y, width: t.width, height: t.height })), ...drag.companions.map(({ model, x, y }) => ({ x, y, width: model.width, height: model.height }))], original = bounds(rects);
275
+ const axis = event.shiftKey ? (Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y') : null;
276
+ const proposal = { ...original, x: original.x + (axis === 'y' ? 0 : dx), y: original.y + (axis === 'x' ? 0 : dy) };
277
+ const moving = new Set([...drag.originals.map(o => o.text.id), ...drag.companions.map(o => o.model.id)]);
278
+ const snap = snapRect(proposal, snapTargets().filter(o => !moving.has(o.id)), 6 / camera.scale);
279
+ if (axis === 'x') snap.y = original.y; if (axis === 'y') snap.x = original.x;
280
+ const ox = Math.round(clamp(snap.x, 0, extent.width - original.width)) - original.x, oy = Math.round(clamp(snap.y, 0, extent.height - original.height)) - original.y;
281
+ for (const { text: t, x, y } of drag.originals) { t.x = x + ox; t.y = y + oy; }
282
+ for (const { model, x, y } of drag.companions) { model.x = x + ox; model.y = y + oy; }
283
+ const lines = axis ? snap.guides.filter(line => line.kind === 'gap' ? line.axis !== axis : line.axis === axis) : snap.guides;
284
+ if (axis) {
285
+ const horizontal = axis === 'x';
286
+ lines.push({ axis: horizontal ? 'y' : 'x', value: original[horizontal ? 'y' : 'x'] + original[horizontal ? 'height' : 'width'] / 2, from: Math.min(original[axis], original[axis] + (horizontal ? ox : oy)), to: Math.max(original[axis] + original[horizontal ? 'width' : 'height'], original[axis] + (horizontal ? ox : oy) + original[horizontal ? 'width' : 'height']), kind: 'axis' });
287
+ }
288
+ drawGuides(lines);
289
+ } else if (kind === 'create') {
290
+ text.width = Math.round(clamp(Math.abs(dx), 8, extent.width)); text.x = Math.round(clamp(Math.min(origin.x, point.x), 0, extent.width - text.width));
291
+ drag.moved = Math.abs(dx) * camera.scale >= 4;
292
+ } else {
293
+ const part = drag.part, horizontal = /[ew]/.test(part), vertical = /[ns]/.test(part);
294
+ const rawWidth = base.width + (part.includes('e') ? dx : part.includes('w') ? -dx : 0);
295
+ const width = horizontal ? Math.round(clamp(rawWidth, Math.min(minWidth(text), drag.maxW), drag.maxW)) : base.width;
296
+ const content = contentHeight(text, width);
297
+ let height = base.fit ? content : Math.min(base.height, content), fit = base.fit, rawHeight = height;
298
+ if (vertical) {
299
+ rawHeight = base.height + (part.includes('s') ? dy : -dy);
300
+ height = clamp(rawHeight, minHeight(text), Math.max(minHeight(text), content));
301
+ fit = height >= content;
302
+ }
303
+ drag.over = { w: horizontal ? rawWidth - width : 0, h: vertical ? rawHeight - height : 0 };
304
+ text.fitWidth = horizontal ? width >= drag.maxW : base.fitWidth;
305
+ text.width = width; text.fit = fit; text.height = fit ? content : Math.round(height);
306
+ text.x = part.includes('w') ? Math.round(base.x + base.width - text.width) : base.x;
307
+ text.y = part.includes('n') ? Math.round(base.y + base.height - text.height) : base.y;
308
+ }
309
+ schedule();
310
+ }
311
+ function release() {
312
+ const previous = drag; drag = null;
313
+ if (viewport.hasPointerCapture?.(previous.id)) viewport.releasePointerCapture(previous.id);
314
+ delete document.body.dataset.textDrag; drawGuides([]);
315
+ return previous;
316
+ }
317
+ function finish(event) {
318
+ if (!drag || event.pointerId !== drag.id) return;
319
+ move(event);
320
+ const previous = release();
321
+ if (previous.kind === 'create') {
322
+ previous.text.fitWidth = !previous.moved;
323
+ if (!previous.moved) previous.text.width = Math.round(TEXT_SIZES[previous.text.size] * .6);
324
+ previous.text.x = Math.round(clamp(previous.text.x, 0, extent.width - previous.text.width));
325
+ setTool('cursor'); startEdit(previous.text, previous.before);
326
+ } else {
327
+ if (previous.kind === 'resize' && (previous.over.w || previous.over.h) && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
328
+ frame.dataset.settling = ''; clearTimeout(settleTimer); settleTimer = setTimeout(() => delete frame.dataset.settling, 450);
329
+ }
330
+ commit(previous.before);
331
+ }
332
+ schedule();
333
+ }
334
+ function cancel() {
335
+ if (drag) {
336
+ const previous = release();
337
+ if (previous.kind === 'create') { texts = texts.filter(t => t !== previous.text); chosen.delete(previous.text.id); elementOf(previous.text)?.remove(); elements.delete(previous.text.id); editBefore = null; }
338
+ else if (previous.kind === 'move') {
339
+ if (previous.duplicated) {
340
+ const gone = new Set(previous.originals.map(o => o.text.id));
341
+ texts = texts.filter(t => !gone.has(t.id)); chosen.clear(); for (const id of previous.sourceIds) chosen.add(id);
342
+ for (const o of previous.originals) { elementOf(o.text)?.remove(); elements.delete(o.text.id); }
343
+ if (previous.companionCopies) discardCompanions(previous.companionCopies, previous.companionSources);
344
+ } else {
345
+ for (const { text, x, y } of previous.originals) Object.assign(text, { x, y });
346
+ for (const { model, x, y } of previous.companions) Object.assign(model, { x, y });
347
+ }
348
+ }
349
+ else Object.assign(previous.text, previous.base);
350
+ schedule(); return true;
351
+ }
352
+ if (editing) { finishEdit(); return true; }
353
+ return false;
354
+ }
355
+ window.addEventListener('pointermove', move); window.addEventListener('pointerup', finish); window.addEventListener('pointercancel', cancel);
356
+ function duplicateDrag() {
357
+ if (texts.length + drag.originals.length > 500) { drag.denied = true; document.querySelector('#canvas-status').textContent = 'The canvas supports up to 500 texts.'; return; }
358
+ drag.duplicated = true; drag.sourceIds = drag.originals.map(o => o.text.id);
359
+ drag.originals = drag.originals.map(({ text, x, y }) => {
360
+ Object.assign(text, { x, y });
361
+ const copy = { ...structuredClone(text), id: crypto.randomUUID() }; texts.push(copy);
362
+ return { text: copy, x, y };
363
+ });
364
+ chosen.clear(); for (const o of drag.originals) chosen.add(o.text.id);
365
+ if (drag.companions.length) {
366
+ for (const { model, x, y } of drag.companions) Object.assign(model, { x, y });
367
+ drag.companionSources = drag.companions.map(c => c.model);
368
+ drag.companionCopies = duplicateCompanions();
369
+ drag.companions = drag.companionCopies.map(model => ({ model, x: model.x, y: model.y }));
370
+ }
371
+ }
372
+ const shiftChanged = event => { if (['Shift', 'Alt'].includes(event.key) && drag?.kind === 'move' && drag.last) move({ pointerId: drag.id, clientX: drag.last.clientX, clientY: drag.last.clientY, shiftKey: event.shiftKey, altKey: event.altKey }); };
373
+ window.addEventListener('keyup', shiftChanged);
374
+ window.addEventListener('keydown', event => {
375
+ shiftChanged(event);
376
+ if (document.body.hasAttribute('data-fullscreen') || document.querySelector('dialog[open]')) return;
377
+ if (event.key === 'Escape' && !event.target.closest?.('input,textarea,select,[contenteditable],.icon-options')) { if (!cancel()) { chosen.clear(); schedule(); } return; }
378
+ if (event.key.toLowerCase() === 't' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.target.closest?.('input,textarea,select,[contenteditable],.icon-options')) setTool('text');
379
+ if (!editing && chosen.size && !event.target.closest?.('input,textarea,select,[contenteditable],.icon-options,dialog') && hotkey(event)) event.preventDefault();
380
+ });
381
+ document.addEventListener('tio-canvas:tool', event => {
382
+ tool = event.detail; if (editing) finishEdit();
383
+ schedule();
384
+ });
385
+ textButton.onclick = () => setTool('text');
386
+
387
+ // ---- Render -------------------------------------------------------------
388
+ function frameRect(text) {
389
+ const scale = camera.scale; let { x, y, width, height } = text;
390
+ if (drag?.kind === 'resize' && drag.text === text) {
391
+ const w = rubberBand(drag.over.w * scale) / scale, h = rubberBand(drag.over.h * scale) / scale, part = drag.part;
392
+ if (part.includes('e')) width += w; else if (part.includes('w')) { x -= w; width += w; }
393
+ if (part.includes('s')) height += h; else if (part.includes('n')) { y -= h; height += h; }
394
+ width = Math.max(width, 8 / scale); height = Math.max(height, 8 / scale);
395
+ }
396
+ return { left: camera.x + x * scale, top: camera.y + y * scale, width: width * scale, height: height * scale };
397
+ }
398
+ function render() {
399
+ const alive = new Set(texts.map(t => t.id));
400
+ for (const [id, el] of elements) if (!alive.has(id)) { el.remove(); elements.delete(id); }
401
+ for (const text of texts) {
402
+ // While typing, a text that hugs its words keeps hugging them as they grow.
403
+ if (editing === text && text.fitWidth) text.width = Math.max(Math.min(maxWidth(text), extent.width - text.x), Math.round(TEXT_SIZES[text.size] * .6));
404
+ const el = paint(text);
405
+ if (text.fit) text.height = Math.max(el.offsetHeight, Math.ceil(minHeight(text)));
406
+ el.toggleAttribute('data-selected', chosen.has(text.id));
407
+ }
408
+ const targets = selected(), single = targets.length === 1 && !screenSelectionCount();
409
+ frame.hidden = !single;
410
+ if (single) {
411
+ const r = frameRect(targets[0]);
412
+ Object.assign(frame.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
413
+ frame.toggleAttribute('data-editing', editing === targets[0]);
414
+ }
415
+ outlines.replaceChildren(...(single ? [] : targets.map(text => {
416
+ const r = frameRect(text), box = document.createElement('div'); box.className = 'text-outline';
417
+ Object.assign(box.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` }); return box;
418
+ })));
419
+ toolbar.hidden = !targets.length || !!drag;
420
+ if (toolbar.hidden) return;
421
+ const sizes = targets.map(t => t.size), same = sizes.every(s => s === sizes[0]);
422
+ const size = same ? sizes[0] : '';
423
+ if (sizeField.value !== size || sizeField.dataset.painted !== 'true') {
424
+ syncing = true; sizeField.value = size; sizeField.dispatchEvent(new Event('change')); syncing = false; sizeField.dataset.painted = 'true';
425
+ }
426
+ buttons.bold.setAttribute('aria-pressed', String(targets.every(t => covered(t, 'b'))));
427
+ buttons.italic.setAttribute('aria-pressed', String(targets.every(t => covered(t, 'i'))));
428
+ const main = document.querySelector('#edit-tools').getBoundingClientRect(), inset = parseFloat(getComputedStyle(toolbar).getPropertyValue('--canvas-panel-inset'));
429
+ const center = Math.max(inset + toolbar.offsetWidth / 2, Math.min(innerWidth - inset - toolbar.offsetWidth / 2, main.left + main.width / 2));
430
+ toolbar.style.left = `${center}px`; toolbar.style.bottom = `${innerHeight - main.top + inset}px`;
431
+ }
432
+ function removeSelected() {
433
+ if (editing && chosen.has(editing.id)) { editing = null; editBefore = null; }
434
+ texts = texts.filter(t => !chosen.has(t.id)); chosen.clear(); schedule();
435
+ }
436
+ return {
437
+ render, cancel, removeSelected,
438
+ capture: () => structuredClone(texts),
439
+ restore(value, ids) {
440
+ drag && release(); if (editing) { elementOf(editing)?.removeAttribute('contenteditable'); editing = null; editBefore = null; }
441
+ texts = readTexts(value); chosen.clear();
442
+ for (const id of Array.isArray(ids) ? ids : []) if (find(id)) chosen.add(id);
443
+ for (const el of elements.values()) delete el.dataset.html;
444
+ schedule();
445
+ },
446
+ layers: () => texts.map(({ id, html }) => ({ id, html })),
447
+ objects: () => texts.map(({ id, x, y, width, height }) => ({ id, x, y, width, height })),
448
+ get ids() { return texts.map(t => t.id); },
449
+ get selectedIds() { return [...chosen]; },
450
+ selectedModels: () => selected(),
451
+ selectOnly(id) { const text = find(id); if (text && !chosen.has(id)) choose(text); },
452
+ duplicateSelected(dx = 24, dy = dx) {
453
+ const sources = selected();
454
+ if (!sources.length || texts.length + sources.length > 500) return null;
455
+ const copies = sources.map(t => ({ ...structuredClone(t), id: crypto.randomUUID(), x: Math.round(clamp(t.x + dx, 0, extent.width - t.width)), y: Math.round(clamp(t.y + dy, 0, extent.height - t.height)) }));
456
+ texts.push(...copies); chosen.clear(); for (const c of copies) chosen.add(c.id);
457
+ schedule(); return copies;
458
+ },
459
+ clearSelection() { if (editing) finishEdit(); chosen.clear(); schedule(); },
460
+ selectRect(box, initial = []) {
461
+ chosen.clear(); for (const id of initial) if (find(id)) chosen.add(id);
462
+ if (box) for (const text of texts) if (intersects(box, text)) chosen.add(text.id);
463
+ schedule();
464
+ },
465
+ };
466
+ }
Binary file
@@ -0,0 +1,128 @@
1
+ // One tooltip for the whole canvas, driven by attributes:
2
+ // data-tooltip the text
3
+ // data-shortcut "mod+shift+z": drawn after the text, platform-aware
4
+ // data-tooltip-disabled the text shown instead while the control is disabled
5
+ // A control with a native title has it moved here (the native one would double up),
6
+ // and an icon-only control falls back to its aria-label.
7
+ const DELAY = 500; // first tooltip: what design tools use, long enough not to flicker
8
+ const LEAVE = 100; // grace before it closes, so a neighbour can take it over instantly
9
+ const GAP = 8, MARGIN = 8;
10
+ import { platform } from './platform.js';
11
+ const symbols = {
12
+ mac: { mod: '⌘', shift: '⇧', alt: '⌥', ctrl: '⌃', esc: 'Esc', del: '⌫' },
13
+ win: { mod: 'Ctrl', shift: 'Shift', alt: 'Alt', ctrl: 'Ctrl', esc: 'Esc', del: 'Del' },
14
+ };
15
+
16
+ export function formatShortcut(value) {
17
+ const kind = platform(), table = symbols[kind];
18
+ const keys = value.toLowerCase().split('+').map(key => table[key] ?? (key.length === 1 ? key.toUpperCase() : key));
19
+ return keys.join(kind === 'mac' ? '' : '+');
20
+ }
21
+
22
+ const tip = document.createElement('div');
23
+ tip.id = 'canvas-tooltip';
24
+ tip.className = 'canvas-tooltip';
25
+ tip.setAttribute('role', 'tooltip');
26
+ tip.popover = 'manual';
27
+ document.body.append(tip);
28
+
29
+ let target = null, suppressed = null, showTimer = 0, hideTimer = 0, described = null;
30
+ const isOpen = () => tip.matches(':popover-open');
31
+
32
+ function resolve(node) {
33
+ if (!(node instanceof Element)) return null;
34
+ let el = node.closest('[data-tooltip],[data-tooltip-disabled],button,[role="button"],[role="checkbox"],label');
35
+ if (el?.matches('label') && !el.dataset.tooltip) el = el.querySelector('input[data-tooltip-disabled]:disabled');
36
+ return el;
37
+ }
38
+
39
+ function read(el) {
40
+ const title = el.getAttribute('title');
41
+ if (title) {
42
+ // Keep the accessible name, then hand the text to the custom tooltip.
43
+ if (!el.hasAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', title);
44
+ el.removeAttribute('title');
45
+ el.dataset.tooltip = title;
46
+ }
47
+ let text = '', shortcut = '';
48
+ if (el.disabled && el.dataset.tooltipDisabled) text = el.dataset.tooltipDisabled;
49
+ else {
50
+ text = el.dataset.tooltip || (!el.textContent.trim() ? el.getAttribute('aria-label') || '' : '');
51
+ shortcut = el.dataset.shortcut || '';
52
+ }
53
+ return text ? { text, shortcut } : null;
54
+ }
55
+
56
+ function describe(el, text) {
57
+ if (described) described.removeAttribute('aria-describedby');
58
+ described = null;
59
+ if (el && el.getAttribute('aria-label') !== text) { el.setAttribute('aria-describedby', tip.id); described = el; }
60
+ }
61
+
62
+ function place(el, info) {
63
+ if (!el.isConnected || !el.checkVisibility()) return;
64
+ const text = document.createElement('span');
65
+ text.textContent = info.text;
66
+ tip.replaceChildren(text);
67
+ if (info.shortcut) {
68
+ const keys = document.createElement('span');
69
+ keys.className = 'tooltip-shortcut';
70
+ keys.textContent = formatShortcut(info.shortcut);
71
+ tip.append(keys);
72
+ }
73
+ if (!isOpen()) tip.showPopover();
74
+ describe(el, info.text);
75
+ const anchor = el.getBoundingClientRect(), width = tip.offsetWidth, height = tip.offsetHeight;
76
+ const above = anchor.top - GAP - height >= MARGIN;
77
+ const left = Math.max(MARGIN, Math.min(anchor.left + anchor.width / 2 - width / 2, innerWidth - width - MARGIN));
78
+ tip.style.left = `${left}px`;
79
+ tip.style.top = `${above ? anchor.top - GAP - height : anchor.bottom + GAP}px`;
80
+ tip.style.transformOrigin = `${anchor.left + anchor.width / 2 - left}px ${above ? '100%' : '0'}`;
81
+ tip.dataset.side = above ? 'top' : 'bottom';
82
+ }
83
+
84
+ function hide() {
85
+ clearTimeout(showTimer); clearTimeout(hideTimer);
86
+ target = null;
87
+ describe(null);
88
+ if (isOpen()) tip.hidePopover();
89
+ }
90
+ function scheduleHide() {
91
+ clearTimeout(showTimer);
92
+ if (target === null && !isOpen()) return;
93
+ clearTimeout(hideTimer);
94
+ hideTimer = setTimeout(hide, LEAVE);
95
+ }
96
+
97
+ document.addEventListener('pointerover', event => {
98
+ if (event.pointerType === 'touch') return;
99
+ const el = resolve(event.target);
100
+ if (el !== suppressed) suppressed = null;
101
+ if (event.buttons) { hide(); return; }
102
+ if (!el) { scheduleHide(); return; }
103
+ if (el === target) { clearTimeout(hideTimer); return; }
104
+ if (el === suppressed) return;
105
+ const info = read(el);
106
+ if (!info) { scheduleHide(); return; }
107
+ clearTimeout(showTimer); clearTimeout(hideTimer);
108
+ target = el;
109
+ // Already showing: switch text and place at once, with no appear animation.
110
+ if (isOpen()) place(el, info);
111
+ else showTimer = setTimeout(() => place(el, info), DELAY);
112
+ }, true);
113
+
114
+ document.addEventListener('pointerdown', () => { suppressed = target; hide(); }, true);
115
+ document.addEventListener('keydown', event => { if (!['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) hide(); }, true);
116
+ document.addEventListener('wheel', hide, { capture: true, passive: true });
117
+ document.addEventListener('scroll', hide, true);
118
+ document.documentElement.addEventListener('pointerleave', hide);
119
+ addEventListener('blur', hide);
120
+ addEventListener('resize', hide);
121
+ document.addEventListener('focusin', event => {
122
+ if (!event.target.matches?.(':focus-visible')) return;
123
+ const el = resolve(event.target), info = el && read(el);
124
+ if (!info) return;
125
+ clearTimeout(showTimer); clearTimeout(hideTimer);
126
+ target = el; place(el, info);
127
+ });
128
+ document.addEventListener('focusout', () => hide());