@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tio Putra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # @tio-canvas/canvas
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A private exploration canvas for a web project. Pages of the project are laid out
4
+ as real, live documents (same-origin iframes) on a finite board that you can pan,
5
+ zoom, arrange, resize to device presets, rename, annotate with arrows and text,
6
+ and preview full screen. It is a development tool: it adds nothing to what a
7
+ visitor of the project downloads unless the project chooses to publish `/canvas`.
8
+
9
+ Plain HTML, CSS and JavaScript. No framework, no bundler for the canvas itself.
10
+ The only dependencies (`interface-kit`, `react`, `react-dom`, `esbuild`) exist to
11
+ build the optional development-only Interface Kit overlay and are never sent to
12
+ a browser except by the dev server, as one script, inside a framed screen.
13
+
14
+ The behaviour of the canvas is described in [`docs/exploration-canvas.md`](docs/exploration-canvas.md).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm add -D @tio-canvas/canvas # or: npm i -D @tio-canvas/canvas
20
+ ```
21
+
22
+ ## Use it in a project
23
+
24
+ ### 1. List your screens
25
+
26
+ Create `dev/canvas/screens.js` (any path works; pass it as `config`). It is a
27
+ standalone ES module that the **browser loads as it stands**, so it must not
28
+ import anything.
29
+
30
+ ```js
31
+ export const storageKey = 'my-project:canvas:v1'; // where the browser keeps the saved layout
32
+ export const screens = [
33
+ { id: 'home', kind: 'production', title: 'Home', description: 'The live homepage.',
34
+ src: '/', x: 160, y: 160, width: 1440, height: 900, preset: 'laptop' },
35
+ ];
36
+ ```
37
+
38
+ - `id` is stable: saved layouts refer to it. `kind` is `production` or `prototype`.
39
+ `description` is required. Both are checked and a mistake throws.
40
+ - `preset` is a key of the device presets (`laptop`, `iphone-16`, `ipad-air`, …; see `client/registry.js`).
41
+ - `storageKey` is optional. Two projects served from one origin (for example
42
+ `localhost:4321`) must not share a key, or they overwrite each other's layout.
43
+
44
+ Page thumbnails live in `dev/canvas/thumbnails/<id>.webp` (see `tio-canvas thumbnails`).
45
+
46
+ ### 2. Serve it from your dev server
47
+
48
+ The package does not own a server. Your dev server asks it where things are:
49
+
50
+ ```js
51
+ import { createCanvasHost } from '@tio-canvas/canvas/server';
52
+ const canvas = createCanvasHost({ root, config: 'dev/canvas/screens.js' });
53
+
54
+ // For a request under /canvas:
55
+ const { directory, localPath, project } = canvas.locate(pathname); // which file to read
56
+ // if (project) send `await canvas.projectModule()` as text/javascript instead of the file
57
+ // For an HTML page in development:
58
+ const { bridge, tools } = canvas.pageScripts(url, request.headers); // scripts to add before </body>
59
+ // For GET /__dev/interface-kit.js:
60
+ const script = await canvas.interfaceKitBundle();
61
+ // Reload the workspace when any of these change:
62
+ for (const dir of canvas.watchTargets()) watch(dir, { recursive: true }, notify);
63
+ ```
64
+
65
+ `tio-web`'s `build/serve.mjs` is the reference integration.
66
+
67
+ ### 3. Publish it with your site (optional)
68
+
69
+ ```js
70
+ import { copyCanvas } from '@tio-canvas/canvas/build';
71
+ const screens = await copyCanvas({ root, config: 'dev/canvas/screens.js', target: 'dist/canvas' });
72
+ ```
73
+
74
+ This writes the canvas, your screens as `project.js`, only the thumbnails of
75
+ screens that exist, and a `noindex` tag. Do not link to it from a public page.
76
+
77
+ ### 4. Thumbnails
78
+
79
+ With the dev server running:
80
+
81
+ ```bash
82
+ npx tio-canvas thumbnails http://localhost:4321 --missing
83
+ npx tio-canvas thumbnails --check # fails when a screen has no thumbnail
84
+ ```
85
+
86
+ Options: `--config <file>`, `--out <dir>`. It uses a Chromium from the Playwright
87
+ cache (`~/Library/Caches/ms-playwright`) or `CHROME_BIN`.
88
+
89
+ ## Updating
90
+
91
+ Releases follow semver. A project updates with `pnpm update @tio-canvas/canvas`.
92
+ Nothing else needs to change as long as the major version stays the same.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ pnpm install
98
+ pnpm test # unit tests and a smoke test of the server and build helpers
99
+ ```
100
+
101
+ `client/` is served at `/canvas/`. Its files are the canvas exactly as it ran
102
+ inside `tio-web` (the commit it was extracted from is in the first commit
103
+ message), with two changes: `screens.js` now reads `project.js` and
104
+ `registry.js`, and `canvas.js` takes its storage key from the project.
105
+
106
+ Publishing: `npm login`, then `npm publish` (the scope is public through
107
+ `publishConfig`). Bump the version first and tag it `vX.Y.Z`.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ const [command, ...rest] = process.argv.slice(2);
3
+ const commands = {
4
+ thumbnails: () => import("../src/thumbnails.js")
5
+ };
6
+ if (!commands[command]) {
7
+ console.error(`Usage: tio-canvas thumbnails [base-url] [--missing|--check] [--config <file>] [--out <dir>]`);
8
+ process.exit(command ? 1 : 0);
9
+ }
10
+ process.argv.splice(2, 1, ...rest);
11
+ process.argv.length = 2 + rest.length;
12
+ await commands[command]();
@@ -0,0 +1,41 @@
1
+ import { bounds, pixelRect } from './geometry.js';
2
+ import { groupSpacing } from './spacing.js';
3
+
4
+ const alignments = {
5
+ left: ['x', 'width', 0], 'horizontal-center': ['x', 'width', .5], right: ['x', 'width', 1],
6
+ top: ['y', 'height', 0], 'vertical-center': ['y', 'height', .5], bottom: ['y', 'height', 1],
7
+ };
8
+ const alignmentKeys = { KeyA: 'left', KeyH: 'horizontal-center', KeyD: 'right', KeyW: 'top', KeyV: 'vertical-center', KeyS: 'bottom' };
9
+
10
+ export function arrangeShortcut(event) {
11
+ if (!event.altKey || event.metaKey || event.shiftKey) return null;
12
+ // Option changes event.key on macOS; physical codes keep these shortcuts usable.
13
+ if (event.ctrlKey) return event.code === 'KeyH' ? 'distribute-x' : event.code === 'KeyV' ? 'distribute-y' : null;
14
+ return Object.hasOwn(alignmentKeys, event.code) ? alignmentKeys[event.code] : null;
15
+ }
16
+
17
+ // Arrange within the selection's existing bounds, never resize or reorder screens.
18
+ // Distribution shares the gap fields' bands, so grid columns/rows stay together.
19
+ export function arrangeScreens(rectangles, action) {
20
+ if (rectangles.length < 2) return null;
21
+ const result = rectangles.map(pixelRect), box = bounds(result);
22
+ const alignment = Object.hasOwn(alignments, action) ? alignments[action] : null;
23
+ if (alignment) {
24
+ const [axis, size, anchor] = alignment;
25
+ for (const rect of result) rect[axis] = Math.round(box[axis] + (box[size] - rect[size]) * anchor);
26
+ return result;
27
+ }
28
+ const axis = action === 'distribute-x' ? 'x' : action === 'distribute-y' ? 'y' : null;
29
+ if (!axis) return null;
30
+ const spacing = groupSpacing(result, { width: 20000, height: 11250 }, axis);
31
+ if (!spacing || spacing.bands.length < 3) return null;
32
+ const total = spacing.bands.reduce((sum, band) => sum + band.size, 0);
33
+ const gap = (box[spacing.size] - total) / (spacing.bands.length - 1);
34
+ let position = box[axis];
35
+ for (const band of spacing.bands) {
36
+ // Rounding cumulative positions spreads fractional pixels instead of drifting.
37
+ for (const { index } of band.items) result[index][axis] = Math.round(Math.max(box[axis], Math.min(position, box[axis] + box[spacing.size] - band.size)));
38
+ position += band.size + gap;
39
+ }
40
+ return result;
41
+ }
@@ -0,0 +1,56 @@
1
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
2
+ export const SIDES = ['top', 'right', 'bottom', 'left'];
3
+ export const ARROW_GAP = 8;
4
+
5
+ // Bindings use object IDs and a fraction along an edge, not screen-specific data.
6
+ export function boundPoint(binding, objects) {
7
+ const object = objects.find(item => item.id === binding.objectId);
8
+ if (!object) return null;
9
+ const { x, y, width, height } = object, t = binding.t;
10
+ if (binding.side === 'top') return { x: x + width * t, y: y - ARROW_GAP };
11
+ if (binding.side === 'bottom') return { x: x + width * t, y: y + height + ARROW_GAP };
12
+ if (binding.side === 'left') return { x: x - ARROW_GAP, y: y + height * t };
13
+ return { x: x + width + ARROW_GAP, y: y + height * t };
14
+ }
15
+ export function resolvePoint(point, objects) {
16
+ return point.binding ? boundPoint(point.binding, objects) ?? point : point;
17
+ }
18
+ export function snapArrow(point, objects, scale) {
19
+ let center = null, edge = null;
20
+ for (const object of objects) for (const side of SIDES) {
21
+ const binding = { objectId: object.id, side, t: .5 };
22
+ const middle = boundPoint(binding, objects);
23
+ const distance = Math.hypot(point.x - middle.x, point.y - middle.y) * scale;
24
+ if (distance <= 20 && (!center || distance < center.distance)) center = { ...middle, binding: { ...binding }, distance };
25
+ const horizontal = side === 'top' || side === 'bottom';
26
+ binding.t = clamp((point[horizontal ? 'x' : 'y'] - object[horizontal ? 'x' : 'y']) / object[horizontal ? 'width' : 'height'], 0, 1);
27
+ const projected = boundPoint(binding, objects);
28
+ const edgeDistance = Math.hypot(point.x - projected.x, point.y - projected.y) * scale;
29
+ if (edgeDistance <= 12 && (!edge || edgeDistance < edge.distance)) edge = { ...projected, binding: { ...binding }, distance: edgeDistance };
30
+ }
31
+ const result = center ?? edge;
32
+ return result ? { x: result.x, y: result.y, binding: result.binding } : { x: point.x, y: point.y };
33
+ }
34
+ export function arrowGeometry(arrow, objects) {
35
+ const start = resolvePoint(arrow.start, objects), end = resolvePoint(arrow.end, objects);
36
+ const dx = end.x - start.x, dy = end.y - start.y, length = Math.hypot(dx, dy);
37
+ const normal = length ? { x: -dy / length, y: dx / length } : { x: 0, y: 1 };
38
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
39
+ const middle = { x: midpoint.x + normal.x * arrow.bend, y: midpoint.y + normal.y * arrow.bend };
40
+ const h = Math.abs(arrow.bend);
41
+ const radius = h ? length * length / (8 * h) + h / 2 : 0;
42
+ const path = h > .01 && length > .01
43
+ ? `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${h > length / 2 ? 1 : 0} ${arrow.bend > 0 ? 0 : 1} ${end.x} ${end.y}`
44
+ : `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
45
+ return { start, end, middle, midpoint, normal, length, path };
46
+ }
47
+ export function readArrows(value) {
48
+ if (!Array.isArray(value)) return [];
49
+ const number = n => Number.isFinite(n) && Math.abs(n) <= 40000;
50
+ const point = p => p && number(p.x) && number(p.y) && (!p.binding || (typeof p.binding.objectId === 'string' && SIDES.includes(p.binding.side) && Number.isFinite(p.binding.t) && p.binding.t >= 0 && p.binding.t <= 1));
51
+ const ids = new Set();
52
+ return value.slice(0, 1000).filter(a => {
53
+ if (!a || typeof a.id !== 'string' || a.id.length > 100 || ids.has(a.id) || !point(a.start) || !point(a.end) || !number(a.bend) || !['arrow', 'line'].includes(a.startStyle) || !['arrow', 'line'].includes(a.endStyle) || !Number.isFinite(a.width) || a.width < 1 || a.width > 16 || !['solid', 'dashed', 'dotted'].includes(a.pattern)) return false;
54
+ ids.add(a.id);return true;
55
+ }).map(a => ({ ...structuredClone(a), width: Math.max(2, Math.min(10, Math.round(a.width / 2) * 2)) }));
56
+ }
@@ -0,0 +1,68 @@
1
+ import { arrowGeometry } from './arrow-geometry.js';
2
+
3
+ function arc(arrow, objects) {
4
+ const g = arrowGeometry(arrow, objects);
5
+ if (Math.abs(arrow.bend) < .01 || g.length < .01) return { ...g, curved: false };
6
+ const radius = g.length ** 2 / (8 * Math.abs(arrow.bend)) + Math.abs(arrow.bend) / 2;
7
+ const signedRadius = Math.sign(arrow.bend) * radius;
8
+ const center = { x: g.midpoint.x + g.normal.x * (arrow.bend - signedRadius), y: g.midpoint.y + g.normal.y * (arrow.bend - signedRadius) };
9
+ const startAngle = Math.atan2(g.start.y - center.y, g.start.x - center.x);
10
+ const endAngle = Math.atan2(g.end.y - center.y, g.end.x - center.x);
11
+ const direction = arrow.bend > 0 ? -1 : 1;
12
+ const distance = angle => ((angle - startAngle) * direction % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
13
+ const sweep = distance(endAngle);
14
+ return { ...g, curved: true, radius, center, contains: point => distance(Math.atan2(point.y - center.y, point.x - center.x)) <= sweep + 1e-8 };
15
+ }
16
+ export function arrowBounds(arrows, objects) {
17
+ const points = [];
18
+ for (const arrow of arrows) {
19
+ const g = arc(arrow, objects);points.push(g.start, g.end);
20
+ if (g.curved) for (const [dx, dy] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
21
+ const point = { x: g.center.x + dx * g.radius, y: g.center.y + dy * g.radius };
22
+ if (g.contains(point)) points.push(point);
23
+ }
24
+ }
25
+ if (!points.length) return null;
26
+ const x = Math.min(...points.map(p => p.x)), y = Math.min(...points.map(p => p.y));
27
+ return { x, y, width: Math.max(...points.map(p => p.x)) - x, height: Math.max(...points.map(p => p.y)) - y };
28
+ }
29
+ export function arrowIntersects(arrow, box, objects) {
30
+ if (box.width <= 0 || box.height <= 0) return false;
31
+ const g = arc(arrow, objects), right = box.x + box.width, bottom = box.y + box.height;
32
+ const inside = p => p.x >= box.x && p.x <= right && p.y >= box.y && p.y <= bottom;
33
+ if (inside(g.start) || inside(g.end)) return true;
34
+ if (!g.curved) {
35
+ // Slab intersection rejects empty parts of a diagonal's bounding box.
36
+ let low = 0, high = 1;
37
+ for (const [axis, min, max] of [['x', box.x, right], ['y', box.y, bottom]]) {
38
+ const d = g.end[axis] - g.start[axis];
39
+ if (!d) { if (g.start[axis] < min || g.start[axis] > max) return false;continue; }
40
+ const a = (min - g.start[axis]) / d, b = (max - g.start[axis]) / d;
41
+ low = Math.max(low, Math.min(a, b));high = Math.min(high, Math.max(a, b));
42
+ }
43
+ return low <= high;
44
+ }
45
+ for (const x of [box.x, right]) {
46
+ const squared = g.radius ** 2 - (x - g.center.x) ** 2;
47
+ if (squared < 0) continue;
48
+ for (const y of [g.center.y - Math.sqrt(squared), g.center.y + Math.sqrt(squared)]) if (y >= box.y && y <= bottom && g.contains({ x, y })) return true;
49
+ }
50
+ for (const y of [box.y, bottom]) {
51
+ const squared = g.radius ** 2 - (y - g.center.y) ** 2;
52
+ if (squared < 0) continue;
53
+ for (const x of [g.center.x - Math.sqrt(squared), g.center.x + Math.sqrt(squared)]) if (x >= box.x && x <= right && g.contains({ x, y })) return true;
54
+ }
55
+ return false;
56
+ }
57
+ export function resizeArrows(originals, box, corner, point, origin) {
58
+ if (originals.some(a => a.start.binding || a.end.binding)) return null;
59
+ const anchor = { x: box.x + (corner.includes('w') ? box.width : 0), y: box.y + (corner.includes('n') ? box.height : 0) };
60
+ const vector = { x: origin.x - anchor.x, y: origin.y - anchor.y };
61
+ const length = vector.x ** 2 + vector.y ** 2;
62
+ const scale = Math.max(.01, length ? ((point.x - anchor.x) * vector.x + (point.y - anchor.y) * vector.y) / length : 1);
63
+ return originals.map(arrow => {
64
+ const next = structuredClone(arrow);
65
+ for (const name of ['start', 'end']) next[name] = { x: anchor.x + (arrow[name].x - anchor.x) * scale, y: anchor.y + (arrow[name].y - anchor.y) * scale };
66
+ next.bend *= scale;return next;
67
+ });
68
+ }
@@ -0,0 +1,205 @@
1
+ import { SIDES, boundPoint, resolvePoint, snapArrow, arrowGeometry, readArrows } from './arrow-geometry.js';
2
+ import { arrowBounds, arrowIntersects, resizeArrows } from './arrow-selection.js';
3
+ import { setupArrowFields } from './select-field.js';
4
+
5
+ const NS = 'http://www.w3.org/2000/svg';
6
+ const svgElement = (tag, attributes = {}) => {
7
+ const node = document.createElementNS(NS, tag);
8
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
9
+ return node;
10
+ };
11
+ export function createArrows({ viewport, world, camera, objects, saved, checkpoint, commit, clearScreens, screenSelectionCount, rank, schedule, stopCanvas }) {
12
+ let arrows = readArrows(saved), tool = 'cursor', drag = null, hover = null;
13
+ const chosen = new Set();
14
+ const layer = document.createElement('div');layer.id = 'arrow-layer';world.append(layer);
15
+ const dots = document.createElement('div');dots.id = 'arrow-snaps';
16
+ const nodes = document.createElement('div');nodes.id = 'arrow-nodes';viewport.append(dots, nodes);
17
+ const toolbar = document.querySelector('#arrow-inspector'), fields = setupArrowFields(toolbar);
18
+ const cursorButton = document.querySelector('#tool-cursor'), arrowButton = document.querySelector('#tool-arrow'), textButton = document.querySelector('#tool-text');
19
+ const pointAt = event => ({ x: (event.clientX - camera.x) / camera.scale, y: (event.clientY - camera.y) / camera.scale });
20
+ const screenPoint = point => ({ x: camera.x + point.x * camera.scale, y: camera.y + point.y * camera.scale });
21
+ const selected = () => arrows.filter(a => chosen.has(a.id));
22
+ function choose(arrow, toggle = false) {
23
+ if (!toggle) { chosen.clear();if (arrow) clearScreens(); }
24
+ if (arrow) { if (toggle && chosen.has(arrow.id)) chosen.delete(arrow.id);else chosen.add(arrow.id); }
25
+ schedule();
26
+ }
27
+ function setTool(value) {
28
+ cancel();stopCanvas();fields.close();tool = value;
29
+ document.body.dataset.canvasTool = tool;
30
+ cursorButton.setAttribute('aria-pressed', String(tool === 'cursor'));arrowButton.setAttribute('aria-pressed', String(tool === 'arrow'));textButton.setAttribute('aria-pressed', String(tool === 'text'));
31
+ if (tool === 'arrow' || tool === 'text') { clearScreens();chosen.clear(); }
32
+ document.dispatchEvent(new CustomEvent('tio-canvas:tool', { detail: tool }));
33
+ schedule();
34
+ }
35
+ cursorButton.onclick = () => setTool('cursor');arrowButton.onclick = () => setTool('arrow');
36
+ function begin(event, arrow, part, before = checkpoint()) {
37
+ if (drag || event.button !== 0) return;
38
+ stopCanvas();fields.close();event.preventDefault();event.stopPropagation();viewport.focus({ preventScroll: true });
39
+ if (arrow && !chosen.has(arrow.id)) choose(arrow);
40
+ drag = { id: event.pointerId, arrow, part, before, origin: pointAt(event), originals: structuredClone(selected()), created: part === 'create', box: arrowBounds(selected(), objects()) };
41
+ document.body.dataset.arrowDrag = '';viewport.setPointerCapture(event.pointerId);schedule();
42
+ }
43
+ viewport.addEventListener('pointerdown', event => {
44
+ if (document.body.hasAttribute('data-fullscreen') || document.body.hasAttribute('data-space') || event.button !== 0 || event.target.closest('#arrow-inspector')) return;
45
+ const corner = event.target.closest('[data-arrow-corner]');
46
+ if (corner) { begin(event, null, corner.dataset.arrowCorner);return; }
47
+ const handle = event.target.closest('[data-arrow-node]');
48
+ if (handle && selected().length === 1) { begin(event, selected()[0], handle.dataset.arrowNode);return; }
49
+ const hit = event.target.closest('[data-arrow-id]');
50
+ if (tool === 'cursor' && hit) {
51
+ const arrow = arrows.find(a => a.id === hit.dataset.arrowId);
52
+ if (event.shiftKey) { event.preventDefault();event.stopPropagation();choose(arrow, true);return; }
53
+ if (arrow) begin(event, arrow, 'move');return;
54
+ }
55
+ if (tool !== 'arrow') return;
56
+ if (arrows.length >= 1000) { event.preventDefault();event.stopPropagation();document.querySelector('#canvas-status').textContent = 'The canvas supports up to 1,000 arrows.';return; }
57
+ const before = checkpoint(), start = snapArrow(pointAt(event), objects(), camera.scale);
58
+ const arrow = { id: crypto.randomUUID(), start, end: structuredClone(start), bend: 0, startStyle: 'line', endStyle: 'arrow', width: 4, pattern: 'solid' };
59
+ arrows.push(arrow);choose(arrow);begin(event, arrow, 'create', before);
60
+ }, true);
61
+ function move(event) {
62
+ if (!drag) { if (tool === 'arrow') { hover = pointAt(event);schedule(); }return; }
63
+ if (event.pointerId !== drag.id) return;
64
+ const { arrow, part, originals, origin } = drag, point = pointAt(event);
65
+ if (!drag.moved && part !== 'create' && Math.hypot(point.x - origin.x, point.y - origin.y) * camera.scale < 3) return;
66
+ drag.moved = true;
67
+ if (['nw', 'ne', 'sw', 'se'].includes(part)) {
68
+ const next = resizeArrows(originals, drag.box, part, point, origin);
69
+ if (next) for (const model of next) Object.assign(arrows.find(a => a.id === model.id), model);
70
+ } else if (part === 'middle') {
71
+ const geometry = arrowGeometry(arrow, objects());
72
+ arrow.bend = (point.x - geometry.midpoint.x) * geometry.normal.x + (point.y - geometry.midpoint.y) * geometry.normal.y;
73
+ if (Math.abs(arrow.bend) * camera.scale < 3) arrow.bend = 0;
74
+ } else if (part === 'move') {
75
+ // A bound multi-selection is config-only; moving it would break its bindings.
76
+ if (originals.length > 1 && originals.some(a => a.start.binding || a.end.binding)) return;
77
+ for (const original of originals) for (const name of ['start', 'end']) {
78
+ const base = resolvePoint(original[name], objects()), target = arrows.find(a => a.id === original.id);
79
+ const nextPoint = { x: base.x + point.x - origin.x, y: base.y + point.y - origin.y };
80
+ target[name] = originals.length > 1 ? nextPoint : snapArrow(nextPoint, objects(), camera.scale);
81
+ }
82
+ } else arrow[part === 'create' ? 'end' : part] = snapArrow(point, objects(), camera.scale);
83
+ hover = point;schedule();
84
+ }
85
+ function finish(event) {
86
+ if (!drag || event.pointerId !== drag.id) return;
87
+ move(event);const previous = drag;drag = null;
88
+ if (viewport.hasPointerCapture(previous.id)) viewport.releasePointerCapture(previous.id);
89
+ delete document.body.dataset.arrowDrag;
90
+ if (previous.created && arrowGeometry(previous.arrow, objects()).length * camera.scale < 4) { arrows.splice(arrows.indexOf(previous.arrow), 1);chosen.clear(); }
91
+ else if (previous.created) setTool('cursor');
92
+ commit(previous.before);schedule();
93
+ }
94
+ function cancel() {
95
+ if (!drag) return false;
96
+ const previous = drag;drag = null;
97
+ if (viewport.hasPointerCapture(previous.id)) viewport.releasePointerCapture(previous.id);
98
+ if (previous.created) { arrows.splice(arrows.indexOf(previous.arrow), 1);chosen.clear(); }
99
+ else for (const original of previous.originals) Object.assign(arrows.find(a => a.id === original.id), original);
100
+ delete document.body.dataset.arrowDrag;schedule();return true;
101
+ }
102
+ window.addEventListener('pointermove', move);window.addEventListener('pointerup', finish);window.addEventListener('pointercancel', cancel);window.addEventListener('blur', cancel);
103
+ window.addEventListener('keydown', event => {
104
+ if (document.body.hasAttribute('data-fullscreen') || document.querySelector('dialog[open]') || event.target.closest?.('input,textarea,select,[contenteditable],.icon-options')) return;
105
+ if (event.key === 'Escape') { cancel();choose(null);setTool('cursor'); }
106
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
107
+ if (event.key.toLowerCase() === 'v') setTool('cursor');if (event.key.toLowerCase() === 'a') setTool('arrow');
108
+ if (chosen.size && ['Delete', 'Backspace'].includes(event.key) && !event.defaultPrevented) { event.preventDefault();const before = checkpoint();removeSelected();commit(before); }
109
+ });
110
+ function removeSelected() { arrows = arrows.filter(a => !chosen.has(a.id));choose(null); }
111
+ toolbar.addEventListener('submit', event => event.preventDefault());
112
+ toolbar.addEventListener('change', event => {
113
+ if (!chosen.size) return;
114
+ const field = event.target.dataset.arrowField;if (!field) return;
115
+ const value = field === 'width' ? Number(event.target.value) : event.target.value;
116
+ if ((field === 'width' && ![2, 4, 6, 8, 10].includes(value)) || (['startStyle', 'endStyle'].includes(field) && !['line', 'arrow'].includes(value)) || (field === 'pattern' && !['solid', 'dashed'].includes(value))) return;
117
+ const before = checkpoint();for (const arrow of selected()) arrow[field] = value;commit(before);schedule();
118
+ });
119
+ document.querySelector('#arrow-straight').onclick = () => { if (!chosen.size) return;const before = checkpoint();for (const arrow of selected()) arrow.bend = 0;commit(before);schedule(); };
120
+ function render() {
121
+ const all = objects(), targets = selected();
122
+ for (const arrow of arrows) for (const name of ['start', 'end']) {
123
+ const point = arrow[name];if (!point.binding) continue;
124
+ const resolved = boundPoint(point.binding, all);if (resolved) Object.assign(point, resolved);else delete point.binding;
125
+ }
126
+ const focusedId = document.activeElement?.dataset?.arrowId, focusedNode = document.activeElement?.dataset?.arrowNode;
127
+ const contents = [];
128
+ for (const arrow of arrows) {
129
+ const geometry = arrowGeometry(arrow, all), active = chosen.has(arrow.id);
130
+ const svg = svgElement('svg', { class: 'arrow-object', 'data-object-id': arrow.id, width: 20000, height: 11250 });svg.style.zIndex = rank(arrow.id);
131
+ const defs = svgElement('defs'), length = 6 + arrow.width * 2, headSize = length + arrow.width * 2;
132
+ for (const end of ['start', 'end']) {
133
+ const marker = svgElement('marker', { id: `arrow-${arrow.id}-${end}`, viewBox: `${-length - arrow.width} ${-headSize / 2} ${headSize} ${headSize}`, markerWidth: headSize, markerHeight: headSize, refX: 0, refY: 0, orient: 'auto-start-reverse', markerUnits: 'userSpaceOnUse', overflow: 'visible' });
134
+ marker.append(svgElement('path', { d: `M ${-length} ${-length / 2} L 0 0 L ${-length} ${length / 2}`, fill: 'none', stroke: 'context-stroke', 'stroke-width': arrow.width, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));defs.append(marker);
135
+ }
136
+ const path = svgElement('path', { d: geometry.path, class: 'arrow-line', 'stroke-width': arrow.width });
137
+ if (arrow.pattern !== 'solid') path.setAttribute('stroke-dasharray', `${arrow.width * 2} ${arrow.width * 3}`);
138
+ for (const end of ['start', 'end']) if (arrow[`${end}Style`] === 'arrow') path.setAttribute(`marker-${end}`, `url(#arrow-${arrow.id}-${end})`);
139
+ svg.append(defs, path);
140
+ if (active) {
141
+ const highlight = svgElement('path', { d: geometry.path, class: 'arrow-highlight', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' });
142
+ if (arrow.pattern !== 'solid') highlight.setAttribute('stroke-dasharray', `${arrow.width * 2 * camera.scale} ${arrow.width * 3 * camera.scale}`);
143
+ svg.append(highlight);
144
+ }
145
+ const hit = svgElement('path', { d: geometry.path, class: 'arrow-hit', 'data-arrow-id': arrow.id, 'stroke-width': Math.max(16 / camera.scale, arrow.width + 8), tabindex: 0, role: 'button', 'aria-label': 'Select arrow', 'aria-pressed': String(active) });
146
+ hit.addEventListener('focus', () => { if (!chosen.has(arrow.id)) choose(arrow); });
147
+ hit.addEventListener('keydown', event => { if (['Enter', ' '].includes(event.key)) { event.preventDefault();choose(arrow, event.shiftKey); } });
148
+ svg.append(hit);contents.push(svg);
149
+ }
150
+ layer.replaceChildren(...contents);
151
+ if (focusedId) [...layer.querySelectorAll('[data-arrow-id]')].find(node => node.dataset.arrowId === focusedId)?.focus({ preventScroll: true });
152
+ dots.replaceChildren();
153
+ if (tool === 'arrow' || (drag && ['start', 'end', 'create'].includes(drag.part))) for (const object of all) {
154
+ const near = !hover || (hover.x >= object.x - 60 / camera.scale && hover.x <= object.x + object.width + 60 / camera.scale && hover.y >= object.y - 60 / camera.scale && hover.y <= object.y + object.height + 60 / camera.scale);
155
+ if (!near) continue;
156
+ for (const side of SIDES) {
157
+ const binding = { objectId: object.id, side, t: .5 }, p = screenPoint(boundPoint(binding, all));
158
+ const dot = document.createElement('span');dot.className = 'arrow-snap';dot.dataset.side = side;
159
+ if (drag && ['start', 'end', 'create'].includes(drag.part)) { const active = drag.arrow[drag.part === 'create' ? 'end' : drag.part].binding;dot.toggleAttribute('data-active', active?.objectId === object.id && active.side === side && active.t === .5); }
160
+ dot.style.left = `${p.x}px`;dot.style.top = `${p.y}px`;dots.append(dot);
161
+ }
162
+ }
163
+ nodes.replaceChildren();toolbar.hidden = !targets.length || !!drag;
164
+ if (toolbar.hidden) fields.close();
165
+ if (!targets.length) return;
166
+ if (targets.length === 1 && !screenSelectionCount()) {
167
+ const arrow = targets[0], geometry = arrowGeometry(arrow, all);
168
+ for (const name of ['start', 'middle', 'end']) {
169
+ const p = screenPoint(geometry[name]), button = document.createElement('button');button.type = 'button';button.className = 'smart-handle arrow-node';button.dataset.arrowNode = name;
170
+ button.setAttribute('aria-label', name === 'middle' ? 'Bend arrow symmetrically' : `Move arrow ${name}`);button.style.left = `${p.x}px`;button.style.top = `${p.y}px`;
171
+ nodes.append(button);
172
+ }
173
+ } else if (targets.length > 1 && !screenSelectionCount()) {
174
+ const box = arrowBounds(targets, all), p = screenPoint(box), frame = document.createElement('div');frame.className = 'arrow-group-box';
175
+ Object.assign(frame.style, { left: `${p.x}px`, top: `${p.y}px`, width: `${box.width * camera.scale}px`, height: `${box.height * camera.scale}px` });nodes.append(frame);
176
+ const locked = targets.some(a => a.start.binding || a.end.binding);frame.dataset.locked = String(locked);
177
+ if (!locked) for (const [corner, x, y] of [['nw', 0, 0], ['ne', 100, 0], ['se', 100, 100], ['sw', 0, 100]]) {
178
+ const button = document.createElement('button');button.type = 'button';button.className = 'handle';button.dataset.arrowCorner = corner;button.setAttribute('aria-label', `Resize arrows ${corner}`);
179
+ button.style.cssText = `left:${x}%;top:${y}%;cursor:${corner}-resize`;frame.append(button);
180
+ }
181
+ }
182
+ if (focusedNode && !drag) nodes.querySelector(`[data-arrow-node="${focusedNode}"]`)?.focus({ preventScroll: true });
183
+ for (const field of toolbar.querySelectorAll('[data-arrow-field]')) {
184
+ const values = targets.map(a => a[field.dataset.arrowField]), same = values.every(value => value === values[0]);
185
+ if (document.activeElement !== field) field.value = same ? String(values[0]) : '';
186
+ }
187
+ fields.update();
188
+ for (const element of toolbar.querySelectorAll('[data-curved-only]')) element.hidden = targets.every(a => Math.abs(a.bend) < .01);
189
+ const main = document.querySelector('#edit-tools').getBoundingClientRect(), inset = parseFloat(getComputedStyle(toolbar).getPropertyValue('--canvas-panel-inset'));
190
+ const center = Math.max(inset + toolbar.offsetWidth / 2, Math.min(innerWidth - inset - toolbar.offsetWidth / 2, main.left + main.width / 2));
191
+ toolbar.style.left = `${center}px`;toolbar.style.bottom = `${innerHeight - main.top + inset}px`;
192
+ }
193
+ return {
194
+ render,
195
+ capture: () => structuredClone(arrows),
196
+ restore(value, ids) { cancel();arrows = readArrows(value);chosen.clear();for (const id of Array.isArray(ids) ? ids : [ids]) if (arrows.some(a => a.id === id)) chosen.add(id);schedule(); },
197
+ get ids() { return arrows.map(a => a.id); },
198
+ get selectedIds() { return [...chosen]; },
199
+ clearSelection() { choose(null); },
200
+ selectRect(box, initial = []) { chosen.clear();for (const id of initial) if (arrows.some(a => a.id === id)) chosen.add(id);if (box) for (const arrow of arrows) if (arrowIntersects(arrow, box, objects())) chosen.add(arrow.id);schedule(); },
201
+ removeSelected,
202
+ cancel,
203
+ setTool,
204
+ };
205
+ }
@@ -0,0 +1,74 @@
1
+ import { arrangeShortcut } from './arrange.js';
2
+ import { layerShortcut } from './layer-order.js';
3
+ import { historyShortcut } from './history.js';
4
+ import { createTouchPreview } from './touch-preview.js';
5
+ // Dev-only host communication. Does not replace handlers or alter preview controls.
6
+ const send = data => parent.postMessage(data, location.origin);
7
+ let space = false, panning = false, active = false, fullscreen = false, canArrange = false;
8
+ const typing = target => target.closest?.('input,textarea,select,[contenteditable]:not([contenteditable="false"])');
9
+ window.addEventListener('pointerdown', event => {
10
+ if(fullscreen){send({type:'tio-canvas:preview-pointer'});return;}
11
+ if(event.button===0 && event.altKey && !space){event.preventDefault();event.stopImmediatePropagation();panning=true;send({type:'tio-canvas:alt-drag',screenX:event.screenX,screenY:event.screenY,shiftKey:event.shiftKey});return;}
12
+ if(event.button===0 && !space){if(!event.shiftKey)active=true;send({type:'tio-canvas:select',shift:event.shiftKey});}
13
+ if(event.button===1 || (event.button===0 && space)) {
14
+ event.preventDefault();panning=true;
15
+ send({type:'tio-canvas:pan',screenX:event.screenX,screenY:event.screenY});
16
+ }
17
+ },true);
18
+ window.addEventListener('pointermove',event=>{if(panning)send({type:'tio-canvas:move',screenX:event.screenX,screenY:event.screenY,shiftKey:event.shiftKey,altKey:event.altKey,metaKey:event.metaKey,ctrlKey:event.ctrlKey});});
19
+ const stop=()=>{const active=panning||space;panning=false;space=false;if(active)send({type:'tio-canvas:stop'});};
20
+ window.addEventListener('pointerup',()=>{if(panning)stop();});window.addEventListener('pointercancel',stop);window.addEventListener('blur',stop);
21
+ window.addEventListener('contextmenu',event=>{if(fullscreen || typing(event.target))return;event.preventDefault();send({type:'tio-canvas:context',x:event.clientX,y:event.clientY});});
22
+ window.addEventListener('auxclick',event=>{if(!fullscreen && event.button===1)event.preventDefault();});
23
+ window.addEventListener('keydown',event=>{
24
+ if(fullscreen){if(event.key==='Escape')send({type:'tio-canvas:preview-escape'});return;}
25
+ if(event.shiftKey && event.code==='Digit1' && !event.metaKey && !event.ctrlKey && !event.altKey && !typing(event.target)){event.preventDefault();if(!event.repeat)send({type:'tio-canvas:fit-all'});}
26
+ if(event.shiftKey && event.code==='Digit2' && !event.metaKey && !event.ctrlKey && !event.altKey && !typing(event.target)){event.preventDefault();if(!event.repeat)send({type:'tio-canvas:fit-selection'});}
27
+ const arrangeAction=arrangeShortcut(event);
28
+ if(arrangeAction && canArrange && !typing(event.target)){event.preventDefault();if(!event.repeat)send({type:'tio-canvas:arrange',action:arrangeAction});return;}
29
+ const layerAction=layerShortcut(event);
30
+ if(layerAction && !typing(event.target)){event.preventDefault();send({type:'tio-canvas:layer',action:layerAction});}
31
+ const direction=historyShortcut(event);
32
+ if(direction && !typing(event.target)){event.preventDefault();send({type:'tio-canvas:history',direction});}
33
+ if(event.code==='Space' && !typing(event.target)){event.preventDefault();space=true;send({type:'tio-canvas:space',down:true});}
34
+ if((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase()==='d' && !typing(event.target)) {
35
+ event.preventDefault();if(!event.repeat)send({type:'tio-canvas:duplicate'});
36
+ }
37
+ if(['Backspace','Delete'].includes(event.key) && !typing(event.target)) {
38
+ event.preventDefault();if(!event.repeat)send({type:'tio-canvas:delete'});
39
+ }
40
+ });
41
+ window.addEventListener('keyup',event=>{if(fullscreen)return;if(event.code==='Space'){space=false;send({type:'tio-canvas:space',down:false});}});
42
+ const touchPreview=createTouchPreview(()=>active && !space && !panning,contact=>send({type:'tio-canvas:contact',...contact}));
43
+ window.addEventListener('message',event=>{
44
+ if(event.source!==parent || event.origin!==location.origin)return;
45
+ if(event.data?.type==='tio-canvas:input'){
46
+ fullscreen=event.data.fullscreen===true;
47
+ canArrange=event.data.canArrange===true;
48
+ if(fullscreen){space=false;panning=false;}
49
+ active=event.data.active===true;touchPreview.configure(!fullscreen && event.data.touch===true,active);return;
50
+ }
51
+ if(event.data?.type!=='tio-canvas:visibility')return;
52
+ const visible=event.data.visible===true;
53
+ document.documentElement.dataset.canvasVisible=String(visible);
54
+ window.dispatchEvent(new CustomEvent('canvasvisibilitychange',{detail:{visible}}));
55
+ });
56
+
57
+ // A single active screen keeps native wheel scrolling. Inactive screens pan
58
+ // the workspace; modified wheel gestures (including trackpad pinch) always zoom.
59
+ window.addEventListener('wheel',event=>{
60
+ if(fullscreen || (active && !event.metaKey && !event.ctrlKey && !space))return;
61
+ event.preventDefault();
62
+ // Embedded pages can scroll in their own wheel listeners even after the
63
+ // browser's default is cancelled. Canvas gestures must not reach them.
64
+ event.stopImmediatePropagation();
65
+ if(event.metaKey || event.ctrlKey)send({type:'tio-canvas:zoom',x:event.clientX,y:event.clientY,delta:event.deltaY,mode:event.deltaMode,ctrlKey:event.ctrlKey && !event.metaKey});
66
+ else send({type:'tio-canvas:wheel-pan',dx:event.deltaX,dy:event.deltaY,mode:event.deltaMode});
67
+ },{passive:false,capture:true});
68
+
69
+ // Report usable document readiness without waiting for images or weather.
70
+ async function reportReady(){
71
+ document.documentElement.getBoundingClientRect();await document.fonts.ready;
72
+ send({type:'tio-canvas:ready'});
73
+ }
74
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',reportReady,{once:true});else void reportReady();