@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.
- package/LICENSE +21 -0
- package/README.md +106 -2
- package/bin/tio-canvas.mjs +12 -0
- package/client/arrange.js +41 -0
- package/client/arrow-geometry.js +56 -0
- package/client/arrow-selection.js +68 -0
- package/client/arrows.js +205 -0
- package/client/bridge.js +74 -0
- package/client/canvas.css +733 -0
- package/client/canvas.js +1009 -0
- package/client/corners.js +22 -0
- package/client/device-gate.js +15 -0
- package/client/fullscreen.js +118 -0
- package/client/geometry.js +79 -0
- package/client/history.js +37 -0
- package/client/hover-preview.js +35 -0
- package/client/index.html +112 -0
- package/client/layer-order.js +20 -0
- package/client/layers.js +154 -0
- package/client/menu-position.js +36 -0
- package/client/onboarding.js +302 -0
- package/client/page-picker.js +76 -0
- package/client/platform.js +16 -0
- package/client/range-selection.js +54 -0
- package/client/registry.js +31 -0
- package/client/screen-loader.js +67 -0
- package/client/screens.js +7 -0
- package/client/select-field.js +191 -0
- package/client/shortcuts.js +158 -0
- package/client/smart-handles.js +28 -0
- package/client/smart-selection.js +52 -0
- package/client/spacing.js +45 -0
- package/client/text-format.js +49 -0
- package/client/text.js +466 -0
- package/client/tio-logo.png +0 -0
- package/client/tooltip.js +128 -0
- package/client/touch-preview.js +112 -0
- package/docs/exploration-canvas.md +315 -0
- package/package.json +31 -4
- package/src/build.js +33 -0
- package/src/index.js +3 -0
- package/src/interface-kit-bundle.js +103 -0
- package/src/interface-kit-entry.js +16 -0
- package/src/project.js +29 -0
- package/src/server.js +70 -0
- 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
|
-
#
|
|
1
|
+
# @tio-canvas/canvas
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/client/arrows.js
ADDED
|
@@ -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
|
+
}
|
package/client/bridge.js
ADDED
|
@@ -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();
|