@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Coordinate visible floating buttons in the current page and its fullscreen host.
|
|
2
|
+
const corners = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
3
|
+
export function claimCorner(element, desired) {
|
|
4
|
+
let docs = [document];
|
|
5
|
+
try {
|
|
6
|
+
const frame = window.frameElement;
|
|
7
|
+
if (frame?.closest('.screen[data-fullscreen]')) docs.push(window.parent.document);
|
|
8
|
+
else if (!frame && document.body.hasAttribute('data-fullscreen')) {
|
|
9
|
+
const child = document.querySelector('.screen[data-fullscreen] iframe')?.contentDocument;
|
|
10
|
+
if (child) docs.push(child);
|
|
11
|
+
}
|
|
12
|
+
} catch { /* A cross-origin frame cannot share the canvas controls. */ }
|
|
13
|
+
const occupied = new Set();
|
|
14
|
+
for (const doc of docs) for (const other of doc.querySelectorAll('[data-floating-corner]')) {
|
|
15
|
+
if (other !== element && !other.hidden) occupied.add(other.dataset.floatingCorner);
|
|
16
|
+
}
|
|
17
|
+
const [vertical, horizontal] = desired.split('-');
|
|
18
|
+
const order = [desired, `${vertical === 'top' ? 'bottom' : 'top'}-${horizontal}`, `${vertical}-${horizontal === 'left' ? 'right' : 'left'}`, ...corners];
|
|
19
|
+
const chosen = order.find(corner => !occupied.has(corner)) ?? desired;
|
|
20
|
+
element.dataset.floatingCorner = chosen;
|
|
21
|
+
return chosen;
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The canvas needs room: a phone-sized viewport gets a notice instead. A tablet
|
|
2
|
+
// is wide enough, so it is let through. The notice sits in the top layer above
|
|
3
|
+
// every dialog, and everything else is made inert while it shows.
|
|
4
|
+
const gate = document.querySelector('#device-gate');
|
|
5
|
+
const small = matchMedia('(max-width: 599px), (pointer: coarse) and (max-height: 499px)');
|
|
6
|
+
|
|
7
|
+
function apply() {
|
|
8
|
+
const block = small.matches;
|
|
9
|
+
for (const element of document.body.children) if (element !== gate && element.tagName !== 'SCRIPT') element.inert = block;
|
|
10
|
+
document.documentElement.toggleAttribute('data-device-blocked', block);
|
|
11
|
+
if (block && !gate.matches(':popover-open')) gate.showPopover();
|
|
12
|
+
if (!block && gate.matches(':popover-open')) gate.hidePopover();
|
|
13
|
+
}
|
|
14
|
+
small.addEventListener('change', apply);
|
|
15
|
+
apply();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { claimCorner } from './corners.js';
|
|
2
|
+
|
|
3
|
+
// Full-page preview keeps the existing iframe mounted, including its page state.
|
|
4
|
+
export function createFullscreen({ onEnter, onExit }) {
|
|
5
|
+
const root = document.querySelector('#fullscreen-tools');
|
|
6
|
+
const button = document.querySelector('#fullscreen-toggle');
|
|
7
|
+
const menu = document.querySelector('#fullscreen-menu');
|
|
8
|
+
let entry = null, right = false, bottom = false, drag = null, suppressClick = false;
|
|
9
|
+
const token = name => parseFloat(getComputedStyle(root).getPropertyValue(name));
|
|
10
|
+
const clamp = (n, low, high) => Math.max(low, Math.min(high, n));
|
|
11
|
+
function close(focus = false) {
|
|
12
|
+
menu.hidden = true; button.setAttribute('aria-expanded', 'false');
|
|
13
|
+
if (focus) button.focus({ preventScroll: true });
|
|
14
|
+
}
|
|
15
|
+
// Interface Kit lives inside the previewed page. While its panel is open it
|
|
16
|
+
// takes the corner over, so the control steps aside until the panel closes.
|
|
17
|
+
// The kit marks a collapsed button with data-floating-corner and removes the
|
|
18
|
+
// mark when it opens (build/interface-kit-bundle.mjs).
|
|
19
|
+
let kitObserver = null, kitFrame = null;
|
|
20
|
+
const syncKit = doc => {
|
|
21
|
+
const mount = doc.querySelector('[data-interface-kit]');
|
|
22
|
+
const open = Boolean(mount) && !mount.hasAttribute('data-floating-corner');
|
|
23
|
+
root.toggleAttribute('data-kit-open', open);
|
|
24
|
+
if (open) close();
|
|
25
|
+
};
|
|
26
|
+
function attachKit() {
|
|
27
|
+
kitObserver?.disconnect();
|
|
28
|
+
try {
|
|
29
|
+
const doc = kitFrame?.contentDocument;
|
|
30
|
+
if (!doc) return;
|
|
31
|
+
kitObserver = new MutationObserver(() => syncKit(doc));
|
|
32
|
+
kitObserver.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-floating-corner'] });
|
|
33
|
+
syncKit(doc);
|
|
34
|
+
} catch { /* A cross-origin frame has no kit to watch. */ }
|
|
35
|
+
}
|
|
36
|
+
function watchKit(frame) {
|
|
37
|
+
unwatchKit();
|
|
38
|
+
kitFrame = frame ?? null;
|
|
39
|
+
if (!kitFrame) return;
|
|
40
|
+
kitFrame.addEventListener('load', attachKit);
|
|
41
|
+
attachKit();
|
|
42
|
+
}
|
|
43
|
+
function unwatchKit() {
|
|
44
|
+
kitFrame?.removeEventListener('load', attachKit);
|
|
45
|
+
kitObserver?.disconnect(); kitObserver = null; kitFrame = null;
|
|
46
|
+
root.removeAttribute('data-kit-open');
|
|
47
|
+
}
|
|
48
|
+
function positionMenu() {
|
|
49
|
+
menu.style.left = right ? 'auto' : '0'; menu.style.right = right ? '0' : 'auto';
|
|
50
|
+
menu.style.top = bottom ? 'auto' : 'calc(var(--canvas-float-size) + var(--canvas-preview-gap))';
|
|
51
|
+
menu.style.bottom = bottom ? 'calc(var(--canvas-float-size) + var(--canvas-preview-gap))' : 'auto';
|
|
52
|
+
}
|
|
53
|
+
function snap() {
|
|
54
|
+
const corner = claimCorner(root, `${bottom ? 'bottom' : 'top'}-${right ? 'right' : 'left'}`);
|
|
55
|
+
right = corner.endsWith('right'); bottom = corner.startsWith('bottom');
|
|
56
|
+
const inset = token('--canvas-preview-inset'), size = button.offsetWidth;
|
|
57
|
+
root.style.transform = `translate(${right ? innerWidth - inset - size : inset}px,${bottom ? innerHeight - inset - size : inset}px)`;
|
|
58
|
+
positionMenu();
|
|
59
|
+
}
|
|
60
|
+
function exit() {
|
|
61
|
+
if (!entry) return;
|
|
62
|
+
close(); unwatchKit(); const previous = entry; entry = null;
|
|
63
|
+
previous.element.removeAttribute('data-fullscreen');
|
|
64
|
+
document.body.removeAttribute('data-fullscreen'); root.hidden = true; delete root.dataset.floatingCorner;
|
|
65
|
+
onExit(previous); previous.fullscreen.focus({ preventScroll: true });
|
|
66
|
+
}
|
|
67
|
+
button.addEventListener('click', () => {
|
|
68
|
+
if (suppressClick) { suppressClick = false; return; }
|
|
69
|
+
if (!menu.hidden) { close(); return; }
|
|
70
|
+
menu.hidden = false; positionMenu(); button.setAttribute('aria-expanded', 'true');
|
|
71
|
+
});
|
|
72
|
+
button.addEventListener('pointerdown', event => {
|
|
73
|
+
if (event.button !== 0) return;
|
|
74
|
+
const rect = root.getBoundingClientRect();
|
|
75
|
+
// Interrupt a snap from its current painted position, not its destination.
|
|
76
|
+
root.dataset.dragging = '';
|
|
77
|
+
root.style.transform = `translate(${rect.left}px,${rect.top}px)`;
|
|
78
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false };
|
|
79
|
+
suppressClick = false; button.setPointerCapture(event.pointerId);
|
|
80
|
+
});
|
|
81
|
+
button.addEventListener('pointermove', event => {
|
|
82
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
83
|
+
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
|
84
|
+
if (!drag.moved && Math.hypot(dx, dy) < 4) return;
|
|
85
|
+
drag.moved = true; close();
|
|
86
|
+
const inset = token('--canvas-preview-inset'), size = button.offsetWidth;
|
|
87
|
+
const x = clamp(drag.left + dx, inset, innerWidth - inset - size);
|
|
88
|
+
const y = clamp(drag.top + dy, inset, innerHeight - inset - size);
|
|
89
|
+
root.style.transform = `translate(${x}px,${y}px)`;
|
|
90
|
+
right = x + size / 2 >= innerWidth / 2; bottom = y + size / 2 >= innerHeight / 2;
|
|
91
|
+
});
|
|
92
|
+
function release(event) {
|
|
93
|
+
if (!drag || (event?.pointerId != null && event.pointerId !== drag.id)) return;
|
|
94
|
+
suppressClick = drag.moved; drag = null; delete root.dataset.dragging; snap();
|
|
95
|
+
}
|
|
96
|
+
button.addEventListener('pointerup', release);
|
|
97
|
+
button.addEventListener('pointercancel', release);
|
|
98
|
+
button.addEventListener('lostpointercapture', release);
|
|
99
|
+
window.addEventListener('blur', () => release());
|
|
100
|
+
// The onboarding card floats over the preview; pressing it is not pressing outside the menu.
|
|
101
|
+
document.addEventListener('pointerdown', event => { if (!root.contains(event.target) && !event.target.closest?.('.onboarding-card')) close(); }, true);
|
|
102
|
+
root.addEventListener('keydown', event => {
|
|
103
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!menu.hidden) close(true); else exit(); }
|
|
104
|
+
});
|
|
105
|
+
document.querySelector('#fullscreen-back').addEventListener('click', exit);
|
|
106
|
+
window.addEventListener('resize', () => { if (entry) { release(); snap(); } });
|
|
107
|
+
return {
|
|
108
|
+
get entry() { return entry; }, close, exit,
|
|
109
|
+
enter(next) {
|
|
110
|
+
if (entry) exit();
|
|
111
|
+
entry = next; onEnter(next);
|
|
112
|
+
next.element.setAttribute('data-fullscreen', ''); document.body.dataset.fullscreen = '';
|
|
113
|
+
root.hidden = false; snap(); watchKit(next.iframe);
|
|
114
|
+
next.iframe?.contentWindow.dispatchEvent(new Event('canvas-corners-changed'));
|
|
115
|
+
button.focus({ preventScroll: true });
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Pure canvas geometry, in world coordinates. Tolerance is supplied in world
|
|
2
|
+
// units so the magnetic distance remains constant on the visitor's screen.
|
|
3
|
+
// Quantize document geometry, never the screen-space camera or pointer input.
|
|
4
|
+
export function pixelRect(rect) {
|
|
5
|
+
return { ...rect, ...Object.fromEntries(['x','y','width','height'].map(axis => [axis, Math.round(rect[axis])])) };
|
|
6
|
+
}
|
|
7
|
+
export const intersects = (a, b) => a.width > 0 && a.height > 0 && b.width > 0 && b.height > 0 && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
8
|
+
export function bounds(rectangles) {
|
|
9
|
+
const x = Math.min(...rectangles.map(r => r.x)), y = Math.min(...rectangles.map(r => r.y));
|
|
10
|
+
return { x, y, width: Math.max(...rectangles.map(r => r.x + r.width)) - x, height: Math.max(...rectangles.map(r => r.y + r.height)) - y };
|
|
11
|
+
}
|
|
12
|
+
export function snapRect(moving, others, tolerance) {
|
|
13
|
+
const result = { x: moving.x, y: moving.y, guides: [] };
|
|
14
|
+
for (const [axis, size, cross, crossSize] of [['x','width','y','height'],['y','height','x','width']]) {
|
|
15
|
+
let best = tolerance + Number.EPSILON, match = null;
|
|
16
|
+
const offer = (value, guide) => {
|
|
17
|
+
const distance = Math.abs(value - moving[axis]);
|
|
18
|
+
if (distance <= tolerance && distance < best) { best = distance; match = { value, guide }; }
|
|
19
|
+
};
|
|
20
|
+
for (const other of others) {
|
|
21
|
+
for (const anchor of [0,.5,1]) for (const target of [0,.5,1]) {
|
|
22
|
+
const line = other[axis] + other[size] * target;
|
|
23
|
+
offer(line - moving[size] * anchor, { axis, value: line, from: Math.min(moving[cross], other[cross]), to: Math.max(moving[cross]+moving[crossSize], other[cross]+other[crossSize]), kind:'align' });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Derive spacing from adjacent frames in the same row/column, not from a
|
|
27
|
+
// fixed grid. Offer that same gap beside any member of the row/column.
|
|
28
|
+
const row = others.filter(r => moving[cross] < r[cross]+r[crossSize] && moving[cross]+moving[crossSize] > r[cross]).sort((a,b)=>a[axis]-b[axis]);
|
|
29
|
+
for (let i=1;i<row.length;i++) {
|
|
30
|
+
const gap = row[i][axis] - row[i-1][axis] - row[i-1][size];
|
|
31
|
+
if (gap <= 0) continue;
|
|
32
|
+
for (const other of row) {
|
|
33
|
+
for (const before of [false,true]) {
|
|
34
|
+
const value = before ? other[axis]-gap-moving[size] : other[axis]+other[size]+gap;
|
|
35
|
+
const from = before ? value+moving[size] : other[axis]+other[size];
|
|
36
|
+
offer(value, { axis:cross, value:moving[cross]+moving[crossSize]/2, from, to:from+gap, kind:'gap', gap, references:[{axis:cross,value:row[i-1][cross]+row[i-1][crossSize]/2,from:row[i-1][axis]+row[i-1][size],to:row[i][axis],kind:'gap',gap,reference:true}] });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Equal spacing when inserting between two existing frames.
|
|
40
|
+
const available = row[i][axis] - row[i-1][axis] - row[i-1][size] - moving[size];
|
|
41
|
+
if (available >= 0) {
|
|
42
|
+
const value = row[i-1][axis]+row[i-1][size]+available/2;
|
|
43
|
+
offer(value, {axis:cross,value:moving[cross]+moving[crossSize]/2,from:row[i-1][axis]+row[i-1][size],to:value,kind:'gap',gap:available/2,
|
|
44
|
+
references:[{axis:cross,value:moving[cross]+moving[crossSize]/2,from:value+moving[size],to:row[i][axis],kind:'gap',gap:available/2,reference:true}]});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (match) { result[axis] = match.value; result.guides.push(match.guide,...(match.guide.references??[])); }
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Keep appending to the current row until its right edge is full. Then search
|
|
53
|
+
// free rows below, followed by earlier rows, without enlarging the workspace.
|
|
54
|
+
// A workspace is a number (square) or { width, height }.
|
|
55
|
+
export const workspace = extent => typeof extent === 'number' ? { width: extent, height: extent } : extent;
|
|
56
|
+
export function duplicateOffset(box, others, extent, gap=80) {
|
|
57
|
+
const space=workspace(extent);
|
|
58
|
+
const fits=(x,y)=>x>=0 && y>=0 && x+box.width<=space.width && y+box.height<=space.height && !others.some(r=>intersects({x:x-gap,y:y-gap,width:box.width+2*gap,height:box.height+2*gap},r));
|
|
59
|
+
const right=others.reduce((edge,r)=>r.y<box.y+box.height && r.y+r.height>box.y?Math.max(edge,r.x+r.width):edge,box.x+box.width);
|
|
60
|
+
if(fits(right+gap,box.y))return{x:right+gap-box.x,y:0};
|
|
61
|
+
const rows=[...new Set([0,...others.map(r=>r.y+r.height+gap)])].sort((a,b)=>a-b);
|
|
62
|
+
const columns=[...new Set([Math.min(box.x,...others.map(r=>r.x)),...others.map(r=>r.x+r.width+gap).sort((a,b)=>a-b),0])];
|
|
63
|
+
for(const y of [...rows.filter(y=>y>=box.y+box.height+gap),...rows.filter(y=>y<box.y+box.height+gap)])for(const x of columns)if(fits(x,y))return{x:x-box.x,y:y-box.y};
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Add pages has its own placement rule; duplication keeps its row wrapping.
|
|
68
|
+
// Plan the whole batch first so a workspace boundary never leaves a partial add.
|
|
69
|
+
export function addedPagePositions(pages, others, extent, gap=100) {
|
|
70
|
+
const space=workspace(extent),occupied=[...others],positions=[];
|
|
71
|
+
for(const page of pages){
|
|
72
|
+
const rightmost=occupied.reduce((right,item)=>!right || item.x+item.width>right.x+right.width?item:right,null);
|
|
73
|
+
const x=rightmost?rightmost.x+rightmost.width+gap:Math.round((space.width-page.width)/2);
|
|
74
|
+
const y=rightmost?rightmost.y:Math.round((space.height-page.height)/2);
|
|
75
|
+
if(x<0 || y<0 || x+page.width>space.width || y+page.height>space.height)return null;
|
|
76
|
+
positions.push({x,y});occupied.push({...page,x,y});
|
|
77
|
+
}
|
|
78
|
+
return positions;
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// History owns canvas document edits only. Camera, theme, project content, and
|
|
2
|
+
// the source catalog never enter a snapshot. A drag is committed once.
|
|
3
|
+
export function createHistory(capture, restore, limit = 100) {
|
|
4
|
+
const undo = [], redo = [];
|
|
5
|
+
return {
|
|
6
|
+
checkpoint() { return capture(); },
|
|
7
|
+
commit(before) {
|
|
8
|
+
const after = capture();
|
|
9
|
+
if (JSON.stringify([before.models,before.arrows,before.texts,before.objectOrder]) === JSON.stringify([after.models,after.arrows,after.texts,after.objectOrder])) return false;
|
|
10
|
+
undo.push({before,after});
|
|
11
|
+
if (undo.length > limit) undo.shift();
|
|
12
|
+
redo.length = 0;
|
|
13
|
+
return true;
|
|
14
|
+
},
|
|
15
|
+
undo() {
|
|
16
|
+
const edit = undo.pop();
|
|
17
|
+
if (!edit) return false;
|
|
18
|
+
restore(edit.before);redo.push(edit);return true;
|
|
19
|
+
},
|
|
20
|
+
redo() {
|
|
21
|
+
const edit = redo.pop();
|
|
22
|
+
if (!edit) return false;
|
|
23
|
+
restore(edit.after);undo.push(edit);return true;
|
|
24
|
+
},
|
|
25
|
+
get canUndo() { return undo.length > 0; },
|
|
26
|
+
get canRedo() { return redo.length > 0; },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Never consume the native undo stack of an input, editor, or rename field.
|
|
31
|
+
export function historyShortcut(event) {
|
|
32
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return null;
|
|
33
|
+
const key = event.key.toLowerCase();
|
|
34
|
+
if (key === 'z') return event.shiftKey ? 'redo' : 'undo';
|
|
35
|
+
if (key === 'y' && event.ctrlKey) return 'redo';
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Make readable preview styles behave as if no pointer can hover. The guard
|
|
2
|
+
// has zero added specificity and becomes inert when the mobile preset is left.
|
|
3
|
+
// No source stylesheet or production asset is rewritten.
|
|
4
|
+
const GUARD=':hover:not(:where([data-canvas-touch-preview], [data-canvas-touch-preview] *))';
|
|
5
|
+
export function suppressPreviewHover() {
|
|
6
|
+
const processed=new WeakMap();
|
|
7
|
+
function visit(sheet) {
|
|
8
|
+
let rules;try{rules=sheet.cssRules;}catch{return;} // Cross-origin CSS is not readable.
|
|
9
|
+
for(const rule of rules){
|
|
10
|
+
if(typeof rule.selectorText==='string' && processed.get(rule)!==rule.selectorText){
|
|
11
|
+
const selector=rule.selectorText.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|:hover\b/g,(match,quoted)=>quoted??GUARD);
|
|
12
|
+
if(selector!==rule.selectorText)rule.selectorText=selector;
|
|
13
|
+
processed.set(rule,rule.selectorText);
|
|
14
|
+
}
|
|
15
|
+
if(rule.styleSheet)visit(rule.styleSheet);
|
|
16
|
+
if(rule.cssRules)visit(rule);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function refresh(){
|
|
20
|
+
if(!document.documentElement.hasAttribute('data-canvas-touch-preview'))return;
|
|
21
|
+
for(const sheet of [...document.styleSheets,...document.adoptedStyleSheets])visit(sheet);
|
|
22
|
+
}
|
|
23
|
+
new MutationObserver(refresh).observe(document.head,{childList:true,subtree:true,characterData:true});
|
|
24
|
+
document.addEventListener('load',event=>{if(event.target.tagName==='LINK')refresh();},true);
|
|
25
|
+
// Real mouse hover events must not activate project hover handlers either.
|
|
26
|
+
for(const type of ['pointerover','pointerenter','mouseover','mouseenter','mousemove']){
|
|
27
|
+
window.addEventListener(type,event=>{
|
|
28
|
+
if(!document.documentElement.hasAttribute('data-canvas-touch-preview'))return;
|
|
29
|
+
// A pressed drag inside an editor is editing, not hover.
|
|
30
|
+
if(type==='mousemove' && event.buttons && event.target.closest?.('input,textarea,select,[contenteditable]'))return;
|
|
31
|
+
event.stopImmediatePropagation();
|
|
32
|
+
},true);
|
|
33
|
+
}
|
|
34
|
+
return refresh;
|
|
35
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Canvas</title>
|
|
7
|
+
<link rel="icon" href="/favicon-light.png" type="image/png" sizes="64x64">
|
|
8
|
+
<link rel="icon" href="/favicon-dark.png" type="image/png" sizes="64x64" media="(prefers-color-scheme: dark)">
|
|
9
|
+
<link rel="stylesheet" href="/canvas/canvas.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main id="viewport" aria-label="Exploration canvas" tabindex="0">
|
|
13
|
+
<div id="world"></div>
|
|
14
|
+
<form id="arrow-inspector" class="canvas-tools" aria-label="Arrow settings" hidden>
|
|
15
|
+
<button type="button" class="select-field icon-select" data-arrow-field="startStyle" value="line" aria-label="Arrow endpoint style"><svg class="field-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 10h14"/></svg><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg></button>
|
|
16
|
+
<button type="button" class="select-field icon-select" data-arrow-field="endStyle" value="line" aria-label="Arrow endpoint style"><svg class="field-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 10h14m-5-5 5 5-5 5"/></svg><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg></button>
|
|
17
|
+
<span class="tool-divider" aria-hidden="true"></span>
|
|
18
|
+
<span class="select-field"><span data-field-label aria-hidden="true"></span><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg><select data-arrow-field="pattern" aria-label="Arrow line pattern"><option value="" disabled hidden>Mixed</option><option value="solid">Solid</option><option value="dashed">Dashed</option></select></span>
|
|
19
|
+
<span class="select-field"><span data-field-label aria-hidden="true"></span><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg><select data-arrow-field="width" aria-label="Arrow line thickness"><option value="" disabled hidden>Mixed</option><option value="2">2px</option><option value="4">4px</option><option value="6">6px</option><option value="8">8px</option><option value="10">10px</option></select></span>
|
|
20
|
+
<span class="tool-divider" data-curved-only aria-hidden="true" hidden></span>
|
|
21
|
+
<button id="arrow-straight" class="secondary-button" type="button" data-curved-only hidden>Reset to straight</button>
|
|
22
|
+
</form>
|
|
23
|
+
<form id="text-inspector" class="canvas-tools" aria-label="Text settings" hidden>
|
|
24
|
+
<span class="select-field"><span data-field-label aria-hidden="true"></span><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg><select id="text-size" data-text-field="size" aria-label="Text size"><option value="" disabled hidden>Mixed</option><option value="sm">16 px</option><option value="md">24 px</option><option value="lg">40 px</option><option value="xl">64 px</option></select></span>
|
|
25
|
+
<span class="tool-divider" aria-hidden="true"></span>
|
|
26
|
+
<button id="text-bold" type="button" class="secondary-button icon-button" data-text-format aria-label="Bold" data-tooltip="Bold" data-shortcut="mod+b" aria-pressed="false"><svg class="field-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M6 4h5a3 3 0 0 1 0 6H6zm0 6h6a3 3 0 0 1 0 6H6z"/></svg></button>
|
|
27
|
+
<button id="text-italic" type="button" class="secondary-button icon-button" data-text-format aria-label="Italic" data-tooltip="Italic" data-shortcut="mod+i" aria-pressed="false"><svg class="field-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 4h6M6 16h6M11.5 4l-3 12"/></svg></button>
|
|
28
|
+
</form>
|
|
29
|
+
<div id="selection" hidden>
|
|
30
|
+
<div id="handles"></div>
|
|
31
|
+
<form id="inspector" class="canvas-tools">
|
|
32
|
+
<label>Preset <span class="select-field"><span id="preset-label" aria-hidden="true">Custom</span><svg class="chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg><select id="preset"></select></span></label>
|
|
33
|
+
<span class="tool-divider" aria-hidden="true"></span>
|
|
34
|
+
<label>W <input id="width" data-tooltip-disabled="Select Custom in Preset to edit the width" type="number" min="160" max="3840" required></label>
|
|
35
|
+
<label>H <input id="height" data-tooltip-disabled="Select Custom in Preset to edit the height" type="number" min="160" max="3840" required></label>
|
|
36
|
+
</form>
|
|
37
|
+
<form id="group-inspector" class="canvas-tools" hidden>
|
|
38
|
+
<label><span id="gap-label">Gap X</span> <input id="gap" type="number" min="0" step="1" aria-label="Gap between selected screens" required></label>
|
|
39
|
+
<label hidden><span>Gap Y</span> <input id="gap-y" type="number" min="0" step="1" aria-label="Vertical gap between selected screens" required></label>
|
|
40
|
+
</form>
|
|
41
|
+
</div>
|
|
42
|
+
</main>
|
|
43
|
+
<nav id="edit-tools" class="canvas-tools" aria-label="Canvas controls">
|
|
44
|
+
<button id="tool-cursor" class="icon-button" aria-label="Cursor tool" data-tooltip="Cursor" data-shortcut="v" aria-pressed="true"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4 3 12 8-6 1-3 5Z"/></svg></button>
|
|
45
|
+
<button id="tool-arrow" class="icon-button" aria-label="Arrow tool" data-tooltip="Arrow" data-shortcut="a" aria-pressed="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 16 16 4M6 4h10v10"/></svg></button>
|
|
46
|
+
<button id="tool-text" class="icon-button" aria-label="Text tool" data-tooltip="Text" data-shortcut="t" aria-pressed="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 5.5V4h12v1.5M10 4v12M7.5 16h5"/></svg></button>
|
|
47
|
+
<span class="tool-divider" aria-hidden="true"></span>
|
|
48
|
+
<button id="add" class="icon-button" aria-label="Add pages" data-tooltip="Add pages"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"/></svg></button>
|
|
49
|
+
</nav>
|
|
50
|
+
<nav id="history-tools" class="canvas-tools" aria-label="History controls">
|
|
51
|
+
<button id="undo" class="icon-button" aria-label="Undo" data-tooltip="Undo" data-shortcut="mod+z" disabled><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 4-4 4 4 4M3 8h8a5 5 0 0 1 0 10"/></svg></button>
|
|
52
|
+
<button id="redo" class="icon-button" aria-label="Redo" data-tooltip="Redo" data-shortcut="shift+mod+z" disabled><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m13 4 4 4-4 4m4-4H9a5 5 0 0 0 0 10"/></svg></button>
|
|
53
|
+
</nav>
|
|
54
|
+
<nav id="zoom-tools" class="canvas-tools" aria-label="Canvas zoom controls">
|
|
55
|
+
<button id="zoom-out" class="icon-button" aria-label="Zoom out" data-tooltip="Zoom out"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13 13 4 4M6 8.5h5"/></svg></button>
|
|
56
|
+
<label class="zoom-field"><input id="zoom" type="text" inputmode="decimal" aria-label="Zoom percentage" value="80%"></label>
|
|
57
|
+
<button id="zoom-in" class="icon-button" aria-label="Zoom in" data-tooltip="Zoom in"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13 13 4 4M6 8.5h5M8.5 6v5"/></svg></button>
|
|
58
|
+
<span class="tool-divider" aria-hidden="true"></span>
|
|
59
|
+
<button id="zoom-reset" aria-label="Zoom to 100%" data-tooltip="Zoom to 100%">100%</button>
|
|
60
|
+
<button id="fit" aria-keyshortcuts="Shift+1" data-tooltip="Fit all screens" data-shortcut="shift+1">Fit all</button>
|
|
61
|
+
<button id="fit-screen" aria-keyshortcuts="Shift+2" data-tooltip="Fit selected screens" data-shortcut="shift+2" hidden>Fit to screen</button>
|
|
62
|
+
</nav>
|
|
63
|
+
<aside id="layers" class="canvas-tools layers-drawer" aria-label="Screens">
|
|
64
|
+
<header class="layers-header">
|
|
65
|
+
<h2 id="layers-title"><span class="layers-logo" role="img" aria-label="Tio"></span><span id="screens-count" hidden>0</span></h2>
|
|
66
|
+
<button id="layers-more" type="button" aria-label="More" aria-haspopup="menu" aria-controls="layers-menu" popovertarget="layers-menu" data-tooltip="More"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5h.01M10 10h.01M10 15.5h.01" stroke-width="2.5"/></svg></button>
|
|
67
|
+
<button id="layers-toggle" type="button" aria-label="Minimize Layers" aria-controls="layers-list" aria-expanded="true"><svg viewBox="0 0 20 20" aria-hidden="true"><path/></svg></button>
|
|
68
|
+
</header>
|
|
69
|
+
<ol id="layers-list" aria-label="Layers, top layer first"></ol>
|
|
70
|
+
</aside>
|
|
71
|
+
<div id="fullscreen-tools" hidden>
|
|
72
|
+
<button id="fullscreen-toggle" type="button" aria-label="Preview controls" aria-expanded="false" aria-controls="fullscreen-menu"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12"/></svg></button>
|
|
73
|
+
<section id="fullscreen-menu" class="canvas-tools" aria-label="Preview controls" hidden>
|
|
74
|
+
<button id="fullscreen-back" type="button">Back to canvas</button>
|
|
75
|
+
</section>
|
|
76
|
+
</div>
|
|
77
|
+
<dialog id="add-pages" class="page-picker" aria-labelledby="add-pages-title">
|
|
78
|
+
<header class="layers-header">
|
|
79
|
+
<h2 id="add-pages-title">Add pages</h2>
|
|
80
|
+
<button id="add-pages-close" type="button" aria-label="Close Add pages" data-tooltip="Close" data-shortcut="esc"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15"/></svg></button>
|
|
81
|
+
</header>
|
|
82
|
+
<section class="page-picker-body" aria-label="Available pages">
|
|
83
|
+
<div id="page-options" role="group" aria-label="Pages"></div>
|
|
84
|
+
<p id="page-picker-error" role="alert"></p>
|
|
85
|
+
</section>
|
|
86
|
+
<footer hidden><button id="add-pages-select-all" type="button">Select all</button><button id="add-pages-unselect-all" type="button" disabled>Unselect all</button><button id="add-pages-submit" type="button" hidden disabled>Add pages</button></footer>
|
|
87
|
+
</dialog>
|
|
88
|
+
<div id="layers-menu" class="canvas-tools" popover="auto" role="menu" aria-label="More" tabindex="-1">
|
|
89
|
+
<button id="theme" type="button" role="menuitem"><svg class="theme-icon" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M10 3a7 7 0 0 1 0 14Z"/></svg><span id="theme-label">Dark mode</span></button>
|
|
90
|
+
<button id="shortcuts-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 5.5C8.6 4.4 6.6 4 3.5 4v11c3.1 0 5.1.4 6.5 1.5m0-11C11.4 4.4 13.4 4 16.5 4v11c-3.1 0-5.1.4-6.5 1.5m0-11v11"/></svg>Guide & hotkeys</button>
|
|
91
|
+
<hr role="separator">
|
|
92
|
+
<button id="onboarding-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M8.2 8a1.9 1.9 0 1 1 2.9 1.6c-.7.4-1.1.8-1.1 1.6M10 14h.01"/></svg>Onboarding</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div id="screen-context" class="canvas-tools" popover="auto" role="menu" aria-label="Screen actions" tabindex="-1">
|
|
95
|
+
<button type="button" role="menuitem" data-action="duplicate"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M12 7V3H3v9h4"/></svg>Duplicate</button>
|
|
96
|
+
<button type="button" role="menuitem" data-action="delete"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 5h14M8 5V3h4v2M5 5l1 12h8l1-12M8 8v6M12 8v6"/></svg>Delete</button>
|
|
97
|
+
</div>
|
|
98
|
+
<dialog id="shortcuts" class="page-picker" aria-labelledby="shortcuts-title">
|
|
99
|
+
<header class="layers-header"><h2 id="shortcuts-title">Guide & hotkeys</h2><div id="shortcuts-platform" class="segmented" role="radiogroup" aria-label="Keyboard layout"><button type="button" role="radio" data-platform="mac" aria-checked="false">macOS</button><button type="button" role="radio" data-platform="win" aria-checked="false">Windows</button></div><button id="shortcuts-close" type="button" aria-label="Close Guide & hotkeys" data-tooltip="Close" data-shortcut="esc">✕</button></header>
|
|
100
|
+
<div class="shortcuts-body"></div>
|
|
101
|
+
</dialog>
|
|
102
|
+
<div id="device-gate" popover="manual" role="alert">
|
|
103
|
+
<div class="device-gate-icons" aria-hidden="true">
|
|
104
|
+
<svg viewBox="0 0 20 20"><rect x="3" y="3" width="14" height="10" rx="1"/><path d="M10 13v4m-4 0h8"/></svg>
|
|
105
|
+
<svg viewBox="0 0 20 20"><rect x="4" y="2" width="12" height="16" rx="2"/><path d="M9 15h2"/></svg>
|
|
106
|
+
</div>
|
|
107
|
+
<p>The canvas works best on a desktop or tablet. Open it on a larger screen.</p>
|
|
108
|
+
</div>
|
|
109
|
+
<p id="canvas-status" role="status"></p>
|
|
110
|
+
<script type="module" src="/canvas/canvas.js"></script>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Layer order is bottom-to-top; the drawer displays that order in reverse.
|
|
2
|
+
export function layerShortcut(event){
|
|
3
|
+
if(!event.metaKey || event.altKey)return null;
|
|
4
|
+
const left=event.code==='BracketLeft' || ['[','{'].includes(event.key);
|
|
5
|
+
const right=event.code==='BracketRight' || [']','}'].includes(event.key);
|
|
6
|
+
return left?(event.shiftKey?'bottom':'below'):right?(event.shiftKey?'top':'above'):null;
|
|
7
|
+
}
|
|
8
|
+
export function reorderLayers(items,ids,action){
|
|
9
|
+
const chosen=new Set(ids),result=[...items];
|
|
10
|
+
if(action==='top')return [...items.filter(i=>!chosen.has(i)),...items.filter(i=>chosen.has(i))];
|
|
11
|
+
if(action==='bottom')return [...items.filter(i=>chosen.has(i)),...items.filter(i=>!chosen.has(i))];
|
|
12
|
+
if(action==='above')for(let i=result.length-2;i>=0;i--)if(chosen.has(result[i])&&!chosen.has(result[i+1]))[result[i],result[i+1]]=[result[i+1],result[i]];
|
|
13
|
+
if(action==='below')for(let i=1;i<result.length;i++)if(chosen.has(result[i])&&!chosen.has(result[i-1]))[result[i],result[i-1]]=[result[i-1],result[i]];
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
export function dropLayers(items,ids,target,before){
|
|
17
|
+
const chosen=new Set(ids);if(chosen.has(target) || !items.includes(target))return [...items];
|
|
18
|
+
const rest=items.filter(i=>!chosen.has(i)),index=rest.indexOf(target)+(before?1:0);
|
|
19
|
+
rest.splice(index,0,...items.filter(i=>chosen.has(i)));return rest;
|
|
20
|
+
}
|
package/client/layers.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createRangeSelection } from './range-selection.js';
|
|
2
|
+
const paths={
|
|
3
|
+
phone:'<rect x="6" y="2" width="8" height="16" rx="2"/><path d="M9 15h2"/>',
|
|
4
|
+
tablet:'<rect x="4" y="2" width="12" height="16" rx="2"/><path d="M9 15h2"/>',
|
|
5
|
+
laptop:'<rect x="4" y="3" width="12" height="10" rx="1"/><path d="M2 15h16l-1 2H3Z"/>',
|
|
6
|
+
desktop:'<rect x="3" y="3" width="14" height="10" rx="1"/><path d="M10 13v4m-4 0h8"/>',
|
|
7
|
+
custom:'<path d="M4 10V4h6M4 4l12 12m0-6v6h-6"/>',
|
|
8
|
+
text:'<path d="M4 5.5V4h12v1.5M10 4v12M7.5 16h5"/>'
|
|
9
|
+
};
|
|
10
|
+
export function deviceKind(preset,presets){return presets[preset]?.device??'custom';}
|
|
11
|
+
export function createLayerDrawer({root,presets,collapsed=false,onSelect,onDrop,onCollapse,onRename,onSelectMany}){
|
|
12
|
+
const list=root.querySelector('ol'),toggle=root.querySelector('#layers-toggle'),count=root.querySelector('#screens-count');
|
|
13
|
+
const rows=new Map(),ghost=document.createElement('div');ghost.className='layer-ghost';ghost.hidden=true;ghost.setAttribute('aria-hidden','true');ghost.inert=true;document.body.append(ghost);
|
|
14
|
+
let current=[],selected=new Set(),drag=null,scrollFrame=0;
|
|
15
|
+
function setCollapsed(value){
|
|
16
|
+
root.toggleAttribute('data-collapsed',value);list.hidden=value;
|
|
17
|
+
toggle.setAttribute('aria-expanded',String(!value));toggle.setAttribute('aria-label',`${value?'Expand':'Minimize'} Layers`);toggle.title=`${value?'Expand':'Minimize'} Layers`;
|
|
18
|
+
toggle.querySelector('path').setAttribute('d',value?'M5 7.5l5 5 5-5':'M5 12.5l5-5 5 5');
|
|
19
|
+
}
|
|
20
|
+
// The drawer is clipped (overflow: hidden), so animating its height reveals or
|
|
21
|
+
// hides the list instead of snapping. One box, 160ms, ease-out-quint.
|
|
22
|
+
let collapseMotion;
|
|
23
|
+
function animateCollapse(value){
|
|
24
|
+
collapseMotion?.cancel();
|
|
25
|
+
const from=root.getBoundingClientRect().height;
|
|
26
|
+
setCollapsed(value);
|
|
27
|
+
if(matchMedia('(prefers-reduced-motion: reduce)').matches)return;
|
|
28
|
+
const to=root.getBoundingClientRect().height;
|
|
29
|
+
if(from===to)return;
|
|
30
|
+
if(value)list.hidden=false;
|
|
31
|
+
const motion=collapseMotion=root.animate([{height:`${from}px`},{height:`${to}px`}],{duration:160,easing:'cubic-bezier(0.23, 1, 0.32, 1)'});
|
|
32
|
+
motion.onfinish=()=>{if(collapseMotion===motion){collapseMotion=null;list.hidden=root.hasAttribute('data-collapsed');}};
|
|
33
|
+
motion.oncancel=()=>{if(collapseMotion===motion){collapseMotion=null;list.hidden=root.hasAttribute('data-collapsed');}};
|
|
34
|
+
}
|
|
35
|
+
toggle.addEventListener('click',()=>{cancel();const value=!root.hasAttribute('data-collapsed');animateCollapse(value);onCollapse(value);});
|
|
36
|
+
setCollapsed(collapsed);
|
|
37
|
+
// Items run bottom to top: { id, kind: 'screen' | 'text', title, preset }. A text layer is named by
|
|
38
|
+
// its content and cannot be renamed; `selection` is a set of layer ids.
|
|
39
|
+
function update(items,selection){
|
|
40
|
+
count.textContent=String(items.length);root.toggleAttribute('data-empty',!items.length);document.getElementById('fit').hidden=!items.length;
|
|
41
|
+
const focused=document.activeElement?.dataset.layerId;
|
|
42
|
+
current=items.map(item=>item.id);selected=new Set(selection);
|
|
43
|
+
const wanted=new Set(current);
|
|
44
|
+
for(const [id,row] of rows)if(!wanted.has(id)){row.remove();rows.delete(id);}
|
|
45
|
+
let previous=null;
|
|
46
|
+
for(const item of [...items].reverse()){
|
|
47
|
+
const {id,title,preset,kind:itemKind}=item;let row=rows.get(id);
|
|
48
|
+
if(!row){
|
|
49
|
+
row=document.createElement('li');const button=document.createElement('button');button.type='button';button.className='layer-row';button.dataset.layerId=id;button.dataset.rangeId=id;
|
|
50
|
+
button.innerHTML='<span class="layer-grab" data-reorder aria-hidden="true"><svg class="layer-grabber" viewBox="0 0 20 20"><path d="M7 5h0m6 0h0M7 10h0m6 0h0M7 15h0m6 0h0"/></svg><svg class="layer-device" viewBox="0 0 20 20"></svg></span><span class="layer-label"></span>';row.append(button);rows.set(id,row);
|
|
51
|
+
}
|
|
52
|
+
const button=row.firstElementChild,kind=itemKind==='text'?'text':deviceKind(preset,presets);button.dataset.kind=itemKind;
|
|
53
|
+
if(button.dataset.device!==kind){button.querySelector('.layer-device').innerHTML=paths[kind];button.dataset.device=kind;}
|
|
54
|
+
if(!button.querySelector('.layer-label').isContentEditable && button.querySelector('.layer-label').textContent!==title)button.querySelector('.layer-label').textContent=title;button.dataset.tooltip='Drag to select, or drag the grip to reorder';
|
|
55
|
+
button.setAttribute('aria-label',`${title}, ${itemKind==='text'?'Text':presets[preset]?.label??'Custom'}`);button.setAttribute('aria-pressed',String(selected.has(id)));
|
|
56
|
+
const next=previous?previous.nextElementSibling:list.firstElementChild;
|
|
57
|
+
if(next!==row)list.insertBefore(row,next);previous=row;
|
|
58
|
+
}
|
|
59
|
+
if(focused && rows.has(focused) && document.activeElement?.dataset.layerId!==focused)rows.get(focused).firstElementChild.focus({preventScroll:true});
|
|
60
|
+
}
|
|
61
|
+
// The drop position is one line between two rows. It slides from gap to gap, and
|
|
62
|
+
// a pointer outside the list keeps it at the nearest end instead of removing it.
|
|
63
|
+
const line=document.createElement('div');line.className='layer-drop-line';line.setAttribute('aria-hidden','true');line.inert=true;line.hidden=true;list.append(line);
|
|
64
|
+
function clearDrop(){line.hidden=true;}
|
|
65
|
+
function targetAt(y){
|
|
66
|
+
const candidates=[...list.children].filter(row=>row!==line).map(row=>[row.firstElementChild.dataset.layerId,row]).filter(([id])=>!drag.ids.includes(id));
|
|
67
|
+
for(const [id,row] of candidates){const r=row.getBoundingClientRect();if(y<r.bottom)return{id,before:y<r.top+r.height/2};}
|
|
68
|
+
return candidates.length?{id:candidates.at(-1)[0],before:false}:null;
|
|
69
|
+
}
|
|
70
|
+
function preview(){
|
|
71
|
+
if(!drag?.moved)return;
|
|
72
|
+
drag.target=targetAt(drag.y);
|
|
73
|
+
if(drag.target){
|
|
74
|
+
const row=rows.get(drag.target.id),half=parseFloat(getComputedStyle(root).getPropertyValue('--canvas-layer-gap'))/2;
|
|
75
|
+
const y=(drag.target.before?row.offsetTop-half:row.offsetTop+row.offsetHeight+half)-line.offsetHeight/2;
|
|
76
|
+
const show=line.hidden;
|
|
77
|
+
if(show){line.style.transition='none';line.hidden=false;}
|
|
78
|
+
line.style.transform=`translateY(${y}px)`;
|
|
79
|
+
if(show){line.offsetWidth;line.style.transition='';}
|
|
80
|
+
}else clearDrop();
|
|
81
|
+
ghost.style.transform=`translate(${drag.pointerX-drag.offsetX}px,${drag.y-drag.offset}px)`;
|
|
82
|
+
}
|
|
83
|
+
function autoScroll(){
|
|
84
|
+
scrollFrame=0;if(!drag?.moved)return;
|
|
85
|
+
const rect=list.getBoundingClientRect(),edge=24;
|
|
86
|
+
const delta=drag.y<rect.top+edge?-10:drag.y>rect.bottom-edge?10:0;
|
|
87
|
+
if(delta){list.scrollTop+=delta;preview();}
|
|
88
|
+
scrollFrame=requestAnimationFrame(autoScroll);
|
|
89
|
+
}
|
|
90
|
+
function cancel(){
|
|
91
|
+
const old=drag;drag=null;cancelAnimationFrame(scrollFrame);clearDrop();ghost.hidden=true;delete document.body.dataset.layerDrag;
|
|
92
|
+
for(const row of rows.values())delete row.dataset.dragging;
|
|
93
|
+
if(old && root.hasPointerCapture(old.pointer))root.releasePointerCapture(old.pointer);
|
|
94
|
+
}
|
|
95
|
+
const rangeSelection=createRangeSelection({root:list,items:()=>[...current].reverse(),selected:()=>[...selected],onChange:onSelectMany,toggleModifiers:true});
|
|
96
|
+
list.addEventListener('dblclick',event=>{
|
|
97
|
+
const label=event.target.closest('.layer-label');
|
|
98
|
+
if(!label || label.isContentEditable || label.closest('[data-kind="text"]'))return;
|
|
99
|
+
event.preventDefault();cancel();onRename(label.closest('[data-layer-id]').dataset.layerId,label);
|
|
100
|
+
});
|
|
101
|
+
list.addEventListener('pointerdown',event=>{
|
|
102
|
+
if(event.target.isContentEditable || !event.target.closest('[data-reorder]'))return;
|
|
103
|
+
const button=event.target.closest('[data-layer-id]');if(event.button!==0 || !button)return;
|
|
104
|
+
const rect=button.getBoundingClientRect();drag={id:button.dataset.layerId,pointer:event.pointerId,x:event.clientX,pointerX:event.clientX,startY:event.clientY,y:event.clientY,left:rect.left,offsetX:event.clientX-rect.left,offset:event.clientY-rect.top,moved:false,shiftKey:event.shiftKey,metaKey:event.metaKey,ctrlKey:event.ctrlKey};
|
|
105
|
+
|
|
106
|
+
});
|
|
107
|
+
root.addEventListener('pointermove',event=>{
|
|
108
|
+
if(!drag || event.pointerId!==drag.pointer)return;drag.y=event.clientY;drag.pointerX=event.clientX;
|
|
109
|
+
if(!drag.moved && Math.hypot(event.clientX-drag.x,event.clientY-drag.startY)<4)return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
if(!drag.moved){
|
|
112
|
+
root.setPointerCapture(event.pointerId);
|
|
113
|
+
drag.moved=true;if(!selected.has(drag.id))onSelect(drag.id,false);
|
|
114
|
+
drag.ids=current.filter(id=>selected.has(id));
|
|
115
|
+
document.body.dataset.layerDrag='';
|
|
116
|
+
const button=rows.get(drag.id).firstElementChild;
|
|
117
|
+
const width=button.offsetWidth;
|
|
118
|
+
for(const id of drag.ids)rows.get(id).dataset.dragging='';
|
|
119
|
+
const cards=[...current].reverse().filter(id=>drag.ids.includes(id)).map(id=>{
|
|
120
|
+
const card=rows.get(id).firstElementChild.cloneNode(true);
|
|
121
|
+
card.removeAttribute('data-layer-id');card.removeAttribute('data-range-id');card.tabIndex=-1;
|
|
122
|
+
return card;
|
|
123
|
+
});
|
|
124
|
+
ghost.replaceChildren(...cards);
|
|
125
|
+
ghost.style.width=`${width}px`;ghost.hidden=false;
|
|
126
|
+
scrollFrame=requestAnimationFrame(autoScroll);
|
|
127
|
+
}
|
|
128
|
+
preview();
|
|
129
|
+
});
|
|
130
|
+
// Focus moves to a row after a click, which a browser treats as keyboard focus.
|
|
131
|
+
// Track the last input so a pointer selection draws no ring and the keyboard still does.
|
|
132
|
+
root.addEventListener('pointerdown',()=>root.setAttribute('data-pointer',''),true);
|
|
133
|
+
document.addEventListener('keydown',()=>root.removeAttribute('data-pointer'),true);
|
|
134
|
+
root.addEventListener('pointerup',event=>{
|
|
135
|
+
if(!drag || event.pointerId!==drag.pointer)return;
|
|
136
|
+
const old=drag;cancel();
|
|
137
|
+
if(old.moved){
|
|
138
|
+
if(old.target){
|
|
139
|
+
const tops=new Map([...rows].map(([id,row])=>[id,row.getBoundingClientRect().top]));
|
|
140
|
+
onDrop(old.ids,old.target.id,old.target.before);
|
|
141
|
+
if(!matchMedia('(prefers-reduced-motion: reduce)').matches)for(const [id,row] of rows){
|
|
142
|
+
const delta=tops.get(id)-row.getBoundingClientRect().top;
|
|
143
|
+
if(delta)row.animate([{transform:`translateY(${delta}px)`},{transform:'none'}],{duration:180,easing:'cubic-bezier(0.23, 1, 0.32, 1)'});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else rangeSelection.select(old.id,old);
|
|
148
|
+
rows.get(old.id)?.firstElementChild.focus({preventScroll:true});
|
|
149
|
+
});
|
|
150
|
+
root.addEventListener('pointercancel',cancel);root.addEventListener('lostpointercapture',()=>{if(drag)cancel();});window.addEventListener('blur',cancel);
|
|
151
|
+
document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
|
|
152
|
+
window.addEventListener('keydown',event=>{if(event.key==='Escape' && drag){event.preventDefault();event.stopImmediatePropagation();cancel();}},true);
|
|
153
|
+
return {update,setCollapsed};
|
|
154
|
+
}
|