htmlboard 0.1.0 → 0.3.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 +120 -92
- package/bin/htmlboard.js +93 -59
- package/package.json +2 -2
- package/skills/htmlboard/SKILL.md +27 -33
- package/src/client/explorer.css +302 -598
- package/src/client/explorer.js +983 -601
- 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 +87 -175
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/designs.js +398 -0
- package/src/index.js +8 -2
- package/src/migrate.js +193 -0
- package/src/server.js +93 -212
- package/src/templates/{artboards/desktop.html → 01-screens/01-desktop.html} +2 -0
- package/src/templates/{artboards/mobile.html → 01-screens/02-mobile.html} +2 -0
- package/src/templates/{artboards/components.html → 02-components/01-ui-kit.html} +2 -0
- package/src/templates/designs.json +0 -50
package/src/client/explorer.js
CHANGED
|
@@ -1,693 +1,1075 @@
|
|
|
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'), filters: $('filters'), 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
|
+
category: '', // the filter: a category, or '' for all
|
|
43
|
+
// What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
|
|
44
|
+
// again when the designs' real sizes come in.
|
|
45
|
+
autoFit: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
49
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
50
|
+
// Case and accents don't matter to the search: "inicio" finds "Início".
|
|
51
|
+
const fold = (text) => String(text).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
52
|
+
|
|
53
|
+
// Stored per project (the server says which folder it serves), so two projects don't share a view.
|
|
54
|
+
let storePrefix = 'htmlboard:';
|
|
55
|
+
|
|
56
|
+
function readStore(key) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(localStorage.getItem(storePrefix + key));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
32
61
|
}
|
|
62
|
+
}
|
|
33
63
|
|
|
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');
|
|
64
|
+
function writeStore(key, value) {
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem(storePrefix + key, JSON.stringify(value));
|
|
67
|
+
} catch {
|
|
68
|
+
// Storage off (private mode): the explorer just forgets.
|
|
57
69
|
}
|
|
70
|
+
}
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
function el(tag, className, text) {
|
|
73
|
+
const node = document.createElement(tag);
|
|
74
|
+
if (className) node.className = className;
|
|
75
|
+
if (text != null) node.textContent = text;
|
|
76
|
+
return node;
|
|
77
|
+
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
this.btnToggleSidebar.addEventListener('click', () => {
|
|
65
|
-
this.sidebar.classList.toggle('collapsed');
|
|
66
|
-
});
|
|
79
|
+
const urlOf = (file) => encodeURI(`/${file}`);
|
|
67
80
|
|
|
68
|
-
|
|
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
|
-
});
|
|
81
|
+
// ---------------------------------------------------------------- loading
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
tab.classList.add('active');
|
|
87
|
-
this.currentCategory = tab.dataset.category;
|
|
88
|
-
this.renderCanvas();
|
|
89
|
-
this.renderSidebar();
|
|
90
|
-
});
|
|
83
|
+
async function fetchManifest() {
|
|
84
|
+
const res = await fetch('/api/designs', { cache: 'no-store' });
|
|
85
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
86
|
+
return res.json();
|
|
87
|
+
}
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
function build(manifest) {
|
|
90
|
+
els.world.replaceChildren();
|
|
91
|
+
els.list.replaceChildren();
|
|
92
|
+
for (const node of els.overlay.querySelectorAll('.label, .section-title')) node.remove();
|
|
93
|
+
state.manifest = JSON.stringify(manifest);
|
|
94
|
+
state.autoDiscovered = Boolean(manifest.autoDiscovered);
|
|
95
|
+
state.sections = [];
|
|
96
|
+
state.designs = [];
|
|
97
|
+
state.selected = null;
|
|
98
|
+
state.hover = null;
|
|
99
|
+
const title = manifest.title || 'Designs';
|
|
100
|
+
els.title.textContent = title;
|
|
101
|
+
document.title = `${title} · htmlboard`;
|
|
102
|
+
for (const entry of manifest.sections || []) {
|
|
103
|
+
const name = entry.name || '';
|
|
104
|
+
const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
|
|
105
|
+
els.overlay.insertBefore(section.el, els.hover);
|
|
106
|
+
const items = el('ul');
|
|
107
|
+
// The files at the top of the folder make a first section without a title
|
|
108
|
+
if (name) section.group.append(el('h2', null, name));
|
|
109
|
+
if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
|
|
110
|
+
section.group.append(items);
|
|
111
|
+
for (const board of entry.artboards || []) {
|
|
112
|
+
const design = makeDesign(board, section);
|
|
113
|
+
section.designs.push(design);
|
|
114
|
+
state.designs.push(design);
|
|
115
|
+
items.append(design.li);
|
|
116
|
+
}
|
|
117
|
+
els.list.append(section.group);
|
|
118
|
+
state.sections.push(section);
|
|
119
|
+
}
|
|
120
|
+
if (!state.designs.length) {
|
|
121
|
+
showNotice(
|
|
122
|
+
'No designs yet',
|
|
123
|
+
state.autoDiscovered
|
|
124
|
+
? 'Save an .html file in this folder and it shows up here by itself.'
|
|
125
|
+
: 'designs.json doesn’t list any designs. Add one and it shows up here by itself.',
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
els.notice.hidden = true;
|
|
129
|
+
}
|
|
130
|
+
buildFilters();
|
|
131
|
+
applySearch();
|
|
132
|
+
layout();
|
|
133
|
+
}
|
|
98
134
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
135
|
+
function makeDesign(board, section) {
|
|
136
|
+
const file = String(board.file);
|
|
137
|
+
const design = {
|
|
138
|
+
id: String(board.id || file),
|
|
139
|
+
category: board.category || '',
|
|
140
|
+
file,
|
|
141
|
+
path: `/${file.split('?')[0]}`,
|
|
142
|
+
title: board.title || file,
|
|
143
|
+
w: Number(board.width) || DEFAULT_SIZE[0],
|
|
144
|
+
h: Number(board.height) || DEFAULT_SIZE[1],
|
|
145
|
+
autoHeight: !Number(board.height),
|
|
146
|
+
x: 0,
|
|
147
|
+
y: 0,
|
|
148
|
+
};
|
|
149
|
+
design.search = fold([design.title, file, board.description, board.category, ...(board.tags || []), section.name]
|
|
150
|
+
.filter(Boolean).join(' '));
|
|
151
|
+
design.frame = el('div', 'frame');
|
|
152
|
+
design.iframe = el('iframe');
|
|
153
|
+
design.iframe.title = design.title;
|
|
154
|
+
design.iframe.tabIndex = -1;
|
|
155
|
+
design.iframe.setAttribute('scrolling', 'no');
|
|
156
|
+
design.iframe.addEventListener('load', () => onLoad(design));
|
|
157
|
+
design.iframe.src = urlOf(file);
|
|
158
|
+
design.frame.append(design.iframe);
|
|
159
|
+
els.world.append(design.frame);
|
|
160
|
+
|
|
161
|
+
design.label = el('div', 'label', design.title);
|
|
162
|
+
els.overlay.insertBefore(design.label, els.hover);
|
|
163
|
+
|
|
164
|
+
design.item = el('button', 'item');
|
|
165
|
+
design.item.type = 'button';
|
|
166
|
+
if (board.description) design.item.title = board.description;
|
|
167
|
+
design.itemDims = el('span', 'dims');
|
|
168
|
+
design.item.append(el('span', 'name', design.title), design.itemDims);
|
|
169
|
+
design.item.addEventListener('click', () => {
|
|
170
|
+
state.autoFit = null;
|
|
171
|
+
select(design, { fit: true });
|
|
172
|
+
});
|
|
173
|
+
design.li = el('li');
|
|
174
|
+
design.li.append(design.item);
|
|
175
|
+
showDims(design);
|
|
176
|
+
return design;
|
|
177
|
+
}
|
|
104
178
|
|
|
105
|
-
|
|
106
|
-
|
|
179
|
+
function showDims(design) {
|
|
180
|
+
design.itemDims.textContent = `${design.w} × ${design.h}`;
|
|
181
|
+
}
|
|
107
182
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
183
|
+
function onLoad(design) {
|
|
184
|
+
const doc = design.iframe.contentDocument;
|
|
185
|
+
if (!doc || !design.autoHeight) return;
|
|
186
|
+
growToPage(design);
|
|
187
|
+
doc.fonts?.ready.then(() => growToPage(design));
|
|
188
|
+
}
|
|
115
189
|
|
|
116
|
-
|
|
190
|
+
// A design without a height takes its page's.
|
|
191
|
+
function growToPage(design) {
|
|
192
|
+
const doc = design.iframe.contentDocument;
|
|
193
|
+
if (!doc?.documentElement) return;
|
|
194
|
+
const h = Math.ceil(Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight || 0));
|
|
195
|
+
if (!h || h === design.h) return;
|
|
196
|
+
design.h = h;
|
|
197
|
+
showDims(design);
|
|
198
|
+
layout();
|
|
199
|
+
}
|
|
117
200
|
|
|
118
|
-
|
|
119
|
-
this.stage.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.scale})`;
|
|
120
|
-
this.zoomLevelEl.textContent = `${Math.round(this.scale * 100)}%`;
|
|
121
|
-
this.updateHUD();
|
|
122
|
-
}
|
|
201
|
+
// ---------------------------------------------------------------- filtering and searching
|
|
123
202
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
203
|
+
// The device filters come together, so a project without tablets shows that it has none; then any
|
|
204
|
+
// other category the designs carry (components, or whatever a designs.json names).
|
|
205
|
+
const DEVICES = ['mobile', 'tablet', 'desktop'];
|
|
206
|
+
const LABELS = { '': 'All', mobile: 'Mobile', tablet: 'Tablet', desktop: 'Desktop', components: 'Components' };
|
|
129
207
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
208
|
+
// The filter lays out only its designs; the search fades the others where they are.
|
|
209
|
+
function inFilter(design) {
|
|
210
|
+
return !state.category || design.category === state.category;
|
|
211
|
+
}
|
|
134
212
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
function matches(design) {
|
|
214
|
+
return state.query.every((word) => design.search.includes(word));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The designs the filter and the search keep.
|
|
218
|
+
function shown() {
|
|
219
|
+
return state.designs.filter((d) => inFilter(d) && matches(d));
|
|
220
|
+
}
|
|
138
221
|
|
|
139
|
-
|
|
222
|
+
function buildFilters() {
|
|
223
|
+
const counts = new Map();
|
|
224
|
+
for (const d of state.designs) if (d.category) counts.set(d.category, (counts.get(d.category) || 0) + 1);
|
|
225
|
+
const others = [...counts.keys()].filter((c) => !DEVICES.includes(c))
|
|
226
|
+
.sort((a, b) => (a === 'components' ? -1 : b === 'components' ? 1 : a.localeCompare(b)));
|
|
227
|
+
const categories = ['', ...(DEVICES.some((c) => counts.has(c)) ? DEVICES : []), ...others];
|
|
228
|
+
if (state.category && !counts.has(state.category)) state.category = '';
|
|
229
|
+
els.filters.replaceChildren(...categories.map((category, i) => {
|
|
230
|
+
const count = category ? counts.get(category) || 0 : state.designs.length;
|
|
231
|
+
const label = LABELS[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
|
|
232
|
+
const button = el('button', 'filter', label);
|
|
233
|
+
button.type = 'button';
|
|
234
|
+
button.dataset.category = category;
|
|
235
|
+
button.disabled = !count;
|
|
236
|
+
button.title = `${count} design${count === 1 ? '' : 's'}${i < 9 ? ` (${i + 1})` : ''}`;
|
|
237
|
+
button.setAttribute('aria-pressed', String(category === state.category));
|
|
238
|
+
button.addEventListener('click', () => setCategory(category));
|
|
239
|
+
return button;
|
|
240
|
+
}));
|
|
241
|
+
// Nothing to tell apart: no categories, or one that every design shares
|
|
242
|
+
els.filters.hidden = !counts.size || (counts.size === 1 && counts.values().next().value === state.designs.length);
|
|
243
|
+
}
|
|
140
244
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
245
|
+
function setCategory(category, { fitAll = true } = {}) {
|
|
246
|
+
if (category === state.category) return;
|
|
247
|
+
state.category = category;
|
|
248
|
+
for (const button of els.filters.children) button.setAttribute('aria-pressed', String(button.dataset.category === category));
|
|
249
|
+
if (state.selected && !inFilter(state.selected)) select(null);
|
|
250
|
+
if (state.hover && !inFilter(state.hover)) state.hover = null;
|
|
251
|
+
state.autoFit = null;
|
|
252
|
+
applySearch();
|
|
253
|
+
layout();
|
|
254
|
+
if (fitAll) fit('all');
|
|
255
|
+
}
|
|
145
256
|
|
|
146
|
-
|
|
257
|
+
function applySearch() {
|
|
258
|
+
let count = 0;
|
|
259
|
+
for (const section of state.sections) {
|
|
260
|
+
let any = false;
|
|
261
|
+
for (const design of section.designs) {
|
|
262
|
+
const kept = inFilter(design);
|
|
263
|
+
const on = kept && matches(design);
|
|
264
|
+
design.li.hidden = !on;
|
|
265
|
+
design.frame.classList.toggle('is-dimmed', kept && !on);
|
|
266
|
+
design.label.classList.toggle('is-dimmed', kept && !on);
|
|
267
|
+
if (on) {
|
|
268
|
+
any = true;
|
|
269
|
+
count++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
section.group.hidden = !any;
|
|
147
273
|
}
|
|
274
|
+
const total = state.designs.length;
|
|
275
|
+
const narrowed = state.query.length || state.category;
|
|
276
|
+
els.count.textContent = narrowed ? `${count} of ${total}` : String(total);
|
|
277
|
+
els.noMatch.hidden = !narrowed || count > 0;
|
|
278
|
+
}
|
|
148
279
|
|
|
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;
|
|
280
|
+
function search(text) {
|
|
281
|
+
state.query = fold(text).split(/\s+/).filter(Boolean);
|
|
282
|
+
applySearch();
|
|
283
|
+
}
|
|
158
284
|
|
|
159
|
-
|
|
285
|
+
// ---------------------------------------------------------------- layout and drawing
|
|
286
|
+
|
|
287
|
+
// Sections are rows, top to bottom; designs sit left to right in each row, tops aligned.
|
|
288
|
+
function layout() {
|
|
289
|
+
let y = 0;
|
|
290
|
+
for (const section of state.sections) {
|
|
291
|
+
let x = 0;
|
|
292
|
+
let height = 0;
|
|
293
|
+
for (const design of section.designs) {
|
|
294
|
+
design.frame.hidden = !inFilter(design);
|
|
295
|
+
if (design.frame.hidden) continue;
|
|
296
|
+
design.x = x;
|
|
297
|
+
design.y = y;
|
|
298
|
+
Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
|
|
299
|
+
x += design.w + FRAME_GAP;
|
|
300
|
+
height = Math.max(height, design.h);
|
|
301
|
+
}
|
|
302
|
+
// A section the filter empties takes no room
|
|
303
|
+
Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height, shown: x > 0 });
|
|
304
|
+
if (section.shown) y += height + SECTION_GAP;
|
|
160
305
|
}
|
|
306
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
307
|
+
else paint();
|
|
308
|
+
}
|
|
161
309
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
310
|
+
function paint() {
|
|
311
|
+
const { x, y, z } = state.view;
|
|
312
|
+
els.world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;
|
|
313
|
+
// Dots every 32 canvas px, doubled or halved to stay 16 to 64 screen px apart.
|
|
314
|
+
let grid = 32 * z;
|
|
315
|
+
while (grid < 16) grid *= 2;
|
|
316
|
+
while (grid >= 64) grid /= 2;
|
|
317
|
+
els.viewport.style.setProperty('--grid', `${grid}px`);
|
|
318
|
+
els.viewport.style.backgroundPosition = `${x}px ${y}px`;
|
|
319
|
+
// Above each row: the section's title, then the designs' names. Zoomed far out the gap between
|
|
320
|
+
// rows gets thin: the names give way first, then the title.
|
|
321
|
+
let first = true;
|
|
322
|
+
for (const section of state.sections) {
|
|
323
|
+
if (!section.shown) {
|
|
324
|
+
section.el.hidden = true;
|
|
325
|
+
for (const design of section.designs) design.label.hidden = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const room = first ? Infinity : SECTION_GAP * z;
|
|
329
|
+
first = false;
|
|
330
|
+
const named = room >= TITLE_SPACE + 2;
|
|
331
|
+
const top = Math.round(y + section.y * z);
|
|
332
|
+
section.el.hidden = room < 30 || !section.name;
|
|
333
|
+
section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
|
|
334
|
+
for (const design of section.designs) {
|
|
335
|
+
if (design.frame.hidden) {
|
|
336
|
+
design.label.hidden = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const width = design.w * z;
|
|
340
|
+
design.label.hidden = !named || width < MIN_NAMED;
|
|
341
|
+
design.label.style.width = `${Math.round(width)}px`;
|
|
342
|
+
design.label.style.transform = `translate(${Math.round(x + design.x * z)}px, ${top - 22}px)`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
els.zoom.textContent = `${Math.round(z * 100)}%`;
|
|
346
|
+
paintMarks();
|
|
347
|
+
saveView();
|
|
348
|
+
}
|
|
167
349
|
|
|
168
|
-
|
|
169
|
-
|
|
350
|
+
// Selection and hover: the outlines and the names' colours.
|
|
351
|
+
function paintMarks() {
|
|
352
|
+
place(els.selection, state.selected);
|
|
353
|
+
place(els.hover, state.hover === state.selected ? null : state.hover);
|
|
354
|
+
const { selected } = state;
|
|
355
|
+
els.size.hidden = !selected;
|
|
356
|
+
if (selected) {
|
|
357
|
+
const { x, y, z } = state.view;
|
|
358
|
+
els.size.textContent = `${selected.w} × ${selected.h}`;
|
|
359
|
+
els.size.style.transform = `translate(${x + (selected.x + selected.w / 2) * z}px, ${y + (selected.y + selected.h) * z + 8}px) translateX(-50%)`;
|
|
360
|
+
}
|
|
361
|
+
for (const design of state.designs) {
|
|
362
|
+
design.label.classList.toggle('is-selected', design === state.selected);
|
|
363
|
+
design.label.classList.toggle('is-hover', design === state.hover);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
170
366
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
367
|
+
function place(node, design) {
|
|
368
|
+
node.hidden = !design;
|
|
369
|
+
if (!design) return;
|
|
370
|
+
const { x, y, z } = state.view;
|
|
371
|
+
node.style.transform = `translate(${x + design.x * z}px, ${y + design.y * z}px)`;
|
|
372
|
+
node.style.width = `${design.w * z}px`;
|
|
373
|
+
node.style.height = `${design.h * z}px`;
|
|
374
|
+
}
|
|
178
375
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
376
|
+
let saveTimer = 0;
|
|
377
|
+
function saveView() {
|
|
378
|
+
clearTimeout(saveTimer);
|
|
379
|
+
saveTimer = setTimeout(() => writeStore('view', state.view), 300);
|
|
380
|
+
}
|
|
184
381
|
|
|
185
|
-
|
|
382
|
+
// ---------------------------------------------------------------- moving the canvas
|
|
186
383
|
|
|
187
|
-
|
|
188
|
-
|
|
384
|
+
function setView(view) {
|
|
385
|
+
state.view = view;
|
|
386
|
+
paint();
|
|
387
|
+
}
|
|
189
388
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
389
|
+
function size() {
|
|
390
|
+
return { w: els.viewport.clientWidth, h: els.viewport.clientHeight };
|
|
391
|
+
}
|
|
193
392
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
393
|
+
function boundsOf(designs) {
|
|
394
|
+
if (!designs.length) return { x: 0, y: 0, w: 1, h: 1 };
|
|
395
|
+
const left = Math.min(...designs.map((d) => d.x));
|
|
396
|
+
const top = Math.min(...designs.map((d) => d.y));
|
|
397
|
+
const right = Math.max(...designs.map((d) => d.x + d.w));
|
|
398
|
+
const bottom = Math.max(...designs.map((d) => d.y + d.h));
|
|
399
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
400
|
+
}
|
|
198
401
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
402
|
+
// The view that shows `rect` whole, centred, with `top` screen px free above it for its names.
|
|
403
|
+
function viewFor(rect, top) {
|
|
404
|
+
const screen = size();
|
|
405
|
+
const w = Math.max(1, screen.w - 2 * MARGIN);
|
|
406
|
+
const h = Math.max(1, screen.h - 2 * MARGIN - top - TOOLBAR_SPACE);
|
|
407
|
+
const z = clamp(Math.min(w / rect.w, h / rect.h), MIN_ZOOM, MAX_FIT_ZOOM);
|
|
408
|
+
return {
|
|
409
|
+
z,
|
|
410
|
+
x: MARGIN + (w - rect.w * z) / 2 - rect.x * z,
|
|
411
|
+
y: MARGIN + top + (h - rect.h * z) / 2 - rect.y * z,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
202
414
|
|
|
203
|
-
|
|
415
|
+
// target: 'all' (what the search keeps) or a design.
|
|
416
|
+
function fit(target, { animate = true } = {}) {
|
|
417
|
+
if (target === 'all') {
|
|
418
|
+
const designs = shown();
|
|
419
|
+
moveTo(viewFor(boundsOf(designs.length ? designs : state.designs.filter(inFilter)), TITLE_SPACE), animate);
|
|
420
|
+
} else {
|
|
421
|
+
moveTo(viewFor(target, NAME_SPACE), animate);
|
|
204
422
|
}
|
|
423
|
+
}
|
|
205
424
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
425
|
+
let animation = 0;
|
|
426
|
+
function moveTo(target, animate = true) {
|
|
427
|
+
cancelAnimationFrame(animation);
|
|
428
|
+
if (!animate || reducedMotion.matches) {
|
|
429
|
+
setView(target);
|
|
430
|
+
return;
|
|
209
431
|
}
|
|
432
|
+
const from = { ...state.view };
|
|
433
|
+
const screen = size();
|
|
434
|
+
const cx = screen.w / 2;
|
|
435
|
+
const cy = screen.h / 2;
|
|
436
|
+
// Glide the canvas point under the centre of the screen, and the zoom in proportion.
|
|
437
|
+
const a = { x: (cx - from.x) / from.z, y: (cy - from.y) / from.z };
|
|
438
|
+
const b = { x: (cx - target.x) / target.z, y: (cy - target.y) / target.z };
|
|
439
|
+
const start = performance.now();
|
|
440
|
+
const frame = (now) => {
|
|
441
|
+
const t = Math.min(1, (now - start) / 260);
|
|
442
|
+
const e = 1 - (1 - t) ** 3;
|
|
443
|
+
const z = from.z * (target.z / from.z) ** e;
|
|
444
|
+
setView({ x: cx - (a.x + (b.x - a.x) * e) * z, y: cy - (a.y + (b.y - a.y) * e) * z, z });
|
|
445
|
+
if (t < 1) animation = requestAnimationFrame(frame);
|
|
446
|
+
};
|
|
447
|
+
animation = requestAnimationFrame(frame);
|
|
448
|
+
}
|
|
210
449
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
450
|
+
// Zoom keeping the canvas point at (px, py) of the screen in place.
|
|
451
|
+
function zoomedAt(px, py, z) {
|
|
452
|
+
const { x, y, z: from } = state.view;
|
|
453
|
+
z = clamp(z, MIN_ZOOM, MAX_ZOOM);
|
|
454
|
+
return { x: px - ((px - x) * z) / from, y: py - ((py - y) * z) / from, z };
|
|
455
|
+
}
|
|
215
456
|
|
|
216
|
-
|
|
217
|
-
|
|
457
|
+
function zoomBy(factor) {
|
|
458
|
+
const screen = size();
|
|
459
|
+
moveTo(zoomedAt(screen.w / 2, screen.h / 2, state.view.z * factor));
|
|
460
|
+
}
|
|
218
461
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
462
|
+
// 100%: the selected design, centred (top first when it is taller than the screen), or the
|
|
463
|
+
// middle of the screen.
|
|
464
|
+
function actualSize() {
|
|
465
|
+
const design = state.selected;
|
|
466
|
+
if (!design) {
|
|
467
|
+
zoomBy(1 / state.view.z);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const screen = size();
|
|
471
|
+
const room = screen.h - TOOLBAR_SPACE - NAME_SPACE - 2 * MARGIN;
|
|
472
|
+
const y = design.h <= room
|
|
473
|
+
? MARGIN + NAME_SPACE + (room - design.h) / 2 - design.y
|
|
474
|
+
: MARGIN + NAME_SPACE - design.y;
|
|
475
|
+
moveTo({ z: 1, x: (screen.w - design.w) / 2 - design.x, y });
|
|
476
|
+
}
|
|
222
477
|
|
|
223
|
-
|
|
224
|
-
item.classList.toggle('active', item.dataset.id === artboardId);
|
|
225
|
-
});
|
|
478
|
+
// ---------------------------------------------------------------- selection
|
|
226
479
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
480
|
+
function select(design, { fit: fitIt = false, animate = true } = {}) {
|
|
481
|
+
state.selected = design || null;
|
|
482
|
+
for (const each of state.designs) {
|
|
483
|
+
if (each === state.selected) each.item.setAttribute('aria-current', 'true');
|
|
484
|
+
else each.item.removeAttribute('aria-current');
|
|
485
|
+
}
|
|
486
|
+
els.open.href = state.selected ? urlOf(state.selected.file) : '#';
|
|
487
|
+
els.open.setAttribute('aria-disabled', String(!state.selected));
|
|
488
|
+
const hash = state.selected ? `#${encodeURIComponent(state.selected.id)}` : '';
|
|
489
|
+
if (location.hash !== hash) history.replaceState(null, '', hash || location.pathname + location.search);
|
|
490
|
+
if (state.selected && !state.selected.li.hidden) state.selected.item.scrollIntoView({ block: 'nearest' });
|
|
491
|
+
if (state.selected && fitIt) fit(state.selected, { animate });
|
|
492
|
+
else paintMarks();
|
|
493
|
+
}
|
|
234
494
|
|
|
235
|
-
|
|
495
|
+
// #<id>, or the design's file (relative to the designs folder, with or without .html).
|
|
496
|
+
function designFromHash() {
|
|
497
|
+
const name = decodeURIComponent(location.hash.slice(1));
|
|
498
|
+
if (!name) return null;
|
|
499
|
+
const bare = (file) => file.replace(/\.html?(?=$|\?)/i, '');
|
|
500
|
+
return state.designs.find((d) => d.id === name)
|
|
501
|
+
|| state.designs.find((d) => [d.file, bare(d.file)].some((file) => file === name || file.endsWith(`/${name}`)))
|
|
502
|
+
|| null;
|
|
503
|
+
}
|
|
236
504
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
505
|
+
function step(delta) {
|
|
506
|
+
const designs = shown();
|
|
507
|
+
const count = designs.length;
|
|
508
|
+
if (!count) return;
|
|
509
|
+
const at = designs.indexOf(state.selected);
|
|
510
|
+
const next = at < 0 ? (delta > 0 ? 0 : count - 1) : (at + delta + count) % count;
|
|
511
|
+
select(designs[next], { fit: true });
|
|
512
|
+
}
|
|
240
513
|
|
|
241
|
-
|
|
242
|
-
|
|
514
|
+
function setHover(design) {
|
|
515
|
+
if (state.hover === design) return;
|
|
516
|
+
state.hover = design;
|
|
517
|
+
paintMarks();
|
|
518
|
+
}
|
|
243
519
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
}
|
|
520
|
+
// The design under a point of the screen: its frame, or its name just above it.
|
|
521
|
+
function hit(point) {
|
|
522
|
+
const { x, y, z } = state.view;
|
|
523
|
+
const cx = (point.x - x) / z;
|
|
524
|
+
const cy = (point.y - y) / z;
|
|
525
|
+
for (let i = state.designs.length - 1; i >= 0; i--) {
|
|
526
|
+
const d = state.designs[i];
|
|
527
|
+
if (d.frame.hidden) continue;
|
|
528
|
+
const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
|
|
529
|
+
if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
|
|
261
530
|
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
262
533
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const dy = e.clientY - this.startY;
|
|
267
|
-
this.panX = this.initialPanX + dx;
|
|
268
|
-
this.panY = this.initialPanY + dy;
|
|
269
|
-
this.applyTransform();
|
|
270
|
-
}
|
|
534
|
+
function openSelected() {
|
|
535
|
+
if (state.selected) window.open(urlOf(state.selected.file), '_blank', 'noopener');
|
|
536
|
+
}
|
|
271
537
|
|
|
272
|
-
|
|
273
|
-
if (this.isPanning) {
|
|
274
|
-
this.isPanning = false;
|
|
275
|
-
this.viewport.classList.remove('panning');
|
|
276
|
-
if (!this.isSpaceDown) {
|
|
277
|
-
this.shield.classList.remove('active');
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
538
|
+
// ---------------------------------------------------------------- panels and messages
|
|
281
539
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this.panY -= e.deltaY;
|
|
292
|
-
this.applyTransform();
|
|
293
|
-
}
|
|
294
|
-
}
|
|
540
|
+
function toggleList(show = els.app.classList.contains('is-collapsed')) {
|
|
541
|
+
// The canvas keeps its place on screen while the list comes and goes.
|
|
542
|
+
const before = els.viewport.getBoundingClientRect().left;
|
|
543
|
+
els.app.classList.toggle('is-collapsed', !show);
|
|
544
|
+
const after = els.viewport.getBoundingClientRect().left;
|
|
545
|
+
els.toggleList.setAttribute('aria-pressed', String(show));
|
|
546
|
+
writeStore('list', show);
|
|
547
|
+
setView({ ...state.view, x: state.view.x + before - after });
|
|
548
|
+
}
|
|
295
549
|
|
|
296
|
-
|
|
550
|
+
function toggleHelp(show = els.help.hidden) {
|
|
551
|
+
els.help.hidden = !show;
|
|
552
|
+
els.helpToggle.setAttribute('aria-expanded', String(show));
|
|
553
|
+
}
|
|
297
554
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
555
|
+
let toastTimer = 0;
|
|
556
|
+
function toast(text) {
|
|
557
|
+
els.toast.textContent = text;
|
|
558
|
+
els.toast.classList.add('is-on');
|
|
559
|
+
clearTimeout(toastTimer);
|
|
560
|
+
toastTimer = setTimeout(() => els.toast.classList.remove('is-on'), 1800);
|
|
561
|
+
}
|
|
305
562
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
} else if (e.key === '-') {
|
|
312
|
-
this.zoomRelative(0.85);
|
|
313
|
-
} else if (e.key === '0') {
|
|
314
|
-
this.setZoom(1.0);
|
|
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
|
-
}
|
|
563
|
+
function showNotice(title, text) {
|
|
564
|
+
els.noticeTitle.textContent = title;
|
|
565
|
+
els.noticeText.textContent = text;
|
|
566
|
+
els.notice.hidden = false;
|
|
567
|
+
}
|
|
332
568
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
}
|
|
569
|
+
// "A", "A and B", "A, B and 3 more".
|
|
570
|
+
function names(designs) {
|
|
571
|
+
const titles = designs.map((d) => d.title);
|
|
572
|
+
if (titles.length <= 2) return titles.join(' and ');
|
|
573
|
+
return `${titles.slice(0, 2).join(', ')} and ${titles.length - 2} more`;
|
|
574
|
+
}
|
|
341
575
|
|
|
342
|
-
|
|
343
|
-
this.shortcutsModal.classList.add('open');
|
|
344
|
-
}
|
|
576
|
+
// ---------------------------------------------------------------- reloading
|
|
345
577
|
|
|
346
|
-
|
|
347
|
-
|
|
578
|
+
function reloadFrame(design) {
|
|
579
|
+
try {
|
|
580
|
+
design.iframe.contentWindow.location.reload();
|
|
581
|
+
} catch {
|
|
582
|
+
design.iframe.src = urlOf(design.file);
|
|
348
583
|
}
|
|
584
|
+
}
|
|
349
585
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
586
|
+
// Whether a design's page loaded the file: its stylesheets, scripts, images, fonts, fetches.
|
|
587
|
+
function uses(design, path) {
|
|
588
|
+
let entries;
|
|
589
|
+
try {
|
|
590
|
+
entries = design.iframe.contentWindow.performance.getEntriesByType('resource');
|
|
591
|
+
} catch {
|
|
592
|
+
return true; // can't tell: reload it
|
|
593
|
+
}
|
|
594
|
+
if (entries.length >= 250) return true; // the browser stops listing at 250: can't tell
|
|
595
|
+
return entries.some((entry) => {
|
|
353
596
|
try {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
}
|
|
375
|
-
|
|
376
|
-
this.renderSidebar();
|
|
377
|
-
this.renderCanvas();
|
|
378
|
-
|
|
379
|
-
// Place the view after the first layout only, so a rebuild keeps the user's pan and zoom:
|
|
380
|
-
// on the artboard named in the URL (#<id>), else on everything
|
|
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';
|
|
597
|
+
const url = new URL(entry.name);
|
|
598
|
+
return url.origin === location.origin && decodeURIComponent(url.pathname) === path;
|
|
599
|
+
} catch {
|
|
600
|
+
return false;
|
|
392
601
|
}
|
|
393
|
-
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
394
604
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
// Category filter
|
|
401
|
-
if (this.currentCategory !== 'all') {
|
|
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);
|
|
423
|
-
}
|
|
605
|
+
function rebuild(manifest) {
|
|
606
|
+
const selected = state.selected?.id;
|
|
607
|
+
build(manifest);
|
|
608
|
+
select(state.designs.find((d) => d.id === selected) || null);
|
|
609
|
+
}
|
|
424
610
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
}
|
|
611
|
+
// What a new canvas changed, from the designs before and after it.
|
|
612
|
+
function changesBetween(before, after) {
|
|
613
|
+
const added = after.filter((d) => !before.some((b) => b.id === d.id));
|
|
614
|
+
const removed = before.filter((b) => !after.some((d) => d.id === b.id));
|
|
615
|
+
if (added.length && !removed.length) return `New: ${names(added)}`;
|
|
616
|
+
if (removed.length && !added.length) return `Removed: ${names(removed)}`;
|
|
617
|
+
if (added.length) return `${added.length} new, ${removed.length} removed`;
|
|
618
|
+
return 'Canvas updated';
|
|
619
|
+
}
|
|
463
620
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
621
|
+
// The same designs in the same sections and order: what changed is names, sizes or categories.
|
|
622
|
+
function sameShape(manifest) {
|
|
623
|
+
const sections = manifest.sections || [];
|
|
624
|
+
if (sections.length !== state.sections.length) return false;
|
|
625
|
+
let i = 0;
|
|
626
|
+
return sections.every((entry, s) => (entry.name || '') === state.sections[s].name
|
|
627
|
+
&& (entry.artboards || []).length === state.sections[s].designs.length
|
|
628
|
+
&& entry.artboards.every((board) => {
|
|
629
|
+
const design = state.designs[i++];
|
|
630
|
+
return design && design.id === String(board.id || board.file) && design.file === String(board.file);
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
467
633
|
|
|
468
|
-
|
|
634
|
+
// Names, sizes, categories and descriptions changed in place: no frame reloads.
|
|
635
|
+
function patch(manifest) {
|
|
636
|
+
const boards = manifest.sections.flatMap((section) => section.artboards.map((board) => [board, section]));
|
|
637
|
+
boards.forEach(([board, section], i) => {
|
|
638
|
+
const design = state.designs[i];
|
|
639
|
+
design.title = board.title || design.file;
|
|
640
|
+
design.label.textContent = design.title;
|
|
641
|
+
design.iframe.title = design.title;
|
|
642
|
+
design.item.querySelector('.name').textContent = design.title;
|
|
643
|
+
if (board.description) design.item.title = board.description;
|
|
644
|
+
else design.item.removeAttribute('title');
|
|
645
|
+
design.category = board.category || '';
|
|
646
|
+
design.search = fold([design.title, design.file, board.description, board.category, ...(board.tags || []), section.name]
|
|
647
|
+
.filter(Boolean).join(' '));
|
|
648
|
+
const autoHeight = !Number(board.height);
|
|
649
|
+
if (Number(board.width)) design.w = Number(board.width);
|
|
650
|
+
if (!autoHeight) design.h = Number(board.height);
|
|
651
|
+
else if (!design.autoHeight) growToPage(design);
|
|
652
|
+
design.autoHeight = autoHeight;
|
|
653
|
+
showDims(design);
|
|
654
|
+
});
|
|
655
|
+
state.manifest = JSON.stringify(manifest);
|
|
656
|
+
buildFilters();
|
|
657
|
+
applySearch();
|
|
658
|
+
layout();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Reads the designs again: 'rebuilt' (a new canvas), 'patched' (names or sizes) or false (no change).
|
|
662
|
+
// message: a text, or nothing to describe the change.
|
|
663
|
+
async function rebuildIfChanged(message) {
|
|
664
|
+
let manifest;
|
|
665
|
+
try {
|
|
666
|
+
manifest = await fetchManifest();
|
|
667
|
+
} catch {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
if (JSON.stringify(manifest) === state.manifest) return false;
|
|
671
|
+
if (sameShape(manifest)) {
|
|
672
|
+
patch(manifest);
|
|
673
|
+
return 'patched';
|
|
469
674
|
}
|
|
675
|
+
const before = state.designs.map(({ id, title }) => ({ id, title }));
|
|
676
|
+
rebuild(manifest);
|
|
677
|
+
toast(message || changesBetween(before, state.designs));
|
|
678
|
+
return 'rebuilt';
|
|
679
|
+
}
|
|
470
680
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
this.applyTransform();
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
681
|
+
async function reloadAll() {
|
|
682
|
+
let manifest;
|
|
683
|
+
try {
|
|
684
|
+
manifest = await fetchManifest();
|
|
685
|
+
} catch (err) {
|
|
686
|
+
toast(`Could not read the designs (${err.message})`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (JSON.stringify(manifest) !== state.manifest) rebuild(manifest);
|
|
690
|
+
else state.designs.forEach(reloadFrame);
|
|
691
|
+
toast('Designs reloaded');
|
|
692
|
+
}
|
|
486
693
|
|
|
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
|
-
}
|
|
694
|
+
const pending = { files: new Set(), manifest: false, folders: false };
|
|
695
|
+
let changeTimer = 0;
|
|
507
696
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
697
|
+
function onChange(change) {
|
|
698
|
+
if (change.type === 'manifest') pending.manifest = true;
|
|
699
|
+
else if (change.type === 'folder') pending.folders = true;
|
|
700
|
+
else if (change.file) pending.files.add(change.file);
|
|
701
|
+
clearTimeout(changeTimer);
|
|
702
|
+
changeTimer = setTimeout(applyChanges, CHANGE_MS);
|
|
703
|
+
}
|
|
511
704
|
|
|
512
|
-
|
|
705
|
+
async function applyChanges() {
|
|
706
|
+
const files = [...pending.files];
|
|
707
|
+
const { manifest: manifestChanged, folders: foldersChanged } = pending;
|
|
708
|
+
pending.files.clear();
|
|
709
|
+
pending.manifest = false;
|
|
710
|
+
pending.folders = false;
|
|
711
|
+
|
|
712
|
+
// A new canvas loads every design afresh, so nothing else is left to do.
|
|
713
|
+
if (manifestChanged) {
|
|
714
|
+
const result = await rebuildIfChanged('designs.json changed: canvas rebuilt');
|
|
715
|
+
if (result === 'rebuilt') return;
|
|
716
|
+
if (result === 'patched' && !files.length) toast('designs.json changed: names and sizes updated');
|
|
717
|
+
}
|
|
718
|
+
// Every HTML file is a design, and it says its own name, size and variants in its <head>: a saved
|
|
719
|
+
// page may be a new design, a renamed or resized one. A folder added or removed may bring or
|
|
720
|
+
// take a whole group of them.
|
|
721
|
+
const pages = files.filter((file) => /\.html?$/i.test(file));
|
|
722
|
+
if (foldersChanged || pages.length) {
|
|
723
|
+
if (await rebuildIfChanged() === 'rebuilt') return;
|
|
513
724
|
}
|
|
514
725
|
|
|
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
|
-
});
|
|
726
|
+
const paths = files.map((file) => `/${file}`);
|
|
727
|
+
const frames = state.designs.filter((d) => paths.includes(d.path) || paths.some((path) => uses(d, path)));
|
|
728
|
+
if (!frames.length) return;
|
|
729
|
+
// Name the shared files the designs use (a stylesheet, a script), not whatever else was saved.
|
|
730
|
+
// Asked before reloading: a reloading page forgets what it loaded.
|
|
731
|
+
const shared = paths
|
|
732
|
+
.filter((path) => !state.designs.some((d) => d.path === path) && frames.some((d) => uses(d, path)))
|
|
733
|
+
.map((path) => path.slice(1));
|
|
734
|
+
frames.forEach(reloadFrame);
|
|
735
|
+
toast(shared.length
|
|
736
|
+
? `${shared.join(', ')} changed: ${frames.length === 1 ? frames[0].title : `${frames.length} designs`} reloaded`
|
|
737
|
+
: `Updated: ${names(frames)}`);
|
|
738
|
+
}
|
|
580
739
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
740
|
+
function connect() {
|
|
741
|
+
const source = new EventSource('/api/events');
|
|
742
|
+
let lost = false;
|
|
743
|
+
source.addEventListener('connected', () => {
|
|
744
|
+
setLive(true);
|
|
745
|
+
// Back after the server stopped: files may have changed meanwhile.
|
|
746
|
+
if (lost) reloadAll();
|
|
747
|
+
lost = false;
|
|
748
|
+
});
|
|
749
|
+
source.addEventListener('change', (event) => {
|
|
750
|
+
try {
|
|
751
|
+
onChange(JSON.parse(event.data));
|
|
752
|
+
} catch {
|
|
753
|
+
// Not ours.
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
// EventSource keeps trying to reconnect by itself.
|
|
757
|
+
source.addEventListener('error', () => {
|
|
758
|
+
lost = true;
|
|
759
|
+
setLive(false);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
584
762
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
763
|
+
function setLive(on) {
|
|
764
|
+
els.live.hidden = false;
|
|
765
|
+
els.live.classList.toggle('is-off', !on);
|
|
766
|
+
els.live.textContent = on ? 'Live' : 'Offline';
|
|
767
|
+
els.live.title = on
|
|
768
|
+
? 'Watching the files: saved changes show up by themselves'
|
|
769
|
+
: 'Lost the server: start htmlboard again and the canvas reconnects';
|
|
770
|
+
}
|
|
589
771
|
|
|
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
|
-
});
|
|
772
|
+
// ---------------------------------------------------------------- input
|
|
601
773
|
|
|
602
|
-
|
|
603
|
-
|
|
774
|
+
const pointers = new Map();
|
|
775
|
+
let press = null; // one pointer down: a click, or a drag that pans
|
|
776
|
+
let pinch = null; // two pointers down
|
|
777
|
+
let lastClick = { time: 0, design: null };
|
|
778
|
+
let spaceDown = false;
|
|
604
779
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
780
|
+
function local(event) {
|
|
781
|
+
const box = els.viewport.getBoundingClientRect();
|
|
782
|
+
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
783
|
+
}
|
|
609
784
|
|
|
610
|
-
|
|
785
|
+
function onCanvas(event) {
|
|
786
|
+
return !event.target.closest('.toolbar, .help, .notice');
|
|
787
|
+
}
|
|
611
788
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
789
|
+
function wire() {
|
|
790
|
+
const { viewport } = els;
|
|
791
|
+
|
|
792
|
+
viewport.addEventListener('wheel', (event) => {
|
|
793
|
+
if (!onCanvas(event)) return;
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
state.autoFit = null;
|
|
796
|
+
cancelAnimationFrame(animation);
|
|
797
|
+
let dx = event.deltaX;
|
|
798
|
+
let dy = event.deltaY;
|
|
799
|
+
if (event.deltaMode === 1) {
|
|
800
|
+
dx *= 16;
|
|
801
|
+
dy *= 16;
|
|
802
|
+
} else if (event.deltaMode === 2) {
|
|
803
|
+
dx *= viewport.clientWidth;
|
|
804
|
+
dy *= viewport.clientHeight;
|
|
615
805
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
806
|
+
if (event.ctrlKey || event.metaKey) {
|
|
807
|
+
// Pinches arrive as Ctrl + small deltas; a mouse wheel notch is capped to one gentle step.
|
|
808
|
+
const point = local(event);
|
|
809
|
+
setView(zoomedAt(point.x, point.y, state.view.z * Math.exp(-clamp(dy, -50, 50) / 100)));
|
|
810
|
+
} else {
|
|
811
|
+
if (event.shiftKey && !dx) {
|
|
812
|
+
dx = dy;
|
|
813
|
+
dy = 0;
|
|
814
|
+
}
|
|
815
|
+
setView({ ...state.view, x: state.view.x - dx, y: state.view.y - dy });
|
|
816
|
+
}
|
|
817
|
+
}, { passive: false });
|
|
818
|
+
|
|
819
|
+
// Ctrl + scroll and pinches anywhere else (the list, the toolbar) would zoom the whole page.
|
|
820
|
+
document.addEventListener('wheel', (event) => {
|
|
821
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
822
|
+
}, { passive: false });
|
|
823
|
+
|
|
824
|
+
// Safari reports trackpad pinches as gestures instead of Ctrl + wheel.
|
|
825
|
+
let gestureZoom = 1;
|
|
826
|
+
document.addEventListener('gesturestart', (event) => event.preventDefault());
|
|
827
|
+
viewport.addEventListener('gesturestart', (event) => {
|
|
828
|
+
event.preventDefault();
|
|
829
|
+
gestureZoom = state.view.z;
|
|
830
|
+
});
|
|
831
|
+
viewport.addEventListener('gesturechange', (event) => {
|
|
832
|
+
event.preventDefault();
|
|
833
|
+
state.autoFit = null;
|
|
834
|
+
const point = local(event);
|
|
835
|
+
setView(zoomedAt(point.x, point.y, gestureZoom * event.scale));
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
viewport.addEventListener('pointerdown', (event) => {
|
|
839
|
+
if (!onCanvas(event) || (event.button !== 0 && event.button !== 1)) return;
|
|
840
|
+
event.preventDefault(); // no text selection, no middle-click autoscroll
|
|
841
|
+
if (document.activeElement === els.search) els.search.blur();
|
|
842
|
+
viewport.focus({ preventScroll: true });
|
|
843
|
+
viewport.setPointerCapture(event.pointerId);
|
|
844
|
+
state.autoFit = null;
|
|
845
|
+
const point = local(event);
|
|
846
|
+
pointers.set(event.pointerId, point);
|
|
847
|
+
if (pointers.size === 2) {
|
|
848
|
+
cancelAnimationFrame(animation);
|
|
849
|
+
const [a, b] = [...pointers.values()];
|
|
850
|
+
pinch = {
|
|
851
|
+
distance: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
852
|
+
middle: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
853
|
+
view: { ...state.view },
|
|
637
854
|
};
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
855
|
+
press = null;
|
|
856
|
+
viewport.classList.remove('is-panning');
|
|
857
|
+
return;
|
|
641
858
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
859
|
+
if (pointers.size > 2) return;
|
|
860
|
+
press = { id: event.pointerId, ...point, moved: false, pan: event.button === 1 || spaceDown, touch: event.pointerType !== 'mouse' };
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
viewport.addEventListener('pointermove', (event) => {
|
|
864
|
+
const point = local(event);
|
|
865
|
+
if (pointers.has(event.pointerId)) pointers.set(event.pointerId, point);
|
|
866
|
+
if (pinch) {
|
|
867
|
+
if (pointers.size < 2) return;
|
|
868
|
+
const [a, b] = [...pointers.values()];
|
|
869
|
+
const z = clamp((pinch.view.z * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.distance, MIN_ZOOM, MAX_ZOOM);
|
|
870
|
+
// The canvas point under the fingers' starting middle follows their middle now.
|
|
871
|
+
const cx = (pinch.middle.x - pinch.view.x) / pinch.view.z;
|
|
872
|
+
const cy = (pinch.middle.y - pinch.view.y) / pinch.view.z;
|
|
873
|
+
setView({ x: (a.x + b.x) / 2 - cx * z, y: (a.y + b.y) / 2 - cy * z, z });
|
|
648
874
|
return;
|
|
649
875
|
}
|
|
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;
|
|
876
|
+
if (press && event.pointerId === press.id) {
|
|
877
|
+
if (!press.moved && Math.hypot(point.x - press.x, point.y - press.y) > DRAG_SLOP) {
|
|
878
|
+
// The drag starts here, from wherever a glide has got to.
|
|
879
|
+
cancelAnimationFrame(animation);
|
|
880
|
+
Object.assign(press, { moved: true, view: { ...state.view }, x: point.x, y: point.y });
|
|
881
|
+
viewport.classList.add('is-panning');
|
|
882
|
+
setHover(null);
|
|
661
883
|
}
|
|
662
|
-
|
|
884
|
+
if (press.moved) setView({ ...state.view, x: press.view.x + point.x - press.x, y: press.view.y + point.y - press.y });
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (event.pointerType === 'mouse' && !pointers.size && onCanvas(event)) setHover(hit(point));
|
|
888
|
+
});
|
|
663
889
|
|
|
664
|
-
|
|
890
|
+
const release = (event) => {
|
|
891
|
+
if (!pointers.delete(event.pointerId)) return;
|
|
892
|
+
if (pinch) {
|
|
893
|
+
if (!pointers.size) pinch = null;
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!press || event.pointerId !== press.id) return;
|
|
897
|
+
const done = press;
|
|
898
|
+
press = null;
|
|
899
|
+
viewport.classList.remove('is-panning');
|
|
900
|
+
if (done.moved || done.pan || event.type === 'pointercancel') return;
|
|
901
|
+
const design = hit(local(event));
|
|
902
|
+
const now = performance.now();
|
|
903
|
+
if (done.touch && design && design === lastClick.design && now - lastClick.time < DOUBLE_MS) {
|
|
904
|
+
lastClick = { time: 0, design: null };
|
|
905
|
+
select(design, { fit: true });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
lastClick = { time: now, design };
|
|
909
|
+
select(design);
|
|
910
|
+
};
|
|
911
|
+
viewport.addEventListener('dblclick', (event) => {
|
|
912
|
+
if (!onCanvas(event)) return;
|
|
913
|
+
const design = hit(local(event));
|
|
914
|
+
if (design) select(design, { fit: true });
|
|
915
|
+
});
|
|
916
|
+
viewport.addEventListener('pointerup', release);
|
|
917
|
+
viewport.addEventListener('pointercancel', release);
|
|
918
|
+
viewport.addEventListener('pointerleave', () => {
|
|
919
|
+
if (!pointers.size) setHover(null);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
els.search.addEventListener('input', () => search(els.search.value));
|
|
923
|
+
els.search.addEventListener('keydown', (event) => {
|
|
924
|
+
if (event.key === 'Escape') {
|
|
925
|
+
event.preventDefault();
|
|
926
|
+
if (els.search.value) {
|
|
927
|
+
els.search.value = '';
|
|
928
|
+
search('');
|
|
929
|
+
} else {
|
|
930
|
+
els.search.blur();
|
|
931
|
+
}
|
|
932
|
+
} else if (event.key === 'Enter') {
|
|
933
|
+
// The first design found, fitted.
|
|
934
|
+
event.preventDefault();
|
|
935
|
+
const [first] = shown();
|
|
936
|
+
if (first) {
|
|
937
|
+
state.autoFit = null;
|
|
938
|
+
select(first, { fit: true });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
document.addEventListener('keydown', (event) => {
|
|
944
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
945
|
+
// Typing in the search (it has its own keys above) is not a shortcut.
|
|
946
|
+
if (event.target.closest?.('input, textarea, select, [contenteditable]')) return;
|
|
947
|
+
const onControl = event.target.closest?.('button, a');
|
|
948
|
+
const key = event.key;
|
|
949
|
+
if (key === ' ' || event.code === 'Space') {
|
|
950
|
+
if (onControl) return;
|
|
951
|
+
event.preventDefault();
|
|
952
|
+
spaceDown = true;
|
|
953
|
+
viewport.classList.add('is-space');
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
let handled = true;
|
|
957
|
+
if ((event.shiftKey && event.code === 'Digit1') || key === 'f' || key === 'F') fit('all');
|
|
958
|
+
else if (event.shiftKey && event.code === 'Digit2') {
|
|
959
|
+
if (state.selected) fit(state.selected);
|
|
960
|
+
} else if ((event.shiftKey && event.code === 'Digit0') || key === '0') actualSize();
|
|
961
|
+
else if (key === '+' || key === '=' || event.code === 'NumpadAdd') zoomBy(ZOOM_STEP);
|
|
962
|
+
else if (key === '-' || key === '_' || event.code === 'NumpadSubtract') zoomBy(1 / ZOOM_STEP);
|
|
963
|
+
else if (key === 'ArrowRight' || key === 'ArrowDown') step(1);
|
|
964
|
+
else if (key === 'ArrowLeft' || key === 'ArrowUp') step(-1);
|
|
965
|
+
else if (key === 'Escape') {
|
|
966
|
+
// One thing at a time: the shortcuts, the selection, the search, the filter
|
|
967
|
+
if (!els.help.hidden) toggleHelp(false);
|
|
968
|
+
else if (state.selected) select(null);
|
|
969
|
+
else if (els.search.value) {
|
|
970
|
+
els.search.value = '';
|
|
971
|
+
search('');
|
|
972
|
+
} else if (state.category) setCategory('');
|
|
973
|
+
} else if (key === 'Enter' && !onControl) openSelected();
|
|
974
|
+
else if (key === 'r' || key === 'R') reloadAll();
|
|
975
|
+
else if (key === '/') {
|
|
976
|
+
if (els.app.classList.contains('is-collapsed')) toggleList(true);
|
|
977
|
+
els.search.focus();
|
|
978
|
+
els.search.select();
|
|
979
|
+
} else if (key === '?') toggleHelp();
|
|
980
|
+
else if (/^[1-9]$/.test(key) && !event.shiftKey && !els.filters.hidden) {
|
|
981
|
+
// 1 shows all, 2 to 4 mobile, tablet, desktop, then the other categories
|
|
982
|
+
const button = els.filters.children[Number(key) - 1];
|
|
983
|
+
if (button && !button.disabled) setCategory(button.dataset.category);
|
|
984
|
+
else handled = false;
|
|
985
|
+
}
|
|
986
|
+
else if (key === '\\' || key === '[' || key === ']') toggleList();
|
|
987
|
+
else handled = false;
|
|
988
|
+
if (handled) {
|
|
989
|
+
event.preventDefault();
|
|
990
|
+
state.autoFit = null;
|
|
991
|
+
}
|
|
992
|
+
});
|
|
665
993
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// A shared file (CSS, script, image, font) may be used by any artboard
|
|
671
|
-
iframes.forEach((iframe) => this.reloadIframe(iframe));
|
|
994
|
+
document.addEventListener('keyup', (event) => {
|
|
995
|
+
if (event.key === ' ' || event.code === 'Space') {
|
|
996
|
+
spaceDown = false;
|
|
997
|
+
viewport.classList.remove('is-space');
|
|
672
998
|
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
window.addEventListener('blur', () => {
|
|
1002
|
+
spaceDown = false;
|
|
1003
|
+
viewport.classList.remove('is-space');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
window.addEventListener('resize', () => {
|
|
1007
|
+
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
1008
|
+
else paint();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
window.addEventListener('hashchange', () => {
|
|
1012
|
+
const design = designFromHash();
|
|
1013
|
+
if (design && design !== state.selected) {
|
|
1014
|
+
state.autoFit = null;
|
|
1015
|
+
if (!inFilter(design)) setCategory('', { fitAll: false });
|
|
1016
|
+
select(design, { fit: true });
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
$('zoom-in').addEventListener('click', () => zoomBy(ZOOM_STEP));
|
|
1021
|
+
$('zoom-out').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
|
|
1022
|
+
els.zoom.addEventListener('click', actualSize);
|
|
1023
|
+
$('fit-all').addEventListener('click', () => fit('all'));
|
|
1024
|
+
$('reload').addEventListener('click', reloadAll);
|
|
1025
|
+
els.toggleList.addEventListener('click', () => toggleList());
|
|
1026
|
+
els.helpToggle.addEventListener('click', () => toggleHelp());
|
|
1027
|
+
for (const id of ['zoom-in', 'zoom-out', 'zoom-reset', 'fit-all']) {
|
|
1028
|
+
$(id).addEventListener('click', () => {
|
|
1029
|
+
state.autoFit = null;
|
|
1030
|
+
});
|
|
673
1031
|
}
|
|
674
1032
|
}
|
|
675
1033
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1034
|
+
// ---------------------------------------------------------------- start
|
|
1035
|
+
|
|
1036
|
+
async function start() {
|
|
1037
|
+
let manifest;
|
|
1038
|
+
try {
|
|
1039
|
+
const [designs, health] = await Promise.all([
|
|
1040
|
+
fetchManifest(),
|
|
1041
|
+
fetch('/api/health', { cache: 'no-store' }).then((res) => res.json()).catch(() => ({})),
|
|
1042
|
+
]);
|
|
1043
|
+
manifest = designs;
|
|
1044
|
+
if (health.dir) storePrefix = `htmlboard:${health.dir}:`;
|
|
1045
|
+
if (health.version) els.version.textContent = health.version;
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
showNotice('Could not load the designs', `${err.message}. Is htmlboard still running?`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const list = readStore('list');
|
|
1052
|
+
if (list === false || (list === null && innerWidth < 720)) {
|
|
1053
|
+
els.app.classList.add('is-collapsed');
|
|
1054
|
+
els.toggleList.setAttribute('aria-pressed', 'false');
|
|
1055
|
+
}
|
|
1056
|
+
els.viewport.tabIndex = -1;
|
|
1057
|
+
build(manifest);
|
|
1058
|
+
wire();
|
|
1059
|
+
|
|
1060
|
+
const design = designFromHash();
|
|
1061
|
+
const saved = readStore('view');
|
|
1062
|
+
if (design) {
|
|
1063
|
+
state.autoFit = design;
|
|
1064
|
+
select(design, { fit: true, animate: false });
|
|
1065
|
+
} else if (saved && [saved.x, saved.y, saved.z].every(Number.isFinite) && saved.z > 0) {
|
|
1066
|
+
setView({ x: saved.x, y: saved.y, z: clamp(saved.z, MIN_ZOOM, MAX_ZOOM) });
|
|
1067
|
+
} else {
|
|
1068
|
+
state.autoFit = 'all';
|
|
1069
|
+
fit('all', { animate: false });
|
|
1070
|
+
}
|
|
679
1071
|
|
|
680
|
-
|
|
681
|
-
if (!str) return '';
|
|
682
|
-
return String(str)
|
|
683
|
-
.replace(/&/g, '&')
|
|
684
|
-
.replace(/</g, '<')
|
|
685
|
-
.replace(/>/g, '>')
|
|
686
|
-
.replace(/"/g, '"')
|
|
687
|
-
.replace(/'/g, ''');
|
|
1072
|
+
connect();
|
|
688
1073
|
}
|
|
689
1074
|
|
|
690
|
-
|
|
691
|
-
window.addEventListener('DOMContentLoaded', () => {
|
|
692
|
-
window.__htmlboardExplorer = new HtmlBoardExplorer();
|
|
693
|
-
});
|
|
1075
|
+
start();
|