htmlboard 0.1.0 → 0.2.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/README.md +44 -27
- package/bin/htmlboard.js +10 -13
- package/package.json +2 -2
- package/skills/htmlboard/SKILL.md +4 -4
- package/src/client/explorer.css +268 -599
- package/src/client/explorer.js +863 -604
- package/src/client/favicon.svg +1 -0
- package/src/client/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/src/client/fonts/OFL-inter.txt +93 -0
- package/src/client/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/client/fonts/inter.woff2 +0 -0
- package/src/client/index.html +85 -175
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/index.js +2 -1
- package/src/server.js +106 -28
package/src/client/explorer.js
CHANGED
|
@@ -1,693 +1,952 @@
|
|
|
1
|
-
// HtmlBoard
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
// HtmlBoard explorer: every design of the project side by side on one canvas.
|
|
2
|
+
// Each design is an HTML file shown in an iframe at its real size, and the canvas pans and zooms
|
|
3
|
+
// with one CSS transform. Names, outlines and section titles are drawn over it in screen pixels,
|
|
4
|
+
// so they stay sharp and readable at any zoom. The server lists the designs at /api/designs and
|
|
5
|
+
// pushes saved files at /api/events.
|
|
6
|
+
|
|
7
|
+
const FRAME_GAP = 120; // canvas px between the designs of a section
|
|
8
|
+
const SECTION_GAP = 560; // canvas px between sections
|
|
9
|
+
const MIN_ZOOM = 0.02;
|
|
10
|
+
const MAX_ZOOM = 8;
|
|
11
|
+
const MAX_FIT_ZOOM = 2; // fitting one small design stops at 200%
|
|
12
|
+
const ZOOM_STEP = 1.5; // + and −, keys and buttons
|
|
13
|
+
const MARGIN = 40; // screen px around what a fit shows
|
|
14
|
+
const NAME_SPACE = 24; // screen px above a design for its name
|
|
15
|
+
const MIN_NAMED = 56; // screen px a design needs to be wide to show its name
|
|
16
|
+
const TITLE_SPACE = 56; // screen px above a section for its title and the names
|
|
17
|
+
const TOOLBAR_SPACE = 64; // screen px kept clear at the bottom for the toolbar
|
|
18
|
+
const DRAG_SLOP = 4; // screen px a press can move and still be a click
|
|
19
|
+
const DOUBLE_MS = 350;
|
|
20
|
+
const CHANGE_MS = 60; // saved files arriving this close together are applied at once
|
|
21
|
+
const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
|
|
22
|
+
|
|
23
|
+
const $ = (id) => document.getElementById(id);
|
|
24
|
+
const els = {
|
|
25
|
+
app: $('app'), title: $('title'), list: $('list'), count: $('count'), search: $('search'),
|
|
26
|
+
noMatch: $('no-match'), viewport: $('viewport'), world: $('world'), overlay: $('overlay'),
|
|
27
|
+
hover: $('hover'), selection: $('selection'), size: $('size'), zoom: $('zoom-reset'), open: $('open'),
|
|
28
|
+
live: $('live'), help: $('help'), helpToggle: $('help-toggle'), toggleList: $('toggle-list'),
|
|
29
|
+
toast: $('toast'), notice: $('notice'), noticeTitle: $('notice-title'), noticeText: $('notice-text'),
|
|
30
|
+
version: $('version'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
manifest: '', // /api/designs as last read, to tell a real change
|
|
35
|
+
autoDiscovered: false, // no designs.json: every HTML file is a design
|
|
36
|
+
sections: [], // { name, designs, el, group, x, y, w, h }
|
|
37
|
+
designs: [], // { id, file, path, title, search, w, h, autoHeight, x, y, frame, iframe, label, item, … }
|
|
38
|
+
view: { x: 0, y: 0, z: 1 },
|
|
39
|
+
selected: null,
|
|
40
|
+
hover: null,
|
|
41
|
+
query: [], // the search's words, folded
|
|
42
|
+
// What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
|
|
43
|
+
// again when the designs' real sizes come in.
|
|
44
|
+
autoFit: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
48
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
49
|
+
// Case and accents don't matter to the search: "inicio" finds "Início".
|
|
50
|
+
const fold = (text) => String(text).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
51
|
+
|
|
52
|
+
// Stored per project (the server says which folder it serves), so two projects don't share a view.
|
|
53
|
+
let storePrefix = 'htmlboard:';
|
|
54
|
+
|
|
55
|
+
function readStore(key) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(localStorage.getItem(storePrefix + key));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
32
60
|
}
|
|
61
|
+
}
|
|
33
62
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.shield = document.getElementById('pointer-shield');
|
|
40
|
-
this.sidebar = document.getElementById('sidebar');
|
|
41
|
-
this.sidebarTree = document.getElementById('sidebar-tree');
|
|
42
|
-
this.artboardCountEl = document.getElementById('artboard-count');
|
|
43
|
-
this.projectTitleEl = document.getElementById('project-title');
|
|
44
|
-
this.zoomLevelEl = document.getElementById('zoom-level');
|
|
45
|
-
this.hudCoordsEl = document.getElementById('hud-coordinates');
|
|
46
|
-
this.liveIndicator = document.getElementById('live-indicator');
|
|
47
|
-
this.shortcutsModal = document.getElementById('shortcuts-modal');
|
|
48
|
-
this.searchInput = document.getElementById('search-input');
|
|
49
|
-
this.filterTabs = document.getElementById('filter-tabs');
|
|
50
|
-
|
|
51
|
-
this.btnToggleSidebar = document.getElementById('btn-toggle-sidebar');
|
|
52
|
-
this.btnZoomIn = document.getElementById('btn-zoom-in');
|
|
53
|
-
this.btnZoomOut = document.getElementById('btn-zoom-out');
|
|
54
|
-
this.btnZoomFit = document.getElementById('btn-zoom-fit');
|
|
55
|
-
this.btnShortcuts = document.getElementById('btn-shortcuts');
|
|
56
|
-
this.btnCloseModal = document.getElementById('btn-close-modal');
|
|
63
|
+
function writeStore(key, value) {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(storePrefix + key, JSON.stringify(value));
|
|
66
|
+
} catch {
|
|
67
|
+
// Storage off (private mode): the explorer just forgets.
|
|
57
68
|
}
|
|
69
|
+
}
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.sidebar.classList.toggle('collapsed');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// Zoom Buttons
|
|
69
|
-
this.btnZoomIn.addEventListener('click', () => this.zoomRelative(1.2));
|
|
70
|
-
this.btnZoomOut.addEventListener('click', () => this.zoomRelative(0.8));
|
|
71
|
-
this.btnZoomFit.addEventListener('click', () => this.fitAll());
|
|
72
|
-
this.zoomLevelEl.addEventListener('click', () => this.setZoom(1.0));
|
|
73
|
-
|
|
74
|
-
// Shortcuts Modal
|
|
75
|
-
this.btnShortcuts.addEventListener('click', () => this.openShortcutsModal());
|
|
76
|
-
this.btnCloseModal.addEventListener('click', () => this.closeShortcutsModal());
|
|
77
|
-
this.shortcutsModal.addEventListener('click', (e) => {
|
|
78
|
-
if (e.target === this.shortcutsModal) this.closeShortcutsModal();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Category Filter Tabs
|
|
82
|
-
this.filterTabs.addEventListener('click', (e) => {
|
|
83
|
-
const tab = e.target.closest('.filter-tab');
|
|
84
|
-
if (!tab) return;
|
|
85
|
-
this.filterTabs.querySelectorAll('.filter-tab').forEach((t) => t.classList.remove('active'));
|
|
86
|
-
tab.classList.add('active');
|
|
87
|
-
this.currentCategory = tab.dataset.category;
|
|
88
|
-
this.renderCanvas();
|
|
89
|
-
this.renderSidebar();
|
|
90
|
-
});
|
|
71
|
+
function el(tag, className, text) {
|
|
72
|
+
const node = document.createElement(tag);
|
|
73
|
+
if (className) node.className = className;
|
|
74
|
+
if (text != null) node.textContent = text;
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
91
77
|
|
|
92
|
-
|
|
93
|
-
this.searchInput.addEventListener('input', (e) => {
|
|
94
|
-
this.searchQuery = e.target.value.toLowerCase().trim();
|
|
95
|
-
this.renderCanvas();
|
|
96
|
-
this.renderSidebar();
|
|
97
|
-
});
|
|
78
|
+
const urlOf = (file) => encodeURI(`/${file}`);
|
|
98
79
|
|
|
99
|
-
|
|
100
|
-
this.viewport.addEventListener('mousedown', (e) => this.onMouseDown(e));
|
|
101
|
-
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
|
102
|
-
window.addEventListener('mouseup', () => this.onMouseUp());
|
|
103
|
-
this.viewport.addEventListener('wheel', (e) => this.onWheel(e), { passive: false });
|
|
80
|
+
// ---------------------------------------------------------------- loading
|
|
104
81
|
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
async function fetchManifest() {
|
|
83
|
+
const res = await fetch('/api/designs', { cache: 'no-store' });
|
|
84
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
107
87
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
88
|
+
function build(manifest) {
|
|
89
|
+
els.world.replaceChildren();
|
|
90
|
+
els.list.replaceChildren();
|
|
91
|
+
for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
|
|
92
|
+
state.manifest = JSON.stringify(manifest);
|
|
93
|
+
state.autoDiscovered = Boolean(manifest.autoDiscovered);
|
|
94
|
+
state.sections = [];
|
|
95
|
+
state.designs = [];
|
|
96
|
+
state.selected = null;
|
|
97
|
+
state.hover = null;
|
|
98
|
+
const title = manifest.title || 'Designs';
|
|
99
|
+
els.title.textContent = title;
|
|
100
|
+
document.title = `${title} · htmlboard`;
|
|
101
|
+
for (const entry of manifest.sections || []) {
|
|
102
|
+
const name = entry.name || '';
|
|
103
|
+
const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
|
|
104
|
+
els.overlay.insertBefore(section.el, els.hover);
|
|
105
|
+
const items = el('ul');
|
|
106
|
+
section.group.append(el('h2', null, name));
|
|
107
|
+
if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
|
|
108
|
+
section.group.append(items);
|
|
109
|
+
for (const board of entry.artboards || []) {
|
|
110
|
+
const design = makeDesign(board, section);
|
|
111
|
+
section.designs.push(design);
|
|
112
|
+
state.designs.push(design);
|
|
113
|
+
items.append(design.li);
|
|
114
|
+
}
|
|
115
|
+
els.list.append(section.group);
|
|
116
|
+
state.sections.push(section);
|
|
114
117
|
}
|
|
118
|
+
if (!state.designs.length) {
|
|
119
|
+
showNotice(
|
|
120
|
+
'No designs yet',
|
|
121
|
+
state.autoDiscovered
|
|
122
|
+
? 'Save an .html file in this folder and it shows up here by itself.'
|
|
123
|
+
: 'designs.json doesn’t list any designs. Add one and it shows up here by itself.',
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
els.notice.hidden = true;
|
|
127
|
+
}
|
|
128
|
+
applySearch();
|
|
129
|
+
layout();
|
|
130
|
+
}
|
|
115
131
|
|
|
116
|
-
|
|
132
|
+
function makeDesign(board, section) {
|
|
133
|
+
const file = String(board.file);
|
|
134
|
+
const design = {
|
|
135
|
+
id: String(board.id || file),
|
|
136
|
+
file,
|
|
137
|
+
path: `/${file.split('?')[0]}`,
|
|
138
|
+
title: board.title || file,
|
|
139
|
+
w: Number(board.width) || DEFAULT_SIZE[0],
|
|
140
|
+
h: Number(board.height) || DEFAULT_SIZE[1],
|
|
141
|
+
autoHeight: !Number(board.height),
|
|
142
|
+
x: 0,
|
|
143
|
+
y: 0,
|
|
144
|
+
};
|
|
145
|
+
design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
|
|
146
|
+
.filter(Boolean).join(' '));
|
|
147
|
+
design.frame = el('div', 'frame');
|
|
148
|
+
design.iframe = el('iframe');
|
|
149
|
+
design.iframe.title = design.title;
|
|
150
|
+
design.iframe.tabIndex = -1;
|
|
151
|
+
design.iframe.setAttribute('scrolling', 'no');
|
|
152
|
+
design.iframe.addEventListener('load', () => onLoad(design));
|
|
153
|
+
design.iframe.src = urlOf(file);
|
|
154
|
+
design.frame.append(design.iframe);
|
|
155
|
+
els.world.append(design.frame);
|
|
156
|
+
|
|
157
|
+
design.label = el('div', 'label', design.title);
|
|
158
|
+
els.overlay.insertBefore(design.label, els.hover);
|
|
159
|
+
|
|
160
|
+
design.item = el('button', 'item');
|
|
161
|
+
design.item.type = 'button';
|
|
162
|
+
if (board.description) design.item.title = board.description;
|
|
163
|
+
design.itemDims = el('span', 'dims');
|
|
164
|
+
design.item.append(el('span', 'name', design.title), design.itemDims);
|
|
165
|
+
design.item.addEventListener('click', () => {
|
|
166
|
+
state.autoFit = null;
|
|
167
|
+
select(design, { fit: true });
|
|
168
|
+
});
|
|
169
|
+
design.li = el('li');
|
|
170
|
+
design.li.append(design.item);
|
|
171
|
+
showDims(design);
|
|
172
|
+
return design;
|
|
173
|
+
}
|
|
117
174
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.updateHUD();
|
|
122
|
-
}
|
|
175
|
+
function showDims(design) {
|
|
176
|
+
design.itemDims.textContent = `${design.w} × ${design.h}`;
|
|
177
|
+
}
|
|
123
178
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
function onLoad(design) {
|
|
180
|
+
const doc = design.iframe.contentDocument;
|
|
181
|
+
if (!doc || !design.autoHeight) return;
|
|
182
|
+
growToPage(design);
|
|
183
|
+
doc.fonts?.ready.then(() => growToPage(design));
|
|
184
|
+
}
|
|
129
185
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
186
|
+
// A design without a height takes its page's.
|
|
187
|
+
function growToPage(design) {
|
|
188
|
+
const doc = design.iframe.contentDocument;
|
|
189
|
+
if (!doc?.documentElement) return;
|
|
190
|
+
const h = Math.ceil(Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0));
|
|
191
|
+
if (!h || h === design.h) return;
|
|
192
|
+
design.h = h;
|
|
193
|
+
showDims(design);
|
|
194
|
+
layout();
|
|
195
|
+
}
|
|
134
196
|
|
|
135
|
-
|
|
136
|
-
let newScale = oldScale * factor;
|
|
137
|
-
newScale = Math.min(Math.max(newScale, this.minScale), this.maxScale);
|
|
197
|
+
// ---------------------------------------------------------------- searching
|
|
138
198
|
|
|
139
|
-
|
|
199
|
+
function matches(design) {
|
|
200
|
+
return state.query.every((word) => design.search.includes(word));
|
|
201
|
+
}
|
|
140
202
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
203
|
+
// The designs the search keeps (all of them without a search).
|
|
204
|
+
function shown() {
|
|
205
|
+
return state.query.length ? state.designs.filter(matches) : state.designs;
|
|
206
|
+
}
|
|
145
207
|
|
|
146
|
-
|
|
208
|
+
function applySearch() {
|
|
209
|
+
let count = 0;
|
|
210
|
+
for (const section of state.sections) {
|
|
211
|
+
let any = false;
|
|
212
|
+
for (const design of section.designs) {
|
|
213
|
+
const on = matches(design);
|
|
214
|
+
design.li.hidden = !on;
|
|
215
|
+
design.frame.classList.toggle('is-dimmed', !on);
|
|
216
|
+
design.label.classList.toggle('is-dimmed', !on);
|
|
217
|
+
if (on) {
|
|
218
|
+
any = true;
|
|
219
|
+
count++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
section.group.hidden = !any;
|
|
147
223
|
}
|
|
224
|
+
const total = state.designs.length;
|
|
225
|
+
els.count.textContent = state.query.length ? `${count} of ${total}` : String(total);
|
|
226
|
+
els.noMatch.hidden = !state.query.length || count > 0;
|
|
227
|
+
}
|
|
148
228
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const ratio = targetScale / this.scale;
|
|
154
|
-
|
|
155
|
-
this.panX = cx - ratio * (cx - this.panX);
|
|
156
|
-
this.panY = cy - ratio * (cy - this.panY);
|
|
157
|
-
this.scale = targetScale;
|
|
229
|
+
function search(text) {
|
|
230
|
+
state.query = fold(text).split(/\s+/).filter(Boolean);
|
|
231
|
+
applySearch();
|
|
232
|
+
}
|
|
158
233
|
|
|
159
|
-
|
|
234
|
+
// ---------------------------------------------------------------- layout and drawing
|
|
235
|
+
|
|
236
|
+
// Sections are rows, top to bottom; designs sit left to right in each row, tops aligned.
|
|
237
|
+
function layout() {
|
|
238
|
+
let y = 0;
|
|
239
|
+
for (const section of state.sections) {
|
|
240
|
+
let x = 0;
|
|
241
|
+
let height = 0;
|
|
242
|
+
for (const design of section.designs) {
|
|
243
|
+
design.x = x;
|
|
244
|
+
design.y = y;
|
|
245
|
+
Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
|
|
246
|
+
x += design.w + FRAME_GAP;
|
|
247
|
+
height = Math.max(height, design.h);
|
|
248
|
+
}
|
|
249
|
+
Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height });
|
|
250
|
+
y += height + SECTION_GAP;
|
|
160
251
|
}
|
|
252
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
253
|
+
else paint();
|
|
254
|
+
}
|
|
161
255
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
256
|
+
function paint() {
|
|
257
|
+
const { x, y, z } = state.view;
|
|
258
|
+
els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
|
|
259
|
+
// Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
|
|
260
|
+
let grid = 32 * z;
|
|
261
|
+
while (grid < 16) grid *= 2;
|
|
262
|
+
while (grid >= 64) grid /= 2;
|
|
263
|
+
els.viewport.style.setProperty('--grid', `${grid}px`);
|
|
264
|
+
els.viewport.style.backgroundPosition = `${x}px ${y}px`;
|
|
265
|
+
// Above each row: the section's title, then the designs' names. Zoomed far out the gap between
|
|
266
|
+
// rows gets thin: the names give way first, then the title.
|
|
267
|
+
state.sections.forEach((section, index) => {
|
|
268
|
+
const room = index ? SECTION_GAP * z : Infinity;
|
|
269
|
+
const named = room >= TITLE_SPACE + 2;
|
|
270
|
+
const top = Math.round(y + section.y * z);
|
|
271
|
+
section.el.hidden = !section.designs.length || room < 30;
|
|
272
|
+
section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
|
|
273
|
+
for (const design of section.designs) {
|
|
274
|
+
const width = design.w * z;
|
|
275
|
+
design.label.hidden = !named || width < MIN_NAMED;
|
|
276
|
+
design.label.style.width = `${Math.round(width)}px`;
|
|
277
|
+
design.label.style.transform = `translate(${Math.round(x + design.x * z)}px, ${top - 22}px)`;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
els.zoom.textContent = `${Math.round(z * 100)}%`;
|
|
281
|
+
paintMarks();
|
|
282
|
+
saveView();
|
|
283
|
+
}
|
|
167
284
|
|
|
168
|
-
|
|
169
|
-
|
|
285
|
+
// Selection and hover: the outlines and the names' colours.
|
|
286
|
+
function paintMarks() {
|
|
287
|
+
place(els.selection, state.selected);
|
|
288
|
+
place(els.hover, state.hover === state.selected ? null : state.hover);
|
|
289
|
+
const { selected } = state;
|
|
290
|
+
els.size.hidden = !selected;
|
|
291
|
+
if (selected) {
|
|
292
|
+
const { x, y, z } = state.view;
|
|
293
|
+
els.size.textContent = `${selected.w} × ${selected.h}`;
|
|
294
|
+
els.size.style.transform = `translate(${x + (selected.x + selected.w / 2) * z}px, ${y + (selected.y + selected.h) * z + 8}px) translateX(-50%)`;
|
|
295
|
+
}
|
|
296
|
+
for (const design of state.designs) {
|
|
297
|
+
design.label.classList.toggle('is-selected', design === state.selected);
|
|
298
|
+
design.label.classList.toggle('is-hover', design === state.hover);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
170
301
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
302
|
+
function place(node, design) {
|
|
303
|
+
node.hidden = !design;
|
|
304
|
+
if (!design) return;
|
|
305
|
+
const { x, y, z } = state.view;
|
|
306
|
+
node.style.transform = `translate(${x + design.x * z}px, ${y + design.y * z}px)`;
|
|
307
|
+
node.style.width = `${design.w * z}px`;
|
|
308
|
+
node.style.height = `${design.h * z}px`;
|
|
309
|
+
}
|
|
178
310
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
311
|
+
let saveTimer = 0;
|
|
312
|
+
function saveView() {
|
|
313
|
+
clearTimeout(saveTimer);
|
|
314
|
+
saveTimer = setTimeout(() => writeStore('view', state.view), 300);
|
|
315
|
+
}
|
|
184
316
|
|
|
185
|
-
|
|
317
|
+
// ---------------------------------------------------------------- moving the canvas
|
|
186
318
|
|
|
187
|
-
|
|
188
|
-
|
|
319
|
+
function setView(view) {
|
|
320
|
+
state.view = view;
|
|
321
|
+
paint();
|
|
322
|
+
}
|
|
189
323
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
324
|
+
function size() {
|
|
325
|
+
return { w: els.viewport.clientWidth, h: els.viewport.clientHeight };
|
|
326
|
+
}
|
|
193
327
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
328
|
+
function boundsOf(designs) {
|
|
329
|
+
if (!designs.length) return { x: 0, y: 0, w: 1, h: 1 };
|
|
330
|
+
const left = Math.min(...designs.map((d) => d.x));
|
|
331
|
+
const top = Math.min(...designs.map((d) => d.y));
|
|
332
|
+
const right = Math.max(...designs.map((d) => d.x + d.w));
|
|
333
|
+
const bottom = Math.max(...designs.map((d) => d.y + d.h));
|
|
334
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
335
|
+
}
|
|
198
336
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
337
|
+
// The view that shows `rect` whole, centred, with `top` screen px free above it for its names.
|
|
338
|
+
function viewFor(rect, top) {
|
|
339
|
+
const screen = size();
|
|
340
|
+
const w = Math.max(1, screen.w - 2 * MARGIN);
|
|
341
|
+
const h = Math.max(1, screen.h - 2 * MARGIN - top - TOOLBAR_SPACE);
|
|
342
|
+
const z = clamp(Math.min(w / rect.w, h / rect.h), MIN_ZOOM, MAX_FIT_ZOOM);
|
|
343
|
+
return {
|
|
344
|
+
z,
|
|
345
|
+
x: MARGIN + (w - rect.w * z) / 2 - rect.x * z,
|
|
346
|
+
y: MARGIN + top + (h - rect.h * z) / 2 - rect.y * z,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
202
349
|
|
|
203
|
-
|
|
350
|
+
// target: 'all' (what the search keeps) or a design.
|
|
351
|
+
function fit(target, { animate = true } = {}) {
|
|
352
|
+
if (target === 'all') {
|
|
353
|
+
const designs = shown();
|
|
354
|
+
moveTo(viewFor(boundsOf(designs.length ? designs : state.designs), TITLE_SPACE), animate);
|
|
355
|
+
} else {
|
|
356
|
+
moveTo(viewFor(target, NAME_SPACE), animate);
|
|
204
357
|
}
|
|
358
|
+
}
|
|
205
359
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
360
|
+
let animation = 0;
|
|
361
|
+
function moveTo(target, animate = true) {
|
|
362
|
+
cancelAnimationFrame(animation);
|
|
363
|
+
if (!animate || reducedMotion.matches) {
|
|
364
|
+
setView(target);
|
|
365
|
+
return;
|
|
209
366
|
}
|
|
367
|
+
const from = { ...state.view };
|
|
368
|
+
const screen = size();
|
|
369
|
+
const cx = screen.w / 2;
|
|
370
|
+
const cy = screen.h / 2;
|
|
371
|
+
// Glide the canvas point under the centre of the screen, and the zoom in proportion.
|
|
372
|
+
const a = { x: (cx - from.x) / from.z, y: (cy - from.y) / from.z };
|
|
373
|
+
const b = { x: (cx - target.x) / target.z, y: (cy - target.y) / target.z };
|
|
374
|
+
const start = performance.now();
|
|
375
|
+
const frame = (now) => {
|
|
376
|
+
const t = Math.min(1, (now - start) / 260);
|
|
377
|
+
const e = 1 - (1 - t) ** 3;
|
|
378
|
+
const z = from.z * (target.z / from.z) ** e;
|
|
379
|
+
setView({ x: cx - (a.x + (b.x - a.x) * e) * z, y: cy - (a.y + (b.y - a.y) * e) * z, z });
|
|
380
|
+
if (t < 1) animation = requestAnimationFrame(frame);
|
|
381
|
+
};
|
|
382
|
+
animation = requestAnimationFrame(frame);
|
|
383
|
+
}
|
|
210
384
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
history.replaceState(null, '', `#${encodeURIComponent(artboardId)}`);
|
|
218
|
-
|
|
219
|
-
// Highlight active card
|
|
220
|
-
document.querySelectorAll('.artboard-frame').forEach((f) => f.classList.remove('focused'));
|
|
221
|
-
frame.classList.add('focused');
|
|
222
|
-
|
|
223
|
-
document.querySelectorAll('.sidebar-item').forEach((item) => {
|
|
224
|
-
item.classList.toggle('active', item.dataset.id === artboardId);
|
|
225
|
-
});
|
|
385
|
+
// Zoom keeping the canvas point at (px, py) of the screen in place.
|
|
386
|
+
function zoomedAt(px, py, z) {
|
|
387
|
+
const { x, y, z: from } = state.view;
|
|
388
|
+
z = clamp(z, MIN_ZOOM, MAX_ZOOM);
|
|
389
|
+
return { x: px - ((px - x) * z) / from, y: py - ((py - y) * z) / from, z };
|
|
390
|
+
}
|
|
226
391
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const frameContentY = (rect.top - viewportRect.top - this.panY) / this.scale;
|
|
232
|
-
const frameW = rect.width / this.scale;
|
|
233
|
-
const frameH = rect.height / this.scale;
|
|
392
|
+
function zoomBy(factor) {
|
|
393
|
+
const screen = size();
|
|
394
|
+
moveTo(zoomedAt(screen.w / 2, screen.h / 2, state.view.z * factor));
|
|
395
|
+
}
|
|
234
396
|
|
|
235
|
-
|
|
397
|
+
// 100%: the selected design, centred (top first when it is taller than the screen), or the
|
|
398
|
+
// middle of the screen.
|
|
399
|
+
function actualSize() {
|
|
400
|
+
const design = state.selected;
|
|
401
|
+
if (!design) {
|
|
402
|
+
zoomBy(1 / state.view.z);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const screen = size();
|
|
406
|
+
const room = screen.h - TOOLBAR_SPACE - NAME_SPACE - 2 * MARGIN;
|
|
407
|
+
const y = design.h <= room
|
|
408
|
+
? MARGIN + NAME_SPACE + (room - design.h) / 2 - design.y
|
|
409
|
+
: MARGIN + NAME_SPACE - design.y;
|
|
410
|
+
moveTo({ z: 1, x: (screen.w - design.w) / 2 - design.x, y });
|
|
411
|
+
}
|
|
236
412
|
|
|
237
|
-
|
|
238
|
-
this.panX = viewportRect.width / 2 - (frameContentX + frameW / 2) * this.scale;
|
|
239
|
-
this.panY = viewportRect.height / 2 - (frameContentY + frameH / 2) * this.scale;
|
|
413
|
+
// ---------------------------------------------------------------- selection
|
|
240
414
|
|
|
241
|
-
|
|
415
|
+
function select(design, { fit: fitIt = false, animate = true } = {}) {
|
|
416
|
+
state.selected = design || null;
|
|
417
|
+
for (const each of state.designs) {
|
|
418
|
+
if (each === state.selected) each.item.setAttribute('aria-current', 'true');
|
|
419
|
+
else each.item.removeAttribute('aria-current');
|
|
242
420
|
}
|
|
421
|
+
els.open.href = state.selected ? urlOf(state.selected.file) : '#';
|
|
422
|
+
els.open.setAttribute('aria-disabled', String(!state.selected));
|
|
423
|
+
const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
|
|
424
|
+
if (location.hash !== hash) history.replaceState(null, '', hash || location.pathname + location.search);
|
|
425
|
+
if (state.selected && !state.selected.li.hidden) state.selected.item.scrollIntoView({ block: 'nearest' });
|
|
426
|
+
if (state.selected && fitIt) fit(state.selected, { animate });
|
|
427
|
+
else paintMarks();
|
|
428
|
+
}
|
|
243
429
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this.startX = e.clientX;
|
|
254
|
-
this.startY = e.clientY;
|
|
255
|
-
this.initialPanX = this.panX;
|
|
256
|
-
this.initialPanY = this.panY;
|
|
257
|
-
this.viewport.classList.add('panning');
|
|
258
|
-
this.shield.classList.add('active');
|
|
259
|
-
e.preventDefault();
|
|
260
|
-
}
|
|
261
|
-
}
|
|
430
|
+
// #<id>, or the design's file (relative to the designs folder, with or without .html).
|
|
431
|
+
function designFromHash() {
|
|
432
|
+
const name = decodeURIComponent(location.hash.slice(1));
|
|
433
|
+
if (!name) return null;
|
|
434
|
+
const bare = (file) => file.replace(/\.html?(?=$|\?)/i, '');
|
|
435
|
+
return state.designs.find((d) => d.id === name)
|
|
436
|
+
|| state.designs.find((d) => [d.file, bare(d.file)].some((file) => file === name || file.endsWith(`/${name}`)))
|
|
437
|
+
|| null;
|
|
438
|
+
}
|
|
262
439
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
440
|
+
function step(delta) {
|
|
441
|
+
const designs = shown();
|
|
442
|
+
const count = designs.length;
|
|
443
|
+
if (!count) return;
|
|
444
|
+
const at = designs.indexOf(state.selected);
|
|
445
|
+
const next = at < 0 ? (delta > 0 ? 0 : count - 1) : (at + delta + count) % count;
|
|
446
|
+
select(designs[next], { fit: true });
|
|
447
|
+
}
|
|
271
448
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.shield.classList.remove('active');
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
449
|
+
function setHover(design) {
|
|
450
|
+
if (state.hover === design) return;
|
|
451
|
+
state.hover = design;
|
|
452
|
+
paintMarks();
|
|
453
|
+
}
|
|
281
454
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this.panY -= e.deltaY;
|
|
292
|
-
this.applyTransform();
|
|
293
|
-
}
|
|
455
|
+
// The design under a point of the screen: its frame, or its name just above it.
|
|
456
|
+
function hit(point) {
|
|
457
|
+
const { x, y, z } = state.view;
|
|
458
|
+
const cx = (point.x - x) / z;
|
|
459
|
+
const cy = (point.y - y) / z;
|
|
460
|
+
for (let i = state.designs.length - 1; i >= 0; i--) {
|
|
461
|
+
const d = state.designs[i];
|
|
462
|
+
const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
|
|
463
|
+
if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
|
|
294
464
|
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
295
467
|
|
|
296
|
-
|
|
468
|
+
function openSelected() {
|
|
469
|
+
if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
|
|
470
|
+
}
|
|
297
471
|
|
|
298
|
-
|
|
299
|
-
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
300
|
-
if (e.key === 'Escape') {
|
|
301
|
-
e.target.blur();
|
|
302
|
-
}
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
472
|
+
// ---------------------------------------------------------------- panels and messages
|
|
305
473
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
} else if (e.key === 'f' || e.key === 'F') {
|
|
316
|
-
this.fitAll();
|
|
317
|
-
} else if (e.key === '/') {
|
|
318
|
-
e.preventDefault();
|
|
319
|
-
this.searchInput.focus();
|
|
320
|
-
} else if (e.key === '[' || e.key === ']') {
|
|
321
|
-
this.sidebar.classList.toggle('collapsed');
|
|
322
|
-
} else if (e.key === '?') {
|
|
323
|
-
this.openShortcutsModal();
|
|
324
|
-
} else if (e.key === 'Escape') {
|
|
325
|
-
this.closeShortcutsModal();
|
|
326
|
-
} else if (['1', '2', '3', '4', '5'].includes(e.key)) {
|
|
327
|
-
const tabs = this.filterTabs.querySelectorAll('.filter-tab');
|
|
328
|
-
const idx = parseInt(e.key, 10) - 1;
|
|
329
|
-
if (tabs[idx]) tabs[idx].click();
|
|
330
|
-
}
|
|
331
|
-
}
|
|
474
|
+
function toggleList(show = els.app.classList.contains('is-collapsed')) {
|
|
475
|
+
// The canvas keeps its place on screen while the list comes and goes.
|
|
476
|
+
const before = els.viewport.getBoundingClientRect().left;
|
|
477
|
+
els.app.classList.toggle('is-collapsed', !show);
|
|
478
|
+
const after = els.viewport.getBoundingClientRect().left;
|
|
479
|
+
els.toggleList.setAttribute('aria-pressed', String(show));
|
|
480
|
+
writeStore('list', show);
|
|
481
|
+
setView({ ...state.view, x: state.view.x + before - after });
|
|
482
|
+
}
|
|
332
483
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
this.shield.classList.remove('active');
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
484
|
+
function toggleHelp(show = els.help.hidden) {
|
|
485
|
+
els.help.hidden = !show;
|
|
486
|
+
els.helpToggle.setAttribute('aria-expanded', String(show));
|
|
487
|
+
}
|
|
341
488
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
489
|
+
let toastTimer = 0;
|
|
490
|
+
function toast(text) {
|
|
491
|
+
els.toast.textContent = text;
|
|
492
|
+
els.toast.classList.add('is-on');
|
|
493
|
+
clearTimeout(toastTimer);
|
|
494
|
+
toastTimer = setTimeout(() => els.toast.classList.remove('is-on'), 1800);
|
|
495
|
+
}
|
|
345
496
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
497
|
+
function showNotice(title, text) {
|
|
498
|
+
els.noticeTitle.textContent = title;
|
|
499
|
+
els.noticeText.textContent = text;
|
|
500
|
+
els.notice.hidden = false;
|
|
501
|
+
}
|
|
349
502
|
|
|
350
|
-
|
|
503
|
+
// "A", "A and B", "A, B and 3 more".
|
|
504
|
+
function names(designs) {
|
|
505
|
+
const titles = designs.map((d) => d.title);
|
|
506
|
+
if (titles.length <= 2) return titles.join(' and ');
|
|
507
|
+
return `${titles.slice(0, 2).join(', ')} and ${titles.length - 2} more`;
|
|
508
|
+
}
|
|
351
509
|
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const res = await fetch('/api/designs');
|
|
355
|
-
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
|
356
|
-
this.manifest = await res.json();
|
|
357
|
-
|
|
358
|
-
this.projectTitleEl.textContent = this.manifest.title || 'Design Explorer';
|
|
359
|
-
|
|
360
|
-
// Flatten artboards
|
|
361
|
-
this.artboards = [];
|
|
362
|
-
if (Array.isArray(this.manifest.sections)) {
|
|
363
|
-
for (const section of this.manifest.sections) {
|
|
364
|
-
if (Array.isArray(section.artboards)) {
|
|
365
|
-
for (const board of section.artboards) {
|
|
366
|
-
this.artboards.push({
|
|
367
|
-
...board,
|
|
368
|
-
sectionId: section.id,
|
|
369
|
-
sectionName: section.name
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
510
|
+
// ---------------------------------------------------------------- reloading
|
|
375
511
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (!this.hasFitted) {
|
|
382
|
-
this.hasFitted = true;
|
|
383
|
-
setTimeout(() => {
|
|
384
|
-
const id = this.artboardIdFromHash();
|
|
385
|
-
if (id) this.focusArtboard(id);
|
|
386
|
-
else this.fitAll();
|
|
387
|
-
}, 150);
|
|
388
|
-
}
|
|
389
|
-
} catch (err) {
|
|
390
|
-
console.error('Failed loading designs manifest:', err);
|
|
391
|
-
this.projectTitleEl.textContent = 'Error loading designs';
|
|
392
|
-
}
|
|
512
|
+
function reloadFrame(design) {
|
|
513
|
+
try {
|
|
514
|
+
design.iframe.contentWindow.location.reload();
|
|
515
|
+
} catch {
|
|
516
|
+
design.iframe.src = urlOf(design.file);
|
|
393
517
|
}
|
|
518
|
+
}
|
|
394
519
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const cat = (board.category || 'responsive').toLowerCase();
|
|
403
|
-
if (cat !== this.currentCategory) return false;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Search query filter
|
|
407
|
-
if (this.searchQuery) {
|
|
408
|
-
const matchTitle = (board.title || '').toLowerCase().includes(this.searchQuery);
|
|
409
|
-
const matchFile = (board.file || '').toLowerCase().includes(this.searchQuery);
|
|
410
|
-
const matchDesc = (board.description || '').toLowerCase().includes(this.searchQuery);
|
|
411
|
-
const matchTags = (board.tags || []).some((t) => t.toLowerCase().includes(this.searchQuery));
|
|
412
|
-
if (!matchTitle && !matchFile && !matchDesc && !matchTags) return false;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return true;
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
return {
|
|
419
|
-
...section,
|
|
420
|
-
artboards: filteredBoards
|
|
421
|
-
};
|
|
422
|
-
}).filter((section) => section.artboards.length > 0);
|
|
520
|
+
// Whether a design's page loaded the file: its stylesheets, scripts, images, fonts, fetches.
|
|
521
|
+
function uses(design, path) {
|
|
522
|
+
let entries;
|
|
523
|
+
try {
|
|
524
|
+
entries = design.iframe.contentWindow.performance.getEntriesByType('resource');
|
|
525
|
+
} catch {
|
|
526
|
+
return true; // can't tell: reload it
|
|
423
527
|
}
|
|
528
|
+
if (entries.length >= 250) return true; // the browser stops listing at 250: can't tell
|
|
529
|
+
return entries.some((entry) => {
|
|
530
|
+
try {
|
|
531
|
+
const url = new URL(entry.name);
|
|
532
|
+
return url.origin === location.origin && decodeURIComponent(url.pathname) === path;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
424
538
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
for (const section of sections) {
|
|
431
|
-
const sectionEl = document.createElement('div');
|
|
432
|
-
sectionEl.className = 'sidebar-section';
|
|
433
|
-
|
|
434
|
-
const headerEl = document.createElement('div');
|
|
435
|
-
headerEl.className = 'sidebar-section-title';
|
|
436
|
-
headerEl.innerHTML = `
|
|
437
|
-
<span>${escapeHtml(section.name)}</span>
|
|
438
|
-
<span>${section.artboards.length}</span>
|
|
439
|
-
`;
|
|
440
|
-
sectionEl.appendChild(headerEl);
|
|
441
|
-
|
|
442
|
-
const itemsEl = document.createElement('div');
|
|
443
|
-
itemsEl.className = 'sidebar-items';
|
|
444
|
-
|
|
445
|
-
for (const board of section.artboards) {
|
|
446
|
-
totalCount++;
|
|
447
|
-
const itemEl = document.createElement('div');
|
|
448
|
-
itemEl.className = 'sidebar-item';
|
|
449
|
-
itemEl.dataset.id = board.id;
|
|
450
|
-
if (board.id === this.activeArtboardId) itemEl.classList.add('active');
|
|
451
|
-
|
|
452
|
-
itemEl.innerHTML = `
|
|
453
|
-
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(board.title || board.id)}</span>
|
|
454
|
-
<span class="sidebar-item-badge">${board.width || 'auto'}×${board.height || 'auto'}</span>
|
|
455
|
-
`;
|
|
456
|
-
|
|
457
|
-
itemEl.addEventListener('click', () => {
|
|
458
|
-
this.focusArtboard(board.id);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
itemsEl.appendChild(itemEl);
|
|
462
|
-
}
|
|
539
|
+
function rebuild(manifest) {
|
|
540
|
+
const selected = state.selected?.id;
|
|
541
|
+
build(manifest);
|
|
542
|
+
select(state.designs.find((d) => d.id === selected) || null);
|
|
543
|
+
}
|
|
463
544
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
545
|
+
// What a new canvas changed, from the designs before and after it.
|
|
546
|
+
function changesBetween(before, after) {
|
|
547
|
+
const added = after.filter((d) => !before.some((b) => b.id === d.id));
|
|
548
|
+
const removed = before.filter((b) => !after.some((d) => d.id === b.id));
|
|
549
|
+
if (added.length && !removed.length) return `New: ${names(added)}`;
|
|
550
|
+
if (removed.length && !added.length) return `Removed: ${names(removed)}`;
|
|
551
|
+
if (added.length) return `${added.length} new, ${removed.length} removed`;
|
|
552
|
+
return 'Canvas updated';
|
|
553
|
+
}
|
|
467
554
|
|
|
468
|
-
|
|
555
|
+
// message: a text, or nothing to describe the change.
|
|
556
|
+
async function rebuildIfChanged(message) {
|
|
557
|
+
let manifest;
|
|
558
|
+
try {
|
|
559
|
+
manifest = await fetchManifest();
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
469
562
|
}
|
|
563
|
+
if (JSON.stringify(manifest) === state.manifest) return false;
|
|
564
|
+
const before = state.designs.map(({ id, title }) => ({ id, title }));
|
|
565
|
+
rebuild(manifest);
|
|
566
|
+
toast(message || changesBetween(before, state.designs));
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
470
569
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
this.applyTransform();
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
570
|
+
async function reloadAll() {
|
|
571
|
+
let manifest;
|
|
572
|
+
try {
|
|
573
|
+
manifest = await fetchManifest();
|
|
574
|
+
} catch (err) {
|
|
575
|
+
toast(`Could not read the designs (${err.message})`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
|
|
579
|
+
else state.designs.forEach(reloadFrame);
|
|
580
|
+
toast('Designs reloaded');
|
|
581
|
+
}
|
|
486
582
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
sectionContainer.className = 'canvas-section';
|
|
490
|
-
sectionContainer.id = `section-${section.id}`;
|
|
491
|
-
|
|
492
|
-
const secHeader = document.createElement('div');
|
|
493
|
-
secHeader.className = 'canvas-section-header';
|
|
494
|
-
secHeader.innerHTML = `
|
|
495
|
-
<h2 class="canvas-section-title">${escapeHtml(section.name)}</h2>
|
|
496
|
-
${section.description ? `<p class="canvas-section-desc">${escapeHtml(section.description)}</p>` : ''}
|
|
497
|
-
`;
|
|
498
|
-
sectionContainer.appendChild(secHeader);
|
|
499
|
-
|
|
500
|
-
const flow = document.createElement('div');
|
|
501
|
-
flow.className = 'artboards-flow';
|
|
502
|
-
|
|
503
|
-
for (const board of section.artboards) {
|
|
504
|
-
const frame = this.createArtboardFrame(board);
|
|
505
|
-
flow.appendChild(frame);
|
|
506
|
-
}
|
|
583
|
+
const pending = { files: new Set(), manifest: false, folders: false };
|
|
584
|
+
let changeTimer = 0;
|
|
507
585
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
586
|
+
function onChange(change) {
|
|
587
|
+
if (change.type === 'manifest') pending.manifest = true;
|
|
588
|
+
else if (change.type === 'folder') pending.folders = true;
|
|
589
|
+
else if (change.file) pending.files.add(change.file);
|
|
590
|
+
clearTimeout(changeTimer);
|
|
591
|
+
changeTimer = setTimeout(applyChanges, CHANGE_MS);
|
|
592
|
+
}
|
|
511
593
|
|
|
512
|
-
|
|
594
|
+
async function applyChanges() {
|
|
595
|
+
const files = [...pending.files];
|
|
596
|
+
const { manifest: manifestChanged, folders: foldersChanged } = pending;
|
|
597
|
+
pending.files.clear();
|
|
598
|
+
pending.manifest = false;
|
|
599
|
+
pending.folders = false;
|
|
600
|
+
|
|
601
|
+
// A new canvas loads every design afresh, so nothing else is left to do.
|
|
602
|
+
if (manifestChanged && await rebuildIfChanged('designs.json changed: canvas rebuilt')) return;
|
|
603
|
+
// Without designs.json every HTML file is a design (and its <title> its name); with it, an
|
|
604
|
+
// HTML file that isn't on the canvas may be one just added to it. A folder added or removed
|
|
605
|
+
// may bring or take a whole group of them.
|
|
606
|
+
const pages = files.filter((file) => /\.html?$/i.test(file));
|
|
607
|
+
if (foldersChanged || (pages.length && (state.autoDiscovered || pages.some((file) => !state.designs.some((d) => d.path === `/${file}`))))) {
|
|
608
|
+
if (await rebuildIfChanged()) return;
|
|
513
609
|
}
|
|
514
610
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
<span class="artboard-badge badge-${category}">${category}</span>
|
|
529
|
-
<span class="artboard-title" title="${escapeHtml(board.title)}">${escapeHtml(board.title)}</span>
|
|
530
|
-
</div>
|
|
531
|
-
<div class="artboard-actions">
|
|
532
|
-
<span class="artboard-dimensions">${width} × ${height}</span>
|
|
533
|
-
<button class="artboard-btn btn-reload-frame" title="Reload Artboard">
|
|
534
|
-
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
535
|
-
<path d="M23 4v6h-6"/>
|
|
536
|
-
<path d="M1 20v-6h6"/>
|
|
537
|
-
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
538
|
-
</svg>
|
|
539
|
-
</button>
|
|
540
|
-
<a href="/${board.file}" target="_blank" rel="noopener" class="artboard-btn" title="Open Standalone in New Tab">
|
|
541
|
-
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
542
|
-
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
543
|
-
<polyline points="15 3 21 3 21 9"/>
|
|
544
|
-
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
545
|
-
</svg>
|
|
546
|
-
</a>
|
|
547
|
-
</div>
|
|
548
|
-
`;
|
|
549
|
-
|
|
550
|
-
const body = document.createElement('div');
|
|
551
|
-
body.className = 'artboard-body';
|
|
552
|
-
body.style.width = `${width}px`;
|
|
553
|
-
body.style.height = `${height}px`;
|
|
554
|
-
|
|
555
|
-
const iframe = document.createElement('iframe');
|
|
556
|
-
iframe.className = 'artboard-iframe';
|
|
557
|
-
iframe.dataset.file = board.file;
|
|
558
|
-
iframe.dataset.id = board.id;
|
|
559
|
-
iframe.src = withCacheBust(board.file, this.cacheBustCounter);
|
|
560
|
-
iframe.style.width = '100%';
|
|
561
|
-
iframe.style.height = '100%';
|
|
562
|
-
|
|
563
|
-
// Auto-measuring support for same-origin artboards
|
|
564
|
-
iframe.addEventListener('load', () => {
|
|
565
|
-
try {
|
|
566
|
-
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
567
|
-
if (doc && doc.body) {
|
|
568
|
-
// If board has no fixed height, auto-fit height
|
|
569
|
-
if (!board.height) {
|
|
570
|
-
const docHeight = doc.documentElement.scrollHeight || doc.body.scrollHeight;
|
|
571
|
-
if (docHeight > 100) {
|
|
572
|
-
body.style.height = `${docHeight}px`;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
} catch (_) {
|
|
577
|
-
// Cross-origin fallback
|
|
578
|
-
}
|
|
579
|
-
});
|
|
611
|
+
const paths = files.map((file) => `/${file}`);
|
|
612
|
+
const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
|
|
613
|
+
if (!frames.length) return;
|
|
614
|
+
// Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
|
|
615
|
+
// Asked before reloading: a reloading page forgets what it loaded.
|
|
616
|
+
const shared = paths
|
|
617
|
+
.filter((path) => !state.designs.some((d) => d.path === path) && frames.some((d) => uses(d, path)))
|
|
618
|
+
.map((path) => path.slice(1));
|
|
619
|
+
frames.forEach(reloadFrame);
|
|
620
|
+
toast(shared.length
|
|
621
|
+
? `${shared.join(', ')} changed: ${frames.length === 1 ? frames[0].title : `${frames.length} designs`} reloaded`
|
|
622
|
+
: `Updated: ${names(frames)}`);
|
|
623
|
+
}
|
|
580
624
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
625
|
+
function connect() {
|
|
626
|
+
const source = new EventSource('/api/events');
|
|
627
|
+
let lost = false;
|
|
628
|
+
source.addEventListener('connected', () => {
|
|
629
|
+
setLive(true);
|
|
630
|
+
// Back after the server stopped: files may have changed meanwhile.
|
|
631
|
+
if (lost) reloadAll();
|
|
632
|
+
lost = false;
|
|
633
|
+
});
|
|
634
|
+
source.addEventListener('change', (event) => {
|
|
635
|
+
try {
|
|
636
|
+
onChange(JSON.parse(event.data));
|
|
637
|
+
} catch {
|
|
638
|
+
// Not ours.
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
// EventSource keeps trying to reconnect by itself.
|
|
642
|
+
source.addEventListener('error', () => {
|
|
643
|
+
lost = true;
|
|
644
|
+
setLive(false);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
584
647
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
648
|
+
function setLive(on) {
|
|
649
|
+
els.live.hidden = false;
|
|
650
|
+
els.live.classList.toggle('is-off', !on);
|
|
651
|
+
els.live.textContent = on ? 'Live' : 'Offline';
|
|
652
|
+
els.live.title = on
|
|
653
|
+
? 'Watching the files: saved changes show up by themselves'
|
|
654
|
+
: 'Lost the server: start htmlboard again and the canvas reconnects';
|
|
655
|
+
}
|
|
589
656
|
|
|
590
|
-
|
|
591
|
-
frame.addEventListener('click', (e) => {
|
|
592
|
-
if (!e.target.closest('a, button')) {
|
|
593
|
-
document.querySelectorAll('.artboard-frame').forEach((f) => f.classList.remove('focused'));
|
|
594
|
-
frame.classList.add('focused');
|
|
595
|
-
this.activeArtboardId = board.id;
|
|
596
|
-
document.querySelectorAll('.sidebar-item').forEach((item) => {
|
|
597
|
-
item.classList.toggle('active', item.dataset.id === board.id);
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
});
|
|
657
|
+
// ---------------------------------------------------------------- input
|
|
601
658
|
|
|
602
|
-
|
|
603
|
-
|
|
659
|
+
const pointers = new Map();
|
|
660
|
+
let press = null; // one pointer down: a click, or a drag that pans
|
|
661
|
+
let pinch = null; // two pointers down
|
|
662
|
+
let lastClick = { time: 0, design: null };
|
|
663
|
+
let spaceDown = false;
|
|
604
664
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
665
|
+
function local(event) {
|
|
666
|
+
const box = els.viewport.getBoundingClientRect();
|
|
667
|
+
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
668
|
+
}
|
|
609
669
|
|
|
610
|
-
|
|
670
|
+
function onCanvas(event) {
|
|
671
|
+
return !event.target.closest('.toolbar, .help, .notice');
|
|
672
|
+
}
|
|
611
673
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
674
|
+
function wire() {
|
|
675
|
+
const { viewport } = els;
|
|
676
|
+
|
|
677
|
+
viewport.addEventListener('wheel', (event) => {
|
|
678
|
+
if (!onCanvas(event)) return;
|
|
679
|
+
event.preventDefault();
|
|
680
|
+
state.autoFit = null;
|
|
681
|
+
cancelAnimationFrame(animation);
|
|
682
|
+
let dx = event.deltaX;
|
|
683
|
+
let dy = event.deltaY;
|
|
684
|
+
if (event.deltaMode === 1) {
|
|
685
|
+
dx *= 16;
|
|
686
|
+
dy *= 16;
|
|
687
|
+
} else if (event.deltaMode === 2) {
|
|
688
|
+
dx *= viewport.clientWidth;
|
|
689
|
+
dy *= viewport.clientHeight;
|
|
615
690
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
691
|
+
if (event.ctrlKey || event.metaKey) {
|
|
692
|
+
// Pinches arrive as Ctrl + small deltas; a mouse wheel notch is capped to one gentle step.
|
|
693
|
+
const point = local(event);
|
|
694
|
+
setView(zoomedAt(point.x, point.y, state.view.z * Math.exp(-clamp(dy, -50, 50) / 100)));
|
|
695
|
+
} else {
|
|
696
|
+
if (event.shiftKey && !dx) {
|
|
697
|
+
dx = dy;
|
|
698
|
+
dy = 0;
|
|
699
|
+
}
|
|
700
|
+
setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
|
|
701
|
+
}
|
|
702
|
+
}, { passive: false });
|
|
703
|
+
|
|
704
|
+
// Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
|
|
705
|
+
document.addEventListener('wheel', (event) => {
|
|
706
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
707
|
+
}, { passive: false });
|
|
708
|
+
|
|
709
|
+
// Safari reports trackpad pinches as gestures instead of Ctrl + wheel.
|
|
710
|
+
let gestureZoom = 1;
|
|
711
|
+
document.addEventListener('gesturestart', (event) => event.preventDefault());
|
|
712
|
+
viewport.addEventListener('gesturestart', (event) => {
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
gestureZoom = state.view.z;
|
|
715
|
+
});
|
|
716
|
+
viewport.addEventListener('gesturechange', (event) => {
|
|
717
|
+
event.preventDefault();
|
|
718
|
+
state.autoFit = null;
|
|
719
|
+
const point = local(event);
|
|
720
|
+
setView(zoomedAt(point.x, point.y, gestureZoom * event.scale));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
viewport.addEventListener('pointerdown', (event) => {
|
|
724
|
+
if (!onCanvas(event) || (event.button !== 0 && event.button !== 1)) return;
|
|
725
|
+
event.preventDefault(); // no text selection, no middle-click autoscroll
|
|
726
|
+
if (document.activeElement === els.search) els.search.blur();
|
|
727
|
+
viewport.focus({ preventScroll: true });
|
|
728
|
+
viewport.setPointerCapture(event.pointerId);
|
|
729
|
+
state.autoFit = null;
|
|
730
|
+
const point = local(event);
|
|
731
|
+
pointers.set(event.pointerId, point);
|
|
732
|
+
if (pointers.size === 2) {
|
|
733
|
+
cancelAnimationFrame(animation);
|
|
734
|
+
const [a, b] = [...pointers.values()];
|
|
735
|
+
pinch = {
|
|
736
|
+
distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
737
|
+
middle: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
738
|
+
view: { ...state.view },
|
|
637
739
|
};
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
740
|
+
press = null;
|
|
741
|
+
viewport.classList.remove('is-panning');
|
|
742
|
+
return;
|
|
641
743
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
744
|
+
if (pointers.size > 2) return;
|
|
745
|
+
press = { id: event.pointerId, ...point, moved: false, pan: event.button === 1 || spaceDown, touch: event.pointerType !== 'mouse' };
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
viewport.addEventListener('pointermove', (event) => {
|
|
749
|
+
const point = local(event);
|
|
750
|
+
if (pointers.has(event.pointerId)) pointers.set(event.pointerId, point);
|
|
751
|
+
if (pinch) {
|
|
752
|
+
if (pointers.size < 2) return;
|
|
753
|
+
const [a, b] = [...pointers.values()];
|
|
754
|
+
const z = clamp((pinch.view.z * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.distance, MIN_ZOOM, MAX_ZOOM);
|
|
755
|
+
// The canvas point under the fingers' starting middle follows their middle now.
|
|
756
|
+
const cx = (pinch.middle.x - pinch.view.x) / pinch.view.z;
|
|
757
|
+
const cy = (pinch.middle.y - pinch.view.y) / pinch.view.z;
|
|
758
|
+
setView({ x: (a.x + b.x) / 2 - cx * z, y: (a.y + b.y) / 2 - cy * z, z });
|
|
648
759
|
return;
|
|
649
760
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const frameFile = (iframe.dataset.file || '').replace(/\\/g, '/').split('?')[0];
|
|
658
|
-
if (frameFile === changedFile) {
|
|
659
|
-
this.reloadIframe(iframe);
|
|
660
|
-
reloadedAny = true;
|
|
761
|
+
if (press && event.pointerId === press.id) {
|
|
762
|
+
if (!press.moved && Math.hypot(point.x - press.x, point.y - press.y) > DRAG_SLOP) {
|
|
763
|
+
// The drag starts here, from wherever a glide has got to.
|
|
764
|
+
cancelAnimationFrame(animation);
|
|
765
|
+
Object.assign(press, { moved: true, view: { ...state.view }, x: point.x, y: point.y });
|
|
766
|
+
viewport.classList.add('is-panning');
|
|
767
|
+
setHover(null);
|
|
661
768
|
}
|
|
662
|
-
|
|
769
|
+
if (press.moved) setView({ ...state.view, x: press.view.x + point.x - press.x, y: press.view.y + point.y - press.y });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (event.pointerType === 'mouse' && !pointers.size && onCanvas(event)) setHover(hit(point));
|
|
773
|
+
});
|
|
663
774
|
|
|
664
|
-
|
|
775
|
+
const release = (event) => {
|
|
776
|
+
if (!pointers.delete(event.pointerId)) return;
|
|
777
|
+
if (pinch) {
|
|
778
|
+
if (!pointers.size) pinch = null;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (!press || event.pointerId !== press.id) return;
|
|
782
|
+
const done = press;
|
|
783
|
+
press = null;
|
|
784
|
+
viewport.classList.remove('is-panning');
|
|
785
|
+
if (done.moved || done.pan || event.type === 'pointercancel') return;
|
|
786
|
+
const design = hit(local(event));
|
|
787
|
+
const now = performance.now();
|
|
788
|
+
if (done.touch && design && design === lastClick.design && now - lastClick.time < DOUBLE_MS) {
|
|
789
|
+
lastClick = { time: 0, design: null };
|
|
790
|
+
select(design, { fit: true });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
lastClick = { time: now, design };
|
|
794
|
+
select(design);
|
|
795
|
+
};
|
|
796
|
+
viewport.addEventListener('dblclick', (event) => {
|
|
797
|
+
if (!onCanvas(event)) return;
|
|
798
|
+
const design = hit(local(event));
|
|
799
|
+
if (design) select(design, { fit: true });
|
|
800
|
+
});
|
|
801
|
+
viewport.addEventListener('pointerup', release);
|
|
802
|
+
viewport.addEventListener('pointercancel', release);
|
|
803
|
+
viewport.addEventListener('pointerleave', () => {
|
|
804
|
+
if (!pointers.size) setHover(null);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
els.search.addEventListener('input', () => search(els.search.value));
|
|
808
|
+
els.search.addEventListener('keydown', (event) => {
|
|
809
|
+
if (event.key === 'Escape') {
|
|
810
|
+
event.preventDefault();
|
|
811
|
+
if (els.search.value) {
|
|
812
|
+
els.search.value = '';
|
|
813
|
+
search('');
|
|
814
|
+
} else {
|
|
815
|
+
els.search.blur();
|
|
816
|
+
}
|
|
817
|
+
} else if (event.key === 'Enter') {
|
|
818
|
+
// The first design found, fitted.
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
const [first] = shown();
|
|
821
|
+
if (first) {
|
|
822
|
+
state.autoFit = null;
|
|
823
|
+
select(first, { fit: true });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
document.addEventListener('keydown', (event) => {
|
|
829
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
830
|
+
// Typing in the search (it has its own keys above) is not a shortcut.
|
|
831
|
+
if (event.target.closest?.('input, textarea, select, [contenteditable]')) return;
|
|
832
|
+
const onControl = event.target.closest?.('button, a');
|
|
833
|
+
const key = event.key;
|
|
834
|
+
if (key === ' ' || event.code === 'Space') {
|
|
835
|
+
if (onControl) return;
|
|
836
|
+
event.preventDefault();
|
|
837
|
+
spaceDown = true;
|
|
838
|
+
viewport.classList.add('is-space');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
let handled = true;
|
|
842
|
+
if ((event.shiftKey && event.code === 'Digit1') || key === 'f' || key === 'F') fit('all');
|
|
843
|
+
else if (event.shiftKey && event.code === 'Digit2') {
|
|
844
|
+
if (state.selected) fit(state.selected);
|
|
845
|
+
} else if ((event.shiftKey && event.code === 'Digit0') || key === '0') actualSize();
|
|
846
|
+
else if (key === '+' || key === '=' || event.code === 'NumpadAdd') zoomBy(ZOOM_STEP);
|
|
847
|
+
else if (key === '-' || key === '_' || event.code === 'NumpadSubtract') zoomBy(1 / ZOOM_STEP);
|
|
848
|
+
else if (key === 'ArrowRight' || key === 'ArrowDown') step(1);
|
|
849
|
+
else if (key === 'ArrowLeft' || key === 'ArrowUp') step(-1);
|
|
850
|
+
else if (key === 'Escape') {
|
|
851
|
+
if (!els.help.hidden) toggleHelp(false);
|
|
852
|
+
else if (state.selected) select(null);
|
|
853
|
+
else if (els.search.value) {
|
|
854
|
+
els.search.value = '';
|
|
855
|
+
search('');
|
|
856
|
+
}
|
|
857
|
+
} else if (key === 'Enter' && !onControl) openSelected();
|
|
858
|
+
else if (key === 'r' || key === 'R') reloadAll();
|
|
859
|
+
else if (key === '/') {
|
|
860
|
+
if (els.app.classList.contains('is-collapsed')) toggleList(true);
|
|
861
|
+
els.search.focus();
|
|
862
|
+
els.search.select();
|
|
863
|
+
} else if (key === '?') toggleHelp();
|
|
864
|
+
else if (key === '\\' || key === '[' || key === ']') toggleList();
|
|
865
|
+
else handled = false;
|
|
866
|
+
if (handled) {
|
|
867
|
+
event.preventDefault();
|
|
868
|
+
state.autoFit = null;
|
|
869
|
+
}
|
|
870
|
+
});
|
|
665
871
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
872
|
+
document.addEventListener('keyup', (event) => {
|
|
873
|
+
if (event.key === ' ' || event.code === 'Space') {
|
|
874
|
+
spaceDown = false;
|
|
875
|
+
viewport.classList.remove('is-space');
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
window.addEventListener('blur', () => {
|
|
880
|
+
spaceDown = false;
|
|
881
|
+
viewport.classList.remove('is-space');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
window.addEventListener('resize', () => {
|
|
885
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
886
|
+
else paint();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
window.addEventListener('hashchange', () => {
|
|
890
|
+
const design = designFromHash();
|
|
891
|
+
if (design && design !== state.selected) {
|
|
892
|
+
state.autoFit = null;
|
|
893
|
+
select(design, { fit: true });
|
|
672
894
|
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
$('zoom-in').addEventListener('click', () => zoomBy(ZOOM_STEP));
|
|
898
|
+
$('zoom-out').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
|
|
899
|
+
els.zoom.addEventListener('click', actualSize);
|
|
900
|
+
$('fit-all').addEventListener('click', () => fit('all'));
|
|
901
|
+
$('reload').addEventListener('click', reloadAll);
|
|
902
|
+
els.toggleList.addEventListener('click', () => toggleList());
|
|
903
|
+
els.helpToggle.addEventListener('click', () => toggleHelp());
|
|
904
|
+
for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
|
|
905
|
+
$(id).addEventListener('click', () => {
|
|
906
|
+
state.autoFit = null;
|
|
907
|
+
});
|
|
673
908
|
}
|
|
674
909
|
}
|
|
675
910
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
911
|
+
// ---------------------------------------------------------------- start
|
|
912
|
+
|
|
913
|
+
async function start() {
|
|
914
|
+
let manifest;
|
|
915
|
+
try {
|
|
916
|
+
const [designs, health] = await Promise.all([
|
|
917
|
+
fetchManifest(),
|
|
918
|
+
fetch('/api/health', { cache: 'no-store' }).then((res) => res.json()).catch(() => ({})),
|
|
919
|
+
]);
|
|
920
|
+
manifest = designs;
|
|
921
|
+
if (health.dir) storePrefix = `htmlboard:${health.dir}:`;
|
|
922
|
+
if (health.version) els.version.textContent = health.version;
|
|
923
|
+
} catch (err) {
|
|
924
|
+
showNotice('Could not load the designs', `${err.message}. Is htmlboard still running?`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const list = readStore('list');
|
|
929
|
+
if (list === false || (list === null && innerWidth < 720)) {
|
|
930
|
+
els.app.classList.add('is-collapsed');
|
|
931
|
+
els.toggleList.setAttribute('aria-pressed', 'false');
|
|
932
|
+
}
|
|
933
|
+
els.viewport.tabIndex = -1;
|
|
934
|
+
build(manifest);
|
|
935
|
+
wire();
|
|
936
|
+
|
|
937
|
+
const design = designFromHash();
|
|
938
|
+
const saved = readStore('view');
|
|
939
|
+
if (design) {
|
|
940
|
+
state.autoFit = design;
|
|
941
|
+
select(design, { fit: true, animate: false });
|
|
942
|
+
} else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {
|
|
943
|
+
setView({ x: saved.x, y: saved.y, z: clamp(saved.z, MIN_ZOOM, MAX_ZOOM) });
|
|
944
|
+
} else {
|
|
945
|
+
state.autoFit = 'all';
|
|
946
|
+
fit('all', { animate: false });
|
|
947
|
+
}
|
|
679
948
|
|
|
680
|
-
|
|
681
|
-
if (!str) return '';
|
|
682
|
-
return String(str)
|
|
683
|
-
.replace(/&/g, '&')
|
|
684
|
-
.replace(/</g, '<')
|
|
685
|
-
.replace(/>/g, '>')
|
|
686
|
-
.replace(/"/g, '"')
|
|
687
|
-
.replace(/'/g, ''');
|
|
949
|
+
connect();
|
|
688
950
|
}
|
|
689
951
|
|
|
690
|
-
|
|
691
|
-
window.addEventListener('DOMContentLoaded', () => {
|
|
692
|
-
window.__htmlboardExplorer = new HtmlBoardExplorer();
|
|
693
|
-
});
|
|
952
|
+
start();
|