@tio-canvas/canvas 0.0.0-stage → 0.1.1
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 +56 -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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
|
2
|
+
|
|
3
|
+
// Search the free vertical lanes beside fixed chrome, nearest the field first.
|
|
4
|
+
export function menuPosition({ anchor, width, height, viewport, margin, gap, obstacles, aboveOnly = false }) {
|
|
5
|
+
const left = margin, right = viewport.width - margin;
|
|
6
|
+
const top = margin, bottom = viewport.height - margin;
|
|
7
|
+
width = Math.min(width, right - left);
|
|
8
|
+
const idealX = anchor.left + (anchor.width - width) / 2;
|
|
9
|
+
const xs = [idealX, left, right - width];
|
|
10
|
+
for (const rect of obstacles) xs.push(rect.left - gap - width, rect.right + gap);
|
|
11
|
+
let best;
|
|
12
|
+
for (const x of new Set(xs.map(value => clamp(value, left, right - width)))) {
|
|
13
|
+
let lanes = [[top, bottom]];
|
|
14
|
+
for (const rect of obstacles) {
|
|
15
|
+
if (x + width <= rect.left - gap || x >= rect.right + gap) continue;
|
|
16
|
+
lanes = lanes.flatMap(([start, end]) => {
|
|
17
|
+
if (rect.bottom + gap <= start || rect.top - gap >= end) return [[start, end]];
|
|
18
|
+
return [[start, Math.min(end, rect.top - gap)], [Math.max(start, rect.bottom + gap), end]].filter(([a, b]) => b > a);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
for (const side of aboveOnly ? ['above'] : ['below', 'above']) {
|
|
22
|
+
for (const [start, end] of lanes) {
|
|
23
|
+
const min = side === 'below' ? Math.max(start, anchor.bottom + gap) : start;
|
|
24
|
+
const max = side === 'above' ? Math.min(end, anchor.top - gap) : end;
|
|
25
|
+
const available = max - min;
|
|
26
|
+
if (available <= 0) continue;
|
|
27
|
+
const h = Math.min(height, available);
|
|
28
|
+
const y = side === 'below' ? min : max - h;
|
|
29
|
+
const idealY = side === 'below' ? anchor.bottom + gap : anchor.top - gap - height;
|
|
30
|
+
const score = (height - h) * 10000 + Math.abs(x - idealX) + Math.abs(y - idealY) + (side === 'above' && !aboveOnly ? 0.5 : 0);
|
|
31
|
+
if (!best || score < best.score) best = { x, y, height: h, score };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// First-run tour for the canvas. It watches the page and never drives it: each
|
|
2
|
+
// step ends when the user does the thing the step is about, so there is no
|
|
3
|
+
// Back, and no Next except where there is nothing to do (a tip, or the finish).
|
|
4
|
+
// While it is up, every press and key outside what is highlighted is swallowed.
|
|
5
|
+
const KEY = 'tio-canvas:onboarding';
|
|
6
|
+
const PAD = 6, GAP = 12, MARGIN = 12, RADIUS = 14;
|
|
7
|
+
const $ = selector => document.querySelector(selector);
|
|
8
|
+
const visible = element => !!element && element.checkVisibility();
|
|
9
|
+
|
|
10
|
+
const inspectorReady = () => visible($('#inspector'));
|
|
11
|
+
const selectScreen = {
|
|
12
|
+
targets: () => [$('#layers')],
|
|
13
|
+
title: 'Select one screen',
|
|
14
|
+
body: 'Click a single screen in this list to edit its size.',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// pan: the target lives on the canvas, so it can end up off screen; the tour pans to it.
|
|
18
|
+
const steps = [
|
|
19
|
+
{
|
|
20
|
+
targets: () => [$('#add')],
|
|
21
|
+
title: 'Add a page',
|
|
22
|
+
body: 'Click here to open the page list.',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
targets: () => [$('#page-options .page-option')],
|
|
26
|
+
deny: '.page-checkbox-target', modal: true,
|
|
27
|
+
title: 'Pick a page',
|
|
28
|
+
body: 'Click a page to add it to the canvas.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
// The screen that was just added, clipped to what is on screen.
|
|
32
|
+
targets: () => [$('.screen[data-selected]') || $('.screen')],
|
|
33
|
+
clip: true,
|
|
34
|
+
title: 'Select a screen',
|
|
35
|
+
body: 'Click a screen to select it. Only the selected screen reacts to your clicks, scrolling and keys.',
|
|
36
|
+
action: 'Next',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
targets: () => [$('#inspector button.select-field')],
|
|
40
|
+
ready: inspectorReady, fallback: selectScreen, pan: true,
|
|
41
|
+
title: 'Change the size',
|
|
42
|
+
body: 'Open Preset and pick a device. The screen resizes to match.',
|
|
43
|
+
action: 'Next',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
targets: () => [$('#width')?.closest('label'), $('#height')?.closest('label')],
|
|
47
|
+
ready: inspectorReady, fallback: selectScreen, pan: true,
|
|
48
|
+
title: 'Or set your own',
|
|
49
|
+
body: 'Choose Custom in Preset to type an exact width and height.',
|
|
50
|
+
action: 'Next',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
targets: () => [$('.screen[data-selected] .screen-fullscreen')],
|
|
54
|
+
ready: () => visible($('.screen[data-selected] .screen-fullscreen')), fallback: selectScreen, pan: true,
|
|
55
|
+
title: 'Preview full screen',
|
|
56
|
+
body: 'Open the screen at its real size, without the canvas around it.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
// The preview's menu is closed until pressed; then the way back is what is pointed at.
|
|
60
|
+
targets: () => [$('#fullscreen-back')],
|
|
61
|
+
ready: () => visible($('#fullscreen-back')),
|
|
62
|
+
fallback: {
|
|
63
|
+
targets: () => [$('#fullscreen-toggle')],
|
|
64
|
+
title: 'Back to canvas',
|
|
65
|
+
body: 'Open this menu to return to the canvas.',
|
|
66
|
+
action: 'Finish',
|
|
67
|
+
},
|
|
68
|
+
title: 'Back to canvas',
|
|
69
|
+
body: 'Choose Back to canvas to leave the preview.',
|
|
70
|
+
action: 'Finish',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
const LAST = steps.length - 1;
|
|
74
|
+
const PICK = 1, SELECT = 2, PRESET = 3, FULLSCREEN = 5, BACK = 6;
|
|
75
|
+
|
|
76
|
+
let dim, card, titleEl, bodyEl, dotsEl, skipEl, actionEl;
|
|
77
|
+
let active = false, paused = false, index = 0, shownKey = '', frame = 0, motionTimer = 0, placed = false;
|
|
78
|
+
let countBefore = 0, activeTargets = [], activeDeny = '', offSince = 0, lastPan = 0;
|
|
79
|
+
|
|
80
|
+
function build() {
|
|
81
|
+
dim = document.createElement('div');
|
|
82
|
+
dim.className = 'onboarding-dim';
|
|
83
|
+
dim.setAttribute('aria-hidden', 'true');
|
|
84
|
+
dim.popover = 'manual';
|
|
85
|
+
card = document.createElement('div');
|
|
86
|
+
card.className = 'onboarding-card';
|
|
87
|
+
card.popover = 'manual';
|
|
88
|
+
card.setAttribute('role', 'group');
|
|
89
|
+
card.setAttribute('aria-label', 'Canvas tour');
|
|
90
|
+
card.innerHTML = '<div class="onboarding-copy" aria-live="polite"><h2></h2><p></p></div>'
|
|
91
|
+
+ '<div class="onboarding-footer"><div class="onboarding-dots" role="img"></div><div class="onboarding-actions">'
|
|
92
|
+
+ '<button type="button" class="secondary-button" data-tooltip="Skip the tour" data-shortcut="esc">Skip</button>'
|
|
93
|
+
+ '<button type="button" class="primary-button" hidden></button></div></div>';
|
|
94
|
+
titleEl = card.querySelector('h2');
|
|
95
|
+
bodyEl = card.querySelector('p');
|
|
96
|
+
dotsEl = card.querySelector('.onboarding-dots');
|
|
97
|
+
skipEl = card.querySelector('.secondary-button');
|
|
98
|
+
actionEl = card.querySelector('.primary-button');
|
|
99
|
+
for (let n = 0; n < steps.length; n++) dotsEl.append(document.createElement('span'));
|
|
100
|
+
skipEl.addEventListener('click', end);
|
|
101
|
+
actionEl.addEventListener('click', () => (index === LAST ? end() : go(index + 1)));
|
|
102
|
+
document.body.append(dim, card);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function remember() { try { localStorage.setItem(KEY, 'seen'); } catch { /* storage blocked: the tour may show again */ } }
|
|
106
|
+
function seen() { try { return localStorage.getItem(KEY) !== null; } catch { return true; } }
|
|
107
|
+
|
|
108
|
+
function bounds(elements) {
|
|
109
|
+
const rects = elements.filter(visible).map(element => element.getBoundingClientRect());
|
|
110
|
+
if (!rects.length) return null;
|
|
111
|
+
const left = Math.min(...rects.map(r => r.left)), top = Math.min(...rects.map(r => r.top));
|
|
112
|
+
const right = Math.max(...rects.map(r => r.right)), bottom = Math.max(...rects.map(r => r.bottom));
|
|
113
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A full-screen rectangle with a rounded hole. Every hole has the same path
|
|
117
|
+
// structure, so the browser can interpolate from one step's hole to the next.
|
|
118
|
+
function holePath(r) {
|
|
119
|
+
const W = innerWidth, H = innerHeight, x = r.left - PAD, y = r.top - PAD, w = r.width + 2 * PAD, h = r.height + 2 * PAD, k = Math.min(RADIUS, w / 2, h / 2);
|
|
120
|
+
return `path(evenodd, "M0 0H${W}V${H}H0Z M${x + k} ${y}H${x + w - k}A${k} ${k} 0 0 1 ${x + w} ${y + k}V${y + h - k}A${k} ${k} 0 0 1 ${x + w - k} ${y + h}H${x + k}A${k} ${k} 0 0 1 ${x} ${y + h - k}V${y + k}A${k} ${k} 0 0 1 ${x + k} ${y}Z")`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function render(step, key) {
|
|
124
|
+
titleEl.textContent = step.title;
|
|
125
|
+
bodyEl.textContent = step.body;
|
|
126
|
+
skipEl.hidden = index === LAST;
|
|
127
|
+
actionEl.hidden = !step.action;
|
|
128
|
+
actionEl.textContent = step.action || '';
|
|
129
|
+
[...dotsEl.children].forEach((dot, n) => { dot.dataset.on = String(n === index); });
|
|
130
|
+
dotsEl.setAttribute('aria-label', `Step ${index + 1} of ${steps.length}`);
|
|
131
|
+
if (placed) {
|
|
132
|
+
dim.dataset.motion = ''; card.dataset.motion = '';
|
|
133
|
+
clearTimeout(motionTimer);
|
|
134
|
+
motionTimer = setTimeout(() => { delete dim.dataset.motion; delete card.dataset.motion; }, 300);
|
|
135
|
+
}
|
|
136
|
+
card.classList.remove('is-swapping'); void card.offsetWidth; card.classList.add('is-swapping');
|
|
137
|
+
shownKey = key;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Top-layer elements stack in the order they were shown, so lift the tour above
|
|
141
|
+
// a dialog that opened after it.
|
|
142
|
+
function raise() {
|
|
143
|
+
for (const element of [dim, card]) { if (element.matches(':popover-open')) element.hidePopover(); element.showPopover(); }
|
|
144
|
+
}
|
|
145
|
+
// A modal dialog makes everything outside its own subtree inert, the card
|
|
146
|
+
// included. Inside the dialog it stays pressable, and Skip stays reachable.
|
|
147
|
+
function dock(modal) {
|
|
148
|
+
const home = modal ? $('#add-pages') : document.body;
|
|
149
|
+
if (home && card.parentElement !== home) { home.append(card); raise(); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Pan the canvas by the least distance that brings an on-canvas target fully into view.
|
|
153
|
+
function reveal(rect) {
|
|
154
|
+
const left = visible($('#layers')) ? $('#layers').getBoundingClientRect().right + 16 : 16;
|
|
155
|
+
const right = innerWidth - 16, top = 150, bottom = innerHeight - 88;
|
|
156
|
+
let dx = 0, dy = 0;
|
|
157
|
+
if (rect.left < left) dx = left - rect.left; else if (rect.right > right) dx = right - rect.right;
|
|
158
|
+
if (rect.bottom > bottom) dy = bottom - rect.bottom; else if (rect.top < top) dy = top - rect.top;
|
|
159
|
+
if (!dx && !dy) { offSince = 0; return; }
|
|
160
|
+
const now = performance.now();
|
|
161
|
+
if (!offSince) offSince = now;
|
|
162
|
+
// Let any camera move that is already running (a refit after a resize) finish first.
|
|
163
|
+
if (now - offSince < 300 || now - lastPan < 700) return;
|
|
164
|
+
lastPan = now;
|
|
165
|
+
document.dispatchEvent(new CustomEvent('tio-canvas:pan-by', { detail: { dx, dy } }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function layout() {
|
|
169
|
+
const step = steps[index];
|
|
170
|
+
const resolved = step.ready && !step.ready() ? step.fallback : step;
|
|
171
|
+
const key = `${index}:${resolved === step ? 'main' : 'fallback'}`;
|
|
172
|
+
dock(!!step.modal && !!$('#add-pages')?.open);
|
|
173
|
+
if (key !== shownKey) render(resolved, key);
|
|
174
|
+
activeTargets = resolved.targets();
|
|
175
|
+
activeDeny = resolved.deny || '';
|
|
176
|
+
const rect = bounds(activeTargets);
|
|
177
|
+
if (rect && resolved.clip) {
|
|
178
|
+
// A screen can be bigger than the window: point at the part that is visible.
|
|
179
|
+
const left = visible($('#layers')) ? Math.max(0, $('#layers').getBoundingClientRect().right) : 0;
|
|
180
|
+
rect.left = Math.max(rect.left, left + PAD); rect.top = Math.max(rect.top, PAD);
|
|
181
|
+
rect.right = Math.min(rect.right, innerWidth - PAD); rect.bottom = Math.min(rect.bottom, innerHeight - PAD);
|
|
182
|
+
rect.width = rect.right - rect.left; rect.height = rect.bottom - rect.top;
|
|
183
|
+
}
|
|
184
|
+
if (!rect) { dim.dataset.on = 'false'; card.dataset.on = 'false'; return; }
|
|
185
|
+
if (resolved.pan) reveal(rect);
|
|
186
|
+
dim.dataset.on = 'true'; card.dataset.on = 'true';
|
|
187
|
+
dim.style.clipPath = holePath(rect);
|
|
188
|
+
const w = card.offsetWidth, h = card.offsetHeight;
|
|
189
|
+
const padded = { top: rect.top - PAD, bottom: rect.bottom + PAD, cx: (rect.left + rect.right) / 2 };
|
|
190
|
+
const above = padded.top - GAP - h >= MARGIN;
|
|
191
|
+
const x = Math.max(MARGIN, Math.min(padded.cx - w / 2, innerWidth - w - MARGIN));
|
|
192
|
+
const y = above ? padded.top - GAP - h : Math.min(padded.bottom + GAP, innerHeight - h - MARGIN);
|
|
193
|
+
card.style.translate = `${x}px ${y}px`;
|
|
194
|
+
placed = true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loop() {
|
|
198
|
+
frame = 0;
|
|
199
|
+
if (!active || paused) return;
|
|
200
|
+
layout();
|
|
201
|
+
frame = requestAnimationFrame(loop);
|
|
202
|
+
}
|
|
203
|
+
function run() { if (!frame && active && !paused) frame = requestAnimationFrame(loop); }
|
|
204
|
+
|
|
205
|
+
function hideUi() {
|
|
206
|
+
cancelAnimationFrame(frame); frame = 0;
|
|
207
|
+
dim.dataset.on = 'false'; card.dataset.on = 'false';
|
|
208
|
+
activeTargets = [];
|
|
209
|
+
}
|
|
210
|
+
function go(next) { index = next; shownKey = ''; offSince = 0; run(); }
|
|
211
|
+
function start() {
|
|
212
|
+
if (!dim) build();
|
|
213
|
+
active = true; paused = false; placed = false; shownKey = ''; index = 0;
|
|
214
|
+
dock(false);
|
|
215
|
+
raise();
|
|
216
|
+
dim.style.transition = 'none'; card.style.transition = 'none';
|
|
217
|
+
layout();
|
|
218
|
+
void dim.offsetWidth;
|
|
219
|
+
dim.style.transition = ''; card.style.transition = '';
|
|
220
|
+
run();
|
|
221
|
+
}
|
|
222
|
+
function end() {
|
|
223
|
+
if (!active) return;
|
|
224
|
+
active = false; paused = false;
|
|
225
|
+
hideUi();
|
|
226
|
+
setTimeout(() => { if (!active) for (const element of [dim, card]) if (element.matches(':popover-open')) element.hidePopover(); }, 250);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function syncDialogs() {
|
|
230
|
+
if (!active) return;
|
|
231
|
+
const adding = $('#add-pages')?.open, shortcuts = $('#shortcuts')?.open;
|
|
232
|
+
if (shortcuts) {
|
|
233
|
+
if (!paused) { paused = true; hideUi(); }
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (adding) {
|
|
237
|
+
if (index === 0) { countBefore = Number($('#screens-count')?.textContent) || 0; go(PICK); raise(); }
|
|
238
|
+
else if (index !== 1 && !paused) { paused = true; hideUi(); }
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
paused = false;
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
if (!active) return;
|
|
244
|
+
if (index === PICK) go((Number($('#screens-count')?.textContent) || 0) > countBefore ? SELECT : 0);
|
|
245
|
+
else run();
|
|
246
|
+
}, 0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Everything outside what is highlighted is dead while the tour is up.
|
|
250
|
+
const POINTER = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu', 'wheel', 'dragstart'];
|
|
251
|
+
function blocking() { return active && !paused && dim?.dataset.on === 'true'; }
|
|
252
|
+
function allowed(event) {
|
|
253
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
254
|
+
if (!target) return true;
|
|
255
|
+
if (card.contains(target) || target.closest('.canvas-tooltip, .icon-options:popover-open')) return true;
|
|
256
|
+
if (activeDeny && target.closest(activeDeny)) return false;
|
|
257
|
+
return activeTargets.some(element => element?.contains(target));
|
|
258
|
+
}
|
|
259
|
+
function guard(event) {
|
|
260
|
+
if (!blocking() || allowed(event)) return;
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
event.stopImmediatePropagation();
|
|
263
|
+
}
|
|
264
|
+
function guardKey(event) {
|
|
265
|
+
if (!blocking()) return;
|
|
266
|
+
if (event.key === 'Escape') {
|
|
267
|
+
if (document.body.hasAttribute('data-fullscreen')) return;
|
|
268
|
+
// Dialogs and menus close themselves; otherwise Escape skips the tour.
|
|
269
|
+
if ($('dialog[open], .icon-options:popover-open, #screen-context:popover-open')) return;
|
|
270
|
+
event.preventDefault(); event.stopImmediatePropagation(); end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (event.key === 'Tab' || allowed(event)) return;
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
event.stopImmediatePropagation();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function init() {
|
|
279
|
+
for (const dialog of [$('#add-pages'), $('#shortcuts')]) {
|
|
280
|
+
if (dialog) new MutationObserver(syncDialogs).observe(dialog, { attributes: true, attributeFilter: ['open'] });
|
|
281
|
+
}
|
|
282
|
+
for (const type of POINTER) window.addEventListener(type, guard, { capture: true, passive: false });
|
|
283
|
+
window.addEventListener('keydown', guardKey, true);
|
|
284
|
+
document.addEventListener('change', event => { if (active && index === PRESET && event.target.id === 'preset') go(PRESET + 1); }, true);
|
|
285
|
+
// Entering the preview moves the tour on; leaving it ends the tour.
|
|
286
|
+
new MutationObserver(() => {
|
|
287
|
+
if (!active) return;
|
|
288
|
+
const inPreview = document.body.hasAttribute('data-fullscreen');
|
|
289
|
+
if (inPreview && index === FULLSCREEN) go(BACK);
|
|
290
|
+
else if (!inPreview && index === BACK) end();
|
|
291
|
+
}).observe(document.body, { attributes: true, attributeFilter: ['data-fullscreen'] });
|
|
292
|
+
$('#onboarding-open')?.addEventListener('click', () => { end(); start(); });
|
|
293
|
+
addEventListener('resize', run);
|
|
294
|
+
if (!seen()) {
|
|
295
|
+
// Not on a phone-sized screen, where the canvas is blocked: the tour waits for a visit that can use it.
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
if (document.documentElement.hasAttribute('data-device-blocked') || !visible($('#add'))) return;
|
|
298
|
+
remember(); start();
|
|
299
|
+
}, 900);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
init();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createRangeSelection } from './range-selection.js';
|
|
2
|
+
|
|
3
|
+
// Pages are grouped by `kind` in an accordion; `groups` is [{id,label}] in display order.
|
|
4
|
+
export function createPagePicker({dialog,trigger,sources,groups,count,onOpen,onAdd}) {
|
|
5
|
+
const list=dialog.querySelector('#page-options'),submit=dialog.querySelector('#add-pages-submit'),error=dialog.querySelector('#page-picker-error');
|
|
6
|
+
let selection=new Set();
|
|
7
|
+
// Display order, so a shift-range follows what is on screen rather than the registry.
|
|
8
|
+
const ordered=groups.flatMap(group=>sources.filter(source=>source.kind===group.id));
|
|
9
|
+
const closedGroups=new Set();
|
|
10
|
+
function update(ids){
|
|
11
|
+
selection=new Set(ids);
|
|
12
|
+
for(const row of list.querySelectorAll('.page-option')){
|
|
13
|
+
row.dataset.checked=String(selection.has(row.dataset.rangeId));
|
|
14
|
+
row.querySelector('[role="checkbox"]').setAttribute('aria-checked',row.dataset.checked);
|
|
15
|
+
}
|
|
16
|
+
dialog.querySelector('footer').hidden=!selection.size;
|
|
17
|
+
submit.hidden=!selection.size;submit.disabled=!selection.size;submit.textContent=selection.size?`Add ${selection.size} ${selection.size===1?'page':'pages'}`:'Add pages';
|
|
18
|
+
dialog.querySelector('#add-pages-select-all').disabled=selection.size===sources.length;
|
|
19
|
+
dialog.querySelector('#add-pages-unselect-all').disabled=!selection.size;
|
|
20
|
+
}
|
|
21
|
+
const ranges=createRangeSelection({root:list,items:()=>ordered.map(source=>source.id),selected:()=>[...selection],onChange:update,toggleClick:true,toggleDrag:true,startTarget:'.page-checkbox-target'});
|
|
22
|
+
function close(){ranges.reset();dialog.close();}
|
|
23
|
+
function add(ids){
|
|
24
|
+
if(onAdd(ids)!==false)close();
|
|
25
|
+
else error.textContent='Cannot add these pages. The canvas is full. Remove screens or select fewer pages.';
|
|
26
|
+
}
|
|
27
|
+
trigger.addEventListener('click',()=>{
|
|
28
|
+
onOpen();ranges.reset();list.replaceChildren();error.textContent='';
|
|
29
|
+
for(const group of groups){
|
|
30
|
+
const members=ordered.filter(source=>source.kind===group.id);
|
|
31
|
+
if(!members.length)continue;
|
|
32
|
+
const section=document.createElement('section');section.className='picker-group';
|
|
33
|
+
const toggle=document.createElement('button');toggle.type='button';toggle.className='picker-group-toggle';
|
|
34
|
+
const panelId=`picker-group-${group.id}`;toggle.setAttribute('aria-controls',panelId);
|
|
35
|
+
toggle.innerHTML='<span class="picker-group-label"></span><span class="picker-group-count"></span><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg>';
|
|
36
|
+
toggle.querySelector('.picker-group-label').textContent=group.label;
|
|
37
|
+
toggle.querySelector('.picker-group-count').textContent=String(members.length);
|
|
38
|
+
const panel=document.createElement('div');panel.className='picker-group-panel';panel.id=panelId;
|
|
39
|
+
const items=document.createElement('div');items.className='picker-group-list';panel.append(items);
|
|
40
|
+
const setOpen=open=>{section.dataset.open=String(open);toggle.setAttribute('aria-expanded',String(open));panel.inert=!open;};
|
|
41
|
+
setOpen(!closedGroups.has(group.id));
|
|
42
|
+
toggle.addEventListener('click',()=>{const open=section.dataset.open!=='true';if(open)closedGroups.delete(group.id);else closedGroups.add(group.id);setOpen(open);});
|
|
43
|
+
section.append(toggle,panel);list.append(section);
|
|
44
|
+
for(const source of members)items.append(buildRow(source));
|
|
45
|
+
}
|
|
46
|
+
update([]);dialog.showModal();
|
|
47
|
+
});
|
|
48
|
+
function buildRow(source){
|
|
49
|
+
const row=document.createElement('div');row.className='page-option';row.dataset.rangeId=source.id;
|
|
50
|
+
const checkbox=document.createElement('button');checkbox.type='button';checkbox.className='page-checkbox-target';checkbox.setAttribute('role','checkbox');checkbox.setAttribute('aria-label',`Select ${source.title}`);
|
|
51
|
+
const mark=document.createElement('span');mark.className='page-checkbox';mark.setAttribute('aria-hidden','true');checkbox.append(mark);
|
|
52
|
+
const action=document.createElement('button');action.type='button';action.className='page-option-add';action.setAttribute('aria-label',`Add ${source.title}`);
|
|
53
|
+
row.addEventListener('click',event=>{if(!event.target.closest('.page-checkbox-target'))add([source.id]);});
|
|
54
|
+
const thumbnail=document.createElement('span');thumbnail.className='page-thumbnail';thumbnail.setAttribute('aria-hidden','true');
|
|
55
|
+
const image=document.createElement('img');image.src=`/canvas/thumbnails/${encodeURIComponent(source.id)}.webp`;image.alt='';image.loading='lazy';image.decoding='async';image.draggable=false;
|
|
56
|
+
image.addEventListener('error',()=>image.remove(),{once:true});thumbnail.append(image);
|
|
57
|
+
const label=document.createElement('span');label.className='page-option-label';label.textContent=source.title;
|
|
58
|
+
const description=document.createElement('small');description.textContent=source.description;label.append(description);action.append(thumbnail,label);row.append(checkbox,action);
|
|
59
|
+
const number=count(source.id);
|
|
60
|
+
if(number){const pill=document.createElement('span');pill.className='page-pill';pill.textContent=`${number} on canvas`;action.append(pill);}
|
|
61
|
+
return row;
|
|
62
|
+
}
|
|
63
|
+
dialog.querySelector('#add-pages-close').onclick=close;
|
|
64
|
+
dialog.querySelector('#add-pages-select-all').onclick=()=>{ranges.reset();update(sources.map(source=>source.id));};
|
|
65
|
+
dialog.querySelector('#add-pages-unselect-all').onclick=()=>{ranges.reset();update([]);};
|
|
66
|
+
dialog.addEventListener('close',()=>{ranges.reset();trigger.focus({preventScroll:true});});
|
|
67
|
+
dialog.addEventListener('click',event=>{
|
|
68
|
+
if(event.target!==dialog)return;
|
|
69
|
+
const rect=dialog.getBoundingClientRect();
|
|
70
|
+
if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)close();
|
|
71
|
+
});
|
|
72
|
+
submit.addEventListener('click',()=>{
|
|
73
|
+
if(!selection.size)return;
|
|
74
|
+
add([...selection]);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Which keyboard the shortcut labels are written for. It follows the visitor's
|
|
2
|
+
// system until they choose, and the choice is remembered on this device.
|
|
3
|
+
const KEY = 'tio-canvas:shortcut-platform';
|
|
4
|
+
const detected = () => /Mac|iPhone|iPad/.test(navigator.userAgentData?.platform || navigator.platform || '') ? 'mac' : 'win';
|
|
5
|
+
let current;
|
|
6
|
+
try { current = localStorage.getItem(KEY); } catch { /* storage blocked: the system's keyboard is used */ }
|
|
7
|
+
if (current !== 'mac' && current !== 'win') current = detected();
|
|
8
|
+
|
|
9
|
+
export const platform = () => current;
|
|
10
|
+
export function setPlatform(value) {
|
|
11
|
+
if (value !== 'mac' && value !== 'win') return;
|
|
12
|
+
try { localStorage.setItem(KEY, value); } catch { /* not remembered */ }
|
|
13
|
+
if (value === current) return;
|
|
14
|
+
current = value;
|
|
15
|
+
window.dispatchEvent(new CustomEvent('tio-canvas:platform'));
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Shared range selection for page rows and the Layers drawer. The caller owns
|
|
2
|
+
// selection state; this helper owns only the pointer gesture and its anchor.
|
|
3
|
+
export function createRangeSelection({root,items,selected,onChange,toggleClick=false,shiftRange=true,toggleModifiers=false,toggleDrag=false,startTarget=null}) {
|
|
4
|
+
let anchor=null,gesture=null,frame=0;
|
|
5
|
+
function range(first,last){const ids=items(),a=ids.indexOf(first),b=ids.indexOf(last);return a<0||b<0?[]:ids.slice(Math.min(a,b),Math.max(a,b)+1);}
|
|
6
|
+
function apply(id){
|
|
7
|
+
const ids=range(gesture.start,id),next=new Set(gesture.base);
|
|
8
|
+
for(const value of ids){if(gesture.remove)next.delete(value);else next.add(value);}
|
|
9
|
+
onChange([...next]);
|
|
10
|
+
}
|
|
11
|
+
function scroll(){
|
|
12
|
+
if(!gesture)return;
|
|
13
|
+
const rect=root.getBoundingClientRect(),delta=gesture.y<rect.top+24?-10:gesture.y>rect.bottom-24?10:0;
|
|
14
|
+
if(delta && gesture.moved){root.scrollTop+=delta;hit();}
|
|
15
|
+
frame=requestAnimationFrame(scroll);
|
|
16
|
+
}
|
|
17
|
+
function hit(){
|
|
18
|
+
const rect=root.getBoundingClientRect();
|
|
19
|
+
const row=document.elementFromPoint(Math.max(rect.left+1,Math.min(rect.right-1,gesture.x)),Math.max(rect.top+1,Math.min(rect.bottom-1,gesture.y)))?.closest('[data-range-id]');
|
|
20
|
+
if(row && root.contains(row))apply(row.dataset.rangeId);
|
|
21
|
+
}
|
|
22
|
+
function cancel(){const old=gesture;gesture=null;cancelAnimationFrame(frame);if(old && root.hasPointerCapture(old.pointer))root.releasePointerCapture(old.pointer);}
|
|
23
|
+
function select(id,event){
|
|
24
|
+
if(event.shiftKey&&shiftRange&&anchor){
|
|
25
|
+
onChange([...new Set([...selected(),...range(anchor,id)])]);
|
|
26
|
+
}else{
|
|
27
|
+
anchor=id;
|
|
28
|
+
const toggling=toggleClick||(toggleModifiers&&(event.metaKey||event.ctrlKey))||(!shiftRange&&event.shiftKey);
|
|
29
|
+
const next=new Set(toggling?selected():[]);
|
|
30
|
+
if(toggling&&next.has(id))next.delete(id);else next.add(id);
|
|
31
|
+
onChange([...next]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
root.addEventListener('pointerdown',event=>{
|
|
35
|
+
const row=event.target.closest('[data-range-id]');
|
|
36
|
+
if(event.button!==0 || !row || (startTarget&&!event.target.closest(startTarget)) || event.target.closest('[contenteditable],svg,[data-reorder]'))return;
|
|
37
|
+
event.preventDefault();const id=row.dataset.rangeId,base=toggleDrag||event.shiftKey||(toggleModifiers&&(event.metaKey||event.ctrlKey))?selected():[];
|
|
38
|
+
const start=event.shiftKey&&shiftRange&&anchor?anchor:id;
|
|
39
|
+
const remove=toggleDrag&&base.includes(id)&&!(event.shiftKey&&shiftRange&&anchor);
|
|
40
|
+
select(id,event);
|
|
41
|
+
gesture={moved:false,pointer:event.pointerId,start,base,remove,x:event.clientX,y:event.clientY};
|
|
42
|
+
(startTarget?row.querySelector(startTarget):row).focus({preventScroll:true});frame=requestAnimationFrame(scroll);
|
|
43
|
+
});
|
|
44
|
+
root.addEventListener('pointermove',event=>{if(gesture && gesture.pointer===event.pointerId){if(!gesture.moved && Math.hypot(event.clientX-gesture.x,event.clientY-gesture.y)<4)return;gesture.moved=true;root.setPointerCapture(event.pointerId);gesture.x=event.clientX;gesture.y=event.clientY;hit();}});
|
|
45
|
+
root.addEventListener('pointerup',()=>{cancel();});root.addEventListener('pointercancel',cancel);root.addEventListener('lostpointercapture',cancel);
|
|
46
|
+
window.addEventListener('keydown',event=>{if(event.key==='Escape')cancel();});
|
|
47
|
+
window.addEventListener('blur',cancel);document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
|
|
48
|
+
root.addEventListener('click',event=>{
|
|
49
|
+
const row=event.target.closest('[data-range-id]');if(!row || event.detail!==0 || (startTarget&&!event.target.closest(startTarget)))return;
|
|
50
|
+
const id=row.dataset.rangeId;
|
|
51
|
+
select(id,event);
|
|
52
|
+
});
|
|
53
|
+
return {cancel,select,reset(){cancel();anchor=null;}};
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What the canvas knows about screens, whatever project it runs in. The project
|
|
2
|
+
// owns the list of screens (project.js, served from its own config file); this
|
|
3
|
+
// file owns the shape a screen must have and the device presets.
|
|
4
|
+
// The page picker groups by kind and shows the description under each title, so
|
|
5
|
+
// every page, production or prototype, must carry both. Fail loudly when one does not.
|
|
6
|
+
export const screenKinds = { production: 'Production', prototype: 'Prototypes' };
|
|
7
|
+
export function validateScreens(screens) {
|
|
8
|
+
for (const screen of screens) {
|
|
9
|
+
if (!Object.hasOwn(screenKinds, screen.kind)) throw new Error(`Canvas screen "${screen.id}" needs kind: "production" or "prototype".`);
|
|
10
|
+
if (typeof screen.description !== 'string' || !screen.description.trim()) throw new Error(`Canvas screen "${screen.id}" needs a short description.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const presets = {
|
|
14
|
+
custom: { label: 'Custom' },
|
|
15
|
+
laptop: { label: 'Laptop', group: 'Desktop', device: 'laptop', width: 1440, height: 900 },
|
|
16
|
+
'macbook-air-13': { label: 'MacBook Air 13-inch', group: 'Desktop', device: 'laptop', width: 1470, height: 956 },
|
|
17
|
+
'macbook-pro-14': { label: 'MacBook Pro 14-inch', group: 'Desktop', device: 'laptop', width: 1512, height: 982 },
|
|
18
|
+
desktop: { label: 'Desktop', group: 'Desktop', device: 'desktop', width: 1920, height: 1080 },
|
|
19
|
+
'desktop-qhd': { label: 'Desktop QHD', group: 'Desktop', device: 'desktop', width: 2560, height: 1440 },
|
|
20
|
+
'iphone-se': { label: 'iPhone SE', group: 'Mobile', device: 'phone', touch: true, width: 375, height: 667 },
|
|
21
|
+
'iphone-16': { label: 'iPhone 16', group: 'Mobile', device: 'phone', touch: true, width: 393, height: 852 },
|
|
22
|
+
'iphone-16-pro-max': { label: 'iPhone 16 Pro Max', group: 'Mobile', device: 'phone', touch: true, width: 440, height: 956 },
|
|
23
|
+
'pixel-9': { label: 'Pixel 9', group: 'Mobile', device: 'phone', touch: true, width: 412, height: 915 },
|
|
24
|
+
'galaxy-s24': { label: 'Galaxy S24', group: 'Mobile', device: 'phone', touch: true, width: 360, height: 780 },
|
|
25
|
+
'ipad-mini': { label: 'iPad mini', group: 'Tablet', device: 'tablet', touch: true, width: 744, height: 1133 },
|
|
26
|
+
'ipad-air': { label: 'iPad Air', group: 'Tablet', device: 'tablet', touch: true, width: 820, height: 1180 },
|
|
27
|
+
'ipad-pro-11': { label: 'iPad Pro 11-inch', group: 'Tablet', device: 'tablet', touch: true, width: 834, height: 1194 },
|
|
28
|
+
'ipad-pro-13': { label: 'iPad Pro 13-inch', group: 'Tablet', device: 'tablet', touch: true, width: 1032, height: 1376 },
|
|
29
|
+
'galaxy-z-fold-6-cover': { label: 'Galaxy Z Fold 6 (cover)', group: 'Foldable', device: 'phone', touch: true, width: 344, height: 748 },
|
|
30
|
+
'galaxy-z-fold-6-open': { label: 'Galaxy Z Fold 6 (open)', group: 'Foldable', device: 'phone', touch: true, width: 673, height: 841 },
|
|
31
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Iframe lifecycle. Document readiness excludes images and
|
|
2
|
+
// other nonessential requests; the old painted screen survives refresh failure.
|
|
3
|
+
// The development server adds the bridge to the pages it serves. A production
|
|
4
|
+
// page is served untouched, so the canvas adds the bridge itself. These are
|
|
5
|
+
// same-origin documents, which is what makes that possible.
|
|
6
|
+
function injectBridge(frame) {
|
|
7
|
+
const doc=frame.contentDocument;
|
|
8
|
+
if(!doc?.body || [...doc.scripts].some(script=>script.src.endsWith('/canvas/bridge.js')))return;
|
|
9
|
+
const script=doc.createElement('script');script.type='module';script.src='/canvas/bridge.js';doc.body.append(script);
|
|
10
|
+
}
|
|
11
|
+
export function createScreenLoader({onReady,onError,timeout=15000}) {
|
|
12
|
+
const documents=new WeakMap();
|
|
13
|
+
function forget(frame) { if(frame?.contentWindow)documents.delete(frame.contentWindow); }
|
|
14
|
+
function cancel(entry) {
|
|
15
|
+
clearTimeout(entry.refreshTimer);
|
|
16
|
+
if(entry.pending){forget(entry.pending);entry.pending.remove();entry.pending=null;}
|
|
17
|
+
entry.refresh.disabled=false;entry.element.removeAttribute('aria-busy');
|
|
18
|
+
}
|
|
19
|
+
function dispose(entry) { cancel(entry);forget(entry.iframe); }
|
|
20
|
+
async function finish(record) {
|
|
21
|
+
const {entry,frame}=record;
|
|
22
|
+
if(record.settling || !frame.isConnected)return;
|
|
23
|
+
record.settling=true;
|
|
24
|
+
try {
|
|
25
|
+
// Layout requests the fonts actually used by this document. Unlike load,
|
|
26
|
+
// fonts.ready does not wait for an image or an unrelated fetch to finish.
|
|
27
|
+
frame.contentDocument?.documentElement.getBoundingClientRect();
|
|
28
|
+
await frame.contentDocument?.fonts?.ready;
|
|
29
|
+
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
|
30
|
+
if(!frame.isConnected || !entry.element.isConnected)return;
|
|
31
|
+
if(entry.pending===frame){
|
|
32
|
+
const previous=entry.iframe,focused=document.activeElement===previous;
|
|
33
|
+
clearTimeout(entry.refreshTimer);entry.pending=null;entry.iframe=frame;
|
|
34
|
+
forget(previous);previous?.remove();
|
|
35
|
+
delete frame.dataset.refreshPending;frame.inert=false;frame.removeAttribute('aria-hidden');frame.title=entry.model.title;
|
|
36
|
+
entry.refresh.disabled=false;entry.element.removeAttribute('aria-busy');
|
|
37
|
+
if(focused)frame.focus({preventScroll:true});
|
|
38
|
+
}
|
|
39
|
+
if(entry.iframe===frame)onReady(entry);
|
|
40
|
+
}finally{record.settling=false;}
|
|
41
|
+
}
|
|
42
|
+
function mount(entry,replacing=false) {
|
|
43
|
+
if(entry.pending)return;
|
|
44
|
+
const frame=document.createElement('iframe'),url=new URL(entry.model.src,location.origin);
|
|
45
|
+
if(url.origin!==location.origin)throw new Error('Canvas screens must use local pages.');
|
|
46
|
+
url.searchParams.set('canvasScreen','1');frame.title=entry.model.title;
|
|
47
|
+
// Native lazy loading keeps host loading independent from embedded media.
|
|
48
|
+
// An invisible refresh replacement must load eagerly, not wait to be seen.
|
|
49
|
+
frame.loading=replacing?'eager':'lazy';
|
|
50
|
+
if(replacing && entry.iframe){
|
|
51
|
+
entry.pending=frame;frame.dataset.refreshPending='';frame.inert=true;frame.setAttribute('aria-hidden','true');
|
|
52
|
+
entry.refresh.disabled=true;entry.element.setAttribute('aria-busy','true');
|
|
53
|
+
entry.refreshTimer=setTimeout(()=>{
|
|
54
|
+
if(entry.pending!==frame)return;
|
|
55
|
+
cancel(entry);onError(entry,'Refresh timed out. The previous screen is still shown. Try again.');
|
|
56
|
+
},timeout);
|
|
57
|
+
}else entry.iframe=frame;
|
|
58
|
+
const record={entry,frame,settling:false};
|
|
59
|
+
// Fallback for a document that cannot run the bridge. A working bridge
|
|
60
|
+
// signals readiness earlier, at DOMContentLoaded plus font readiness.
|
|
61
|
+
frame.addEventListener('load',()=>{injectBridge(frame);void finish(record);});
|
|
62
|
+
frame.src=url.href;entry.element.append(frame);
|
|
63
|
+
documents.set(frame.contentWindow,record);
|
|
64
|
+
}
|
|
65
|
+
function ready(source) { const record=documents.get(source);if(record)void finish(record); }
|
|
66
|
+
return {mount,ready,dispose};
|
|
67
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The canvas reads its screens from here. `project.js` is not a file of the
|
|
2
|
+
// package: the development server serves the project's own config in its place
|
|
3
|
+
// (and a build writes it), so the list of pages always belongs to the project.
|
|
4
|
+
import { screens, storageKey } from './project.js';
|
|
5
|
+
import { screenKinds, presets, validateScreens } from './registry.js';
|
|
6
|
+
validateScreens(screens);
|
|
7
|
+
export { screens, screenKinds, presets, storageKey };
|