@tio-canvas/canvas 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -2
- package/bin/tio-canvas.mjs +12 -0
- package/client/arrange.js +41 -0
- package/client/arrow-geometry.js +56 -0
- package/client/arrow-selection.js +68 -0
- package/client/arrows.js +205 -0
- package/client/bridge.js +74 -0
- package/client/canvas.css +733 -0
- package/client/canvas.js +1009 -0
- package/client/corners.js +22 -0
- package/client/device-gate.js +15 -0
- package/client/fullscreen.js +118 -0
- package/client/geometry.js +79 -0
- package/client/history.js +37 -0
- package/client/hover-preview.js +35 -0
- package/client/index.html +112 -0
- package/client/layer-order.js +20 -0
- package/client/layers.js +154 -0
- package/client/menu-position.js +36 -0
- package/client/onboarding.js +302 -0
- package/client/page-picker.js +76 -0
- package/client/platform.js +16 -0
- package/client/range-selection.js +54 -0
- package/client/registry.js +31 -0
- package/client/screen-loader.js +67 -0
- package/client/screens.js +7 -0
- package/client/select-field.js +191 -0
- package/client/shortcuts.js +158 -0
- package/client/smart-handles.js +28 -0
- package/client/smart-selection.js +52 -0
- package/client/spacing.js +45 -0
- package/client/text-format.js +49 -0
- package/client/text.js +466 -0
- package/client/tio-logo.png +0 -0
- package/client/tooltip.js +128 -0
- package/client/touch-preview.js +112 -0
- package/docs/exploration-canvas.md +315 -0
- package/package.json +31 -4
- package/src/build.js +33 -0
- package/src/index.js +3 -0
- package/src/interface-kit-bundle.js +103 -0
- package/src/interface-kit-entry.js +16 -0
- package/src/project.js +29 -0
- package/src/server.js +70 -0
- package/src/thumbnails.js +95 -0
package/client/canvas.js
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import './tooltip.js';
|
|
2
|
+
import './shortcuts.js';
|
|
3
|
+
import './device-gate.js';
|
|
4
|
+
import './onboarding.js';
|
|
5
|
+
import { setupSelectFields, closeSelectMenus, positionSelectMenus } from './select-field.js';
|
|
6
|
+
import { smartLayout, reorderSmartLayout, nearestSmartSlot } from './smart-selection.js';
|
|
7
|
+
import { createSmartHandles } from './smart-handles.js';
|
|
8
|
+
import { arrangeScreens, arrangeShortcut } from './arrange.js';
|
|
9
|
+
import { createPagePicker } from './page-picker.js';
|
|
10
|
+
import { groupSpacing, spaceGroup } from './spacing.js';
|
|
11
|
+
import { createFullscreen } from './fullscreen.js';
|
|
12
|
+
import { createLayerDrawer } from './layers.js';
|
|
13
|
+
import { layerShortcut, reorderLayers, dropLayers } from './layer-order.js';
|
|
14
|
+
import { createScreenLoader } from './screen-loader.js';
|
|
15
|
+
import { screens, presets, screenKinds, storageKey } from './screens.js';
|
|
16
|
+
import { bounds, intersects, snapRect, duplicateOffset, addedPagePositions, pixelRect } from './geometry.js';
|
|
17
|
+
import { createHistory, historyShortcut } from './history.js';
|
|
18
|
+
import { createArrows } from './arrows.js';
|
|
19
|
+
import { createTexts } from './text.js';
|
|
20
|
+
|
|
21
|
+
const $ = selector => document.querySelector(selector);
|
|
22
|
+
const viewport = $('#viewport'), world = $('#world'), selection = $('#selection');
|
|
23
|
+
const key = storageKey, extent = { width: 20000, height: 11250 }, MIN_ZOOM_FLOOR = .01;
|
|
24
|
+
// Zoom out stops where the whole workspace fills the viewport.
|
|
25
|
+
const minScale = () => Math.max(MIN_ZOOM_FLOOR, Math.min(innerWidth / extent.width, innerHeight / extent.height));
|
|
26
|
+
const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
|
|
27
|
+
const finite = (n, fallback, min, max) => Number.isFinite(n) ? clamp(n, min, max) : fallback;
|
|
28
|
+
let saved = {};
|
|
29
|
+
try { const raw = localStorage.getItem(key); if (raw?.length < 1000000) { const value = JSON.parse(raw); if (value.version === 1) saved = value; } } catch {}
|
|
30
|
+
const initialInset=parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--canvas-panel-inset'));
|
|
31
|
+
const initialX=$('#layers').getBoundingClientRect().right+initialInset-(screens[0]?.x??0)*.8;
|
|
32
|
+
const camera = { x: finite(saved.camera?.x, initialX, -extent.width * 2, extent.width), y: finite(saved.camera?.y, 60, -extent.height * 2, extent.height), scale: finite(saved.camera?.scale, .8, MIN_ZOOM_FLOOR, 2) };
|
|
33
|
+
let objectOrder=Array.isArray(saved.objectOrder)?saved.objectOrder.filter(id=>typeof id==='string'):[],objectRanks=new Map();
|
|
34
|
+
let selected = null, gesture = null, frameRequest = 0, saveTimer, pendingMove = null;
|
|
35
|
+
let cachedWorkArea = null, cameraMotion = null, arrowTool, textTool;
|
|
36
|
+
const selectedEntries = new Set();
|
|
37
|
+
const groupGrab = document.createElement('div');groupGrab.id='group-grab';groupGrab.hidden=true;selection.prepend(groupGrab);
|
|
38
|
+
const marquee = document.createElement('div');marquee.id='marquee';marquee.hidden=true;viewport.append(marquee);
|
|
39
|
+
const guides = document.createElement('div');guides.id='guides';viewport.append(guides);
|
|
40
|
+
const smartHandles=createSmartHandles({selection,viewport,onDrag:startReorder});
|
|
41
|
+
// A host overlay paints above every iframe layer, including native popovers.
|
|
42
|
+
const contact=document.createElement('div');contact.id='canvas-contact';contact.hidden=true;contact.setAttribute('aria-hidden','true');document.body.append(contact);
|
|
43
|
+
let contactState=null;
|
|
44
|
+
function clearContact(){contactState=null;contact.hidden=true;}
|
|
45
|
+
window.addEventListener('pointermove',clearContact);
|
|
46
|
+
function renderContact(){
|
|
47
|
+
const state=contactState,entry=state?.entry;
|
|
48
|
+
if(document.body.dataset.gesture || document.body.hasAttribute('data-space') || !entry || !entries.includes(entry) || !entry.visible || !presets[entry.model.preset].touch || state.x<0 || state.y<0 || state.x>entry.model.width || state.y>entry.model.height){contact.hidden=true;return;}
|
|
49
|
+
contact.hidden=false;contact.style.setProperty('--contact-scale',camera.scale);
|
|
50
|
+
contact.style.transform=`translate(${camera.x+(entry.model.x+state.x)*camera.scale}px,${camera.y+(entry.model.y+state.y)*camera.scale}px) translate(-50%,-50%)`;
|
|
51
|
+
}
|
|
52
|
+
function createEntry(definition, loadSaved = true) {
|
|
53
|
+
const value = loadSaved ? saved.screens?.[definition.id] : null;
|
|
54
|
+
const preset = Object.hasOwn(presets, value?.preset) ? value.preset : definition.preset;
|
|
55
|
+
const model = { ...definition, preset, x: finite(value?.x, definition.x, 0, extent.width - 3840), y: finite(value?.y, definition.y, 0, extent.height - 3840), width: finite(value?.width, definition.width, 160, 3840), height: finite(value?.height, definition.height, 160, 3840) };
|
|
56
|
+
if (preset !== 'custom') {
|
|
57
|
+
const size = presets[preset];
|
|
58
|
+
const rotated = ['phone','tablet'].includes(size.device) && value?.width === size.height && value?.height === size.width;
|
|
59
|
+
Object.assign(model, { width: rotated ? size.height : size.width, height: rotated ? size.width : size.height });
|
|
60
|
+
}
|
|
61
|
+
Object.assign(model,pixelRect(model));
|
|
62
|
+
model.x = Math.round(finite(value?.x, model.x, 0, extent.width-model.width));
|
|
63
|
+
model.y = Math.round(finite(value?.y, model.y, 0, extent.height-model.height));
|
|
64
|
+
if (typeof value?.name === 'string' && value.name.trim()) {model.name=value.name.trim().slice(0,200);model.title=model.name;}
|
|
65
|
+
const element = document.createElement('section'); element.className = 'screen'; element.dataset.id = model.id;
|
|
66
|
+
const title = document.createElement('button'); title.className = 'screen-title'; title.textContent = model.title; title.setAttribute('aria-label', `Select or move ${model.title}`);
|
|
67
|
+
const refresh=document.createElement('button');refresh.className='screen-refresh';refresh.type='button';
|
|
68
|
+
refresh.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 7a6.5 6.5 0 1 0 .3 5M16 3v4h-4"/></svg>';
|
|
69
|
+
refresh.setAttribute('aria-label',`Refresh ${model.title}`);refresh.title=`Refresh ${model.title}`;
|
|
70
|
+
const fullscreen=document.createElement('button');fullscreen.className='screen-fullscreen';fullscreen.type='button';
|
|
71
|
+
fullscreen.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 3H3v4m10-4h4v4M3 13v4h4m10-4v4h-4"/></svg>';
|
|
72
|
+
fullscreen.setAttribute('aria-label',`Open ${model.title} fullscreen`);fullscreen.title='Open fullscreen';
|
|
73
|
+
const orientation=document.createElement('button');orientation.className='screen-orientation';orientation.type='button';
|
|
74
|
+
orientation.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="5" y="2" width="10" height="16" rx="2"/></svg>';
|
|
75
|
+
element.append(title,orientation,refresh,fullscreen); world.append(element);
|
|
76
|
+
const entry = { model, element, title, orientation, refresh, fullscreen, iframe: null, visible: false };
|
|
77
|
+
orientation.addEventListener('click',()=>{
|
|
78
|
+
stop();const before=history.checkpoint();
|
|
79
|
+
[model.width,model.height]=[model.height,model.width];
|
|
80
|
+
model.x=clamp(model.x,0,extent.width-model.width);model.y=clamp(model.y,0,extent.height-model.height);
|
|
81
|
+
updateSelection();schedule();commitEdit(before);
|
|
82
|
+
});
|
|
83
|
+
fullscreen.addEventListener('click',()=>preview.enter(entry));
|
|
84
|
+
title.title = model.title;
|
|
85
|
+
refresh.addEventListener('click',()=>{
|
|
86
|
+
stop();
|
|
87
|
+
mount(entry,!!entry.iframe);
|
|
88
|
+
});
|
|
89
|
+
title.addEventListener('pointerdown', event => {
|
|
90
|
+
if (title.isContentEditable || event.button !== 0 || document.body.hasAttribute('data-space')) return;
|
|
91
|
+
const toggleAfterClick=event.shiftKey && selectedEntries.has(entry);
|
|
92
|
+
if (event.shiftKey && !toggleAfterClick) select(entry, true);
|
|
93
|
+
else if (!event.shiftKey && !selectedEntries.has(entry)) select(entry);
|
|
94
|
+
if (selectedEntries.has(entry)){start('move', event, entry);if(toggleAfterClick)gesture.shiftToggle=entry;}
|
|
95
|
+
});
|
|
96
|
+
title.addEventListener('click', event => { if (!title.isContentEditable && event.detail === 0) select(entry, event.shiftKey); });
|
|
97
|
+
title.addEventListener('dblclick',event=>{event.preventDefault();beginRename(entry);});
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
// A first visit (nothing saved on this device) opens an empty canvas; pages are
|
|
101
|
+
// added from the picker. Saved state lives in this browser's localStorage only.
|
|
102
|
+
const entries = saved.version===1 ? screens.map(definition=>createEntry(definition)) : [];
|
|
103
|
+
// Restore instances only from known local sources, never a stored URL.
|
|
104
|
+
// The first screen of a page carries no number; copies count up from (1). When the
|
|
105
|
+
// original was removed, the lowest saved copy stands in as that first screen.
|
|
106
|
+
const titleFor = (source, number) => number > 0 ? `${source.title} (${number})` : source.title;
|
|
107
|
+
const firstCopy = new Map();
|
|
108
|
+
const hasFirst = new Set((saved.instances ?? []).filter(instance => instance?.number === 0).map(instance => instance.sourceId));
|
|
109
|
+
if (Array.isArray(saved.instances) && Array.isArray(saved.removedSources)) for (const instance of saved.instances.slice(0, 200)) {
|
|
110
|
+
if (!saved.removedSources.includes(instance?.sourceId) || hasFirst.has(instance.sourceId) || !Number.isInteger(instance.number) || instance.number < 1) continue;
|
|
111
|
+
const lowest = firstCopy.get(instance.sourceId);
|
|
112
|
+
if (!lowest || instance.number < lowest.number) firstCopy.set(instance.sourceId, instance);
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(saved.instances)) for (const instance of saved.instances.slice(0, 200)) {
|
|
115
|
+
const source = screens.find(screen => screen.id === instance?.sourceId);
|
|
116
|
+
if (!source || typeof instance.id !== 'string' || instance.id.length > 100 || entries.some(entry => entry.model.id === instance.id) || !Number.isInteger(instance.number) || instance.number < 0 || instance.number > 100000) continue;
|
|
117
|
+
const number = firstCopy.get(source.id) === instance ? 0 : instance.number;
|
|
118
|
+
entries.push(createEntry({ ...source, id: instance.id, sourceId: source.id, number, title: titleFor(source, number) }));
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(saved.removedSources)) for (const id of saved.removedSources) {
|
|
121
|
+
const index = entries.findIndex(entry => entry.model.id === id && !entry.model.sourceId);
|
|
122
|
+
if (index >= 0) entries.splice(index,1)[0].element.remove();
|
|
123
|
+
}
|
|
124
|
+
if(Array.isArray(saved.order)){
|
|
125
|
+
const order=new Map(saved.order.filter(id=>typeof id==='string').map((id,index)=>[id,index]));
|
|
126
|
+
entries.sort((a,b)=>(order.get(a.model.id)??Infinity)-(order.get(b.model.id)??Infinity));
|
|
127
|
+
}
|
|
128
|
+
let layersCollapsed=saved.layersCollapsed===true;
|
|
129
|
+
const drawer=createLayerDrawer({root:$('#layers'),presets,collapsed:layersCollapsed,
|
|
130
|
+
onSelect(id,shift){
|
|
131
|
+
if(isTextLayer(id)){
|
|
132
|
+
const model=textTool?.objects().find(t=>t.id===textLayerId(id));if(!model)return;
|
|
133
|
+
selectLayers(shift?[...currentLayerSelection(),id]:[id]);
|
|
134
|
+
if(!shift){const x=camera.x+model.x*camera.scale,y=camera.y+model.y*camera.scale;if(x+model.width*camera.scale<=0||y+model.height*camera.scale<=0||x>=innerWidth||y>=innerHeight)fitEntries([{model}]);}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const entry=entries.find(e=>e.model.id===id);if(!entry)return;
|
|
138
|
+
if(shift)selectLayers([...currentLayerSelection(),id]);else select(entry,false);
|
|
139
|
+
if(!shift && !entry.visible)fitEntries([entry]);
|
|
140
|
+
},
|
|
141
|
+
onSelectMany:selectLayers,
|
|
142
|
+
onDrop(ids,target,before){applyLayerOrder(dropLayers(objectOrder,ids.map(layerKey),layerKey(target),before));},
|
|
143
|
+
onRename(id,label){const entry=entries.find(e=>e.model.id===id);if(entry)beginRename(entry,label);},
|
|
144
|
+
onCollapse(value){cameraMotion=null;layersCollapsed=value;cachedWorkArea=null;schedule();persist();}
|
|
145
|
+
});
|
|
146
|
+
function syncObjectOrder(){
|
|
147
|
+
const ids=[...entries.map(e=>`screen:${e.model.id}`),...(arrowTool?.ids??[]).map(id=>`arrow:${id}`),...(textTool?.ids??[]).map(id=>`text:${id}`)],wanted=new Set(ids);
|
|
148
|
+
objectOrder=[...new Set(objectOrder.filter(id=>wanted.has(id)))];
|
|
149
|
+
const existing=new Set(objectOrder);
|
|
150
|
+
for(const id of ids)if(!existing.has(id)){objectOrder.push(id);existing.add(id);}
|
|
151
|
+
objectRanks=new Map(objectOrder.map((id,index)=>[id,index+1]));
|
|
152
|
+
}
|
|
153
|
+
// The Layers drawer lists screens and texts. A screen's layer id is its model id; a text's is "text:" and its id.
|
|
154
|
+
const isTextLayer=id=>id.startsWith('text:'),textLayerId=id=>id.slice(5),layerKey=id=>isTextLayer(id)?id:`screen:${id}`;
|
|
155
|
+
const currentLayerSelection=()=>[...[...selectedEntries].map(entry=>entry.model.id),...(textTool?.selectedIds??[]).map(id=>`text:${id}`)];
|
|
156
|
+
function selectLayers(ids){
|
|
157
|
+
const wanted=new Set(ids);
|
|
158
|
+
selectedEntries.clear();for(const entry of entries)if(wanted.has(entry.model.id))selectedEntries.add(entry);
|
|
159
|
+
arrowTool?.clearSelection();
|
|
160
|
+
textTool?.selectRect(null,ids.filter(isTextLayer).map(textLayerId));
|
|
161
|
+
updateSelection(true);
|
|
162
|
+
}
|
|
163
|
+
// A text layer is named by its content on one line; a text with no content reads "Text".
|
|
164
|
+
const textTitles=new Map(),titleBox=document.createElement('template');
|
|
165
|
+
function textTitle(html){
|
|
166
|
+
let title=textTitles.get(html);
|
|
167
|
+
if(title===undefined){
|
|
168
|
+
titleBox.innerHTML=html.replace(/<br\s*\/?>|<\/(?:div|p)>/gi,' ');
|
|
169
|
+
title=titleBox.content.textContent.replace(/\s+/g,' ').trim().slice(0,200)||'Text';
|
|
170
|
+
if(textTitles.size>500)textTitles.clear();
|
|
171
|
+
textTitles.set(html,title);
|
|
172
|
+
}
|
|
173
|
+
return title;
|
|
174
|
+
}
|
|
175
|
+
let layerSignature='';
|
|
176
|
+
function syncLayers(force=false){
|
|
177
|
+
const byScreen=new Map(entries.map(entry=>[`screen:${entry.model.id}`,entry.model])),byText=new Map((textTool?.layers()??[]).map(text=>[`text:${text.id}`,text]));
|
|
178
|
+
const items=[];
|
|
179
|
+
for(const key of objectOrder){
|
|
180
|
+
const model=byScreen.get(key);
|
|
181
|
+
if(model)items.push({id:model.id,kind:'screen',title:model.title,preset:model.preset});
|
|
182
|
+
else if(byText.has(key))items.push({id:key,kind:'text',title:textTitle(byText.get(key).html)});
|
|
183
|
+
}
|
|
184
|
+
const selection=currentLayerSelection(),signature=JSON.stringify([items,selection]);
|
|
185
|
+
if(!force && signature===layerSignature)return;
|
|
186
|
+
layerSignature=signature;drawer.update(items,selection);
|
|
187
|
+
}
|
|
188
|
+
function applyLayerOrder(next){
|
|
189
|
+
if(next.every((id,index)=>id===objectOrder[index]))return;
|
|
190
|
+
stop();const before=history.checkpoint();objectOrder=next;syncObjectOrder();
|
|
191
|
+
entries.sort((a,b)=>objectRanks.get(`screen:${a.model.id}`)-objectRanks.get(`screen:${b.model.id}`));
|
|
192
|
+
updateSelection(true);commitEdit(before);
|
|
193
|
+
}
|
|
194
|
+
function moveLayers(action){
|
|
195
|
+
stop();const before=history.checkpoint();
|
|
196
|
+
const chosen=[...[...selectedEntries].map(e=>`screen:${e.model.id}`),...(arrowTool?.selectedIds??[]).map(id=>`arrow:${id}`),...(textTool?.selectedIds??[]).map(id=>`text:${id}`)];
|
|
197
|
+
objectOrder=reorderLayers(objectOrder,chosen,action);syncObjectOrder();
|
|
198
|
+
entries.sort((a,b)=>objectRanks.get(`screen:${a.model.id}`)-objectRanks.get(`screen:${b.model.id}`));
|
|
199
|
+
syncLayers(true);schedule();commitEdit(before);
|
|
200
|
+
}
|
|
201
|
+
function arrangeSelection(action){
|
|
202
|
+
const models=[...[...selectedEntries].map(entry=>entry.model),...(textTool?.selectedModels()??[])];
|
|
203
|
+
if(models.length<2)return;
|
|
204
|
+
stop();const next=arrangeScreens(models.map(m=>({x:m.x,y:m.y,width:m.width,height:m.height})),action);
|
|
205
|
+
if(!next)return;
|
|
206
|
+
const before=history.checkpoint();
|
|
207
|
+
models.forEach((model,index)=>{model.x=next[index].x;model.y=next[index].y;});
|
|
208
|
+
updateSelection(true);commitEdit(before);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function captureDocument() {
|
|
212
|
+
syncObjectOrder();
|
|
213
|
+
return {objectOrder:[...objectOrder],arrows:arrowTool?.capture()??[],arrowSelection:arrowTool?.selectedIds??[],texts:textTool?.capture()??[],textSelection:textTool?.selectedIds??[],models:entries.map(entry=>({...entry.model})),selection:[...selectedEntries].map(entry=>entry.model.id),sources:screens.map(source=>source.id)};
|
|
214
|
+
}
|
|
215
|
+
function restoreDocument(snapshot) {
|
|
216
|
+
const wanted = new Set(snapshot.models.map(model=>model.id));
|
|
217
|
+
// A newly registered source is outside the history of older edits.
|
|
218
|
+
for (const entry of [...entries]) if(snapshot.sources.includes(sourceOf(entry)) && !wanted.has(entry.model.id)) {
|
|
219
|
+
loader.dispose(entry);entry.element.remove();entries.splice(entries.indexOf(entry),1);
|
|
220
|
+
}
|
|
221
|
+
for (const model of snapshot.models) {
|
|
222
|
+
let entry=entries.find(item=>item.model.id===model.id);
|
|
223
|
+
if(entry){entry.model=pixelRect(model);syncTitle(entry);}
|
|
224
|
+
else {entry=createEntry({...model},false);entries.push(entry);}
|
|
225
|
+
}
|
|
226
|
+
const order=new Map(snapshot.models.map((m,i)=>[m.id,i]));
|
|
227
|
+
entries.sort((a,b)=>(order.get(a.model.id)??Infinity)-(order.get(b.model.id)??Infinity));
|
|
228
|
+
selectedEntries.clear();
|
|
229
|
+
for(const entry of entries)if(snapshot.selection.includes(entry.model.id))selectedEntries.add(entry);
|
|
230
|
+
updateSelection();arrowTool?.restore(snapshot.arrows,snapshot.arrowSelection);textTool?.restore(snapshot.texts??[],snapshot.textSelection);objectOrder=[...(snapshot.objectOrder??[])];syncObjectOrder();persist();
|
|
231
|
+
}
|
|
232
|
+
const history=createHistory(captureDocument,restoreDocument);
|
|
233
|
+
function updateHistory() {
|
|
234
|
+
$('#undo').disabled=!history.canUndo;$('#redo').disabled=!history.canRedo;
|
|
235
|
+
}
|
|
236
|
+
function commitEdit(before) {history.commit(before);updateHistory();persist();}
|
|
237
|
+
function runHistory(direction) {arrowTool?.cancel();textTool?.cancel();stop();history[direction]();updateHistory();}
|
|
238
|
+
function syncTitle(entry) {
|
|
239
|
+
entry.title.textContent=entry.model.title;entry.title.title=entry.model.title;
|
|
240
|
+
entry.title.setAttribute('aria-label',`Select or move ${entry.model.title}`);
|
|
241
|
+
entry.fullscreen.setAttribute('aria-label',`Open ${entry.model.title} fullscreen`);
|
|
242
|
+
entry.refresh.setAttribute('aria-label',`Refresh ${entry.model.title}`);entry.refresh.title=`Refresh ${entry.model.title}`;
|
|
243
|
+
if(entry.iframe)entry.iframe.title=entry.model.title;
|
|
244
|
+
}
|
|
245
|
+
function beginRename(entry, title=entry.title) {
|
|
246
|
+
if(title.isContentEditable)return;
|
|
247
|
+
stop();select(entry);
|
|
248
|
+
const before=history.checkpoint();
|
|
249
|
+
title.contentEditable='plaintext-only';title.spellcheck=false;title.setAttribute('role','textbox');title.setAttribute('aria-label','Screen name');
|
|
250
|
+
title.focus();
|
|
251
|
+
const range=document.createRange();range.selectNodeContents(title);
|
|
252
|
+
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
|
|
253
|
+
let cancelled=false;
|
|
254
|
+
const onKey=event=>{
|
|
255
|
+
if(event.key==='Enter'){event.preventDefault();title.blur();}
|
|
256
|
+
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cancelled=true;title.blur();}
|
|
257
|
+
};
|
|
258
|
+
title.addEventListener('keydown',onKey);
|
|
259
|
+
title.addEventListener('blur',()=>{
|
|
260
|
+
const value=title.textContent.trim().slice(0,200);
|
|
261
|
+
title.removeAttribute('contenteditable');title.removeAttribute('role');title.removeAttribute('aria-label');title.removeEventListener('keydown',onKey);
|
|
262
|
+
if(!cancelled){
|
|
263
|
+
const source=screens.find(source=>source.id===sourceOf(entry));
|
|
264
|
+
const next=value||source.title;
|
|
265
|
+
if(next!==entry.model.title){entry.model.name=next;entry.model.title=next;}
|
|
266
|
+
}
|
|
267
|
+
syncTitle(entry);syncLayers(true);if(!cancelled)commitEdit(before);
|
|
268
|
+
},{once:true});
|
|
269
|
+
}
|
|
270
|
+
function save() {
|
|
271
|
+
syncObjectOrder();
|
|
272
|
+
try { localStorage.setItem(key, JSON.stringify({ version: 1, camera, objectOrder, arrows:arrowTool?.capture()??[], texts:textTool?.capture()??[], order:entries.map(e=>e.model.id),layersCollapsed, removedSources: screens.filter(source => !entries.some(entry => entry.model.id === source.id)).map(source => source.id), instances: entries.filter(entry => entry.model.sourceId).map(({model:m}) => ({id:m.id,sourceId:m.sourceId,number:m.number})), screens: Object.fromEntries(entries.map(({model:m}) => [m.id, { x:m.x,y:m.y,width:m.width,height:m.height,preset:m.preset,name:m.name }])) })); } catch {}
|
|
273
|
+
}
|
|
274
|
+
function persist() {
|
|
275
|
+
clearTimeout(saveTimer); saveTimer = setTimeout(save, 150);
|
|
276
|
+
}
|
|
277
|
+
// A change made in the last 150ms would be lost if the tab closed now.
|
|
278
|
+
addEventListener('pagehide',()=>{if(saveTimer){clearTimeout(saveTimer);saveTimer=0;save();}});
|
|
279
|
+
function schedule() { if (!frameRequest) frameRequest = requestAnimationFrame(render); }
|
|
280
|
+
function workArea(){
|
|
281
|
+
if(cachedWorkArea)return cachedWorkArea;
|
|
282
|
+
const inset=parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--canvas-panel-inset'));
|
|
283
|
+
const left=layersCollapsed?0:Math.min($('#layers').getBoundingClientRect().right+inset,Math.max(0,innerWidth-160));
|
|
284
|
+
return cachedWorkArea={left,width:innerWidth-left};
|
|
285
|
+
}
|
|
286
|
+
function flushMove() {
|
|
287
|
+
const event=pendingMove;pendingMove=null;
|
|
288
|
+
if(event)applyMove(event);
|
|
289
|
+
}
|
|
290
|
+
// While the camera moves, the browser scales one saved picture of the world, which is
|
|
291
|
+
// cheap but soft. Once it has been still for a moment, drop the layer hint for one frame
|
|
292
|
+
// so the text is drawn again at the final scale, then restore it. Costs nothing while moving.
|
|
293
|
+
let sharpenTimer = 0;
|
|
294
|
+
function sharpenWorldSoon() {
|
|
295
|
+
clearTimeout(sharpenTimer);
|
|
296
|
+
sharpenTimer = setTimeout(() => {
|
|
297
|
+
if (cameraMotion || document.body.hasAttribute('data-fullscreen')) return;
|
|
298
|
+
world.style.willChange = 'auto';
|
|
299
|
+
requestAnimationFrame(() => requestAnimationFrame(() => { world.style.willChange = ''; }));
|
|
300
|
+
}, 140);
|
|
301
|
+
}
|
|
302
|
+
function render(now) {
|
|
303
|
+
if(cameraMotion){
|
|
304
|
+
// Keep screen-space controls and iframe visibility in sync with every zoom frame.
|
|
305
|
+
const {from,to,start,duration}=cameraMotion;
|
|
306
|
+
const progress=matchMedia('(prefers-reduced-motion: reduce)').matches?1:clamp((now-start)/duration,0,1);
|
|
307
|
+
const eased=1-(1-progress)**3;
|
|
308
|
+
for(const axis of ['x','y','scale'])camera[axis]=from[axis]+(to[axis]-from[axis])*eased;
|
|
309
|
+
if(progress===1){cameraMotion=null;persist();}
|
|
310
|
+
}
|
|
311
|
+
// Consume the newest pointer position once per paint, not once per event.
|
|
312
|
+
// Keep the request marked as pending while flushing to avoid a second frame.
|
|
313
|
+
flushMove();
|
|
314
|
+
frameRequest = 0;
|
|
315
|
+
// The camera and frames share the same 0..extent workspace. Never pan
|
|
316
|
+
// beyond its edges; center only when an axis fits inside the viewport.
|
|
317
|
+
const panAxis = (position, viewportSize, size, inset=0) => {
|
|
318
|
+
const room = viewportSize - inset - size * camera.scale;
|
|
319
|
+
return room >= 0 ? inset+room / 2 : clamp(position, viewportSize-size*camera.scale, inset);
|
|
320
|
+
};
|
|
321
|
+
if(!preview.entry){
|
|
322
|
+
camera.scale = Math.max(camera.scale, minScale());
|
|
323
|
+
camera.x = panAxis(camera.x, innerWidth, extent.width, workArea().left);
|
|
324
|
+
camera.y = panAxis(camera.y, innerHeight, extent.height);
|
|
325
|
+
}
|
|
326
|
+
let dotSpacing=24*camera.scale;while(dotSpacing<12)dotSpacing*=2;
|
|
327
|
+
viewport.style.backgroundSize=`${dotSpacing}px ${dotSpacing}px`;
|
|
328
|
+
viewport.style.backgroundPosition=`${camera.x}px ${camera.y}px`;
|
|
329
|
+
syncObjectOrder();
|
|
330
|
+
syncLayers();
|
|
331
|
+
const worldTransform = `translate(${camera.x}px,${camera.y}px) scale(${camera.scale})`;
|
|
332
|
+
if (world.style.transform !== worldTransform) { world.style.transform = worldTransform; sharpenWorldSoon(); }
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
const {model:m,element,title} = entry;
|
|
335
|
+
const order=objectRanks.get(`screen:${m.id}`);
|
|
336
|
+
if(entry.paintOrder!==order){element.style.zIndex=String(order);entry.paintOrder=order;}
|
|
337
|
+
const geometry=`${m.x}:${m.y}:${m.width}:${m.height}`;
|
|
338
|
+
if(entry.paintGeometry!==geometry){
|
|
339
|
+
Object.assign(element.style, { left:`${m.x}px`,top:`${m.y}px`,width:`${m.width}px`,height:`${m.height}px` });
|
|
340
|
+
entry.paintGeometry=geometry;
|
|
341
|
+
}
|
|
342
|
+
const canRotate=['phone','tablet'].includes(presets[m.preset].device);
|
|
343
|
+
entry.orientation.hidden=!canRotate;
|
|
344
|
+
const landscape=m.width>m.height;
|
|
345
|
+
entry.orientation.setAttribute('aria-label',`Switch ${m.title} to ${landscape?'portrait':'landscape'}`);
|
|
346
|
+
entry.orientation.title=`Switch to ${landscape?'portrait':'landscape'}`;
|
|
347
|
+
entry.orientation.setAttribute('aria-pressed',String(landscape));
|
|
348
|
+
entry.orientation.querySelector('svg').style.rotate=landscape?'0deg':'90deg';
|
|
349
|
+
if(entry.paintScale!==camera.scale || entry.paintWidth!==m.width || entry.paintCanRotate!==canRotate){
|
|
350
|
+
entry.paintCanRotate=canRotate;
|
|
351
|
+
title.style.transform = `scale(${1/camera.scale})`;
|
|
352
|
+
title.style.maxWidth = `${Math.max(0,m.width*camera.scale-(canRotate?92:64))}px`;
|
|
353
|
+
entry.orientation.style.transform=`translateX(${-56/camera.scale}px) scale(${1/camera.scale})`;
|
|
354
|
+
entry.refresh.style.transform=`translateX(${-28/camera.scale}px) scale(${1/camera.scale})`;
|
|
355
|
+
entry.fullscreen.style.transform=`scale(${1/camera.scale})`;
|
|
356
|
+
element.style.setProperty('--screen-outline', `${1/camera.scale}px`);
|
|
357
|
+
entry.paintScale=camera.scale;entry.paintWidth=m.width;
|
|
358
|
+
}
|
|
359
|
+
const x = camera.x+m.x*camera.scale, y = camera.y+m.y*camera.scale;
|
|
360
|
+
const visible = preview.entry ? preview.entry===entry : x+m.width*camera.scale > 0 && y+m.height*camera.scale > 0 && x < innerWidth && y < innerHeight;
|
|
361
|
+
if (visible && !entry.iframe) mount(entry);
|
|
362
|
+
notifyInput(entry);
|
|
363
|
+
if (entry.visible !== visible) { entry.visible = visible; notify(entry); }
|
|
364
|
+
|
|
365
|
+
}
|
|
366
|
+
syncGroupUi();
|
|
367
|
+
const mixedTexts=textTool?.selectedModels()??[];
|
|
368
|
+
if (selectedEntries.size || mixedTexts.length>1) {
|
|
369
|
+
const chosen=[...selectedEntries],box = bounds([...chosen.map(entry=>entry.model),...mixedTexts]);
|
|
370
|
+
Object.assign(selection.style,{left:`${camera.x+box.x*camera.scale}px`,top:`${camera.y+box.y*camera.scale}px`,width:`${box.width*camera.scale}px`,height:`${box.height*camera.scale}px`});
|
|
371
|
+
const reordering=gesture?.kind==='reorder';
|
|
372
|
+
smartHandles.update(chosen,camera,box,{enabled:reordering || (!gesture && !mixedTexts.length && !!smartLayout(chosen.map(entry=>entry.model))),active:reordering?gesture.entry:null,destination:reordering?gesture.plan?.[gesture.sourceIndex]:null});
|
|
373
|
+
} else smartHandles.update([],camera,null,{enabled:false});
|
|
374
|
+
updateGap();
|
|
375
|
+
renderContact();
|
|
376
|
+
textTool?.render();
|
|
377
|
+
arrowTool?.render();
|
|
378
|
+
if (document.activeElement !== $('#zoom')) $('#zoom').value = `${Math.round(camera.scale*100)}%`;
|
|
379
|
+
const zoomTarget=cameraMotion?.kind==='zoom'?cameraMotion.to.scale:camera.scale;
|
|
380
|
+
$('#zoom-out').disabled=zoomTarget<=minScale()+1e-9;$('#zoom-in').disabled=zoomTarget>=2;
|
|
381
|
+
for(const direction of ['out','in']){
|
|
382
|
+
const button=$(`#zoom-${direction}`),label=`Zoom ${direction} by ${zoomStep(zoomTarget)*100}%`;
|
|
383
|
+
button.title=label;button.setAttribute('aria-label',label);
|
|
384
|
+
}
|
|
385
|
+
positionSelectMenus();
|
|
386
|
+
if(cameraMotion)schedule();
|
|
387
|
+
}
|
|
388
|
+
function notifyInput(entry,force=false) {
|
|
389
|
+
if(!entry.iframe)return;
|
|
390
|
+
const active=selected===entry,touch=presets[entry.model.preset].touch===true;
|
|
391
|
+
const fullscreen=preview.entry===entry;
|
|
392
|
+
const canArrange=selectedEntries.size>1;
|
|
393
|
+
const state=`${active}:${touch}:${fullscreen}:${canArrange}`;if(!force && entry.inputState===state)return;
|
|
394
|
+
entry.inputState=state;entry.iframe.contentWindow.postMessage({type:'tio-canvas:input',active,touch,fullscreen,canArrange},location.origin);
|
|
395
|
+
}
|
|
396
|
+
function notify(entry) {
|
|
397
|
+
entry.iframe?.contentWindow.postMessage({type:'tio-canvas:visibility', visible:entry.visible && !document.hidden}, location.origin);
|
|
398
|
+
}
|
|
399
|
+
const loader=createScreenLoader({
|
|
400
|
+
onReady(entry){notify(entry);notifyInput(entry,true);entry.refresh.title=`Refresh ${entry.model.title}`;$('#canvas-status').textContent='';},
|
|
401
|
+
onError(entry,message){entry.refresh.title=message;$('#canvas-status').textContent=message;}
|
|
402
|
+
});
|
|
403
|
+
function mount(entry,replacing=false){loader.mount(entry,replacing);}
|
|
404
|
+
// Screens and texts share one selection box, one set of gap fields and one group drag.
|
|
405
|
+
const groupModels=()=>[...[...selectedEntries].map(entry=>entry.model),...(textTool?.selectedModels()??[])];
|
|
406
|
+
function syncGroupUi(){
|
|
407
|
+
const texts=textTool?.selectedModels().length??0,total=selectedEntries.size+texts;
|
|
408
|
+
selection.hidden=!(selectedEntries.size||texts>1);
|
|
409
|
+
groupGrab.hidden=total<2||!selectedEntries.size;
|
|
410
|
+
$('#inspector').hidden=!selected||texts>0;
|
|
411
|
+
$('#group-inspector').hidden=total<2;
|
|
412
|
+
selection.toggleAttribute('data-locked',!selected||selected.model.preset!=='custom'||texts>0);
|
|
413
|
+
}
|
|
414
|
+
function updateSelection(preserveArrows=false) {
|
|
415
|
+
closeSelectMenus();
|
|
416
|
+
if(selectedEntries.size && !preserveArrows){arrowTool?.clearSelection();textTool?.clearSelection();}
|
|
417
|
+
selected = selectedEntries.size === 1 ? [...selectedEntries][0] : null;
|
|
418
|
+
for (const item of entries) {
|
|
419
|
+
item.element.toggleAttribute('data-selected', selectedEntries.has(item));
|
|
420
|
+
item.element.toggleAttribute('data-input-active', item===selected);
|
|
421
|
+
}
|
|
422
|
+
$('#fit-screen').hidden = !selectedEntries.size;
|
|
423
|
+
syncGroupUi();
|
|
424
|
+
updateGap();
|
|
425
|
+
if (selected) {
|
|
426
|
+
const m = selected.model;
|
|
427
|
+
$('#preset-label').textContent = presets[m.preset].label; $('#preset').value = m.preset; $('#width').value = m.width; $('#height').value = m.height;
|
|
428
|
+
$('#width').disabled = $('#height').disabled = m.preset !== 'custom';
|
|
429
|
+
}
|
|
430
|
+
syncLayers(true);
|
|
431
|
+
schedule();
|
|
432
|
+
}
|
|
433
|
+
function select(entry, toggle = false) {
|
|
434
|
+
if (!toggle) {selectedEntries.clear();arrowTool?.clearSelection();textTool?.clearSelection();}
|
|
435
|
+
if (entry) {
|
|
436
|
+
if (toggle && selectedEntries.has(entry)) selectedEntries.delete(entry);
|
|
437
|
+
else selectedEntries.add(entry);
|
|
438
|
+
}
|
|
439
|
+
updateSelection(toggle);
|
|
440
|
+
}
|
|
441
|
+
function sourceOf(entry) { return entry.model.sourceId ?? entry.model.id; }
|
|
442
|
+
// Nothing of this page on the canvas yet: the first screen has no number. Otherwise count on from the highest.
|
|
443
|
+
function nextNumber(sourceId) {
|
|
444
|
+
const same = entries.filter(entry => sourceOf(entry) === sourceId);
|
|
445
|
+
return same.length ? Math.max(...same.map(entry => entry.model.number ?? 0)) + 1 : 0;
|
|
446
|
+
}
|
|
447
|
+
function deletableEntries() {
|
|
448
|
+
return [...selectedEntries];
|
|
449
|
+
}
|
|
450
|
+
function deleteSelection() {
|
|
451
|
+
stop();const before=history.checkpoint();
|
|
452
|
+
for (const entry of deletableEntries()) {
|
|
453
|
+
selectedEntries.delete(entry);loader.dispose(entry);entry.element.remove();entries.splice(entries.indexOf(entry),1);
|
|
454
|
+
}
|
|
455
|
+
arrowTool?.removeSelected();textTool?.removeSelected();updateSelection();commitEdit(before);
|
|
456
|
+
}
|
|
457
|
+
let presetGroup;
|
|
458
|
+
for (const [id,preset] of Object.entries(presets)) {
|
|
459
|
+
if (preset.group && preset.group !== presetGroup) {
|
|
460
|
+
presetGroup = preset.group;
|
|
461
|
+
const group = document.createElement('optgroup'); group.label = preset.group; $('#preset').append(group);
|
|
462
|
+
}
|
|
463
|
+
const option = document.createElement('option'); option.value=id; option.textContent=preset.label;
|
|
464
|
+
(preset.group ? $('#preset').lastElementChild : $('#preset')).append(option);
|
|
465
|
+
}
|
|
466
|
+
setupSelectFields();
|
|
467
|
+
$('#preset').addEventListener('change', event => {
|
|
468
|
+
if (!selected) return; const before=history.checkpoint();selected.model.preset = event.target.value;
|
|
469
|
+
if (event.target.value !== 'custom') {
|
|
470
|
+
Object.assign(selected.model,{width:presets[event.target.value].width,height:presets[event.target.value].height});
|
|
471
|
+
selected.model.x=clamp(selected.model.x,0,extent.width-selected.model.width);
|
|
472
|
+
selected.model.y=clamp(selected.model.y,0,extent.height-selected.model.height);
|
|
473
|
+
}
|
|
474
|
+
select(selected); commitEdit(before);
|
|
475
|
+
});
|
|
476
|
+
for (const axis of ['width','height']) $(`#${axis}`).addEventListener('change', event => {
|
|
477
|
+
if (!selected || selected.model.preset !== 'custom') return;
|
|
478
|
+
const before=history.checkpoint();
|
|
479
|
+
const position = axis === 'width' ? 'x' : 'y';
|
|
480
|
+
selected.model[axis] = Math.round(finite(event.target.valueAsNumber, selected.model[axis],160,Math.min(3840,Math.floor(extent[axis]-selected.model[position])))); select(selected); commitEdit(before);
|
|
481
|
+
});
|
|
482
|
+
function updateGap() {
|
|
483
|
+
const models=groupModels();
|
|
484
|
+
const horizontal=groupSpacing(models,extent,'x'),vertical=groupSpacing(models,extent,'y');
|
|
485
|
+
const primary=horizontal??vertical;
|
|
486
|
+
for(const [input,spacing] of [[$('#gap'),primary],[$('#gap-y'),horizontal&&vertical]]){
|
|
487
|
+
input.parentElement.hidden=!spacing;
|
|
488
|
+
if(!spacing)continue;
|
|
489
|
+
input.dataset.axis=spacing.axis;
|
|
490
|
+
input.setAttribute('aria-label',`${spacing.axis==='x'?'Horizontal':'Vertical'} gap between selected objects`);
|
|
491
|
+
if(input.id==='gap')$('#gap-label').textContent=spacing.axis==='x'?'Gap X':'Gap Y';
|
|
492
|
+
input.disabled=spacing.max<0;
|
|
493
|
+
input.max=String(Math.max(0,spacing.max));
|
|
494
|
+
if(document.activeElement!==input){
|
|
495
|
+
input.value=spacing.gap!==null && spacing.gap>=0?String(spacing.gap):'';
|
|
496
|
+
input.placeholder=spacing.gap!==null && spacing.gap<0?'Overlap':'Mixed';
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for(const input of [$('#gap'),$('#gap-y')])input.addEventListener('change',event=>{
|
|
501
|
+
const items=groupModels(),axis=input.dataset.axis;
|
|
502
|
+
const models=spaceGroup(items,input.valueAsNumber,extent,axis);
|
|
503
|
+
if(!models){input.blur();updateGap();return;}
|
|
504
|
+
const before=history.checkpoint();
|
|
505
|
+
items.forEach((model,index)=>Object.assign(model,{x:models[index].x,y:models[index].y}));
|
|
506
|
+
input.value=String(groupSpacing(models,extent,axis).gap);
|
|
507
|
+
updateSelection(true);commitEdit(before);
|
|
508
|
+
});
|
|
509
|
+
$('#group-inspector').addEventListener('submit',event=>event.preventDefault());
|
|
510
|
+
$('#inspector').addEventListener('submit',event=>event.preventDefault());
|
|
511
|
+
for (const [direction,x,y] of [['nw',0,0],['n',50,0],['ne',100,0],['e',100,50],['se',100,100],['s',50,100],['sw',0,100],['w',0,50]]) {
|
|
512
|
+
const handle = document.createElement('button'); handle.type='button'; handle.className='handle'; handle.setAttribute('aria-label',`Resize ${direction}`);
|
|
513
|
+
handle.style.cssText=`left:${x}%;top:${y}%;cursor:${direction}-resize`;
|
|
514
|
+
if (direction.length === 1) handle.dataset.side = x===50?'horizontal':'vertical';
|
|
515
|
+
handle.addEventListener('pointerdown',event=>{ if (event.button===0 && selected?.model.preset==='custom') start(direction,event,selected); });
|
|
516
|
+
$('#handles').append(handle);
|
|
517
|
+
}
|
|
518
|
+
function start(kind,event,entry=null) {
|
|
519
|
+
cameraMotion=null;
|
|
520
|
+
closeContext();
|
|
521
|
+
event.preventDefault?.();
|
|
522
|
+
// A canvas gesture ends field editing even though pointer defaults are blocked.
|
|
523
|
+
// Do not steal focus from an iframe forwarding a pan gesture.
|
|
524
|
+
// Panning while typing in a text keeps the caret where it was.
|
|
525
|
+
const typingInText=kind==='pan' && document.activeElement?.closest?.('.text-object[contenteditable="true"]');
|
|
526
|
+
if(event.target instanceof Element && !typingInText)viewport.focus({preventScroll:true});
|
|
527
|
+
gesture = { duplicate:kind==='move' && event.altKey, kind, x:event.screenX,y:event.screenY, camera:{...camera}, entry, model:entry?{...entry.model}:null,
|
|
528
|
+
models: [...selectedEntries].map(item=>({entry:item,model:{...item.model}})),texts:kind==='move'?(textTool?.selectedModels()??[]).map(model=>({model,x:model.x,y:model.y})):[],
|
|
529
|
+
clientX:event.clientX,clientY:event.clientY,initialArrows:event.shiftKey?(arrowTool?.selectedIds??[]):[],initialTexts:event.shiftKey?(textTool?.selectedIds??[]):[],initial: new Set(event.shiftKey ? selectedEntries : []),before:!['pan','marquee'].includes(kind)?history.checkpoint():null };
|
|
530
|
+
if(kind==='marquee') {
|
|
531
|
+
if(!event.shiftKey)select(null);
|
|
532
|
+
// A press starts a fresh gesture, not a replay of the last rectangle.
|
|
533
|
+
marquee.hidden=true;
|
|
534
|
+
marquee.removeAttribute('style');
|
|
535
|
+
}
|
|
536
|
+
document.body.dataset.gesture = kind==='pan'?'pan':'resize';
|
|
537
|
+
}
|
|
538
|
+
function move(event) {
|
|
539
|
+
if(!gesture)return;
|
|
540
|
+
if(gesture.kind==='move' && event.altKey)gesture.duplicate=true;
|
|
541
|
+
pendingMove={screenX:event.screenX,screenY:event.screenY,clientX:event.clientX,clientY:event.clientY,shiftKey:event.shiftKey,altKey:event.altKey,metaKey:event.metaKey,ctrlKey:event.ctrlKey};
|
|
542
|
+
gesture.lastPointer=pendingMove;
|
|
543
|
+
schedule();
|
|
544
|
+
}
|
|
545
|
+
function applyMove(event) {
|
|
546
|
+
if (!gesture) return;
|
|
547
|
+
const g=gesture, dx=event.screenX-g.x, dy=event.screenY-g.y;
|
|
548
|
+
if (g.kind==='pan') { camera.x=g.camera.x+dx; camera.y=g.camera.y+dy; }
|
|
549
|
+
else if (g.kind==='marquee') {
|
|
550
|
+
const x=g.clientX+dx,y=g.clientY+dy;
|
|
551
|
+
const box={x:Math.min(g.clientX,x),y:Math.min(g.clientY,y),width:Math.abs(dx),height:Math.abs(dy)};
|
|
552
|
+
const dragging=(box.width>2 || box.height>2) && box.width>0 && box.height>0;
|
|
553
|
+
marquee.hidden=!dragging;
|
|
554
|
+
Object.assign(marquee.style,{left:`${box.x}px`,top:`${box.y}px`,width:`${box.width}px`,height:`${box.height}px`});
|
|
555
|
+
const worldBox={x:(box.x-camera.x)/camera.scale,y:(box.y-camera.y)/camera.scale,width:box.width/camera.scale,height:box.height/camera.scale};
|
|
556
|
+
const next=new Set(g.initial);
|
|
557
|
+
if(dragging)for(const entry of entries)if(intersects(worldBox,entry.model))next.add(entry);
|
|
558
|
+
if(next.size!==selectedEntries.size || [...next].some(entry=>!selectedEntries.has(entry))){
|
|
559
|
+
selectedEntries.clear();for(const entry of next)selectedEntries.add(entry);
|
|
560
|
+
updateSelection(true);
|
|
561
|
+
}
|
|
562
|
+
arrowTool?.selectRect(dragging?worldBox:null,g.initialArrows);
|
|
563
|
+
textTool?.selectRect(dragging?worldBox:null,g.initialTexts);
|
|
564
|
+
} else if(g.kind==='reorder'){
|
|
565
|
+
if(!g.moved && Math.hypot(dx,dy)<3)return;
|
|
566
|
+
g.moved=true;
|
|
567
|
+
const model=g.models[g.sourceIndex].model;
|
|
568
|
+
const target=nearestSmartSlot(g.layout,model.x+model.width/2+dx/camera.scale,model.y+model.height/2+dy/camera.scale);
|
|
569
|
+
const plan=reorderSmartLayout(g.layout,g.sourceSlot,target,extent);
|
|
570
|
+
if(plan){
|
|
571
|
+
g.plan=plan;g.models.forEach((item,index)=>Object.assign(item.entry.model,plan[index]));
|
|
572
|
+
g.entry.model.x=Math.round(clamp(model.x+dx/camera.scale,0,extent.width-model.width));
|
|
573
|
+
g.entry.model.y=Math.round(clamp(model.y+dy/camera.scale,0,extent.height-model.height));
|
|
574
|
+
}else{
|
|
575
|
+
g.plan=null;g.models.forEach(item=>Object.assign(item.entry.model,item.model));
|
|
576
|
+
$('#canvas-status').textContent='This order would exceed the canvas boundary.';
|
|
577
|
+
}
|
|
578
|
+
} else if (g.kind==='move') {
|
|
579
|
+
if(!g.moved && Math.hypot(dx,dy)<3)return;
|
|
580
|
+
g.duplicate ||= event.altKey===true;
|
|
581
|
+
if(g.duplicate && !g.duplicated && !g.duplicateDenied){
|
|
582
|
+
if(entries.filter(entry=>entry.model.sourceId).length+g.models.length>200){
|
|
583
|
+
$('#canvas-status').textContent='The canvas supports up to 200 added screens.';g.duplicateDenied=true;
|
|
584
|
+
}else{
|
|
585
|
+
const copies=g.models.map(({entry,model})=>{
|
|
586
|
+
// A late Option press restores the original and continues with a copy.
|
|
587
|
+
Object.assign(entry.model,{x:model.x,y:model.y});
|
|
588
|
+
const copy=duplicate(entry,0);return{entry:copy,model:{...copy.model}};
|
|
589
|
+
});
|
|
590
|
+
selectedEntries.clear();copies.forEach(item=>selectedEntries.add(item.entry));
|
|
591
|
+
g.models=copies;g.shiftToggle=null;g.duplicated=true;
|
|
592
|
+
updateSelection(true);
|
|
593
|
+
if(g.texts.length){
|
|
594
|
+
g.texts.forEach(t=>{t.model.x=t.x;t.model.y=t.y;});
|
|
595
|
+
const textCopies=textTool.duplicateSelected(0);
|
|
596
|
+
if(textCopies)g.texts=textCopies.map(model=>({model,x:model.x,y:model.y}));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
g.moved=true;
|
|
601
|
+
const original=bounds([...g.models.map(item=>item.model),...g.texts.map(t=>({x:t.x,y:t.y,width:t.model.width,height:t.model.height}))]);
|
|
602
|
+
// Re-evaluate the dominant direction from the drag origin while Shift is held.
|
|
603
|
+
g.axis=event.shiftKey?(Math.abs(dx)>=Math.abs(dy)?'x':'y'):null;
|
|
604
|
+
const proposal={...original,x:original.x+(g.axis==='y'?0:dx/camera.scale),y:original.y+(g.axis==='x'?0:dy/camera.scale)};
|
|
605
|
+
const snap=snapRect(proposal,[...entries.filter(entry=>!selectedEntries.has(entry)).map(entry=>entry.model),...(textTool?.objects()??[]).filter(o=>!g.texts.some(t=>t.model.id===o.id))],6/camera.scale);
|
|
606
|
+
if(g.axis==='x')snap.y=original.y;if(g.axis==='y')snap.x=original.x;
|
|
607
|
+
const x=Math.round(clamp(snap.x,0,extent.width-original.width))-original.x,y=Math.round(clamp(snap.y,0,extent.height-original.height))-original.y;
|
|
608
|
+
for(const item of g.models){item.entry.model.x=item.model.x+x;item.entry.model.y=item.model.y+y;}
|
|
609
|
+
for(const t of g.texts){t.model.x=t.x+x;t.model.y=t.y+y;}
|
|
610
|
+
const lines=g.axis?snap.guides.filter(line=>line.kind==='gap'?line.axis!==g.axis:line.axis===g.axis):snap.guides;
|
|
611
|
+
if(g.axis){const horizontal=g.axis==='x';lines.push({axis:horizontal?'y':'x',value:original[horizontal?'y':'x']+original[horizontal?'height':'width']/2,from:Math.min(original[g.axis],original[g.axis]+(horizontal?x:y)),to:Math.max(original[g.axis]+original[horizontal?'width':'height'],original[g.axis]+(horizontal?x:y)+original[horizontal?'width':'height']),kind:'axis'});}
|
|
612
|
+
drawGuides(lines);
|
|
613
|
+
} else {
|
|
614
|
+
const m=g.entry.model, base=g.model, x=dx/camera.scale,y=dy/camera.scale;
|
|
615
|
+
if (g.kind.includes('e')) m.width=Math.round(clamp(base.width+x,160,Math.min(3840,Math.floor(extent.width-base.x))));
|
|
616
|
+
if (g.kind.includes('s')) m.height=Math.round(clamp(base.height+y,160,Math.min(3840,Math.floor(extent.height-base.y))));
|
|
617
|
+
if (g.kind.includes('w')) { m.width=Math.round(clamp(base.width-x,160,Math.min(3840,Math.floor(base.x+base.width)))); m.x=base.x+base.width-m.width; }
|
|
618
|
+
if (g.kind.includes('n')) { m.height=Math.round(clamp(base.height-y,160,Math.min(3840,Math.floor(base.y+base.height)))); m.y=base.y+base.height-m.height; }
|
|
619
|
+
$('#width').value=m.width;$('#height').value=m.height;
|
|
620
|
+
}
|
|
621
|
+
schedule();
|
|
622
|
+
}
|
|
623
|
+
function drawGuides(lines) {
|
|
624
|
+
guides.replaceChildren(...lines.map(line=>{
|
|
625
|
+
const element=document.createElement('div');element.className=`guide ${line.kind}`;
|
|
626
|
+
if(line.reference)element.dataset.reference='';
|
|
627
|
+
const vertical=line.axis==='x';element.dataset.axis=vertical?'vertical':'horizontal';
|
|
628
|
+
Object.assign(element.style,{left:`${camera.x+(vertical?line.value:line.from)*camera.scale}px`,top:`${camera.y+(vertical?line.from:line.value)*camera.scale}px`,width:vertical?'1px':`${(line.to-line.from)*camera.scale}px`,height:vertical?`${(line.to-line.from)*camera.scale}px`:'1px'});
|
|
629
|
+
if(line.kind==='gap'){const label=document.createElement('span');label.textContent=String(Math.round(line.gap));element.append(label);}
|
|
630
|
+
return element;
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
function startReorder(entry,event){
|
|
634
|
+
const layout=smartLayout([...selectedEntries].map(item=>item.model));if(!layout)return;
|
|
635
|
+
start('reorder',event,entry);
|
|
636
|
+
gesture.layout=layout;gesture.sourceIndex=gesture.models.findIndex(item=>item.entry===entry);
|
|
637
|
+
gesture.sourceSlot=layout.order.indexOf(gesture.sourceIndex);
|
|
638
|
+
entry.element.setAttribute('data-reordering','');
|
|
639
|
+
document.body.dataset.gesture='reorder';schedule();
|
|
640
|
+
}
|
|
641
|
+
function stop() {
|
|
642
|
+
flushMove();const previous=gesture;gesture=null;
|
|
643
|
+
if(previous?.kind==='reorder'){
|
|
644
|
+
previous.entry.element.removeAttribute('data-reordering');
|
|
645
|
+
previous.models.forEach((item,index)=>Object.assign(item.entry.model,previous.plan?.[index]??item.model));
|
|
646
|
+
updateSelection();
|
|
647
|
+
}
|
|
648
|
+
if(previous?.shiftToggle && !previous.moved)select(previous.shiftToggle,true);
|
|
649
|
+
if(previous?.emptyClick && !previous.moved)select(null);
|
|
650
|
+
if(previous?.before)commitEdit(previous.before);else if(previous)persist();
|
|
651
|
+
marquee.hidden=true;guides.replaceChildren();delete document.body.dataset.gesture;if(previous)schedule();
|
|
652
|
+
}
|
|
653
|
+
function cancelReorder(){
|
|
654
|
+
if(gesture?.kind!=='reorder')return false;
|
|
655
|
+
pendingMove=null;const previous=gesture;gesture=null;
|
|
656
|
+
previous.entry.element.removeAttribute('data-reordering');
|
|
657
|
+
previous.models.forEach(item=>Object.assign(item.entry.model,item.model));
|
|
658
|
+
delete document.body.dataset.gesture;updateSelection();return true;
|
|
659
|
+
}
|
|
660
|
+
function release() { if(!cancelReorder())stop();delete document.body.dataset.space; }
|
|
661
|
+
window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',release);window.addEventListener('blur',release);
|
|
662
|
+
viewport.addEventListener('pointerdown',event=>{
|
|
663
|
+
if(preview.entry)return;
|
|
664
|
+
if (event.button===1 || (event.button===0 && document.body.hasAttribute('data-space'))) start('pan',event);
|
|
665
|
+
else if (event.button===0 && event.target.matches('.screen')) {
|
|
666
|
+
const entry=entries.find(item=>item.element===event.target);
|
|
667
|
+
if(!entry)return;
|
|
668
|
+
event.preventDefault();select(entry,event.shiftKey);
|
|
669
|
+
if(event.altKey && selectedEntries.has(entry))start('move',event,entry);
|
|
670
|
+
}
|
|
671
|
+
else if (event.button===0 && (event.target===viewport || event.target===world)) start('marquee',event);
|
|
672
|
+
});
|
|
673
|
+
groupGrab.addEventListener('pointerdown',event=>{
|
|
674
|
+
if(event.button!==0 || document.body.hasAttribute('data-space'))return;
|
|
675
|
+
const x=(event.clientX-camera.x)/camera.scale,y=(event.clientY-camera.y)/camera.scale;
|
|
676
|
+
const contains=entry=>x>=entry.model.x&&x<=entry.model.x+entry.model.width&&y>=entry.model.y&&y<=entry.model.y+entry.model.height;
|
|
677
|
+
const unselected=event.shiftKey?[...entries].reverse().find(entry=>!selectedEntries.has(entry)&&contains(entry)):null;
|
|
678
|
+
if(unselected){select(unselected,true);start('move',event,unselected);return;}
|
|
679
|
+
const toggleAfterClick=event.shiftKey?[...selectedEntries].find(entry=>x>=entry.model.x&&x<=entry.model.x+entry.model.width&&y>=entry.model.y&&y<=entry.model.y+entry.model.height):null;
|
|
680
|
+
const overText=(textTool?.selectedModels()??[]).some(m=>x>=m.x&&x<=m.x+m.width&&y>=m.y&&y<=m.y+m.height);
|
|
681
|
+
const overSelected=[...selectedEntries].some(contains)||overText;
|
|
682
|
+
start('move',event);if(toggleAfterClick)gesture.shiftToggle=toggleAfterClick;
|
|
683
|
+
// A plain click on empty space inside the box lets go of the selection.
|
|
684
|
+
if(!overSelected&&!event.shiftKey&&!event.altKey)gesture.emptyClick=true;
|
|
685
|
+
});
|
|
686
|
+
viewport.addEventListener('auxclick',event=>{if(event.button===1)event.preventDefault();});
|
|
687
|
+
let nudgeBefore=null;
|
|
688
|
+
function endNudge(){if(!nudgeBefore)return;const before=nudgeBefore;nudgeBefore=null;commitEdit(before);}
|
|
689
|
+
function nudgeSelection(event){
|
|
690
|
+
const dx=event.key==='ArrowLeft'?-1:event.key==='ArrowRight'?1:0,dy=event.key==='ArrowUp'?-1:event.key==='ArrowDown'?1:0,step=event.shiftKey?20:10;
|
|
691
|
+
const models=[...[...selectedEntries].map(entry=>entry.model),...(textTool?.selectedModels()??[])];
|
|
692
|
+
const mx=Math.min(...models.map(m=>m.x)),my=Math.min(...models.map(m=>m.y)),Mx=Math.max(...models.map(m=>m.x+m.width)),My=Math.max(...models.map(m=>m.y+m.height));
|
|
693
|
+
const ox=clamp(dx*step,-mx,extent.width-Mx),oy=clamp(dy*step,-my,extent.height-My);
|
|
694
|
+
if(!ox&&!oy)return;
|
|
695
|
+
stop();nudgeBefore??=history.checkpoint();
|
|
696
|
+
models.forEach(m=>{m.x+=ox;m.y+=oy;});
|
|
697
|
+
updateSelection(true);schedule();
|
|
698
|
+
}
|
|
699
|
+
function keyboard(event) {
|
|
700
|
+
if(preview.entry || document.querySelector('dialog[open]'))return;
|
|
701
|
+
if(document.body.hasAttribute('data-layer-drag'))return;
|
|
702
|
+
if(event.key==='Shift' && gesture?.kind==='move' && gesture.lastPointer)move({...gesture.lastPointer,shiftKey:event.type==='keydown'});
|
|
703
|
+
if(event.key==='Alt' && gesture?.kind==='move' && gesture.lastPointer)move({...gesture.lastPointer,altKey:event.type==='keydown'});
|
|
704
|
+
const typing=event.target?.closest?.('input,textarea,select,.icon-options,[contenteditable]:not([contenteditable="false"])');
|
|
705
|
+
if (event.code==='Space' && !typing && !event.target.closest?.('#layers')) { if(event.type==='keydown') {event.preventDefault();document.body.dataset.space='';} else delete document.body.dataset.space; }
|
|
706
|
+
if(event.type==='keyup' && event.key.startsWith('Arrow'))endNudge();
|
|
707
|
+
if(event.type==='keydown' && !typing && (selectedEntries.size || textTool?.selectedIds.length) && ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key) && !event.metaKey && !event.ctrlKey && !event.altKey && !event.target.closest?.('#layers,#screen-context,#layers-menu,dialog,select') && !gesture){event.preventDefault();nudgeSelection(event);return;}
|
|
708
|
+
const arrangeAction=arrangeShortcut(event);
|
|
709
|
+
if(event.type==='keydown' && !typing && arrangeAction && selectedEntries.size+(textTool?.selectedIds.length??0)>1){event.preventDefault();if(!event.repeat)arrangeSelection(arrangeAction);return;}
|
|
710
|
+
const layerAction=layerShortcut(event);
|
|
711
|
+
if(event.type==='keydown' && !typing && event.shiftKey && ['Digit1','Digit2'].includes(event.code) && !event.metaKey && !event.ctrlKey && !event.altKey){event.preventDefault();if(!event.repeat)(event.code==='Digit1'?fitEntries(fitAll()):fitEntries([...selectedEntries,...(textTool?.selectedModels()??[]).map(model=>({model}))],{fullViewport:true}));}
|
|
712
|
+
if(event.type==='keydown' && !typing && layerAction){event.preventDefault();moveLayers(layerAction);}
|
|
713
|
+
if(event.type==='keydown' && !typing && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase()==='a'){
|
|
714
|
+
event.preventDefault();stop();
|
|
715
|
+
entries.forEach(entry=>selectedEntries.add(entry));arrowTool?.selectRect(null,arrowTool.ids);textTool?.selectRect(null,textTool.ids);updateSelection(true);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const historyAction=historyShortcut(event);
|
|
719
|
+
if(event.type==='keydown' && !typing && historyAction){event.preventDefault();runHistory(historyAction);}
|
|
720
|
+
if (event.type === 'keydown' && (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 'd' && !typing && (selectedEntries.size || textTool?.selectedIds.length)) {
|
|
721
|
+
event.preventDefault(); if (!event.repeat) duplicateSelection();
|
|
722
|
+
}
|
|
723
|
+
if (event.type==='keydown' && !typing && ['Backspace','Delete'].includes(event.key) && (selectedEntries.size || arrowTool?.selectedIds.length || textTool?.selectedIds.length)) {event.preventDefault();deleteSelection();}
|
|
724
|
+
if(event.key==='Escape') cancelReorder();
|
|
725
|
+
}
|
|
726
|
+
window.addEventListener('keydown',keyboard);window.addEventListener('keyup',keyboard);window.addEventListener('blur',endNudge);
|
|
727
|
+
function moveCamera(to, animated, kind) {
|
|
728
|
+
if(animated && !matchMedia('(prefers-reduced-motion: reduce)').matches){
|
|
729
|
+
cameraMotion={from:{...camera},to,kind,start:performance.now(),duration:parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--canvas-camera-duration'))};
|
|
730
|
+
}else{cameraMotion=null;Object.assign(camera,to);persist();}
|
|
731
|
+
schedule();
|
|
732
|
+
}
|
|
733
|
+
// The onboarding asks for a pan when what it points at has left the screen.
|
|
734
|
+
document.addEventListener('tio-canvas:pan-by',event=>{
|
|
735
|
+
const {dx=0,dy=0}=event.detail??{};
|
|
736
|
+
if(!Number.isFinite(dx)||!Number.isFinite(dy))return;
|
|
737
|
+
moveCamera({scale:camera.scale,x:clamp(camera.x+dx,innerWidth-extent.width*camera.scale,workArea().left),y:clamp(camera.y+dy,innerHeight-extent.height*camera.scale,0)},true,'pan');
|
|
738
|
+
});
|
|
739
|
+
function zoomAt(scale, x, y, animated=false) {
|
|
740
|
+
if (![scale,x,y].every(Number.isFinite)) return;
|
|
741
|
+
scale = clamp(scale,minScale(),2);
|
|
742
|
+
const ratio = scale/camera.scale;
|
|
743
|
+
const to={scale,
|
|
744
|
+
x:clamp(x-(x-camera.x)*ratio,innerWidth-extent.width*scale,workArea().left),
|
|
745
|
+
y:clamp(y-(y-camera.y)*ratio,innerHeight-extent.height*scale,0)};
|
|
746
|
+
moveCamera(to,animated,'zoom');
|
|
747
|
+
}
|
|
748
|
+
const ZOOM_SENSITIVITY = .002;
|
|
749
|
+
const PINCH_ZOOM_SENSITIVITY = .008;
|
|
750
|
+
function wheelZoom(delta, mode, x, y, sensitivity = ZOOM_SENSITIVITY) {
|
|
751
|
+
const pixels = delta * (mode === 1 ? 16 : mode === 2 ? innerHeight : 1);
|
|
752
|
+
zoomAt(camera.scale*Math.exp(-clamp(pixels,-1000,1000)*sensitivity),x,y);
|
|
753
|
+
}
|
|
754
|
+
function wheelPan(dx,dy,mode) {
|
|
755
|
+
cameraMotion=null;
|
|
756
|
+
const unit=mode===1?16:mode===2?innerHeight:1;
|
|
757
|
+
camera.x-=dx*(mode===2?innerWidth:unit);camera.y-=dy*unit;schedule();persist();
|
|
758
|
+
}
|
|
759
|
+
viewport.addEventListener('wheel',event=>{
|
|
760
|
+
if(preview.entry || event.target.closest('#inspector,#group-inspector')) return;
|
|
761
|
+
event.preventDefault();
|
|
762
|
+
if(event.metaKey || event.ctrlKey)wheelZoom(event.deltaY,event.deltaMode,event.clientX,event.clientY,event.ctrlKey && !event.metaKey ? PINCH_ZOOM_SENSITIVITY : ZOOM_SENSITIVITY);
|
|
763
|
+
else wheelPan(event.deltaX,event.deltaY,event.deltaMode);
|
|
764
|
+
},{passive:false});
|
|
765
|
+
window.addEventListener('message',event=>{
|
|
766
|
+
if(event.origin!==location.origin) return;
|
|
767
|
+
if(event.data?.type==='tio-canvas:ready'){loader.ready(event.source);return;}
|
|
768
|
+
const entry=entries.find(item=>item.iframe?.contentWindow===event.source);if(!entry)return;
|
|
769
|
+
const data=event.data;
|
|
770
|
+
if(document.querySelector('dialog[open]'))return;
|
|
771
|
+
if(preview.entry){
|
|
772
|
+
if(entry===preview.entry && data?.type==='tio-canvas:preview-pointer')preview.close();
|
|
773
|
+
if(entry===preview.entry && data?.type==='tio-canvas:preview-escape')preview.close();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if(data?.type==='tio-canvas:context' && [data.x,data.y].every(Number.isFinite))openContext(entry,camera.x+(entry.model.x+data.x)*camera.scale,camera.y+(entry.model.y+data.y)*camera.scale);
|
|
777
|
+
if(data?.type==='tio-canvas:alt-drag' && [data.screenX,data.screenY].every(Number.isFinite)){
|
|
778
|
+
if(!selectedEntries.has(entry))select(entry);
|
|
779
|
+
start('move',{...data,altKey:true},entry);
|
|
780
|
+
}
|
|
781
|
+
if(data?.type==='tio-canvas:contact'){
|
|
782
|
+
if(data.visible===true && [data.x,data.y].every(Number.isFinite)){contactState={entry,x:data.x,y:data.y};renderContact();}
|
|
783
|
+
else if(contactState?.entry===entry)clearContact();
|
|
784
|
+
}
|
|
785
|
+
if(data?.type==='tio-canvas:arrange')arrangeSelection(data.action);
|
|
786
|
+
if(data?.type==='tio-canvas:fit-all')fitEntries(fitAll());
|
|
787
|
+
if(data?.type==='tio-canvas:fit-selection')fitEntries([...selectedEntries],{fullViewport:true});
|
|
788
|
+
if(data?.type==='tio-canvas:layer' && ['above','below','top','bottom'].includes(data.action))moveLayers(data.action);
|
|
789
|
+
if(data?.type==='tio-canvas:history' && ['undo','redo'].includes(data.direction))runHistory(data.direction);
|
|
790
|
+
if(data?.type==='tio-canvas:select'){
|
|
791
|
+
// Simulated touch can cancel native focus transfer out of a host field.
|
|
792
|
+
// Only blur that host field; never steal focus from a project editor.
|
|
793
|
+
if(document.activeElement?.matches('input,textarea,select,[contenteditable]'))document.activeElement.blur();
|
|
794
|
+
select(entry,data.shift===true);
|
|
795
|
+
}
|
|
796
|
+
if(data?.type==='tio-canvas:duplicate') { if(!selectedEntries.size)select(entry);duplicateSelection(); }
|
|
797
|
+
if(data?.type==='tio-canvas:delete') { if(!selectedEntries.size)select(entry);deleteSelection(); }
|
|
798
|
+
if(data?.type==='tio-canvas:zoom' && [data.x,data.y,data.delta,data.mode].every(Number.isFinite)) {
|
|
799
|
+
// Map iframe viewport coordinates into the host's screen-space camera.
|
|
800
|
+
const m = entry.model;
|
|
801
|
+
wheelZoom(data.delta,data.mode,camera.x+(m.x+data.x)*camera.scale,camera.y+(m.y+data.y)*camera.scale,data.ctrlKey === true ? PINCH_ZOOM_SENSITIVITY : ZOOM_SENSITIVITY);
|
|
802
|
+
}
|
|
803
|
+
if(data?.type==='tio-canvas:wheel-pan' && [data.dx,data.dy,data.mode].every(Number.isFinite))wheelPan(data.dx,data.dy,data.mode);
|
|
804
|
+
if(data?.type==='tio-canvas:pan') {start('pan',data);}
|
|
805
|
+
if(data?.type==='tio-canvas:move') move(data);
|
|
806
|
+
if(data?.type==='tio-canvas:stop') release();
|
|
807
|
+
if(data?.type==='tio-canvas:space') document.body.toggleAttribute('data-space',data.down===true);
|
|
808
|
+
});
|
|
809
|
+
// A fit of the selection centres on the whole viewport, as with the Layers drawer
|
|
810
|
+
// closed; "Fit all" keeps to the area the drawer leaves free.
|
|
811
|
+
const fitAll=()=>[...entries,...(textTool?.objects()??[]).map(model=>({model}))];
|
|
812
|
+
function fitEntries(targets,{fullViewport=false}={}) {
|
|
813
|
+
if(!targets.length)return;
|
|
814
|
+
const minX=Math.min(...targets.map(e=>e.model.x)),minY=Math.min(...targets.map(e=>e.model.y));
|
|
815
|
+
const maxX=Math.max(...targets.map(e=>e.model.x+e.model.width)),maxY=Math.max(...targets.map(e=>e.model.y+e.model.height));
|
|
816
|
+
const {left:availableLeft,width:availableWidth}=fullViewport?{left:0,width:innerWidth}:workArea();
|
|
817
|
+
const scale=clamp(Math.min((availableWidth-160)/(maxX-minX),(innerHeight-280)/(maxY-minY)),minScale(),2);
|
|
818
|
+
const to={scale,
|
|
819
|
+
x:clamp(availableLeft+(availableWidth-(maxX-minX)*scale)/2-minX*scale,innerWidth-extent.width*scale,availableLeft),
|
|
820
|
+
y:clamp((innerHeight-(maxY-minY)*scale)/2-minY*scale,innerHeight-extent.height*scale,0)};
|
|
821
|
+
moveCamera(to,true,'fit');
|
|
822
|
+
}
|
|
823
|
+
function duplicate(entry,offsetX,offsetY=0) {
|
|
824
|
+
if (entries.filter(item => item.model.sourceId).length >= 200) return;
|
|
825
|
+
const sourceId = entry.model.sourceId ?? entry.model.id;
|
|
826
|
+
const source = screens.find(screen => screen.id === sourceId);
|
|
827
|
+
const number = nextNumber(sourceId);
|
|
828
|
+
const model = { ...entry.model, id:crypto.randomUUID(),sourceId,number,name:undefined,title:titleFor(source,number),x:entry.model.x+offsetX,y:entry.model.y+offsetY };
|
|
829
|
+
const copy = createEntry(model,false);entries.push(copy);return copy;
|
|
830
|
+
}
|
|
831
|
+
// A text-led Option drag carries the selected screens along; these copy and undo-copy them.
|
|
832
|
+
function duplicateScreensInPlace() {
|
|
833
|
+
const originals=[...selectedEntries];
|
|
834
|
+
if(!originals.length || entries.filter(entry=>entry.model.sourceId).length+originals.length>200)return [];
|
|
835
|
+
const copies=originals.map(entry=>duplicate(entry,0)).filter(Boolean);
|
|
836
|
+
selectedEntries.clear();copies.forEach(copy=>selectedEntries.add(copy));updateSelection(true);
|
|
837
|
+
return copies.map(copy=>copy.model);
|
|
838
|
+
}
|
|
839
|
+
function discardScreens(copies,sources) {
|
|
840
|
+
for(const entry of [...entries])if(copies.includes(entry.model)){selectedEntries.delete(entry);loader.dispose(entry);entry.element.remove();entries.splice(entries.indexOf(entry),1);}
|
|
841
|
+
for(const entry of entries)if(sources.includes(entry.model))selectedEntries.add(entry);
|
|
842
|
+
updateSelection(true);
|
|
843
|
+
}
|
|
844
|
+
function duplicateSelection() {
|
|
845
|
+
stop();
|
|
846
|
+
const originals=[...selectedEntries];
|
|
847
|
+
if(!originals.length){
|
|
848
|
+
if(!textTool?.selectedIds.length)return;
|
|
849
|
+
const before=history.checkpoint();if(textTool.duplicateSelected())commitEdit(before);return;
|
|
850
|
+
}
|
|
851
|
+
const box=bounds(originals.map(entry=>entry.model));
|
|
852
|
+
const offset=duplicateOffset(box,entries.map(entry=>entry.model),extent);
|
|
853
|
+
if(!offset || entries.filter(entry=>entry.model.sourceId).length+originals.length>200)return;
|
|
854
|
+
const before=history.checkpoint();
|
|
855
|
+
const copies=originals.map(entry=>duplicate(entry,offset.x,offset.y)).filter(Boolean);
|
|
856
|
+
if(!copies.length)return;
|
|
857
|
+
selectedEntries.clear();copies.forEach(copy=>selectedEntries.add(copy));updateSelection(true);textTool?.duplicateSelected(offset.x,offset.y);fitEntries(copies);commitEdit(before);
|
|
858
|
+
}
|
|
859
|
+
createPagePicker({dialog:$('#add-pages'),trigger:$('#add'),sources:screens,
|
|
860
|
+
groups:Object.entries(screenKinds).map(([id,label])=>({id,label})),
|
|
861
|
+
count:id=>entries.filter(entry=>sourceOf(entry)===id).length,
|
|
862
|
+
onOpen(){closeContext();stop();clearContact();delete document.body.dataset.space;},
|
|
863
|
+
onAdd(ids){
|
|
864
|
+
const sources=ids.map(id=>screens.find(source=>source.id===id)).filter(Boolean);
|
|
865
|
+
if(entries.filter(entry=>entry.model.sourceId).length+sources.length>200)return false;
|
|
866
|
+
const positions=addedPagePositions(sources,[...entries.map(entry=>entry.model),...(textTool?.objects()??[])],extent);
|
|
867
|
+
if(!positions)return false;
|
|
868
|
+
const before=history.checkpoint(),copies=[];
|
|
869
|
+
for(const [index,source] of sources.entries()){
|
|
870
|
+
const number=nextNumber(source.id);
|
|
871
|
+
const copy=createEntry({...source,id:crypto.randomUUID(),sourceId:source.id,number,title:titleFor(source,number),...positions[index]},false);
|
|
872
|
+
entries.push(copy);copies.push(copy);
|
|
873
|
+
}
|
|
874
|
+
selectedEntries.clear();copies.forEach(copy=>selectedEntries.add(copy));updateSelection();fitEntries(copies);commitEdit(before);return true;
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
// Native popover dismissal and dialog focus handling keep overlays keyboard-safe.
|
|
878
|
+
const context=$('#screen-context');
|
|
879
|
+
let contextTrigger=null;
|
|
880
|
+
function closeContext(){if(context.matches(':popover-open'))context.hidePopover();}
|
|
881
|
+
function openContext(entry,x,y){
|
|
882
|
+
if(preview.entry || document.querySelector('dialog[open]'))return;
|
|
883
|
+
stop();if(entry && !selectedEntries.has(entry))select(entry);
|
|
884
|
+
if(!selectedEntries.size && !textTool?.selectedIds.length)return;
|
|
885
|
+
contextTrigger=entry?.title??document.activeElement;
|
|
886
|
+
context.showPopover();
|
|
887
|
+
const rect=context.getBoundingClientRect();
|
|
888
|
+
const inset=parseFloat(getComputedStyle(context).getPropertyValue('--canvas-panel-inset'));
|
|
889
|
+
context.style.left=`${Math.max(inset,Math.min(x,innerWidth-rect.width-inset))}px`;
|
|
890
|
+
context.style.top=`${Math.max(inset,Math.min(y,innerHeight-rect.height-inset))}px`;
|
|
891
|
+
// Focus the menu itself so opening it does not highlight an action.
|
|
892
|
+
context.focus({preventScroll:true});
|
|
893
|
+
}
|
|
894
|
+
context.addEventListener('click',event=>{
|
|
895
|
+
const action=event.target.closest('[data-action]')?.dataset.action;if(!action)return;
|
|
896
|
+
closeContext();if(action==='duplicate')duplicateSelection();else deleteSelection();viewport.focus();
|
|
897
|
+
});
|
|
898
|
+
context.addEventListener('keydown',event=>{
|
|
899
|
+
event.stopPropagation();
|
|
900
|
+
const buttons=[...context.querySelectorAll('button')],index=buttons.indexOf(document.activeElement);
|
|
901
|
+
if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){
|
|
902
|
+
event.preventDefault();buttons[event.key==='Home'?0:event.key==='End'?buttons.length-1:index<0?(event.key==='ArrowDown'?0:buttons.length-1):(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length].focus();
|
|
903
|
+
}
|
|
904
|
+
if(event.key==='Escape'){event.preventDefault();closeContext();if(contextTrigger?.isConnected)contextTrigger.focus();else viewport.focus();}
|
|
905
|
+
});
|
|
906
|
+
document.addEventListener('contextmenu',event=>{
|
|
907
|
+
if(event.target.closest('input,textarea,[contenteditable="plaintext-only"],[contenteditable="true"],dialog'))return;
|
|
908
|
+
const textObject=event.target.closest('.text-object');
|
|
909
|
+
if(textObject){event.preventDefault();textTool.selectOnly(textObject.dataset.textId);openContext(null,event.clientX,event.clientY);return;}
|
|
910
|
+
const screen=event.target.closest('.screen'),row=event.target.closest('[data-layer-id]');
|
|
911
|
+
const entry=entries.find(item=>item.element===screen || item.model.id===row?.dataset.layerId);
|
|
912
|
+
if(!entry && event.target!==groupGrab && event.target!==viewport && event.target!==world)return;
|
|
913
|
+
event.preventDefault();openContext(entry,event.clientX,event.clientY);
|
|
914
|
+
});
|
|
915
|
+
const shortcuts=$('#shortcuts');
|
|
916
|
+
$('#shortcuts-open').onclick=()=>{closeContext();stop();clearContact();delete document.body.dataset.space;shortcuts.showModal();};
|
|
917
|
+
$('#shortcuts-close').onclick=()=>shortcuts.close();
|
|
918
|
+
shortcuts.addEventListener('click',event=>{
|
|
919
|
+
if(event.target!==shortcuts)return;
|
|
920
|
+
const rect=shortcuts.getBoundingClientRect();
|
|
921
|
+
if(event.clientX<rect.left || event.clientX>rect.right || event.clientY<rect.top || event.clientY>rect.bottom)shortcuts.close();
|
|
922
|
+
});
|
|
923
|
+
shortcuts.addEventListener('close',()=>$('#layers-more').focus());
|
|
924
|
+
$('#undo').onclick=()=>runHistory('undo');
|
|
925
|
+
$('#redo').onclick=()=>runHistory('redo');
|
|
926
|
+
$('#fit').onclick=()=>fitEntries(fitAll());
|
|
927
|
+
$('#fit-screen').onclick=()=>fitEntries([...selectedEntries],{fullViewport:true});
|
|
928
|
+
$('#zoom-reset').onclick=()=>zoomAt(1,innerWidth/2,innerHeight/2,true);
|
|
929
|
+
function zoomStep(scale){return scale>.5?.2:scale>.2?.1:.02;}
|
|
930
|
+
function stepZoom(direction){
|
|
931
|
+
const scale=cameraMotion?.kind==='zoom'?cameraMotion.to.scale:camera.scale;
|
|
932
|
+
zoomAt(Math.round((scale+direction*zoomStep(scale))*1e10)/1e10,innerWidth/2,innerHeight/2,true);
|
|
933
|
+
}
|
|
934
|
+
$('#zoom-out').onclick=()=>stepZoom(-1);
|
|
935
|
+
$('#zoom-in').onclick=()=>stepZoom(1);
|
|
936
|
+
function commitZoom() {
|
|
937
|
+
const text = $('#zoom').value.trim().replace(/%$/, '').trim();
|
|
938
|
+
const percentage = text === '' ? NaN : Number(text);
|
|
939
|
+
if (Number.isFinite(percentage)) zoomAt(percentage/100,innerWidth/2,innerHeight/2);
|
|
940
|
+
$('#zoom').value=`${Math.round(camera.scale*100)}%`;
|
|
941
|
+
}
|
|
942
|
+
$('#zoom').addEventListener('change',commitZoom);
|
|
943
|
+
$('#zoom').addEventListener('keydown',event=>{
|
|
944
|
+
if(event.key==='Enter'){event.preventDefault();commitZoom();$('#zoom').blur();}
|
|
945
|
+
if(event.key==='Escape'){$('#zoom').value=`${Math.round(camera.scale*100)}%`;$('#zoom').blur();}
|
|
946
|
+
});
|
|
947
|
+
let themeTimer;
|
|
948
|
+
$('#theme').onclick=()=>{
|
|
949
|
+
// The theme fades through the root tokens; controls must not add their own transitions on top.
|
|
950
|
+
const root=document.documentElement;root.setAttribute('data-theming','');
|
|
951
|
+
clearTimeout(themeTimer);themeTimer=setTimeout(()=>root.removeAttribute('data-theming'),300);
|
|
952
|
+
const dark=document.documentElement.toggleAttribute('data-dark');
|
|
953
|
+
$('#theme-label').textContent=dark?'Light mode':'Dark mode';
|
|
954
|
+
};
|
|
955
|
+
const moreMenu=$('#layers-menu'),moreButton=$('#layers-more');
|
|
956
|
+
moreMenu.addEventListener('beforetoggle',event=>{
|
|
957
|
+
if(event.newState!=='open')return;
|
|
958
|
+
closeContext();stop();clearContact();
|
|
959
|
+
const anchor=moreButton.getBoundingClientRect();
|
|
960
|
+
const gap=parseFloat(getComputedStyle(moreMenu).getPropertyValue('--canvas-menu-offset')),inset=parseFloat(getComputedStyle(moreMenu).getPropertyValue('--canvas-panel-inset')),width=parseFloat(getComputedStyle(moreMenu).getPropertyValue('--canvas-preview-menu'));
|
|
961
|
+
moreMenu.style.top=`${anchor.bottom+gap}px`;
|
|
962
|
+
moreMenu.style.left=`${Math.max(inset,Math.min(anchor.left+(anchor.width-width)/2,innerWidth-width-inset))}px`;
|
|
963
|
+
});
|
|
964
|
+
moreMenu.addEventListener('toggle',event=>{if(event.newState==='open')moreMenu.focus({preventScroll:true});moreButton.setAttribute('aria-expanded',String(event.newState==='open'));});
|
|
965
|
+
moreMenu.addEventListener('click',event=>{if(event.target.closest('#shortcuts-open,#onboarding-open'))moreMenu.hidePopover();});
|
|
966
|
+
moreMenu.addEventListener('keydown',event=>{
|
|
967
|
+
event.stopPropagation();
|
|
968
|
+
if(!['ArrowDown','ArrowUp','Home','End'].includes(event.key))return;
|
|
969
|
+
event.preventDefault();
|
|
970
|
+
const buttons=[...moreMenu.querySelectorAll('button')],index=buttons.indexOf(document.activeElement);
|
|
971
|
+
buttons[event.key==='Home'?0:event.key==='End'?buttons.length-1:index<0?(event.key==='ArrowDown'?0:buttons.length-1):(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length].focus();
|
|
972
|
+
});
|
|
973
|
+
window.addEventListener('resize',()=>{cameraMotion=null;cachedWorkArea=null;schedule();});
|
|
974
|
+
document.addEventListener('visibilitychange',()=>{release();entries.forEach(notify);});
|
|
975
|
+
const preview=createFullscreen({
|
|
976
|
+
onEnter(entry){cameraMotion=null;stop();clearContact();delete document.body.dataset.space;select(entry);if(!entry.iframe)mount(entry);schedule();},
|
|
977
|
+
onExit(){cachedWorkArea=null;schedule();},
|
|
978
|
+
});
|
|
979
|
+
textTool=createTexts({viewport,world,camera,extent,saved:saved.texts,screenSelectionCount:()=>selectedEntries.size,rank:id=>objectRanks.get(`text:${id}`)??1,checkpoint:()=>history.checkpoint(),commit:commitEdit,clearScreens:()=>select(null),schedule,stopCanvas:()=>{cameraMotion=null;stop();},setTool:value=>arrowTool.setTool(value),companions:()=>[...selectedEntries].map(entry=>entry.model),duplicateCompanions:duplicateScreensInPlace,discardCompanions:discardScreens,snapTargets:()=>[...entries.map(entry=>entry.model),...textTool.objects()],drawGuides});
|
|
980
|
+
arrowTool=createArrows({viewport,world,camera,screenSelectionCount:()=>selectedEntries.size,rank:id=>objectRanks.get(`arrow:${id}`)??1,objects:()=>[...entries.map(entry=>entry.model),...textTool.objects()],saved:saved.arrows,checkpoint:()=>history.checkpoint(),commit:commitEdit,clearScreens:()=>select(null),schedule,stopCanvas:()=>{cameraMotion=null;stop();}});
|
|
981
|
+
updateSelection();
|
|
982
|
+
updateHistory();
|
|
983
|
+
// Migrate any fractional geometry from older saved layouts on first load.
|
|
984
|
+
persist();
|
|
985
|
+
// The toolbars are plain markup that this module resizes (Fit all appears or goes,
|
|
986
|
+
// the Layers drawer fills with rows). Keep them invisible until that is done, so a
|
|
987
|
+
// reload never paints the markup's first guess and then jumps to the real state.
|
|
988
|
+
requestAnimationFrame(()=>requestAnimationFrame(()=>document.body.setAttribute('data-ready','')));
|
|
989
|
+
|
|
990
|
+
// Scrolling over a number field nudges it by 5; up raises it, down lowers it.
|
|
991
|
+
// Trackpads send many small deltas, so they add up to one step per 50 units.
|
|
992
|
+
{
|
|
993
|
+
let carry=0;
|
|
994
|
+
document.addEventListener('wheel',event=>{
|
|
995
|
+
const input=event.target instanceof Element?event.target.closest('input[type="number"]'):null;
|
|
996
|
+
if(!input||input.disabled||input.readOnly||event.ctrlKey)return;
|
|
997
|
+
event.preventDefault();
|
|
998
|
+
carry+=event.deltaMode===0?event.deltaY:event.deltaY*50;
|
|
999
|
+
const steps=Math.trunc(carry/50);
|
|
1000
|
+
if(!steps)return;
|
|
1001
|
+
carry-=steps*50;
|
|
1002
|
+
const min=input.min===''?-Infinity:Number(input.min),max=input.max===''?Infinity:Number(input.max);
|
|
1003
|
+
const current=Number.isFinite(input.valueAsNumber)?input.valueAsNumber:(Number.isFinite(min)?min:0);
|
|
1004
|
+
const next=Math.min(max,Math.max(min,current-steps*5));
|
|
1005
|
+
if(next===current)return;
|
|
1006
|
+
input.value=String(next);
|
|
1007
|
+
input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));
|
|
1008
|
+
},{passive:false});
|
|
1009
|
+
}
|