@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/bin/tio-canvas.mjs +12 -0
  4. package/client/arrange.js +41 -0
  5. package/client/arrow-geometry.js +56 -0
  6. package/client/arrow-selection.js +68 -0
  7. package/client/arrows.js +205 -0
  8. package/client/bridge.js +74 -0
  9. package/client/canvas.css +733 -0
  10. package/client/canvas.js +1009 -0
  11. package/client/corners.js +22 -0
  12. package/client/device-gate.js +15 -0
  13. package/client/fullscreen.js +118 -0
  14. package/client/geometry.js +79 -0
  15. package/client/history.js +37 -0
  16. package/client/hover-preview.js +35 -0
  17. package/client/index.html +112 -0
  18. package/client/layer-order.js +20 -0
  19. package/client/layers.js +154 -0
  20. package/client/menu-position.js +36 -0
  21. package/client/onboarding.js +302 -0
  22. package/client/page-picker.js +76 -0
  23. package/client/platform.js +16 -0
  24. package/client/range-selection.js +54 -0
  25. package/client/registry.js +31 -0
  26. package/client/screen-loader.js +67 -0
  27. package/client/screens.js +7 -0
  28. package/client/select-field.js +191 -0
  29. package/client/shortcuts.js +158 -0
  30. package/client/smart-handles.js +28 -0
  31. package/client/smart-selection.js +52 -0
  32. package/client/spacing.js +45 -0
  33. package/client/text-format.js +49 -0
  34. package/client/text.js +466 -0
  35. package/client/tio-logo.png +0 -0
  36. package/client/tooltip.js +128 -0
  37. package/client/touch-preview.js +112 -0
  38. package/docs/exploration-canvas.md +315 -0
  39. package/package.json +56 -4
  40. package/src/build.js +33 -0
  41. package/src/index.js +3 -0
  42. package/src/interface-kit-bundle.js +103 -0
  43. package/src/interface-kit-entry.js +16 -0
  44. package/src/project.js +29 -0
  45. package/src/server.js +70 -0
  46. package/src/thumbnails.js +95 -0
