@tio-canvas/canvas 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -2
- package/bin/tio-canvas.mjs +12 -0
- package/client/arrange.js +41 -0
- package/client/arrow-geometry.js +56 -0
- package/client/arrow-selection.js +68 -0
- package/client/arrows.js +205 -0
- package/client/bridge.js +74 -0
- package/client/canvas.css +733 -0
- package/client/canvas.js +1009 -0
- package/client/corners.js +22 -0
- package/client/device-gate.js +15 -0
- package/client/fullscreen.js +118 -0
- package/client/geometry.js +79 -0
- package/client/history.js +37 -0
- package/client/hover-preview.js +35 -0
- package/client/index.html +112 -0
- package/client/layer-order.js +20 -0
- package/client/layers.js +154 -0
- package/client/menu-position.js +36 -0
- package/client/onboarding.js +302 -0
- package/client/page-picker.js +76 -0
- package/client/platform.js +16 -0
- package/client/range-selection.js +54 -0
- package/client/registry.js +31 -0
- package/client/screen-loader.js +67 -0
- package/client/screens.js +7 -0
- package/client/select-field.js +191 -0
- package/client/shortcuts.js +158 -0
- package/client/smart-handles.js +28 -0
- package/client/smart-selection.js +52 -0
- package/client/spacing.js +45 -0
- package/client/text-format.js +49 -0
- package/client/text.js +466 -0
- package/client/tio-logo.png +0 -0
- package/client/tooltip.js +128 -0
- package/client/touch-preview.js +112 -0
- package/docs/exploration-canvas.md +315 -0
- package/package.json +31 -4
- package/src/build.js +33 -0
- package/src/index.js +3 -0
- package/src/interface-kit-bundle.js +103 -0
- package/src/interface-kit-entry.js +16 -0
- package/src/project.js +29 -0
- package/src/server.js +70 -0
- package/src/thumbnails.js +95 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Dev-only mouse-to-touch preview. Native device emulation and trusted touch
|
|
2
|
+
// events require browser tooling; this supplies a scaled contact and inertial drag scroll.
|
|
3
|
+
import { suppressPreviewHover } from './hover-preview.js';
|
|
4
|
+
|
|
5
|
+
// Exponential coast: velocity falls to 1/e every 325ms. Values use iframe CSS
|
|
6
|
+
// pixels and elapsed time, so zoom and refresh rate do not change the gesture.
|
|
7
|
+
const COAST={decay:325,maxSpeed:3,minSpeed:.02,sampleWindow:80,stale:100};
|
|
8
|
+
export function createTouchPreview(canDrag,onContact) {
|
|
9
|
+
const style=document.createElement('style');
|
|
10
|
+
// A simulated tap must not inherit keyboard focus styling from the editor.
|
|
11
|
+
// Keyboard focus inside a native popover needs its own top-layer outline.
|
|
12
|
+
style.textContent=`html[data-canvas-touch-preview],html[data-canvas-touch-preview] *{cursor:none!important}
|
|
13
|
+
html[data-canvas-touch-preview][data-canvas-touch-pointer] :focus-visible{outline:none!important}
|
|
14
|
+
html[data-canvas-touch-preview][data-canvas-touch-pointer] tp-focus-ring{display:none!important}
|
|
15
|
+
html[data-canvas-touch-preview]:not([data-canvas-touch-pointer]) :is(:popover-open:focus-visible,:popover-open :focus-visible){outline:var(--ring-width,2px) solid var(--ring,currentColor);outline-offset:var(--ring-offset,2px)}`;
|
|
16
|
+
document.head.append(style);
|
|
17
|
+
const refreshHover=suppressPreviewHover();
|
|
18
|
+
const reducedMotion=matchMedia('(prefers-reduced-motion: reduce)');
|
|
19
|
+
let enabled=false,drag=null,suppressClick=false,coastFrame=0;
|
|
20
|
+
function stopCoast(){cancelAnimationFrame(coastFrame);coastFrame=0;}
|
|
21
|
+
function coast(target,vx,vy){
|
|
22
|
+
stopCoast();if(reducedMotion.matches || !canDrag())return;
|
|
23
|
+
let previous=performance.now();
|
|
24
|
+
function tick(now){
|
|
25
|
+
coastFrame=0;
|
|
26
|
+
if(!enabled || !canDrag() || !target.isConnected)return;
|
|
27
|
+
const elapsed=Math.min(50,now-previous);previous=now;
|
|
28
|
+
const decay=Math.exp(-elapsed/COAST.decay);
|
|
29
|
+
const moved=scrollFrom(target,vx*COAST.decay*(1-decay),vy*COAST.decay*(1-decay));
|
|
30
|
+
vx=moved.x?vx*decay:0;vy=moved.y?vy*decay:0;
|
|
31
|
+
if(Math.hypot(vx,vy)>=COAST.minSpeed)coastFrame=requestAnimationFrame(tick);
|
|
32
|
+
}
|
|
33
|
+
if(Math.hypot(vx,vy)>=COAST.minSpeed)coastFrame=requestAnimationFrame(tick);
|
|
34
|
+
}
|
|
35
|
+
reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)stopCoast();});
|
|
36
|
+
const hide=()=>onContact({visible:false});
|
|
37
|
+
function stop() {
|
|
38
|
+
const current=drag;drag=null;
|
|
39
|
+
if(current?.target.hasPointerCapture?.(current.id))current.target.releasePointerCapture(current.id);
|
|
40
|
+
}
|
|
41
|
+
function cancel(){stopCoast();stop();hide();}
|
|
42
|
+
window.addEventListener('pointerdown',event=>{
|
|
43
|
+
if(enabled){stopCoast();document.documentElement.setAttribute('data-canvas-touch-pointer','');}
|
|
44
|
+
if(!enabled || !canDrag() || event.pointerType!=='mouse' || event.button!==0 || event.shiftKey || event.metaKey || event.ctrlKey)return;
|
|
45
|
+
if(event.target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])'))return;
|
|
46
|
+
suppressClick=false;
|
|
47
|
+
drag={id:event.pointerId,target:event.target,x:event.clientX,y:event.clientY,startX:event.clientX,startY:event.clientY,moved:false,samples:[{x:event.clientX,y:event.clientY,time:performance.now()}]};
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
},true);
|
|
50
|
+
window.addEventListener('pointermove',event=>{
|
|
51
|
+
if(!enabled || event.pointerType!=='mouse')return;
|
|
52
|
+
if(!event.buttons || drag)event.stopImmediatePropagation();
|
|
53
|
+
onContact({visible:true,x:event.clientX,y:event.clientY});
|
|
54
|
+
if(!drag)return;
|
|
55
|
+
if(!drag.moved && Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<6)return;
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
if(!drag.moved){drag.moved=true;drag.target.setPointerCapture?.(drag.id);}
|
|
58
|
+
scrollFrom(drag.target,drag.x-event.clientX,drag.y-event.clientY);
|
|
59
|
+
const now=performance.now();
|
|
60
|
+
drag.samples.push({x:event.clientX,y:event.clientY,time:now});
|
|
61
|
+
while(drag.samples.length>2 && now-drag.samples[0].time>COAST.sampleWindow)drag.samples.shift();
|
|
62
|
+
drag.x=event.clientX;drag.y=event.clientY;
|
|
63
|
+
},{capture:true,passive:false});
|
|
64
|
+
window.addEventListener('pointerup',()=>{
|
|
65
|
+
const current=drag;stop();if(!current?.moved)return;
|
|
66
|
+
suppressClick=true;
|
|
67
|
+
const first=current.samples[0],last=current.samples.at(-1),elapsed=Math.max(8,last.time-first.time);
|
|
68
|
+
if(performance.now()-last.time>COAST.stale)return; // Holding still stops a fling.
|
|
69
|
+
const speed=value=>Math.max(-COAST.maxSpeed,Math.min(COAST.maxSpeed,value));
|
|
70
|
+
coast(current.target,speed((first.x-last.x)/elapsed),speed((first.y-last.y)/elapsed));
|
|
71
|
+
},true);
|
|
72
|
+
window.addEventListener('click',event=>{
|
|
73
|
+
if(suppressClick && event.detail>0){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;}
|
|
74
|
+
},true);
|
|
75
|
+
window.addEventListener('lostpointercapture',()=>{if(drag)cancel();});
|
|
76
|
+
window.addEventListener('keydown',event=>{
|
|
77
|
+
if(!['Shift','Control','Alt','Meta'].includes(event.key))document.documentElement.removeAttribute('data-canvas-touch-pointer');
|
|
78
|
+
stopCoast();if(event.key==='Escape')cancel();
|
|
79
|
+
},true);
|
|
80
|
+
window.addEventListener('wheel',stopCoast,{capture:true,passive:true});
|
|
81
|
+
window.addEventListener('pointercancel',()=>{suppressClick=false;cancel();});
|
|
82
|
+
window.addEventListener('pointerout',event=>{if(!event.relatedTarget && !drag)hide();});
|
|
83
|
+
window.addEventListener('blur',cancel);
|
|
84
|
+
document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
|
|
85
|
+
window.addEventListener('canvasvisibilitychange',event=>{if(!event.detail.visible)cancel();});
|
|
86
|
+
return {configure(touch,active){
|
|
87
|
+
enabled=touch;document.documentElement.toggleAttribute('data-canvas-touch-preview',touch);
|
|
88
|
+
if(touch)refreshHover();else document.documentElement.removeAttribute('data-canvas-touch-pointer');
|
|
89
|
+
if(!touch || !active)cancel();
|
|
90
|
+
}};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scrollFrom(target,dx,dy) {
|
|
94
|
+
const root=document.scrollingElement;let movedX=0,movedY=0;
|
|
95
|
+
for(let node=target;node && (dx || dy);node=node.parentElement){
|
|
96
|
+
const style=getComputedStyle(node),isRoot=node===root;
|
|
97
|
+
for(const axis of ['X','Y']){
|
|
98
|
+
const delta=axis==='X'?dx:dy;if(!delta)continue;
|
|
99
|
+
const overflow=style[`overflow${axis}`];
|
|
100
|
+
const bodyOverflow=isRoot?getComputedStyle(document.body)[`overflow${axis}`]:'';
|
|
101
|
+
if(!/auto|scroll|overlay/.test(overflow) && !(isRoot && !/hidden|clip/.test(overflow) && !/hidden|clip/.test(bodyOverflow)))continue;
|
|
102
|
+
const property=axis==='X'?'scrollLeft':'scrollTop',before=node[property];
|
|
103
|
+
node.scrollBy({left:axis==='X'?delta:0,top:axis==='Y'?delta:0,behavior:'instant'});
|
|
104
|
+
const moved=node[property]-before;
|
|
105
|
+
if(axis==='X')movedX+=moved;else movedY+=moved;
|
|
106
|
+
const remainder=delta-moved;
|
|
107
|
+
const remaining=style[`overscrollBehavior${axis}`]==='auto'?remainder:0;
|
|
108
|
+
if(axis==='X')dx=remaining;else dy=remaining;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {x:movedX,y:movedY};
|
|
112
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Private exploration canvas and future interactive gallery
|
|
2
|
+
|
|
3
|
+
> **Note for the package.** This document was written inside `tio-web`, where the canvas files lived in `dev/canvas/`. In this package they are in `client/`, and the page list (`screens.js`) and thumbnails belong to the project that installs it. Project-specific details (the tioputrad.com pages, Worker gate, prototypes) describe `tio-web`, the reference project.
|
|
4
|
+
|
|
5
|
+
## Agreed scope
|
|
6
|
+
|
|
7
|
+
The canvas is a private workspace at `/canvas`, accessible only by typing its URL, in development and in production. It is not a portfolio page, has no site navigation link, carries `noindex`, and is not in the sitemap. `build/build.mjs` copies `dev/canvas/` to `dist/canvas/` with a generated `screens.js`. Prototype pages ship to `dist/prototypes/` too, but the Worker (`src/worker-gate.js`) serves them only to a frame inside the site: they open in the canvas, and a typed address is a 404. A command-line client can forge the headers, so this is not authentication. Interface Kit is injected by the dev server alone and never ships. The production canvas adds its own bridge script to the same-origin screens it frames (the dev server does that for development), and `public/_headers` allows same-origin framing (`X-Frame-Options: SAMEORIGIN`). `pnpm preview` serves `dist/canvas` exactly as a visitor gets it.
|
|
8
|
+
|
|
9
|
+
The canvas and public gallery are independent. Canvas experiments do not automatically appear in the gallery. Build the canvas first, then tackle the gallery using the requirements below without another requirements interview.
|
|
10
|
+
|
|
11
|
+
## Canvas behavior
|
|
12
|
+
|
|
13
|
+
- Finite 20,000 × 20,000 CSS-pixel workspace. Screen placement, resizing, and pan share the same 0–20,000 bounds: a frame at a workspace corner can meet the usable canvas corner when pan reaches that edge (to the right of the expanded Layers drawer), without extra outside whitespace. If a zoomed workspace axis is smaller than the usable viewport, center it on that axis. The expanded Layers drawer reserves its width plus the 10px outside gaps for pan limits and fit calculations; collapsing it restores the full canvas width. At an edge, this hard boundary takes priority over pointer-anchored zoom or centering a fit operation. Device presets move a frame inward if needed to preserve the exact preset size; custom resizing cannot extend past the workspace edge. Ordinary vertical and horizontal wheel/trackpad scrolling pans the canvas on both axes over the background or inactive screens. A single selected screen is active: ordinary scrolling inside it stays native and scrolls its own contents instead of the canvas. Clicking screen content or its title activates it; background selection clearing deactivates it. Multi-selection remains a group drag surface, not an active document. Command/Ctrl+wheel zooms around the pointer on the host and inside screens; trackpad pinch uses the browser's Ctrl+wheel event with a stronger zoom response. Plain scrolling must never change scale. Zoom range: 10–200%. Middle-button drag or Space + primary-button drag pans. Ordinary clicks belong to the screen. Command/Ctrl-modified wheel always zooms, including in an active screen; trackpad pinch zooms through the browser's Ctrl+wheel event. Ordinary active-screen scrolling, scrollbars, and keyboard scrolling remain available. Space+wheel pans even over active content. This is a canvas-only revision, not a change to the future public gallery scroll requirements.
|
|
14
|
+
- Screen document geometry uses whole CSS pixels: x, y, width, and height are always integers. Round restored legacy layouts, drag destinations, numeric dimensions, and spacing edits to the nearest pixel. Group movement rounds one shared translation so its internal gaps do not change. Magnetic edge/center/gap snapping is applied before pixel rounding; when an exact center alignment needs half a pixel, whole-pixel geometry wins. Restored fractional positions and custom dimensions are migrated back to storage. Camera pan/zoom and pointer measurements retain fractional precision; this does not constrain the document to device pixels at arbitrary zoom. Gap calculations use the integer document geometry, never transformed browser rectangles, so entering 50 stays exactly 50 rather than a floating-point remainder.
|
|
15
|
+
- Screen content is real HTML in a same-origin iframe, not a screenshot or an HTML canvas drawing. Each document owns its styles, fonts, responsive layout, and interaction state.
|
|
16
|
+
- Clicking content selects its screen without consuming the click. Shift+click toggles membership in the selection, including clicks inside screen documents. Drag on empty canvas to draw a selection rectangle; any positive-area intersection with a screen's content frame selects it, not just full containment. Shift+drag adds to the current selection. A background press starts with a hidden, cleared selection rectangle; only an actual drag draws it. A click must never redisplay the previous rectangle. Zero-area rectangles do not select screens. Middle-button and Space+drag remain pan gestures.
|
|
17
|
+
- Dragging a selected title moves the whole selection without changing its internal spacing. A multi-selection gets individual outlines plus a group bounding outline. Fit to screen and Cmd+D operate on the entire selection. The size inspector and resize handles are hidden for groups; resizing remains a single-screen operation. A group instead has an editable Gap toolbar centered beneath its bounding box, using the same styling and screen-space offset as the single-screen inspector. For a multi-selection, the entire group bounding box is a drag surface, including frame contents and empty gaps. This intentionally takes priority over embedded controls while the group is selected. Return to a single selection to interact with its controls. Shift+click within that surface can still deselect an individual member. Single-screen contents remain interactive, not a drag surface.
|
|
18
|
+
- Holding Shift while dragging a title or selection group constrains motion horizontally or vertically relative to the original position. The dominant initial movement chooses the axis, which stays fixed while Shift remains held. Release Shift for free movement; press it again to choose the current dominant axis. The locked coordinate does not magnetically snap away from its original value. A guide follows the original center line. Shift+click still toggles membership when no drag occurs.
|
|
19
|
+
- The group Gap toolbar shows Gap X for a predominantly horizontal arrangement or Gap Y for a predominantly vertical one, determined by the larger spread of screen origins (horizontal wins ties). It measures edge-to-edge spacing in workspace pixels, independent of zoom. Unequal distances show Mixed; overlapping equal distances show Overlap. Entering a nonnegative integer distributes the selected screens along that axis in spatial order, anchored at the first screen, while preserving each screen's other coordinate, dimensions, preset, stacking, and mounted document. This is one ordered row or column, not automatic grid packing. Zero gap is allowed. Values are capped to the room remaining in the finite workspace; if the selected sizes cannot fit after the anchor, the field is disabled. A change is one undoable, persisted position edit. Single selection restores the ordinary size inspector.
|
|
20
|
+
- While moving a frame or group, snap to other frames' edges/centers or matching gaps in the same row/column. The attraction distance is 6 screen pixels at any zoom. Gap values are measured from existing adjacent frames, not a hardcoded grid; insertion between two frames can also snap to equal gaps. Alignment/spacing guides appear during a move and clear on release/cancel. Equal-gap snapping draws labeled indicators both for the active gap and the existing reference gap. Insertion between two frames draws two separate equal-gap segments, not a line across the moving frame. Snapping does not change the selected frames' relative positions.
|
|
21
|
+
- Selected screen titles turn blue. Titles retain their screen-space font size but are ellipsized to the frame's visible width when zoomed out. The native title tooltip and accessible name keep the full name.
|
|
22
|
+
- Delete only through Delete/Backspace or the right-click context menu, from the host or a screen document, except while editing text. There is no Delete button in the toolbar. Clicking a screen title or starting a canvas gesture ends host field editing; selecting mobile content also releases a stale host field without taking focus from project editors. Forwarded shortcuts act on the current selection even if keyboard focus remains in an older iframe. Every selected instance can be deleted, including the last instance of a source. An empty canvas stays empty after refresh; removed originals and copies do not return. The source catalog remains available in Add pages without mounting hidden documents. This changes canvas instances only, never source files. Fit to screen stays visible but is disabled when no screen is selected.
|
|
23
|
+
- Double-click a screen title to rename it inline. The title editor hugs its text up to the existing title-width limit, with 4px padding on every side. It grows and shrinks as text changes instead of filling the screen width. Enter or blur commits; Escape cancels. A whitespace-only name restores the original source title without a duplicate number. Names are instance-specific, limited to 200 characters, persisted, and undoable. Renaming does not change source IDs, URLs, files, or the numeric naming of future default copies.
|
|
24
|
+
- The Layers drawer is on the **left**, inset 10px from the left and top, with a separate help/appearance toolbar beneath it. The toolbar sits 10px from the left and bottom, with a 10px gap between it and the expanded drawer. Minimizing the drawer never hides that toolbar. It uses the same container styling as the other canvas tools. One screen is one layer, shown with its current device icon; Custom uses a diagonal two-way arrow. The top row is the frontmost screen. Click to select, Shift+click to toggle membership, or drag across row labels to select a contiguous range. A muted grabber appears left of the device icon on hover or keyboard focus, without shifting the label. Its tint follows the canvas theme. Drag anywhere across the grabber, device icon, or their shared gap to reorder. Dragging a selected group preserves its relative order, with an insertion guide and automatic list scrolling at the edges. Escape cancels a layer drag. Cmd+[ / Cmd+] move the selection one layer backward/forward; Cmd+Shift+[ / Cmd+Shift+] send it to the bottom/top. These shortcuts also work from non-editing iframe contexts, while host fields and project editors keep native keyboard behavior. The header button sits at the right of the Layers heading and minimizes the drawer to a header of the same width. Rows have a 4px gap. Double-click a layer name to rename it inline; Enter or blur saves, and Escape cancels. Layer order and minimized state survive reload; order changes are undoable, while minimized state is a view preference outside history. Reordering changes stacking without moving iframe DOM nodes, so documents, scroll, and project state remain intact. Native layer buttons also support Tab, Enter, and Space.
|
|
25
|
+
- Canvas document history supports undo/redo for preset changes, numeric sizing, whole resize/move gestures, add/duplicate/delete, rename, and layer order. One drag is one edit. Use Cmd+Z / Cmd+Shift+Z on macOS, Ctrl+Z / Ctrl+Shift+Z or Ctrl+Y elsewhere, or the toolbar buttons. Native text fields, inline rename editing, and project editors retain their own undo behavior. Theme, pan/zoom/fit, drawer minimization, selection-only changes, and new source registration are excluded. History is bounded to 100 edits and resets on refresh; final layout/names remain persisted. New source entries are added in `screens.js`, not through history; its dev reload starts a fresh history session.
|
|
26
|
+
- Undo restores canvas document state, not arbitrary project internals. Existing unchanged iframe documents remain mounted during history restoration. Undoing deletion or redoing a duplicate recreates that removed document, so its internal project content starts fresh. This avoids keeping every historically deleted document alive in memory.
|
|
27
|
+
- Resize changes the iframe's actual viewport dimensions. Device presets lock both numeric fields and resize handles. The user must explicitly choose Custom before resizing; dragging never switches presets automatically.
|
|
28
|
+
- Four corner nodes and four midpoint grabbers resize Custom frames. Midpoint grabbers appear on hover. The selected frame has a toolbar centered 10 screen pixels beneath it, with preset, width, and height. Controls, outline, handles, and the gap retain their screen-space size at every zoom.
|
|
29
|
+
- Custom select menus follow their fields on every canvas render, including pan and animated zoom. They open below or above according to available space, stay inside the viewport using `--canvas-panel-inset`, and keep that same clearance from visible fixed toolbars and the Layers drawer. An offscreen field does not hide its open menu: the menu follows as far as the visible free space allows. Long menus scroll within that space. Start/End arrow menus remain above their fields and content-sized; other selects have a 150px minimum width. Changing or clearing selection closes the menu immediately. Choosing an option releases focus rather than returning it to the field. `tests/canvas-menu-position.mjs` checks placement geometry; `tests/canvas-select-menus.mjs` checks live pan tracking, viewport boundaries, toolbar clearance, and menu lifecycle in the browser.
|
|
30
|
+
- Canvas toolbars share the Screens panel's 10px outside inset. The bottom-center editing toolbar holds Cursor, Arrow, and Add pages. Undo and Redo live in a separate top-right history toolbar. The bottom-right zoom toolbar holds, in order: Fit to screen, Fit all, Zoom to 100%, a divider, magnifying-glass minus, editable zoom percentage, and magnifying-glass plus. Minus and plus change zoom by 10 percentage points at or below 50%, and 20 points above 50%, clamped to 10–200%. The step uses the pending zoom target during an animation so rapid clicks correctly cross the threshold. Tooltips and accessible labels report the current step. Both buttons and Zoom to 100% use the same 260ms eased camera transition as Fit. Rapid clicks accumulate full steps toward the latest target while movement continues from the current camera; reduced motion applies the target immediately. Below 1100px the editing toolbar moves above the zoom toolbar with a 10px gap to avoid overlap. The bottom-left toolbar holds Keyboard shortcuts and the square icon-only theme toggle independently of drawer minimization. Fit all (Shift+1) and Fit selected screens (Shift+2) ease camera position and scale together over 260ms, including shortcuts forwarded from screen documents. Repeated fits retarget from the current camera; wheel zoom, field edits, or pan interrupts the movement. Reduced motion applies the final camera immediately. Fields, dialogs, and fullscreen previews keep their native keyboard behavior. Add pages opens the page picker. The native modal dialog has a scrim and a 500 × 700px footprint capped to the viewport. Its header matches the Layers header height; its middle section scrolls the registered page catalog; its entire footer is hidden until at least one page is checked. The footer places Select all then Unselect all on the left and Add page(s) on the right. Each row has a custom accessible checkbox with a muted outline and a 16px visual inside a 44px target (negative inline margins preserve the thumbnail position) to the left of its thumbnail. Only the checkbox toggles selection; clicking the rest of a card immediately adds that page alone, regardless of other checked pages. A pill shows the number of existing screens for a page without disabling it. Shift-click adds a contiguous range. Dragging from an unchecked checkbox checks a range with edge scrolling and preserves earlier checks; dragging from a checked checkbox unchecks that range. Multiple selected pages are added as independent new screens in one undoable edit, selected and fitted into view. The close button, Escape, or a scrim click closes without changes. Canvas shortcuts are inactive while a modal is open. Adding uses the same 200-copy safety limit as duplication, but its placement is separate: on an empty canvas the first added screen is centered at workspace coordinate (10,000, 10,000). Otherwise each new screen starts 100 workspace pixels beyond the furthest right edge of all existing screens, aligned to that screen’s top edge. Batch additions continue to the right with the same gap. A batch that cannot fit within the workspace is rejected in full; Add pages never wraps to another row. Reset view is intentionally removed. Buttons have gray surfaces and no border. Fields have 8px horizontal padding, centered numeric values, and the preset chevron has a 6px gap after its label.
|
|
31
|
+
- Cmd+D duplicates the selected screen(s) when not editing a text field, including when focus is inside that screen. Copies reuse one source URL but mount separate documents with fresh internal state. Names use the original source title plus `(1)`, `(2)`, and so on, even when duplicating a copy. Copies retain their own position, dimensions, and preset after refresh. A copy is placed to the right of the occupied row, selected, and fitted into view. Repeated shortcuts append beyond earlier copies even when keyboard focus stays in the original iframe. Group copies share one translation and preserve internal spacing. When the current row reaches the right workspace boundary, copies wrap to a free row below, then search earlier free rows if necessary. Placement stays inside the finite workspace and never overlaps existing frames. Duplication does nothing only if no candidate placement fits or the instance safety limit would be exceeded; a group is never partially duplicated. At most 200 duplicate instances are retained to bound storage and document count; this is a safety limit, not a performance promise.
|
|
32
|
+
- Position, dimensions, preset, name, layer order, minimized drawer state, and camera survive refresh using versioned localStorage. Malformed, oversized, and inaccessible storage falls back safely. Internal project state need not survive refresh.
|
|
33
|
+
- A screen is visible if any of its content frame intersects the viewport. Zoom level alone never replaces it with a preview. A not-yet-seen screen loads on its first intersection. Once loaded, it is never unloaded merely for leaving the viewport: input, component state, and scroll remain intact.
|
|
34
|
+
- Loaded screens receive visibility notifications, also accounting for a hidden browser tab. Their own animation/timer work must cooperate to pause. Arbitrary third-party code cannot be universally frozen while guaranteeing its state remains intact.
|
|
35
|
+
- Mobile exploration is deferred. No framework or dependency was approved for this implementation.
|
|
36
|
+
|
|
37
|
+
## Arrows
|
|
38
|
+
|
|
39
|
+
The bottom-center toolbar has Cursor (V), Arrow (A), and Add pages. Cursor keeps
|
|
40
|
+
normal screen interaction, selection, and dragging. In Arrow mode, drag from
|
|
41
|
+
empty space or a screen edge to create a connector. Completing an arrow returns
|
|
42
|
+
to Cursor and selects it. Escape cancels an unfinished gesture and returns to
|
|
43
|
+
Cursor. Space-drag and middle-button pan still work in either tool.
|
|
44
|
+
|
|
45
|
+
Arrows can remain free or bind either endpoint to any position along an object's
|
|
46
|
+
four edges. Bindings store an object ID, side, and normalized edge position, so
|
|
47
|
+
the geometry is not coupled to screens. The four side-center dots use the same
|
|
48
|
+
size and surface treatment as reorder dots. Center snapping has a 20 screen-pixel
|
|
49
|
+
capture zone and takes priority over the 12 screen-pixel edge capture zone.
|
|
50
|
+
A bound endpoint stays 8 workspace pixels outside its edge at every position;
|
|
51
|
+
zoom scales that distance with the object. Moving or resizing the object updates
|
|
52
|
+
the connector. Deleting the object leaves its endpoint at its last position.
|
|
53
|
+
|
|
54
|
+
Click an arrow to select it; Shift-click toggles membership. A background drag
|
|
55
|
+
selects every arrow stroke the rectangle intersects, alongside screens. Selection
|
|
56
|
+
uses the actual line or circular arc, not empty space inside its bounding box.
|
|
57
|
+
A selected arrow retains its normal ink with a 1px blue center stroke that stays
|
|
58
|
+
1px at every zoom. Its head keeps its ordinary ink.
|
|
59
|
+
|
|
60
|
+
A single arrow retains its two endpoint nodes and balanced-arc middle node. Drag
|
|
61
|
+
an endpoint to attach or detach it, its line to move it, or the middle node
|
|
62
|
+
perpendicular to its chord to bend it. Focused endpoint nodes accept arrow keys
|
|
63
|
+
(one workspace pixel, or ten with Shift); the middle node's arrow keys adjust
|
|
64
|
+
its bend. Multiple free arrows have a group box with four corner handles only.
|
|
65
|
+
Corner resizing scales endpoint positions and bend uniformly about the opposite
|
|
66
|
+
corner, preserving circular arcs; stroke thickness stays unchanged. If any arrow
|
|
67
|
+
in the group is bound, group movement and resizing are locked and configuration
|
|
68
|
+
remains editable. A mixed screen/arrow selection exposes configuration but not
|
|
69
|
+
arrow resize handles.
|
|
70
|
+
|
|
71
|
+
The arrow toolbar is docked directly above the main toolbar with the same 10px
|
|
72
|
+
outside margin as its vertical gap. It follows the main toolbar's responsive
|
|
73
|
+
position, keeping within the viewport. It contains two icon-only endpoint menus:
|
|
74
|
+
a horizontal line or a left arrow for the start, a horizontal line or a right
|
|
75
|
+
arrow for the end. Accessible names still identify each endpoint. After a divider,
|
|
76
|
+
line thickness offers exactly 2px, 4px, 6px, 8px, and 10px (default 4px), followed
|
|
77
|
+
by Solid/Dotted. Legacy thicknesses are normalized to the nearest allowed value.
|
|
78
|
+
A curved selection adds a final divider and the secondary Reset to straight
|
|
79
|
+
button. Configuration edits apply to every selected arrow in one history step;
|
|
80
|
+
unequal field values show Mixed. Nodes and tools retain their screen-space size.
|
|
81
|
+
Arrow tools are hidden in fullscreen previews without reloading the document.
|
|
82
|
+
|
|
83
|
+
Screens and arrows share one bottom-to-top object order. Command+[ / Command+]
|
|
84
|
+
move the selected objects backward/forward by one position; adding Shift sends
|
|
85
|
+
them to the back/front. This includes arrows even though the Layers drawer lists
|
|
86
|
+
only screens. Order is persisted and undoable. Every floating canvas toolbar
|
|
87
|
+
paints above the entire object stack, regardless of an object's position.
|
|
88
|
+
|
|
89
|
+
Creation, endpoint edits, movement, bending, styles, reset, and deletion participate
|
|
90
|
+
in canvas undo/redo. Delete/Backspace removes a selected arrow unless a field is
|
|
91
|
+
being edited. Final arrows and bindings persist alongside the existing layout;
|
|
92
|
+
selection and active tool do not. Up to 1,000 arrows can be restored, with
|
|
93
|
+
validation and a 1 MB total saved-document read limit. All arrow code is served
|
|
94
|
+
only by the development canvas and is excluded from production.
|
|
95
|
+
|
|
96
|
+
Verification: `node tests/canvas-arrow-geometry.mjs` checks geometry, validation,
|
|
97
|
+
and history. `node tests/canvas-arrows.mjs` checks the actual browser tools,
|
|
98
|
+
endpoint and middle-node dragging, circular midpoint, toolbar placement, styles,
|
|
99
|
+
reset, edge binding and detachment, screen movement/resizing, deletion, undo/redo,
|
|
100
|
+
reload persistence, cancellation, and the existing iframe/fullscreen flow.
|
|
101
|
+
`node tests/canvas-arrow-revisions.mjs` covers marquee and multi-selection,
|
|
102
|
+
proportional corner resize, binding locks, batch configuration, icon menus,
|
|
103
|
+
docked toolbar spacing, shared dropdown styling, 1px selection, shared-object
|
|
104
|
+
stacking, and persistence.
|
|
105
|
+
|
|
106
|
+
## Fullscreen screen preview
|
|
107
|
+
|
|
108
|
+
Every screen has a fullscreen icon immediately to the right of Refresh, whether
|
|
109
|
+
selected or not. Both icons stay visible without selecting the screen.
|
|
110
|
+
This is fullscreen within the browser viewport, not the browser Fullscreen API.
|
|
111
|
+
It hides all canvas tools and fills the viewport with the selected document.
|
|
112
|
+
The existing iframe stays mounted: page state, scroll, and inputs survive entry
|
|
113
|
+
and exit. The preview always follows the browser's viewport dimensions, including window
|
|
114
|
+
resizes, without changing the saved screen size. Canvas camera position and zoom
|
|
115
|
+
are preserved.
|
|
116
|
+
|
|
117
|
+
A dark, circular 36 × 36px hamburger button floats above the preview. Its resting
|
|
118
|
+
position is 16px from both edges of a corner. Dragging uses pointer capture and
|
|
119
|
+
works with mouse or touch. On release, the button's center chooses one of four
|
|
120
|
+
viewport quadrants, and the button travels to that corner over 260ms using a
|
|
121
|
+
transform transition. A new drag interrupts from the currently painted position.
|
|
122
|
+
Reduced motion snaps without travel. Dragging does not trigger a menu click.
|
|
123
|
+
|
|
124
|
+
The button toggles a controls panel containing only Back to canvas. Resolution
|
|
125
|
+
presets and custom W/H are available only in the canvas inspector, never in
|
|
126
|
+
fullscreen. The panel shares the canvas tools' surfaces, borders, and typography. The panel aligns with the button's outside edge and
|
|
127
|
+
opens below a top corner or above a bottom corner, separated by 8px. Its outer
|
|
128
|
+
edge stays 16px from the viewport edge; long panels scroll within the available
|
|
129
|
+
height. Clicking outside, including inside the preview document, closes it.
|
|
130
|
+
Escape inside the controls closes the panel and returns focus to the button;
|
|
131
|
+
Escape on the closed button returns to the canvas. Escape in the project only
|
|
132
|
+
closes the controls, leaving project keyboard handling intact.
|
|
133
|
+
|
|
134
|
+
Exiting restores the screen's canvas dimensions and camera without changing its
|
|
135
|
+
preset or adding a history edit. Floating position and fullscreen state are
|
|
136
|
+
session-only.
|
|
137
|
+
|
|
138
|
+
Fullscreen input stays native: the bridge does not intercept canvas keyboard
|
|
139
|
+
shortcuts, wheel zoom/pan, Space, or middle-button gestures, and simulated mobile
|
|
140
|
+
touch/hover overrides are disabled. Other loaded screens receive hidden-state
|
|
141
|
+
notifications while the fullscreen document is visible. Everything here lives
|
|
142
|
+
under `dev/canvas/` and is excluded from production output.
|
|
143
|
+
|
|
144
|
+
## Active screen input, touch preview, and refresh
|
|
145
|
+
|
|
146
|
+
The host sends each loaded screen its active state and preset input mode, then
|
|
147
|
+
resends them after a load, preset/selection change, or history restoration. Only
|
|
148
|
+
one selected screen is active. Its wheel listener does not prevent ordinary
|
|
149
|
+
scrolling, preserving the browser's own nested scroll containers and page scroll.
|
|
150
|
+
Command+wheel is always routed to canvas zoom. Inactive screens still forward
|
|
151
|
+
ordinary wheel to canvas pan. These policies remain development-only.
|
|
152
|
+
|
|
153
|
+
Phone and tablet presets (`touch: true` in `screens.js`) enable a lightweight
|
|
154
|
+
mouse touch preview in `touch-preview.js`. It reports pointer positions in iframe CSS pixels to a translucent contact
|
|
155
|
+
drawn by the host canvas. Its diameter is 24 logical CSS pixels, scaled with the
|
|
156
|
+
screen when the canvas zooms, unlike fixed-size tool chrome. Drawing outside the
|
|
157
|
+
iframe keeps the contact above native popovers and dialogs: their browser top
|
|
158
|
+
layer bypasses every ordinary `z-index` within that iframe. The host validates
|
|
159
|
+
the source window and maps coordinates through the current frame and camera. A primary-button drag on active
|
|
160
|
+
non-editor content scrolls eligible containers under the pointer on either
|
|
161
|
+
axis, respecting overflow locks and overscroll containment. A stationary tap
|
|
162
|
+
keeps normal click behavior; a drag suppresses the following click to prevent
|
|
163
|
+
accidental activation. Releasing a moving drag now starts simulated momentum on
|
|
164
|
+
the same scroll chain: recent pointer velocity decays exponentially (325ms time
|
|
165
|
+
constant), with a 3 CSS-pixels/ms speed cap and no overscroll bounce. Velocity
|
|
166
|
+
uses iframe CSS pixels and elapsed time, independent of canvas zoom and refresh
|
|
167
|
+
rate. Holding still before release produces no fling. A new press, wheel input,
|
|
168
|
+
keyboard input, lost capture, deactivation, preset change, hidden/offscreen
|
|
169
|
+
screen, or blur cancels the gesture/coast. `prefers-reduced-motion` retains direct
|
|
170
|
+
drag scrolling but omits the inertial tail. Text editors retain ordinary mouse editing, and Space /
|
|
171
|
+
middle-button gestures remain canvas pan. The contact hides on exit/blur;
|
|
172
|
+
preset changes, cancellation, loss of pointer capture, and hidden documents
|
|
173
|
+
release gestures.
|
|
174
|
+
|
|
175
|
+
This is not Chrome DevTools device emulation. It does not create trusted touch
|
|
176
|
+
events, change `pointerType` or touch/hover media queries, emulate multi-touch,
|
|
177
|
+
or reproduce native device momentum/physics exactly. The inertial tail is a
|
|
178
|
+
local simulation. Browser-native touch gestures and project-specific touch-only
|
|
179
|
+
handlers still require actual device/browser tooling.
|
|
180
|
+
|
|
181
|
+
Mobile presets also disable hover interaction in the preview. `hover-preview.js`
|
|
182
|
+
guards `:hover` selectors in readable preview stylesheets without increasing
|
|
183
|
+
specificity, including nested rules and styles loaded later. The guard becomes
|
|
184
|
+
inert when leaving the mobile preset, restoring desktop hover without reload.
|
|
185
|
+
Mouse hover/move handlers are suppressed while ordinary presses, taps, keyboard
|
|
186
|
+
focus, and editor drags remain available. This changes only the iframe's live
|
|
187
|
+
CSS rules/events, never source files or built production assets. It does not
|
|
188
|
+
change browser media-query capabilities; inaccessible cross-origin stylesheets
|
|
189
|
+
and closed shadow roots cannot be rewritten by this preview. Current portfolio
|
|
190
|
+
styles are same-origin and are covered by the browser regressions.
|
|
191
|
+
|
|
192
|
+
Simulated taps also suppress native focus outlines and the portfolio's separate
|
|
193
|
+
focus-ring overlay while using the pointer. The preview still moves actual focus
|
|
194
|
+
into the activity card; it does not blur controls or remove keyboard access.
|
|
195
|
+
A non-modifier key restores keyboard focus styling. Focused controls inside an
|
|
196
|
+
open popover get a native outline so their keyboard indicator is painted in the
|
|
197
|
+
same top layer, rather than behind the card. These overrides are inactive in
|
|
198
|
+
desktop presets and never enter the production stylesheets.
|
|
199
|
+
|
|
200
|
+
Each screen has a fixed-screen-size refresh icon at the right edge of its
|
|
201
|
+
name row. It loads a replacement document from that instance's registered source page,
|
|
202
|
+
resetting its internal state and scroll without refreshing the canvas or other
|
|
203
|
+
screens. The previous document stays painted until the replacement stylesheet,
|
|
204
|
+
fonts, and initial layout are ready. The pending frame is invisible, inert, and
|
|
205
|
+
hidden from accessibility tools; promotion removes the old frame. This avoids a
|
|
206
|
+
blank white navigation frame, including when refreshing a dark page. `screen-loader.js` owns creation, readiness, replacement, and cleanup. The bridge reports document/font readiness without waiting for images or weather; slow nonessential requests must not hold the refresh button locked. Native lazy loading keeps initial embedded media separate from host load completion. A 15-second refresh deadline preserves the previous document, removes the stalled replacement, unlocks retry, and reports an accessible error if readiness never arrives. The refresh
|
|
207
|
+
button is disabled while a replacement is pending, preventing stacked reloads.
|
|
208
|
+
Position, dimensions, preset, name, camera, and selection stay unchanged;
|
|
209
|
+
refresh is not a canvas history edit. Readiness notification restores active/touch
|
|
210
|
+
configuration automatically.
|
|
211
|
+
|
|
212
|
+
## Canvas tool appearance and isolation
|
|
213
|
+
|
|
214
|
+
The public [Untitled UI component catalog](https://www.untitledui.com/react/components),
|
|
215
|
+
[buttons](https://www.untitledui.com/react/components/buttons),
|
|
216
|
+
[inputs](https://www.untitledui.com/react/components/inputs), and
|
|
217
|
+
[select](https://www.untitledui.com/react/components/select) pages are visual references,
|
|
218
|
+
not installed libraries. The implementation is original native HTML/CSS/JavaScript:
|
|
219
|
+
neutral surfaces, compact rounded controls, subtle panel and screen shadows, muted labels,
|
|
220
|
+
and a clear keyboard focus ring. It is inspired by the reference, not an exact
|
|
221
|
+
port of its React components. No React, Tailwind, Untitled UI package, copied
|
|
222
|
+
proprietary component source, external stylesheet, or external font is used.
|
|
223
|
+
|
|
224
|
+
`dev/canvas/canvas.css` owns the canvas-only tokens and controls. It is loaded
|
|
225
|
+
only by the canvas document; iframe documents keep their own design systems.
|
|
226
|
+
No portfolio styles or tokens are changed. No tool assets reach production. The canvas background has a subtle dotted grid (muted ink at 12%, reduced from 28%), anchored to camera movement and zoom with minimum spacing to avoid a dense pattern at low zoom. Dots remain behind the iframe surfaces; each screen has a thin shadow to distinguish it from the workspace.
|
|
227
|
+
|
|
228
|
+
## Files and adding screens
|
|
229
|
+
|
|
230
|
+
All canvas code is in `dev/canvas/`, outside build inputs. The dev server explicitly serves this directory. Changes reload the development browser.
|
|
231
|
+
|
|
232
|
+
1. Put a local HTML exploration under `dev/prototypes/web-pages/<experiment>/` or `dev/prototypes/mini-projects/<project>/` (or use an existing local page, such as `/`). Use its `/prototypes/.../` URL. See `dev/prototypes/README.md` for promotion rules.
|
|
233
|
+
2. Add an entry in `dev/canvas/screens.js`: stable `id`, `kind` (`production` or `prototype`; the Add pages dialog groups pages by it in an accordion), `title`, a short required `description` (shown under the title in that dialog; the file throws without one), local `src`, `x`, `y`, `width`, `height`, and `preset`. The default is `id: 'web-page-prod'`, `kind: 'production'`, `title: 'Web Page'`, `src: '/'`, using the 1440 × 900 laptop preset. This renders the local production implementation of tioputrad.com, not the deployed remote domain. Future experiments should use distinct IDs such as `web-page-exp-1`, readable titles such as `Web Page (Exp 1)`, and their own local page. The first screen of a page has no number; its copies are named `Web Page (Exp 1) (1)`, `(2)`, and so on. If the first screen is deleted and the page is added again, the new screen is the unnumbered one. Display names are editable; stable IDs identify sources regardless of rename.
|
|
234
|
+
3. Use `custom` or a preset key from that file. Presets specify CSS viewport sizes, not full browser device emulation: no device pixel ratio, user-agent, capability/media-query, or browser-chrome emulation. Phone and tablet presets enable the limited touch preview described below.
|
|
235
|
+
4. Do not add production credentials, writes, or unsafe remote fixtures. The initial interaction study is local-only and makes no network calls beyond its local files. A real website preview may load its usual image/weather requests; review this before adding it.
|
|
236
|
+
|
|
237
|
+
The old Interaction study page is removed from the development canvas. Its small interactive fixture exists only under `tests/fixtures/` and is supplied through test-only browser request interception, never a server route or registered screen. The default portfolio can make its usual image/weather requests and has normal outbound links; it is the local website implementation, not a mock.
|
|
238
|
+
|
|
239
|
+
The dev server injects `bridge.js` into a screen requested with `?canvasScreen=1`. Its messages are checked by origin and iframe window identity. It forwards selection and canvas gestures without replacing component handlers. This is trusted-document style isolation, not a security sandbox. Do not run untrusted code here.
|
|
240
|
+
|
|
241
|
+
A cooperating exploration can listen to:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
window.addEventListener('canvasvisibilitychange', ({ detail: { visible } }) => {
|
|
245
|
+
// Stop or resume this project's own animation or timer work.
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
It can also use `html[data-canvas-visible="false"]` to pause CSS animation. The fixture demonstrates this. No production component imports canvas code.
|
|
250
|
+
|
|
251
|
+
The canvas host owns the only live-reload EventSource connection. Embedded
|
|
252
|
+
`?canvasScreen=1` documents receive the bridge but no individual reload stream;
|
|
253
|
+
a host reload already refreshes every mounted screen. One stream per iframe
|
|
254
|
+
exhausts the browser's per-origin HTTP connection limit after a few screens and
|
|
255
|
+
can block later documents, assets, and refresh replacements indefinitely.
|
|
256
|
+
|
|
257
|
+
There is a real memory tradeoff: preserving every visited document retains its memory. Visibility notifications reduce active work, not retained memory. Measure realistic collections before claiming that any number of screens remains smooth.
|
|
258
|
+
|
|
259
|
+
## Future public gallery: preserved requirements
|
|
260
|
+
|
|
261
|
+
- Keep the existing square tiles and the site's settled layout/motion. Each project has an independent design system; its styles must not affect the portfolio or other projects.
|
|
262
|
+
- The primary direction is automatically interactive as a tile comes into view, without a manual activation click. Load only what is necessary near the visible gallery; avoid running every project's animation simultaneously.
|
|
263
|
+
- Some later projects may use an opening interaction that fills the main container. This is acknowledged but deferred; do not implement it in the canvas phase.
|
|
264
|
+
- Native nested scrolling should continue into the outer page when the inner project reaches its boundary. Test actual iframe/browser behavior before promising reliable scroll chaining.
|
|
265
|
+
- Mobile is deferred: possible static UI images instead of interactivity, or a different exploration flow. Do not commit to either yet.
|
|
266
|
+
- No custom project keyboard navigation is required. Preserve native buttons, typing, and an escape path from focus; do not deliberately disable native keyboard access.
|
|
267
|
+
- A preview must come from the same implementation, at matching dimensions and initial state. Keep it until the live view is ready. Account for fonts, assets, randomized data, and entrance animations so the handoff does not jump or restart visibly. Exact equivalence is not automatic.
|
|
268
|
+
- A framework may technically live inside one project's build and iframe without being imported by the portfolio. This is NOT approval to install one. Ask for approval per project and update the repo-wide runtime-dependency policy if an exception is agreed. Keep the portfolio framework-free.
|
|
269
|
+
- Measure built bytes, network downloads, frame behavior, and retained memory. Isolation does not remove the cost of frameworks or multiple loaded documents.
|
|
270
|
+
|
|
271
|
+
## Drag duplication and spatial reorder
|
|
272
|
+
|
|
273
|
+
Alt/Option can be held before a move or pressed after a screen or selection has
|
|
274
|
+
already started moving. The first press creates one copy of each selected screen,
|
|
275
|
+
restores the original positions, and continues the drag with the copies without
|
|
276
|
+
moving the camera or jumping the pointer. Repeated presses during the same drag
|
|
277
|
+
do not create more copies. The complete drag is one history edit. If the 200-added-
|
|
278
|
+
instance limit would be exceeded, no part of the group is copied and the original
|
|
279
|
+
move can continue.
|
|
280
|
+
|
|
281
|
+
A multi-selection arranged as a non-overlapping row, column, or complete aligned
|
|
282
|
+
grid shows pink center dots. Scattered or overlapping selections do not get dots;
|
|
283
|
+
reorder never silently tidies a selection. Drag a dot to insert that screen into
|
|
284
|
+
another spatial slot, shifting the screens in between. A blue outline previews
|
|
285
|
+
the drop position. Escape,
|
|
286
|
+
pointer cancellation, or window blur cancels a reorder and restores all positions.
|
|
287
|
+
|
|
288
|
+
Reorder changes positions, not the hierarchy in Layers, dimensions, preset, or
|
|
289
|
+
iframe identity. Existing horizontal and vertical gaps are preserved. With mixed
|
|
290
|
+
screen sizes, row heights and column widths follow the largest screen in each
|
|
291
|
+
band; an order that would exceed the workspace boundary is rejected. Each drag
|
|
292
|
+
is one undoable edit, and positions survive reload.
|
|
293
|
+
|
|
294
|
+
`dev/canvas/smart-selection.js` owns layout validation and pure slot planning.
|
|
295
|
+
`dev/canvas/smart-handles.js` owns the screen-space buttons and destination marker;
|
|
296
|
+
`canvas.js` owns gestures, model updates, and history. Neither module ships in the
|
|
297
|
+
production portfolio.
|
|
298
|
+
|
|
299
|
+
## Verification
|
|
300
|
+
|
|
301
|
+
Run `node tests/canvas-history.mjs` for history/shortcut checks, `node tests/canvas-geometry.mjs` for pure geometry checks and `node tests/canvas.mjs` after a build for browser checks. This uses the already installed Chromium headless shell and the Chrome DevTools Protocol, without adding a dependency. It checks development routing, preview denial, build exclusion, selection/click-through, preset locks, custom sizing, persistence, gestures, visibility/state retention, two-axis wheel pan and Command-modified inside-screen zoom, editable zoom, fit controls, duplicate shortcuts/restoration, toolbar spacing, multi-selection, partial/additive marquee selection, group movement, snap guides/equal gaps, protected deletion/restoration, title truncation, shared pan/frame bounds at several zoom levels, stale-marquee prevention, full-area group dragging, reference gap indicators, the real portfolio source, native active-screen scrolling, mobile contact scaling/drag/tap, vertical and nested horizontal inertia, deceleration and interruption, reduced-motion behavior, CSS/JavaScript hover suppression and desktop restoration, isolated refresh/configuration restoration, and repeated iframe duplication placement, document undo/redo and its exclusions, native text undo, rename/reset/cancel/persistence, hidden unavailable actions, and browser errors. Test physical trackpad/mouse behavior and platform-specific scrolling manually as well; automation does not replace those checks.
|
|
302
|
+
|
|
303
|
+
Run `node tests/canvas-activity.mjs` for the focused mobile activity regression. It opens the real portfolio card, compares a screenshot pixel with and without the cursor to prove paint order, checks that a tap has no focus ring, and verifies that Tab restores keyboard focus and a native top-layer outline.
|
|
304
|
+
|
|
305
|
+
Run `node tests/canvas-regressions.mjs` for field-to-screen Delete focus, native field editing, shortcuts from an older iframe, mobile selection focus, wide-screen row wrapping, seven fully loaded portfolio instances with one host reload stream, and a screenshot-pixel check during deliberately delayed refresh CSS. Pass `delete`, `duplicate`, `connections`, or `refresh` to run one case.
|
|
306
|
+
|
|
307
|
+
The focused `add-pages` regression covers modal dimensions, catalog indicators, adding duplicates, empty-canvas persistence and re-adding, add undo/redo, multi-page Shift-click and pointer-drag selection using a test-only catalog, and Layers pointer-drag selection. The `layers` regression uses device-icon drag handles for reordering and retains inline rename and iframe-state checks. The older `tests/canvas.mjs` suite still assumes last-instance deletion protection and includes wheel coordinates covered by the Layers drawer; it needs a separate update before its full result is meaningful.
|
|
308
|
+
|
|
309
|
+
Run `node tests/canvas-smart-selection.mjs` for row/column/grid reorder geometry,
|
|
310
|
+
mixed-size gap preservation, insertion, invalid layouts, and bounds.
|
|
311
|
+
The focused `node tests/canvas-regressions.mjs smart` browser case verifies dot
|
|
312
|
+
centering and hit targets, destination preview, Escape rollback, iframe retention,
|
|
313
|
+
plain insertion with modifiers held, absence of arrow-key position changes, and undo/redo. `node tests/canvas-regressions.mjs late-alt` checks
|
|
314
|
+
late modifier presses, stable moving-copy positions, group duplication, capacity
|
|
315
|
+
rejection, and history.
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tio-canvas/canvas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A private exploration canvas: lay your project's pages out on an infinite board, frame real documents, annotate. Development tool, plain JavaScript, ships nothing to visitors.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Tio Putra",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/tiobedil/tio-canvas.git" },
|
|
9
|
+
"homepage": "https://github.com/tiobedil/tio-canvas#readme",
|
|
10
|
+
"bugs": { "url": "https://github.com/tiobedil/tio-canvas/issues" },
|
|
11
|
+
"bin": { "tio-canvas": "./bin/tio-canvas.mjs" },
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.js",
|
|
14
|
+
"./server": "./src/server.js",
|
|
15
|
+
"./build": "./src/build.js",
|
|
16
|
+
"./project": "./src/project.js",
|
|
17
|
+
"./thumbnails": "./src/thumbnails.js",
|
|
18
|
+
"./client/*": "./client/*",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": ["bin", "client", "src", "docs", "LICENSE", "README.md"],
|
|
22
|
+
"publishConfig": { "access": "public" },
|
|
23
|
+
"engines": { "node": ">=22" },
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node tests/run.mjs"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"esbuild": "^0.28.2",
|
|
29
|
+
"interface-kit": "0.1.3",
|
|
30
|
+
"react": "^19.3.1",
|
|
31
|
+
"react-dom": "^19.3.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { resolveProject, loadProject } from "./project.js";
|
|
6
|
+
|
|
7
|
+
const CLIENT = fileURLToPath(new URL("../client", import.meta.url));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Copy the canvas into `<target>`, ready to be served at /canvas: the package's
|
|
11
|
+
* files, the project's screens written as `project.js`, and only the thumbnails
|
|
12
|
+
* of screens that exist. Returns the screens, so the project can add its own
|
|
13
|
+
* checks (for example, that no production screen points at a prototype).
|
|
14
|
+
*/
|
|
15
|
+
export async function copyCanvas({ target, ...options }) {
|
|
16
|
+
const project = resolveProject(options);
|
|
17
|
+
const { screens, storageKey } = await loadProject(project);
|
|
18
|
+
const keep = new Set(screens.map((screen) => `${screen.id}.webp`));
|
|
19
|
+
await cp(CLIENT, target, { recursive: true });
|
|
20
|
+
await writeFile(path.join(target, "project.js"), `export const screens = ${JSON.stringify(screens)};\nexport const storageKey = ${JSON.stringify(storageKey)};\n`);
|
|
21
|
+
await mkdir(path.join(target, "thumbnails"), { recursive: true });
|
|
22
|
+
if (existsSync(project.thumbnailsDir)) {
|
|
23
|
+
await cp(project.thumbnailsDir, path.join(target, "thumbnails"), {
|
|
24
|
+
recursive: true,
|
|
25
|
+
filter: (source) => path.extname(source) !== ".webp" || keep.has(path.basename(source))
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const page = path.join(target, "index.html");
|
|
29
|
+
const html = await readFile(page, "utf8");
|
|
30
|
+
if (!html.includes("<head>")) throw new Error("canvas/index.html needs a <head> for the noindex tag.");
|
|
31
|
+
await writeFile(page, html.replace("<head>", '<head>\n <meta name="robots" content="noindex, nofollow">'));
|
|
32
|
+
return screens;
|
|
33
|
+
}
|
package/src/index.js
ADDED