@@ -0,0 +1,191 @@
1
+ import { menuPosition } from './menu-position.js';
2
+
3
+ const selectMenus = new Map();
4
+ const groupIcons = {
5
+ Desktop: '<rect x="3" y="3" width="14" height="10" rx="1"/><path d="M10 13v4m-4 0h8"/>',
6
+ Mobile: '<rect x="6" y="2" width="8" height="16" rx="2"/><path d="M9 15h2"/>',
7
+ Tablet: '<rect x="4" y="2" width="12" height="16" rx="2"/><path d="M9 15h2"/>',
8
+ Foldable: '<rect x="4" y="2" width="12" height="16" rx="2"/><path d="M10 2v16"/>',
9
+ };
10
+
11
+ export function closeSelectMenus() {
12
+ for (const menu of selectMenus.keys()) if (menu.matches(':popover-open')) menu.hidePopover();
13
+ }
14
+
15
+ export function positionSelectMenus() {
16
+ for (const [menu, position] of selectMenus) if (menu.matches(':popover-open')) position();
17
+ }
18
+
19
+ function connectMenu(button, menu, focusOption) {
20
+ button.removeAttribute('popovertarget');
21
+ button.setAttribute('aria-controls', menu.id);
22
+ const position = () => {
23
+ menu.style.removeProperty('max-height');
24
+ const anchor = button.getBoundingClientRect(), box = menu.getBoundingClientRect();
25
+ const style = getComputedStyle(menu);
26
+ const margin = parseFloat(style.getPropertyValue('--canvas-panel-inset'));
27
+ const gap = parseFloat(style.getPropertyValue('--canvas-menu-offset'));
28
+ const owner = button.closest('.canvas-tools');
29
+ const obstacles = [...document.querySelectorAll('.canvas-tools')]
30
+ .filter(element => element !== owner && !element.hasAttribute('popover') && getComputedStyle(element).position === 'fixed' && element.checkVisibility())
31
+ .map(element => element.getBoundingClientRect());
32
+ const placement = menuPosition({ anchor, width: box.width, height: box.height,
33
+ viewport: { width: innerWidth, height: innerHeight }, margin, gap, obstacles,
34
+ aboveOnly: menu.classList.contains('endpoint-options') });
35
+ if (!placement) return;
36
+ menu.style.maxHeight = `${placement.height}px`;
37
+ menu.style.left = `${placement.x}px`;
38
+ menu.style.top = `${placement.y}px`;
39
+ // Scale from the trigger: its centre on x, the edge facing it on y.
40
+ menu.style.transformOrigin = `${anchor.left + anchor.width / 2 - placement.x}px ${placement.y >= anchor.bottom - 1 ? '0' : '100%'}`;
41
+ };
42
+ selectMenus.set(menu, position);
43
+ // A popover="auto" menu is light-dismissed by the press that lands on its own
44
+ // trigger, so by click time it is already closed and would reopen at once.
45
+ let wasOpen = false;
46
+ button.addEventListener('pointerdown', () => { wasOpen = menu.matches(':popover-open'); });
47
+ button.addEventListener('click', () => {
48
+ const pressedWhileOpen = wasOpen;
49
+ wasOpen = false;
50
+ if (pressedWhileOpen || menu.matches(':popover-open')) {
51
+ if (menu.matches(':popover-open')) menu.hidePopover();
52
+ // Closing with a pointer must not leave a keyboard ring on the trigger.
53
+ if (pressedWhileOpen) button.blur();
54
+ return;
55
+ }
56
+ closeSelectMenus();
57
+ menu.showPopover();
58
+ // Position in the opening task, before the browser can paint an old location.
59
+ position();
60
+ const option = focusOption();
61
+ option?.focus({ preventScroll: true });
62
+ option?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
63
+ });
64
+ menu.addEventListener('beforetoggle', event => {
65
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
66
+ });
67
+ window.addEventListener('resize', () => { if (menu.matches(':popover-open')) position(); });
68
+ }
69
+
70
+ // Icon menus share the preset field's padding, chevron, focus ring, and surface.
71
+ const icons = {
72
+ line: 'M3 10h14',
73
+ start: 'M17 10H3m5-5-5 5 5 5',
74
+ end: 'M3 10h14m-5-5 5 5-5 5',
75
+ };
76
+ export function setupArrowFields(toolbar) {
77
+ const menus = [];
78
+ for (const button of toolbar.querySelectorAll('button[data-arrow-field]')) {
79
+ const side = button.dataset.arrowField === 'startStyle' ? 'start' : 'end';
80
+ const menu = document.createElement('div');menu.className = 'canvas-tools icon-options endpoint-options';menu.popover = 'auto';menu.setAttribute('role', 'menu');menu.setAttribute('aria-label', `${side === 'start' ? 'Start' : 'End'} style`);
81
+ menu.id = `arrow-${side}-options`;button.setAttribute('popovertarget', menu.id);button.setAttribute('aria-haspopup', 'menu');
82
+ for (const value of ['line', 'arrow']) {
83
+ const option = document.createElement('button');option.type = 'button';option.dataset.value = value;option.setAttribute('role', 'menuitemradio');
84
+ option.setAttribute('aria-label', value === 'line' ? 'Line' : `${side === 'start' ? 'Left' : 'Right'} arrow`);
85
+ option.innerHTML = `<svg class="field-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="${icons[value === 'line' ? 'line' : side]}"/></svg>`;
86
+ option.onclick = () => { button.value = value;button.dispatchEvent(new Event('change', { bubbles: true }));menu.hidePopover();document.activeElement?.blur(); };
87
+ menu.append(option);
88
+ }
89
+ document.body.append(menu);menus.push({ button, menu, side });
90
+ connectMenu(button, menu, () => menu.querySelector('[aria-checked="true"]') || menu.firstElementChild);
91
+ menu.addEventListener('keydown', event => {
92
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape'].includes(event.key)) return;
93
+ event.preventDefault();event.stopPropagation();
94
+ if (event.key === 'Escape') { menu.hidePopover();button.focus();return; }
95
+ const options = [...menu.children], index = options.indexOf(document.activeElement);
96
+ options[event.key === 'Home' ? 0 : event.key === 'End' ? 1 : (index + 1) % 2].focus();
97
+ });
98
+ }
99
+ return {
100
+ update() {
101
+ for (const field of toolbar.querySelectorAll('select[data-arrow-field]')) {
102
+ const label = field.parentElement.previousElementSibling.querySelector('[data-field-label]');
103
+ label.textContent = field.selectedOptions[0]?.textContent ?? 'Mixed';
104
+ }
105
+ for (const { button, menu, side } of menus) {
106
+ button.querySelector('path').setAttribute('d', icons[button.value === 'arrow' ? side : 'line']);
107
+ button.dataset.mixed = String(!button.value);
108
+ button.setAttribute('aria-label', `${side === 'start' ? 'Start' : 'End'} style: ${button.value || 'Mixed'}`);
109
+ for (const option of menu.children) option.setAttribute('aria-checked', String(option.dataset.value === button.value));
110
+ }
111
+ },
112
+ close() { for (const { menu } of menus) if (menu.matches(':popover-open')) menu.hidePopover(); },
113
+ };
114
+ }
115
+
116
+ // Keep select values as the data interface, never as the visible control.
117
+ export function setupSelectFields(root = document) {
118
+ for (const select of root.querySelectorAll('.select-field select')) {
119
+ const field = select.parentElement;
120
+ const button = document.createElement('button');
121
+ button.type = 'button';
122
+ button.className = 'select-field';
123
+ button.setAttribute('aria-label', select.getAttribute('aria-label') || 'Preset');
124
+ button.dataset.tooltip = button.getAttribute('aria-label');
125
+ button.setAttribute('aria-haspopup', 'menu');
126
+ button.setAttribute('aria-expanded', 'false');
127
+ for (const child of [...field.children]) if (child !== select) button.append(child);
128
+ field.before(button);
129
+ select.hidden = true;
130
+ field.hidden = true;
131
+ const menu = document.createElement('div');
132
+ menu.className = 'canvas-tools icon-options text-options';
133
+ menu.popover = 'auto';
134
+ menu.id = `select-options-${select.id || select.dataset.arrowField}`;
135
+ menu.setAttribute('role', 'menu');
136
+ menu.setAttribute('aria-label', button.getAttribute('aria-label'));
137
+ button.setAttribute('popovertarget', menu.id);
138
+ document.body.append(menu);
139
+ const sync = () => {
140
+ button.querySelector('span').textContent = select.selectedOptions[0]?.textContent || 'Mixed';
141
+ for (const option of menu.querySelectorAll('button[data-value]')) option.setAttribute('aria-checked', String(option.dataset.value === select.value));
142
+ };
143
+ const rebuild = () => {
144
+ menu.replaceChildren();
145
+ let groupLabel;
146
+ for (const item of select.options) {
147
+ if (item.hidden || item.disabled) continue;
148
+ const group = item.parentElement instanceof HTMLOptGroupElement ? item.parentElement.label : '';
149
+ if (group && group !== groupLabel) {
150
+ groupLabel = group;
151
+ const heading = document.createElement('div'); heading.className = 'option-group-label';
152
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
153
+ icon.setAttribute('viewBox', '0 0 20 20'); icon.setAttribute('aria-hidden', 'true');
154
+ icon.innerHTML = groupIcons[group] || groupIcons.Desktop;
155
+ const label = document.createElement('span'); label.textContent = group;
156
+ heading.append(icon, label); menu.append(heading);
157
+ }
158
+ const option = document.createElement('button');
159
+ option.type = 'button';
160
+ option.textContent = item.textContent;
161
+ option.dataset.value = item.value;
162
+ option.setAttribute('role', 'menuitemradio');
163
+ option.onclick = () => {
164
+ select.value = item.value;
165
+ select.dispatchEvent(new Event('change', { bubbles: true }));
166
+ menu.hidePopover();
167
+ document.activeElement?.blur();
168
+ sync();
169
+ };
170
+ menu.append(option);
171
+ }
172
+ sync();
173
+ };
174
+ button.addEventListener('click', rebuild);
175
+ select.addEventListener('change', sync);
176
+ connectMenu(button, menu, () => {
177
+ sync();
178
+ return menu.querySelector('[aria-checked="true"]') || menu.querySelector('button[data-value]');
179
+ });
180
+ menu.addEventListener('keydown', event => {
181
+ event.stopPropagation();
182
+ const options = [...menu.querySelectorAll('button[data-value]')], index = options.indexOf(document.activeElement);
183
+ if (event.key === 'Escape') { event.preventDefault();menu.hidePopover();button.focus();return; }
184
+ if (event.key === 'Tab') { menu.hidePopover();button.focus();return; }
185
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
186
+ event.preventDefault();
187
+ options[event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + options.length) % options.length]?.focus();
188
+ });
189
+ rebuild();
190
+ }
191
+ }
@@ -0,0 +1,158 @@
1
+ import { platform, setPlatform } from './platform.js';
2
+
3
+ // One table, written once. A key is a name resolved for the chosen keyboard
4
+ // (mod, alt, shift, backspace), a plain label, or a mouse gesture {g}. A row is
5
+ // a list of alternatives; an alternative is a list of keys pressed together.
6
+ const drag = { g: 'drag' }, click = { g: 'click' }, arrows = { arrows: true };
7
+ const sections = [
8
+ ['Editing', [
9
+ ['Duplicate selection', [['mod', 'D']]],
10
+ ['Duplicate before or during drag', [['alt', drag]]],
11
+ ['Delete selection', [['backspace'], ['Delete']]],
12
+ ['Undo', [['mod', 'Z']]],
13
+ ['Redo', { mac: [['mod', 'shift', 'Z']], win: [['mod', 'shift', 'Z'], ['mod', 'Y']] }],
14
+ ['Rename screen', [[{ g: 'Double-click title or layer name' }]]],
15
+ ]],
16
+ ['Navigation and selection', [
17
+ ['Nudge selected screens by 10 px', [[arrows]]],
18
+ ['Nudge selected screens by 20 px', [['shift', arrows]]],
19
+ ['Select all', [['mod', 'A']]],
20
+ ['Fit all screens', [['shift', '1']]],
21
+ ['Fit selected screens', [['shift', '2']]],
22
+ ['Toggle screen selection', [['shift', click]]],
23
+ ['Constrain drag to one axis', [['shift', drag]]],
24
+ ['Pan canvas', [['Space', drag]]],
25
+ ['Pan with mouse', [[{ g: 'Middle-button drag' }]]],
26
+ ['Zoom at pointer', [['mod', { g: 'wheel' }]]],
27
+ ['Close menu, dialog or preview', [['Esc']]],
28
+ ['Release pan', [['Esc']]],
29
+ ]],
30
+ ['Tools', [
31
+ ['Cursor tool', [['V']]],
32
+ ['Arrow tool', [['A']]],
33
+ ['Text tool', [['T']]],
34
+ ['Edit selected text', [['Enter']]],
35
+ ['Bold or italic: selected words, or the whole text', [['mod', 'B'], ['mod', 'I']]],
36
+ ['Back to cursor tool and clear arrow selection', [['Esc']]],
37
+ ['Select arrow', [['Enter'], ['Space']]],
38
+ ['Add arrow to selection', [['shift', click]]],
39
+ ]],
40
+ ['Arrange · select two or more screens', [
41
+ ['Align left', [['alt', 'A']]],
42
+ ['Align horizontal centers', [['alt', 'H']]],
43
+ ['Align right', [['alt', 'D']]],
44
+ ['Align top', [['alt', 'W']]],
45
+ ['Align vertical centers', [['alt', 'V']]],
46
+ ['Align bottom', [['alt', 'S']]],
47
+ ['Distribute horizontal spacing', [['ctrl', 'alt', 'H']]],
48
+ ['Distribute vertical spacing', [['ctrl', 'alt', 'V']]],
49
+ ]],
50
+ ['Reorder · select a row, column or grid', [
51
+ ['Reorder screens', [[{ g: 'Drag a center dot' }]]],
52
+ ['Cancel reorder drag', [['Esc']]],
53
+ ]],
54
+ ['Layers', [
55
+ ['Bring forward', [['mod', ']']]],
56
+ ['Send backward', [['mod', '[']]],
57
+ ['Bring to front', [['mod', 'shift', ']']]],
58
+ ['Send to back', [['mod', 'shift', '[']]],
59
+ ['Select a range', [['shift', click]]],
60
+ ['Toggle individual layers', [['mod', { g: 'click' }]]],
61
+ ]],
62
+ ['Fields and menus', [
63
+ ['Save screen name / apply zoom', [['Enter']]],
64
+ ['Cancel rename / reset zoom', [['Esc']]],
65
+ ['Move through context menu', [['↑'], ['↓']]],
66
+ ['First / last menu item', [['Home'], ['End']]],
67
+ ['Activate menu item', [['Enter'], ['Space']]],
68
+ ]],
69
+ ];
70
+
71
+ const names = {
72
+ mac: { mod: ['⌘', 'Command'], alt: ['⌥', 'Option'], shift: ['⇧', 'Shift'], ctrl: ['Ctrl', 'Control'], backspace: ['⌫', 'Backspace'] },
73
+ win: { mod: ['Ctrl', 'Control'], alt: ['Alt', 'Alt'], shift: ['Shift', 'Shift'], ctrl: ['Ctrl', 'Control'], backspace: ['Backspace', 'Backspace'] },
74
+ };
75
+ const mouse = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="5" y="2" width="10" height="16" rx="5"/><path d="M10 2v6"/></svg>';
76
+ const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; };
77
+
78
+ // The four arrow keys laid out as on a keyboard: one above, three below.
79
+ const arrowKeys = [['↑', 'Up arrow', 'up'], ['←', 'Left arrow', 'left'], ['↓', 'Down arrow', 'down'], ['→', 'Right arrow', 'right']];
80
+ function key(token, kind) {
81
+ if (typeof token === 'object' && token.arrows) {
82
+ const cluster = el('span', 'shortcut-arrows');
83
+ cluster.setAttribute('role', 'group');
84
+ cluster.setAttribute('aria-label', 'Arrow keys');
85
+ for (const [glyph, name, place] of arrowKeys) {
86
+ const cap = el('kbd', '', glyph);
87
+ cap.dataset.arrow = place;
88
+ cap.setAttribute('aria-label', name);
89
+ cluster.append(cap);
90
+ }
91
+ return cluster;
92
+ }
93
+ if (typeof token === 'object') {
94
+ const gesture = el('span', 'shortcut-gesture');
95
+ gesture.innerHTML = mouse;
96
+ gesture.append(token.g);
97
+ return gesture;
98
+ }
99
+ const named = names[kind][token];
100
+ const cap = el('kbd', '', named ? named[0] : token);
101
+ if (named && named[0] !== named[1]) cap.setAttribute('aria-label', named[1]);
102
+ return cap;
103
+ }
104
+
105
+ function render(body, kind) {
106
+ body.replaceChildren();
107
+ sections.forEach(([title, rows], index) => {
108
+ const section = el('section', 'shortcut-section');
109
+ const heading = el('h3', '', title);
110
+ heading.id = `shortcut-group-${index}`;
111
+ section.setAttribute('aria-labelledby', heading.id);
112
+ const list = el('dl');
113
+ for (const [label, spec] of rows) {
114
+ const options = Array.isArray(spec) ? spec : spec[kind];
115
+ const row = el('div', 'shortcut-row');
116
+ const keys = el('dd');
117
+ options.forEach((chord, n) => {
118
+ if (n) keys.append(el('span', 'shortcut-or', 'or'));
119
+ const pad = chord.some(token => token.arrows);
120
+ const group = el('span', pad ? 'shortcut-chord shortcut-keypad' : 'shortcut-chord');
121
+ chord.forEach((token, i) => {
122
+ if (pad && i) group.append(el('span', 'shortcut-plus', '+'));
123
+ group.append(key(token, kind));
124
+ });
125
+ if (pad) row.classList.add('shortcut-row-stack');
126
+ keys.append(group);
127
+ });
128
+ row.append(el('dt', '', label), keys);
129
+ list.append(row);
130
+ }
131
+ section.append(heading, list);
132
+ body.append(section);
133
+ });
134
+ }
135
+
136
+ const dialog = document.querySelector('#shortcuts');
137
+ const body = dialog.querySelector('.shortcuts-body');
138
+ const toggle = dialog.querySelector('#shortcuts-platform');
139
+ function sync() {
140
+ const kind = platform();
141
+ const scroll = body.scrollTop;
142
+ render(body, kind);
143
+ body.scrollTop = scroll;
144
+ for (const option of toggle.querySelectorAll('[data-platform]')) option.setAttribute('aria-checked', String(option.dataset.platform === kind));
145
+ }
146
+ toggle.addEventListener('click', event => {
147
+ const option = event.target.closest('[data-platform]');
148
+ if (option) setPlatform(option.dataset.platform);
149
+ });
150
+ toggle.addEventListener('keydown', event => {
151
+ if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
152
+ event.preventDefault(); event.stopPropagation();
153
+ const next = platform() === 'mac' ? 'win' : 'mac';
154
+ setPlatform(next);
155
+ toggle.querySelector(`[data-platform="${next}"]`).focus();
156
+ });
157
+ window.addEventListener('tio-canvas:platform', sync);
158
+ sync();
@@ -0,0 +1,28 @@
1
+ // Host controls stay above the multi-selection grab area and every preview.
2
+ export function createSmartHandles({ selection, viewport, onDrag }) {
3
+ const root=document.createElement('div');root.id='smart-handles';selection.append(root);
4
+ const target=document.createElement('div');target.id='smart-target';target.hidden=true;target.setAttribute('aria-hidden','true');viewport.append(target);
5
+ const buttons=new Map();
6
+ return {
7
+ update(entries,camera,box,{enabled,active=null,destination=null}){
8
+ const wanted=new Set(enabled?entries:[]);
9
+ for(const [entry,button] of buttons)if(!wanted.has(entry)){button.remove();buttons.delete(entry);}
10
+ for(const entry of wanted){
11
+ let button=buttons.get(entry);
12
+ if(!button){
13
+ button=document.createElement('button');button.type='button';button.className='smart-handle';button.dataset.screenId=entry.model.id;
14
+ button.addEventListener('pointerdown',event=>{if(event.button===0 && !document.body.hasAttribute('data-space')){event.stopPropagation();onDrag(entry,event);}});
15
+ button.dataset.tooltip='Drag to reorder screens';
16
+ root.append(button);buttons.set(entry,button);
17
+ }
18
+ const name=`Reorder ${entry.model.title}`;
19
+ if(button.getAttribute('aria-label')!==name)button.setAttribute('aria-label',name);
20
+ const m=entry.model,position=`${(m.x+m.width/2-box.x)*camera.scale}px ${(m.y+m.height/2-box.y)*camera.scale}px`;
21
+ if(button.style.translate!==position)button.style.translate=position;
22
+ button.toggleAttribute('data-moving',entry===active);
23
+ }
24
+ target.hidden=!destination;
25
+ if(destination)Object.assign(target.style,{left:`${camera.x+destination.x*camera.scale}px`,top:`${camera.y+destination.y*camera.scale}px`,width:`${destination.width*camera.scale}px`,height:`${destination.height*camera.scale}px`});
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,52 @@
1
+ import { pixelRect, workspace } from './geometry.js';
2
+
3
+ // Capture spatial slots, not layer order. Only non-overlapping rows, columns,
4
+ // and complete aligned grids have an unambiguous order to rearrange.
5
+ export function smartLayout(rectangles) {
6
+ if (rectangles.length < 2) return null;
7
+ const models = rectangles.map(pixelRect), rows = [];
8
+ const ordered = models.map((rect, index) => ({ rect, index })).sort((a,b) => a.rect.y-b.rect.y || a.rect.x-b.rect.x);
9
+ for (const item of ordered) {
10
+ let row = rows.at(-1);
11
+ if (!row || row.y !== item.rect.y) { row = { y:item.rect.y, items:[] }; rows.push(row); }
12
+ row.items.push(item);
13
+ }
14
+ const columns = rows[0].items.length;
15
+ // Every row is full, except that the last may stop short (three screens in a two-by-two).
16
+ if (rows.some((row, i) => i < rows.length-1 ? row.items.length !== columns : row.items.length > columns)) return null;
17
+ const xs = rows[0].items.map(item => item.rect.x);
18
+ if (rows.some(row => row.items.some((item, i) => item.rect.x !== xs[i]))) return null;
19
+ const widths = xs.map((_, i) => Math.max(...rows.filter(row => row.items[i]).map(row => row.items[i].rect.width)));
20
+ const heights = rows.map(row => Math.max(...row.items.map(item => item.rect.height)));
21
+ const gapsX = xs.slice(1).map((x,i) => x-xs[i]-widths[i]);
22
+ const gapsY = rows.slice(1).map((row,i) => row.y-rows[i].y-heights[i]);
23
+ if ([...gapsX,...gapsY].some(gap => gap < 0)) return null;
24
+ return { models, columns, rows:rows.length, x:xs[0], y:rows[0].y, gapsX, gapsY, order:ordered.map(item => item.index) };
25
+ }
26
+
27
+ // Insert shifts intervening screens.
28
+ // Repack with the captured gaps and each screen's own dimensions.
29
+ export function reorderSmartLayout(layout, source, target, extent = 20000) {
30
+ if (!layout || !Number.isInteger(source) || !Number.isInteger(target) || source < 0 || target < 0 || source >= layout.order.length || target >= layout.order.length) return null;
31
+ const order = [...layout.order];
32
+ order.splice(target,0,order.splice(source,1)[0]);
33
+ const widths = Array.from({length:layout.columns},(_,col) => Math.max(...Array.from({length:layout.rows},(_,row) => order[row*layout.columns+col]).filter(i => i !== undefined).map(i => layout.models[i].width)));
34
+ const heights = Array.from({length:layout.rows},(_,row) => Math.max(...order.slice(row*layout.columns,(row+1)*layout.columns).map(i => layout.models[i].height)));
35
+ const xs = [layout.x], ys = [layout.y];
36
+ for (let col=1;col<layout.columns;col++) xs.push(xs[col-1]+widths[col-1]+layout.gapsX[col-1]);
37
+ for (let row=1;row<layout.rows;row++) ys.push(ys[row-1]+heights[row-1]+layout.gapsY[row-1]);
38
+ const result = layout.models.map(rect => ({...rect}));
39
+ order.forEach((index,slot) => Object.assign(result[index],{x:xs[slot%layout.columns],y:ys[Math.floor(slot/layout.columns)]}));
40
+ const space = workspace(extent);
41
+ if (result.some(rect => rect.x<0 || rect.y<0 || rect.x+rect.width>space.width || rect.y+rect.height>space.height)) return null;
42
+ return result;
43
+ }
44
+
45
+ export function nearestSmartSlot(layout, x, y) {
46
+ let nearest = 0, distance = Infinity;
47
+ layout.order.forEach((index,slot) => {
48
+ const rect = layout.models[index], next = Math.hypot(x-rect.x-rect.width/2,y-rect.y-rect.height/2);
49
+ if (next < distance) { nearest = slot; distance = next; }
50
+ });
51
+ return nearest;
52
+ }
@@ -0,0 +1,45 @@
1
+ import { pixelRect, workspace } from './geometry.js';
2
+
3
+ // Group spacing changes positions only. Dimensions and stacking stay untouched.
4
+ export function groupSpacing(rectangles, extent = 20000, requestedAxis = null) {
5
+ if (rectangles.length < 2) return null;
6
+ const spread = axis => {
7
+ const positions = rectangles.map(r => r[axis]);
8
+ return Math.max(...positions) - Math.min(...positions);
9
+ };
10
+ const axis = requestedAxis ?? (spread('x') >= spread('y') ? 'x' : 'y');
11
+ const size = axis === 'x' ? 'width' : 'height';
12
+ const ordered = rectangles.map((rect, index) => ({ rect, index })).sort((a, b) => a.rect[axis] - b.rect[axis]);
13
+ // Screens sharing an axis position form one band. Moving a grid's columns
14
+ // must not turn every screen into a separate column (likewise for rows).
15
+ const bands = [];
16
+ for (const item of ordered) {
17
+ let band = bands.at(-1);
18
+ if (!band || band.position !== item.rect[axis]) {
19
+ band = { position: item.rect[axis], size: 0, items: [] };
20
+ bands.push(band);
21
+ }
22
+ band.size = Math.max(band.size, item.rect[size]);
23
+ band.items.push(item);
24
+ }
25
+ if (bands.length < 2) return null;
26
+ const gaps = bands.slice(1).map((band, i) => band.position - bands[i].position - bands[i].size);
27
+ const gap = gaps.every(value => value === gaps[0]) ? gaps[0] : null;
28
+ const total = bands.reduce((sum, band) => sum + band.size, 0);
29
+ const max = Math.floor((workspace(extent)[size] - ordered[0].rect[axis] - total) / (bands.length - 1));
30
+ return { axis, size, ordered, bands, gap, max };
31
+ }
32
+
33
+ export function spaceGroup(rectangles, value, extent = 20000, requestedAxis = null) {
34
+ rectangles = rectangles.map(pixelRect);
35
+ const spacing = groupSpacing(rectangles, extent, requestedAxis);
36
+ if (!spacing || !Number.isFinite(value) || value < 0 || spacing.max < 0) return null;
37
+ const gap = Math.min(Math.round(value), spacing.max);
38
+ let position = spacing.ordered[0].rect[spacing.axis];
39
+ const result = rectangles.map(rect => ({ ...rect }));
40
+ for (const band of spacing.bands) {
41
+ for (const { index } of band.items) result[index][spacing.axis] = position;
42
+ position += band.size + gap;
43
+ }
44
+ return result;
45
+ }
@@ -0,0 +1,49 @@
1
+ // Text objects: sizes, the markup a text may hold, and reading saved texts.
2
+ // Markup is data from storage and from pasted content, so it is rebuilt from a
3
+ // small allow-list instead of being trusted.
4
+ export const TEXT_SIZES = { sm: 16, md: 24, lg: 40, xl: 64 };
5
+ export const TEXT_LINE_HEIGHT = 1.25;
6
+ export const TEXT_MAX_HTML = 20000;
7
+ export const TEXT_DEFAULT_WIDTH = 240;
8
+
9
+ const TAGS = { B: 'b', STRONG: 'b', I: 'i', EM: 'i', BR: 'br', DIV: 'div', P: 'div' };
10
+ const SKIPPED = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED']);
11
+
12
+ export function sanitizeHtml(html) {
13
+ const source = new DOMParser().parseFromString(`<body>${String(html ?? '').slice(0, TEXT_MAX_HTML)}</body>`, 'text/html').body;
14
+ const target = document.createElement('div');
15
+ const walk = (from, to, depth) => {
16
+ for (const node of from.childNodes) {
17
+ if (node.nodeType === Node.TEXT_NODE) { to.append(node.data); continue; }
18
+ if (node.nodeType !== Node.ELEMENT_NODE || SKIPPED.has(node.tagName)) continue;
19
+ const tag = TAGS[node.tagName];
20
+ if (!tag || depth > 24) { walk(node, to, depth + 1); continue; }
21
+ if (tag === 'br') { to.append(document.createElement('br')); continue; }
22
+ const element = document.createElement(tag);
23
+ walk(node, element, depth + 1); to.append(element);
24
+ }
25
+ };
26
+ walk(source, target, 0);
27
+ return target.innerHTML;
28
+ }
29
+
30
+ export const escapeHtml = value => String(value).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
31
+ export const plainToHtml = value => escapeHtml(String(value).replace(/\r\n?/g, '\n')).replace(/\n/g, '<br>');
32
+
33
+ export function readTexts(value) {
34
+ if (!Array.isArray(value)) return [];
35
+ const finite = (n, min, max) => Number.isFinite(n) && n >= min && n <= max;
36
+ const ids = new Set();
37
+ return value.slice(0, 500).filter(t => {
38
+ if (!t || typeof t.id !== 'string' || t.id.length > 100 || ids.has(t.id) || typeof t.html !== 'string'
39
+ || !finite(t.x, -40000, 40000) || !finite(t.y, -40000, 40000) || !finite(t.width, 8, 8000)
40
+ || !Object.hasOwn(TEXT_SIZES, t.size) || !(t.fit === true || finite(t.height, 8, 20000))) return false;
41
+ ids.add(t.id); return true;
42
+ }).map(t => ({ id: t.id, x: t.x, y: t.y, width: t.width, height: t.fit === true ? 0 : t.height, fit: t.fit === true, fitWidth: t.fitWidth === true, size: t.size, html: sanitizeHtml(t.html) }));
43
+ }
44
+
45
+ // A pull past a limit moves a fraction of the distance, with less and less
46
+ // give: the iOS scroll curve, c = .55.
47
+ export function rubberBand(distance, dimension = 48, c = .55) {
48
+ return Math.sign(distance) * (1 - 1 / (Math.abs(distance) * c / dimension + 1)) * dimension;
49
+ }