@lark-apaas/coding-registry-buildless 0.1.20 → 0.1.22
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/components/animations.jsx +173 -6
- package/components/design-canvas.jsx +260 -14
- package/components/tweaks-panel.jsx +8 -5
- package/package.json +1 -1
- package/scripts/android-frame.js +253 -0
- package/scripts/browser-window.js +246 -0
- package/scripts/deck-stage.js +176 -6
- package/scripts/design-canvas.js +2189 -0
- package/scripts/ios-frame.js +338 -0
- package/scripts/macos-window.js +260 -0
|
@@ -0,0 +1,2189 @@
|
|
|
1
|
+
/* BEGIN USAGE */
|
|
2
|
+
// design-canvas.js — Figma-ish design canvas, as custom elements.
|
|
3
|
+
// Sections + artboards + post-it notes on an infinite pan/zoom canvas. No React.
|
|
4
|
+
// Defines: <design-canvas>, <dc-section>, <dc-artboard>, <dc-postit>
|
|
5
|
+
//
|
|
6
|
+
// Usage — load with a plain <script src="design-canvas.js"></script>:
|
|
7
|
+
//
|
|
8
|
+
// <design-canvas>
|
|
9
|
+
// <dc-section id="onboarding" title="Onboarding" subtitle="First-run variants">
|
|
10
|
+
// <dc-artboard id="welcome" label="01 · Welcome" width="390">
|
|
11
|
+
// ...your screen markup...
|
|
12
|
+
// </dc-artboard>
|
|
13
|
+
// <dc-artboard id="phone" label="02 · Phone number" width="390" height="844">…</dc-artboard>
|
|
14
|
+
// </dc-section>
|
|
15
|
+
// <dc-postit top="40" left="60">A quick note</dc-postit>
|
|
16
|
+
// </design-canvas>
|
|
17
|
+
//
|
|
18
|
+
// Attributes:
|
|
19
|
+
// <design-canvas> min-scale (0.1) · max-scale (8)
|
|
20
|
+
// <dc-section> id · title · subtitle · gap (48)
|
|
21
|
+
// <dc-artboard> id · label · width (260) · height (omit to grow with content) · chromeless
|
|
22
|
+
// <dc-postit> top · left · right · bottom · rotate (-2) · width (180)
|
|
23
|
+
// Booleans are by attribute presence: `chromeless` on, absent off.
|
|
24
|
+
//
|
|
25
|
+
// Artboards are reorderable (drag the grip), renameable (click the label),
|
|
26
|
+
// deletable (⋯ menu), and focusable (click the label or the expand button).
|
|
27
|
+
// Order / titles / labels / deletions persist to a .design-canvas.state.json
|
|
28
|
+
// sidecar next to the HTML; the viewport transform persists to localStorage.
|
|
29
|
+
/* END USAGE */
|
|
30
|
+
|
|
31
|
+
(() => {
|
|
32
|
+
const DC = {
|
|
33
|
+
bg: '#FCFCFC',
|
|
34
|
+
label: '#8F959E',
|
|
35
|
+
title: '#1F2329',
|
|
36
|
+
subtitle: '#646A73',
|
|
37
|
+
postitBg: '#fef4a8',
|
|
38
|
+
postitText: '#5a4a2a',
|
|
39
|
+
// Single quotes around font names: this string goes into a style="..." attribute,
|
|
40
|
+
// and a double quote inside it would close the attribute early.
|
|
41
|
+
font: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mirrors the host platform's isMobile() (UA regex plus iPad's maxTouchPoints).
|
|
46
|
+
* The generated page runs in a cross-origin iframe, so the shared helper cannot
|
|
47
|
+
* be imported. Deliberately NOT a viewport-width test: the desktop workbench
|
|
48
|
+
* puts the canvas inside a narrow preview iframe, and a width breakpoint would
|
|
49
|
+
* read that as mobile. Evaluated once at load. Keep in sync with the copy in
|
|
50
|
+
* deck-stage.js.
|
|
51
|
+
*/
|
|
52
|
+
const DC_MOBILE_UA_RE =
|
|
53
|
+
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
|
|
54
|
+
const dcIsMobile =
|
|
55
|
+
typeof navigator !== 'undefined' &&
|
|
56
|
+
(DC_MOBILE_UA_RE.test(navigator.userAgent) ||
|
|
57
|
+
(/iPad|Tab|Tablet/i.test(navigator.userAgent) &&
|
|
58
|
+
!!navigator.maxTouchPoints &&
|
|
59
|
+
navigator.maxTouchPoints > 1));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* This CSS goes into every shadow root instead of being injected into the
|
|
63
|
+
* document: once these became custom elements, their styles should no longer
|
|
64
|
+
* leak into the host page (the author's design has its own). Class names keep
|
|
65
|
+
* the dc- prefix — generated pages and downstream tooling recognise them.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* Thumbnail capture pages stay out of Magic Share: a capture is just another
|
|
69
|
+
* render of the same page, and broadcasting from it would clobber the
|
|
70
|
+
* presenter's framing.
|
|
71
|
+
*/
|
|
72
|
+
const dcCaptureMode =
|
|
73
|
+
typeof location !== 'undefined' && /[?&](_snthumb|thumbnail)=/.test(location.search || '');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Magic Share transport. This is the same singleton the JSX version defines —
|
|
77
|
+
* whichever file loads first owns it, so the two implementations must behave
|
|
78
|
+
* identically or the two halves of one page disagree.
|
|
79
|
+
*
|
|
80
|
+
* emit(msg, opts) — broadcast a snapshot. opts.key is the coalescing key
|
|
81
|
+
* (the host merges consecutive frames per key) and
|
|
82
|
+
* opts.throttle the suggested merge interval in ms.
|
|
83
|
+
* Snapshot semantics make dropped frames harmless.
|
|
84
|
+
* onApply(type, fn) — claim replays of one message type; returns an
|
|
85
|
+
* unsubscribe. fn answers applied / failed / ignored:
|
|
86
|
+
* 'failed' makes the host retry, 'ignored' is terminal.
|
|
87
|
+
* Unclaimed types stay silent on purpose — another script
|
|
88
|
+
* on the page may own them, and the host's timeout is the
|
|
89
|
+
* backstop.
|
|
90
|
+
*/
|
|
91
|
+
if (!window.__miaodaMirror) {
|
|
92
|
+
const appliers = new Map(); // message type -> Set<handler>
|
|
93
|
+
window.__miaodaMirror = {
|
|
94
|
+
emit(msg, opts) {
|
|
95
|
+
try {
|
|
96
|
+
window.parent.postMessage(
|
|
97
|
+
Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }),
|
|
98
|
+
'*',
|
|
99
|
+
);
|
|
100
|
+
} catch (e) {}
|
|
101
|
+
},
|
|
102
|
+
onApply(type, fn) {
|
|
103
|
+
if (!appliers.has(type)) appliers.set(type, new Set());
|
|
104
|
+
appliers.get(type).add(fn);
|
|
105
|
+
return () => {
|
|
106
|
+
const set = appliers.get(type);
|
|
107
|
+
if (set) set.delete(fn);
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
window.addEventListener('message', async (e) => {
|
|
112
|
+
// Parent frame only: generated pages embed arbitrary third-party iframes,
|
|
113
|
+
// any of which could otherwise forge an apply and drive state — along with
|
|
114
|
+
// the persistence that follows from it.
|
|
115
|
+
if (e.source !== window.parent) return;
|
|
116
|
+
const d = e.data;
|
|
117
|
+
if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
|
|
118
|
+
const fns = appliers.get(d.message.type);
|
|
119
|
+
if (!fns || !fns.size) return;
|
|
120
|
+
// Instances share one channel, so one success is enough. A sibling that is
|
|
121
|
+
// not ready yet must not drag an already-applied result down to 'failed' —
|
|
122
|
+
// that would put the host into a retry storm of no-ops. Every handler still
|
|
123
|
+
// runs (no short-circuit) so each instance receives the state.
|
|
124
|
+
let result = { type: 'ignored', reason: 'no applier result' };
|
|
125
|
+
for (const fn of Array.from(fns)) {
|
|
126
|
+
let r;
|
|
127
|
+
try {
|
|
128
|
+
r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
r = { type: 'failed', reason: String((err && err.message) || err) };
|
|
131
|
+
}
|
|
132
|
+
if (!r) continue;
|
|
133
|
+
if (r.type === 'applied') result = r;
|
|
134
|
+
else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed'))
|
|
135
|
+
result = r;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
window.parent.postMessage(
|
|
139
|
+
{ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result },
|
|
140
|
+
'*',
|
|
141
|
+
);
|
|
142
|
+
} catch (e2) {}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The React version lives in the light DOM, so its box model comes from the
|
|
148
|
+
* product's own `*` reset (most products set border-box). That reset never
|
|
149
|
+
* reaches into a shadow root, and hardcoding either value gets half the products
|
|
150
|
+
* wrong. `inherit` is wrong too — it passes an inline border-box down to
|
|
151
|
+
* descendants, which a `*` rule would not. Copying the host's computed value is
|
|
152
|
+
* the only thing that matches the React version exactly.
|
|
153
|
+
*/
|
|
154
|
+
const boxRule = (el) => `*,*::before,*::after{box-sizing:${getComputedStyle(el).boxSizing}}`;
|
|
155
|
+
|
|
156
|
+
const DC_CSS = `
|
|
157
|
+
/* Drag and selection: the React version injects these into the document and
|
|
158
|
+
matches the host element via [data-dc-slot]. Rules inside a shadow root cannot
|
|
159
|
+
reach their own host, so they have to go through :host(...). Miss this and the
|
|
160
|
+
card does not track the pointer and the dragged one gets no highlight — the
|
|
161
|
+
behaviour is all there, it just looks broken. */
|
|
162
|
+
:host{transition:transform .18s cubic-bezier(.2,.7,.3,1)}
|
|
163
|
+
:host(.dc-dragging){transition:none;z-index:10;pointer-events:none}
|
|
164
|
+
:host(.dc-dragging) .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #336DF4;transform:scale(1.02)}
|
|
165
|
+
.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}
|
|
166
|
+
.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}
|
|
167
|
+
.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}
|
|
168
|
+
.dc-card *{scrollbar-width:none}
|
|
169
|
+
.dc-card *::-webkit-scrollbar{display:none}
|
|
170
|
+
.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;
|
|
171
|
+
display:flex;align-items:center;container-type:inline-size;
|
|
172
|
+
width:calc((100% + 4px) / var(--dc-inv-zoom,1));
|
|
173
|
+
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left;will-change:transform}
|
|
174
|
+
.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}
|
|
175
|
+
.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}
|
|
176
|
+
.dc-grip:hover{background:rgba(0,0,0,.08)}
|
|
177
|
+
.dc-grip:active{cursor:grabbing}
|
|
178
|
+
.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;
|
|
179
|
+
display:flex;align-items:center;transition:background .12s;overflow:hidden}
|
|
180
|
+
@container (max-width: 110px){
|
|
181
|
+
.dc-labeltext{display:none}
|
|
182
|
+
.dc-grip{opacity:0}
|
|
183
|
+
:host(:hover) .dc-grip{opacity:1}
|
|
184
|
+
}
|
|
185
|
+
.dc-labeltext:hover{background:rgba(31,35,41,0.05)}
|
|
186
|
+
.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
187
|
+
.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}
|
|
188
|
+
.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
|
189
|
+
:host(:hover) .dc-header,:host(:hover) .dc-header .dc-editable{color:#646A73!important}
|
|
190
|
+
:host(:hover) .dc-btns,.dc-btns:has(.dc-menu){opacity:1}
|
|
191
|
+
@media (hover: none){.dc-btns{display:none}}
|
|
192
|
+
:host([data-dc-mobile]) .dc-grip{display:none}
|
|
193
|
+
:host(:hover) .dc-card.chromeless>*,:host(:hover) .dc-card:not(.chromeless){box-shadow:0 4px 8px -8px rgba(0,0,0,0.06),0 6px 12px 0 rgba(0,0,0,0.04),0 8px 24px 8px rgba(0,0,0,0.04)}
|
|
194
|
+
.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;
|
|
195
|
+
background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;
|
|
196
|
+
font:inherit;transition:background .12s,color .12s}
|
|
197
|
+
.dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}
|
|
198
|
+
.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;
|
|
199
|
+
box-shadow:0 4px 8px 0 rgba(0,0,0,0.03),0 3px 6px -6px rgba(0,0,0,0.05),0 6px 18px 6px rgba(0,0,0,0.03);padding:3px;min-width:120px;z-index:10}
|
|
200
|
+
.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;
|
|
201
|
+
border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;
|
|
202
|
+
color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}
|
|
203
|
+
.dc-menu button:hover{background:rgba(31,35,41,0.05)}
|
|
204
|
+
.dc-menu .dc-danger{color:#F54A45}
|
|
205
|
+
`;
|
|
206
|
+
|
|
207
|
+
const CANVAS_CSS = `
|
|
208
|
+
:host{display:block}
|
|
209
|
+
.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}
|
|
210
|
+
.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}
|
|
211
|
+
.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;
|
|
212
|
+
box-shadow:0 4px 8px 0 rgba(0,0,0,0.03),0 3px 6px -6px rgba(0,0,0,0.05),0 6px 18px 6px rgba(0,0,0,0.03);padding:3px;min-width:120px;z-index:10}
|
|
213
|
+
.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;
|
|
214
|
+
border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;
|
|
215
|
+
color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}
|
|
216
|
+
.dc-menu button:hover{background:rgba(31,35,41,0.05)}
|
|
217
|
+
.dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;
|
|
218
|
+
background:#fff;border-radius:8px;padding:4px;font-family:inherit;
|
|
219
|
+
border:0.5px solid #DEE0E3;box-shadow:0 2px 4px -4px rgba(31,35,41,0.02),0 4px 8px 0 rgba(31,35,41,0.02),0 4px 16px 4px rgba(31,35,41,0.03)}
|
|
220
|
+
.dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;border:none;cursor:pointer;padding:0;
|
|
221
|
+
background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;
|
|
222
|
+
font:inherit;transition:background .12s,color .12s}
|
|
223
|
+
.dc-zoom-btn:hover{background:#1f23290d}
|
|
224
|
+
.dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}
|
|
225
|
+
.dc-zoom-toggle{position:relative;display:flex}
|
|
226
|
+
.dc-zoom-trigger{padding:0 6px;gap:4px}
|
|
227
|
+
.dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}
|
|
228
|
+
.dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}
|
|
229
|
+
.dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
230
|
+
.dc-sectionhead{}
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* React version renders text and attributes through JSX, which escapes them. These
|
|
235
|
+
* elements build their shadow with innerHTML template strings, so that protection is
|
|
236
|
+
* gone — every value that comes from an author attribute, from persisted state, or
|
|
237
|
+
* from a Magic Share replay has to go through here. The artboard label matters most:
|
|
238
|
+
* it is renameable, it round-trips through the sidecar, AND a peer can push one in
|
|
239
|
+
* over Magic Share, so an unescaped one is script execution in the follower's page.
|
|
240
|
+
*/
|
|
241
|
+
const esc = (v) =>
|
|
242
|
+
String(v ?? '').replace(
|
|
243
|
+
/[&<>"']/g,
|
|
244
|
+
(ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Non-numeric input falls through into a style="..." attribute, so it must be
|
|
248
|
+
// escaped too — a quote in it would break out of the attribute.
|
|
249
|
+
const px = (v) => (/^-?\d+(\.\d+)?$/.test(String(v)) ? `${v}px` : esc(v));
|
|
250
|
+
|
|
251
|
+
/** An event's propagation path. composedPath throws in some implementations;
|
|
252
|
+
* fall back to target — better to under-match than to mis-match. */
|
|
253
|
+
const pathOf = (e) => {
|
|
254
|
+
try {
|
|
255
|
+
const p = e.composedPath ? e.composedPath() : null;
|
|
256
|
+
if (p && p.length) return p.filter(Boolean);
|
|
257
|
+
} catch {}
|
|
258
|
+
return e.target ? [e.target] : [];
|
|
259
|
+
};
|
|
260
|
+
const numAttr = (el, name, fallback) => {
|
|
261
|
+
const v = el.getAttribute(name);
|
|
262
|
+
const n = v === null || v === '' ? NaN : Number(v);
|
|
263
|
+
return Number.isFinite(n) ? n : fallback;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
class Base extends HTMLElement {
|
|
267
|
+
constructor() {
|
|
268
|
+
super();
|
|
269
|
+
this.attachShadow({ mode: 'open' });
|
|
270
|
+
}
|
|
271
|
+
connectedCallback() {
|
|
272
|
+
this._render();
|
|
273
|
+
}
|
|
274
|
+
attributeChangedCallback() {
|
|
275
|
+
if (this.shadowRoot) this._render();
|
|
276
|
+
}
|
|
277
|
+
attr(name, fallback) {
|
|
278
|
+
const v = this.getAttribute(name);
|
|
279
|
+
return v === null || v === '' ? fallback : v;
|
|
280
|
+
}
|
|
281
|
+
/** Bubbles to <design-canvas>: it owns the state, children only report intent. */
|
|
282
|
+
emit(type, detail) {
|
|
283
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Inline rename, committed on blur or Enter. Read-only on mobile, where a tap
|
|
288
|
+
* would pop the soft keyboard instead of browsing. */
|
|
289
|
+
function editable(cls, value, style) {
|
|
290
|
+
const editableAttr = dcIsMobile ? '' : ' contenteditable="true"';
|
|
291
|
+
return `<span class="dc-editable ${cls}"${editableAttr} style="${style}">${esc(value)}</span>`;
|
|
292
|
+
}
|
|
293
|
+
function wireEditable(root, sel, onChange) {
|
|
294
|
+
const el = root.querySelector(sel);
|
|
295
|
+
if (!el || dcIsMobile) return;
|
|
296
|
+
let committed = false;
|
|
297
|
+
// Commit entry point. The normal path is the blur event, but blur() throws in
|
|
298
|
+
// some implementations — the click-outside handler then calls this directly,
|
|
299
|
+
// so a value is never lost to a difference in how focus is dropped.
|
|
300
|
+
el.__dcCommit = () => {
|
|
301
|
+
if (committed) return;
|
|
302
|
+
committed = true;
|
|
303
|
+
onChange(el.textContent);
|
|
304
|
+
};
|
|
305
|
+
// The editable reports its own edit state rather than relying on activeElement:
|
|
306
|
+
// across a shadow boundary that returns the host element, and implementations
|
|
307
|
+
// disagree on the exact semantics.
|
|
308
|
+
el.addEventListener('focus', () => {
|
|
309
|
+
committed = false;
|
|
310
|
+
el.dispatchEvent(new CustomEvent('dc:edit-start', { bubbles: true, composed: true }));
|
|
311
|
+
});
|
|
312
|
+
el.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
313
|
+
el.addEventListener('blur', () => {
|
|
314
|
+
el.__dcCommit();
|
|
315
|
+
el.dispatchEvent(new CustomEvent('dc:edit-end', { bubbles: true, composed: true }));
|
|
316
|
+
});
|
|
317
|
+
el.addEventListener('keydown', (e) => {
|
|
318
|
+
if (e.key === 'Enter') {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
el.blur();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ───────────────────────────────────────────────────────────
|
|
326
|
+
// <dc-postit> — an absolutely positioned sticky note
|
|
327
|
+
// ───────────────────────────────────────────────────────────
|
|
328
|
+
class DCPostIt extends Base {
|
|
329
|
+
static get observedAttributes() {
|
|
330
|
+
return ['top', 'left', 'right', 'bottom', 'rotate', 'width'];
|
|
331
|
+
}
|
|
332
|
+
_render() {
|
|
333
|
+
const pos = ['top', 'left', 'right', 'bottom']
|
|
334
|
+
.filter((k) => this.hasAttribute(k))
|
|
335
|
+
.map((k) => `${k}:${px(this.getAttribute(k))}`)
|
|
336
|
+
.join(';');
|
|
337
|
+
// Position, size and rotation go on the host element itself: it stands in for
|
|
338
|
+
// the div the React version rendered, and putting them on an inner node would
|
|
339
|
+
// hide them from anything measuring from outside.
|
|
340
|
+
this.style.cssText =
|
|
341
|
+
`position:absolute;${pos};width:${px(numAttr(this, 'width', 180))};` +
|
|
342
|
+
`transform:rotate(${numAttr(this, 'rotate', -2)}deg);z-index:5;display:block`;
|
|
343
|
+
this.shadowRoot.innerHTML = `<style>${boxRule(this)}:host{display:block}</style>
|
|
344
|
+
<div part="frame" style="background:${DC.postitBg};padding:14px 16px;
|
|
345
|
+
font-family:'Comic Sans MS','Marker Felt','Segoe Print',cursive;
|
|
346
|
+
font-size:14px;line-height:1.4;color:${DC.postitText};
|
|
347
|
+
box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 2px rgba(0,0,0,0.08)"><slot></slot></div>`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ───────────────────────────────────────────────────────────
|
|
352
|
+
// <dc-artboard> — header bar (grip / label / ⋯ / expand) plus the card
|
|
353
|
+
// ───────────────────────────────────────────────────────────
|
|
354
|
+
class DCArtboard extends Base {
|
|
355
|
+
static get observedAttributes() {
|
|
356
|
+
return ['id', 'label', 'width', 'height', 'chromeless'];
|
|
357
|
+
}
|
|
358
|
+
get slotId() {
|
|
359
|
+
return this.getAttribute('id') || this.getAttribute('label') || '';
|
|
360
|
+
}
|
|
361
|
+
/** The persisted label, pushed down by <design-canvas> (a rename wins). */
|
|
362
|
+
setLabel(v) {
|
|
363
|
+
this._label = v;
|
|
364
|
+
const el = this.shadowRoot?.querySelector('.dc-labeltext .dc-editable');
|
|
365
|
+
if (el && el.textContent !== v) el.textContent = v ?? '';
|
|
366
|
+
}
|
|
367
|
+
connectedCallback() {
|
|
368
|
+
this.setAttribute('data-dc-slot', this.slotId);
|
|
369
|
+
if (dcIsMobile) this.setAttribute('data-dc-mobile', '');
|
|
370
|
+
/**
|
|
371
|
+
* Layout-critical properties go inline rather than into :host. Per the Shadow
|
|
372
|
+
* DOM spec a :host rule loses to ANY document rule that matches the host, so a
|
|
373
|
+
* single `* { margin: 0 }` in the author's stylesheet wipes it out. The React
|
|
374
|
+
* version already set these inline; this just keeps that.
|
|
375
|
+
*/
|
|
376
|
+
this.style.position = 'relative';
|
|
377
|
+
this.style.flexShrink = '0';
|
|
378
|
+
this.style.display = 'block';
|
|
379
|
+
this._render();
|
|
380
|
+
}
|
|
381
|
+
_render() {
|
|
382
|
+
const chromeless = this.hasAttribute('chromeless');
|
|
383
|
+
const width = numAttr(this, 'width', 260);
|
|
384
|
+
const hasHeight = this.hasAttribute('height');
|
|
385
|
+
const height = hasHeight ? numAttr(this, 'height', 0) : undefined;
|
|
386
|
+
const label = this._label ?? this.attr('label', '');
|
|
387
|
+
const cardStyle = chromeless
|
|
388
|
+
? ''
|
|
389
|
+
: `width:${px(width)};${hasHeight ? `height:${px(height)};` : ''}` +
|
|
390
|
+
'border-radius:4px;border:1px solid #DEE0E3;overflow:hidden;background:#fff;';
|
|
391
|
+
|
|
392
|
+
this.shadowRoot.innerHTML = `<style>${boxRule(this)}${DC_CSS}
|
|
393
|
+
:host{display:block;position:relative;flex-shrink:0}
|
|
394
|
+
:host([data-dc-hidden]){display:none}
|
|
395
|
+
</style>
|
|
396
|
+
<div part="frame" style="display:contents">
|
|
397
|
+
<div class="dc-header" part="header" data-miaoda-chrome="" style="color:${DC.label}">
|
|
398
|
+
<div class="dc-labelrow">
|
|
399
|
+
<div class="dc-grip" part="grip" title="Drag to reorder">
|
|
400
|
+
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="dc-labeltext" part="labeltext" title="Click to focus">
|
|
403
|
+
${editable('', label, `font-size:14px;font-weight:400;color:${DC.label};line-height:22px`)}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="dc-btns">
|
|
407
|
+
<div class="dc-kebab-wrap" style="position:relative">
|
|
408
|
+
<button class="dc-kebab" part="kebab" title="More">
|
|
409
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
<button class="dc-expand" part="expand" title="Focus">
|
|
413
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="dc-card${chromeless ? ' chromeless' : ''}" part="card" style="${cardStyle}"><slot></slot>${
|
|
418
|
+
this.childElementCount === 0 && !this.textContent.trim()
|
|
419
|
+
? `<div style="min-height:160px;height:100%;display:flex;align-items:center;justify-content:center;color:#bbb;font-size:13px;font-family:${DC.font}">${esc(this.slotId)}</div>`
|
|
420
|
+
: ''
|
|
421
|
+
}</div>
|
|
422
|
+
</div>`;
|
|
423
|
+
|
|
424
|
+
this._wire();
|
|
425
|
+
}
|
|
426
|
+
_wire() {
|
|
427
|
+
const r = this.shadowRoot;
|
|
428
|
+
// On desktop, swallow pointerdown so header interactions do not start a
|
|
429
|
+
// background drag. On mobile it must reach the viewport, where the slop
|
|
430
|
+
// threshold arbitrates between tap and pan.
|
|
431
|
+
if (!dcIsMobile) {
|
|
432
|
+
r.querySelector('.dc-header')?.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
433
|
+
r.querySelector('.dc-grip')?.addEventListener('pointerdown', (e) => this._onGripDown(e));
|
|
434
|
+
}
|
|
435
|
+
r.querySelector('.dc-labeltext')?.addEventListener('click', () =>
|
|
436
|
+
this.emit('dc:focus', { slotId: this.slotId }),
|
|
437
|
+
);
|
|
438
|
+
r.querySelector('.dc-labeltext .dc-editable')?.addEventListener('click', (e) =>
|
|
439
|
+
e.stopPropagation(),
|
|
440
|
+
);
|
|
441
|
+
wireEditable(r, '.dc-labeltext .dc-editable', (v) =>
|
|
442
|
+
this.emit('dc:rename-label', { slotId: this.slotId, value: v }),
|
|
443
|
+
);
|
|
444
|
+
r.querySelector('.dc-expand')?.addEventListener('click', () =>
|
|
445
|
+
this.emit('dc:focus', { slotId: this.slotId }),
|
|
446
|
+
);
|
|
447
|
+
r.querySelector('.dc-kebab')?.addEventListener('click', () => this._toggleMenu());
|
|
448
|
+
}
|
|
449
|
+
_toggleMenu() {
|
|
450
|
+
const wrap = this.shadowRoot.querySelector('.dc-kebab-wrap');
|
|
451
|
+
const open = wrap.querySelector('.dc-menu');
|
|
452
|
+
if (open) {
|
|
453
|
+
open.remove();
|
|
454
|
+
this._confirming = false;
|
|
455
|
+
document.removeEventListener('pointerdown', this._offMenu, true);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this._confirming = false;
|
|
459
|
+
const menu = document.createElement('div');
|
|
460
|
+
menu.className = 'dc-menu';
|
|
461
|
+
menu.innerHTML =
|
|
462
|
+
'<button data-act="png">下载 PNG</button><button data-act="html">下载 HTML</button>' +
|
|
463
|
+
'<button class="dc-danger" data-act="del">删除</button>';
|
|
464
|
+
menu.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
465
|
+
menu.addEventListener('click', (e) => {
|
|
466
|
+
const act = e.target.closest('button')?.dataset.act;
|
|
467
|
+
if (act === 'png' || act === 'html') {
|
|
468
|
+
this._toggleMenu();
|
|
469
|
+
this._export(act);
|
|
470
|
+
} else if (act === 'del') {
|
|
471
|
+
// Two-click confirm: the first click only changes the wording
|
|
472
|
+
if (this._confirming) {
|
|
473
|
+
this._toggleMenu();
|
|
474
|
+
this.emit('dc:delete', { slotId: this.slotId });
|
|
475
|
+
} else {
|
|
476
|
+
this._confirming = true;
|
|
477
|
+
e.target.closest('button').textContent = '确认删除';
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
wrap.appendChild(menu);
|
|
482
|
+
// Attach immediately and use composedPath to tell whether the click landed
|
|
483
|
+
// inside the menu. Deferring with setTimeout would never fire under fake
|
|
484
|
+
// timers, leaving the listener unattached and the menu permanently open.
|
|
485
|
+
const kebab = this.shadowRoot.querySelector('.dc-kebab');
|
|
486
|
+
this._offMenu = (e) => {
|
|
487
|
+
const inside = pathOf(e).some(
|
|
488
|
+
(n) => n === menu || menu.contains(n) || n === kebab || kebab?.contains(n),
|
|
489
|
+
);
|
|
490
|
+
if (!inside) this._toggleMenu();
|
|
491
|
+
};
|
|
492
|
+
document.addEventListener('pointerdown', this._offMenu, true);
|
|
493
|
+
}
|
|
494
|
+
_export(kind) {
|
|
495
|
+
const card = this.shadowRoot.querySelector('.dc-card');
|
|
496
|
+
if (!card) return;
|
|
497
|
+
// Unicode-friendly filename scrub: keep letters (CJK included), digits,
|
|
498
|
+
// whitespace and . _ -
|
|
499
|
+
const name = String(
|
|
500
|
+
this._label || this.attr('label', '') || this.slotId || 'artboard',
|
|
501
|
+
).replace(/[^\p{L}\p{N}\s._-]+/gu, '_');
|
|
502
|
+
dcExport(
|
|
503
|
+
card,
|
|
504
|
+
card.offsetWidth || numAttr(this, 'width', 260),
|
|
505
|
+
card.offsetHeight || numAttr(this, 'height', 0),
|
|
506
|
+
name,
|
|
507
|
+
kind,
|
|
508
|
+
).catch((e) => console.error('[design-canvas] export failed:', e));
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Drag to reorder: the dragged card follows the pointer, the others make room
|
|
512
|
+
* live via transform, and the real order only changes on drop.
|
|
513
|
+
* Peers come from the light DOM — they are <dc-section>'s own children, so no
|
|
514
|
+
* shadow boundary is crossed.
|
|
515
|
+
*/
|
|
516
|
+
_onGripDown(e) {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
e.stopPropagation();
|
|
519
|
+
const section = this.closest('dc-section');
|
|
520
|
+
if (!section) return;
|
|
521
|
+
const id = this.slotId;
|
|
522
|
+
// translateX applies in unscaled local space while pointer movement is in
|
|
523
|
+
// screen space — divide by the current scale.
|
|
524
|
+
const scale = this.getBoundingClientRect().width / this.offsetWidth || 1;
|
|
525
|
+
const peers = [...section.querySelectorAll(':scope > dc-artboard')];
|
|
526
|
+
const homes = peers.map((el) => ({ el, id: el.slotId, x: el.getBoundingClientRect().left }));
|
|
527
|
+
const order = section.currentOrder();
|
|
528
|
+
const slotXs = homes.map((h) => h.x);
|
|
529
|
+
const startIdx = order.indexOf(id);
|
|
530
|
+
const startX = e.clientX;
|
|
531
|
+
let liveOrder = order.slice();
|
|
532
|
+
this.classList.add('dc-dragging');
|
|
533
|
+
|
|
534
|
+
const layout = () => {
|
|
535
|
+
for (const h of homes) {
|
|
536
|
+
if (h.id === id) continue;
|
|
537
|
+
const slot = liveOrder.indexOf(h.id);
|
|
538
|
+
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const move = (ev) => {
|
|
542
|
+
const dx = ev.clientX - startX;
|
|
543
|
+
this.style.transform = `translateX(${dx / scale}px)`;
|
|
544
|
+
const cur = homes[startIdx].x + dx;
|
|
545
|
+
let nearest = 0,
|
|
546
|
+
best = Infinity;
|
|
547
|
+
for (let i = 0; i < slotXs.length; i++) {
|
|
548
|
+
const d = Math.abs(slotXs[i] - cur);
|
|
549
|
+
if (d < best) {
|
|
550
|
+
best = d;
|
|
551
|
+
nearest = i;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (liveOrder.indexOf(id) !== nearest) {
|
|
555
|
+
liveOrder = order.filter((k) => k !== id);
|
|
556
|
+
liveOrder.splice(nearest, 0, id);
|
|
557
|
+
layout();
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
const up = () => {
|
|
561
|
+
document.removeEventListener('pointermove', move);
|
|
562
|
+
document.removeEventListener('pointerup', up);
|
|
563
|
+
const finalSlot = liveOrder.indexOf(id);
|
|
564
|
+
this.classList.remove('dc-dragging');
|
|
565
|
+
this.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
|
566
|
+
// When the settle animation ends, drop the transition, clear the transforms and
|
|
567
|
+
// commit the new order in the same frame, so nothing snaps back visibly.
|
|
568
|
+
setTimeout(() => {
|
|
569
|
+
for (const h of homes) {
|
|
570
|
+
h.el.style.transition = 'none';
|
|
571
|
+
h.el.style.transform = '';
|
|
572
|
+
}
|
|
573
|
+
if (liveOrder.join('|') !== order.join('|'))
|
|
574
|
+
this.emit('dc:reorder', { order: liveOrder });
|
|
575
|
+
requestAnimationFrame(() =>
|
|
576
|
+
requestAnimationFrame(() => {
|
|
577
|
+
for (const h of homes) h.el.style.transition = '';
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
}, 180);
|
|
581
|
+
};
|
|
582
|
+
document.addEventListener('pointermove', move);
|
|
583
|
+
document.addEventListener('pointerup', up);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ───────────────────────────────────────────────────────────
|
|
588
|
+
// <dc-section> — a renameable heading plus one row of artboards, in persisted order
|
|
589
|
+
// ───────────────────────────────────────────────────────────
|
|
590
|
+
class DCSection extends Base {
|
|
591
|
+
static get observedAttributes() {
|
|
592
|
+
return ['id', 'title', 'subtitle', 'gap'];
|
|
593
|
+
}
|
|
594
|
+
get sectionId() {
|
|
595
|
+
return this.getAttribute('id') || this.getAttribute('title') || '';
|
|
596
|
+
}
|
|
597
|
+
/** The artboard order in effect, deletions already removed. */
|
|
598
|
+
currentOrder() {
|
|
599
|
+
return this._order ?? this.srcOrder();
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The SOURCE order — the order the author wrote, not the current DOM order.
|
|
603
|
+
*
|
|
604
|
+
* srcKey is derived from it and decides whether persisted deletions still apply
|
|
605
|
+
* to this version of the content. Reading the DOM directly breaks that: a
|
|
606
|
+
* reorder changes DOM order, srcKey changes with it, and every prior deletion
|
|
607
|
+
* is silently discarded. The React version reads JSX children, which never move;
|
|
608
|
+
* remembering the first-seen order restores the same semantics here.
|
|
609
|
+
* Attributes are read directly rather than through the slotId getter, since a
|
|
610
|
+
* child may not have upgraded yet.
|
|
611
|
+
*/
|
|
612
|
+
srcOrder() {
|
|
613
|
+
const cur = [...this.querySelectorAll(':scope > dc-artboard')]
|
|
614
|
+
.map((a) => a.getAttribute('id') || a.getAttribute('label'))
|
|
615
|
+
.filter(Boolean);
|
|
616
|
+
const seen = this._srcSeen || [];
|
|
617
|
+
const next = [
|
|
618
|
+
...seen.filter((id) => cur.includes(id)),
|
|
619
|
+
...cur.filter((id) => !seen.includes(id)),
|
|
620
|
+
];
|
|
621
|
+
this._srcSeen = next;
|
|
622
|
+
return next;
|
|
623
|
+
}
|
|
624
|
+
connectedCallback() {
|
|
625
|
+
this.setAttribute('data-dc-section', this.sectionId);
|
|
626
|
+
// Same reason as the artboard: only inline beats the author's `*` reset. The
|
|
627
|
+
// gap counter-scales so it stays constant on screen.
|
|
628
|
+
this.style.display = 'block';
|
|
629
|
+
this.style.position = 'relative';
|
|
630
|
+
this.style.marginBottom = 'calc(80px * var(--dc-inv-zoom, 1))';
|
|
631
|
+
this._render();
|
|
632
|
+
}
|
|
633
|
+
/** Pushed down by <design-canvas>: renamed title, order, deleted artboards. */
|
|
634
|
+
applyState(meta) {
|
|
635
|
+
this._order = meta.slotIds;
|
|
636
|
+
const t = this.shadowRoot?.querySelector('.dc-sectionhead .dc-editable');
|
|
637
|
+
if (t && meta.title != null && t.textContent !== meta.title) t.textContent = meta.title;
|
|
638
|
+
const boards = [...this.querySelectorAll(':scope > dc-artboard')];
|
|
639
|
+
for (const ab of boards) {
|
|
640
|
+
const shown = meta.ready && meta.slotIds.includes(ab.slotId);
|
|
641
|
+
/**
|
|
642
|
+
* A deleted artboard loses its data-dc-slot attribute too, not just
|
|
643
|
+
* display:none. That attribute is the shared anchor for author scripts, the
|
|
644
|
+
* export pipeline and the first-visit fit — leaving it in place means "still
|
|
645
|
+
* here" to all three.
|
|
646
|
+
*/
|
|
647
|
+
if (shown) ab.setAttribute('data-dc-slot', ab.slotId);
|
|
648
|
+
else ab.removeAttribute('data-dc-slot');
|
|
649
|
+
ab.toggleAttribute('data-dc-hidden', !shown);
|
|
650
|
+
// display is inline now, so :host([data-dc-hidden]) cannot beat it — set the
|
|
651
|
+
// inline value directly.
|
|
652
|
+
ab.style.display = shown ? 'block' : 'none';
|
|
653
|
+
if (meta.labels && meta.labels[ab.slotId] != null) ab.setLabel(meta.labels[ab.slotId]);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Reordering really moves DOM nodes rather than using CSS order:
|
|
657
|
+
* querySelectorAll order is the external contract (author scripts, export and
|
|
658
|
+
* the thumbnail pipeline all read it), and CSS order changes only the visuals.
|
|
659
|
+
*
|
|
660
|
+
* Two safeguards; drop either one and this spins forever in a real browser:
|
|
661
|
+
* 1. Move nothing when the order is already right — a move is itself a
|
|
662
|
+
* mutation;
|
|
663
|
+
* 2. Tell the canvas to stop reacting while moving — its MutationObserver
|
|
664
|
+
* watches childList+subtree, so without the mute "reorder -> observe ->
|
|
665
|
+
* reorder" never terminates.
|
|
666
|
+
* happy-dom's MutationObserver is less eager, which is why the unit tests
|
|
667
|
+
* cannot see this one.
|
|
668
|
+
*/
|
|
669
|
+
const cur = boards.filter((b) => meta.slotIds.includes(b.slotId)).map((b) => b.slotId);
|
|
670
|
+
if (cur.join('|') === meta.slotIds.join('|')) return;
|
|
671
|
+
const canvas = this.closest('design-canvas');
|
|
672
|
+
canvas?.pauseObserving();
|
|
673
|
+
let anchor = null;
|
|
674
|
+
for (const id of meta.slotIds) {
|
|
675
|
+
const ab = boards.find((b) => b.slotId === id);
|
|
676
|
+
if (!ab) continue;
|
|
677
|
+
if (anchor) anchor.after(ab);
|
|
678
|
+
else this.prepend(ab);
|
|
679
|
+
anchor = ab;
|
|
680
|
+
}
|
|
681
|
+
canvas?.resumeObserving();
|
|
682
|
+
}
|
|
683
|
+
_render() {
|
|
684
|
+
const subtitle = this.attr('subtitle', '');
|
|
685
|
+
const gap = numAttr(this, 'gap', 48);
|
|
686
|
+
this.shadowRoot.innerHTML = `<style>${boxRule(this)}${DC_CSS}
|
|
687
|
+
:host{display:block;position:relative;margin-bottom:calc(80px * var(--dc-inv-zoom,1))}
|
|
688
|
+
.dc-sectionhead{padding-bottom:calc(36px * var(--dc-inv-zoom,1))}
|
|
689
|
+
</style>
|
|
690
|
+
<div part="frame">
|
|
691
|
+
<div style="padding:0 60px">
|
|
692
|
+
<div class="dc-sectionhead" part="sectionhead">
|
|
693
|
+
${editable(
|
|
694
|
+
'',
|
|
695
|
+
this.attr('title', ''),
|
|
696
|
+
`font-size:calc(20px * var(--dc-inv-zoom,1));font-weight:600;color:${DC.title};` +
|
|
697
|
+
'letter-spacing:calc(-0.4px * var(--dc-inv-zoom,1));margin-bottom:calc(2px * var(--dc-inv-zoom,1));' +
|
|
698
|
+
'display:inline-block;line-height:calc(28px * var(--dc-inv-zoom,1))',
|
|
699
|
+
)}
|
|
700
|
+
${
|
|
701
|
+
subtitle
|
|
702
|
+
? `<div style="font-size:calc(14px * var(--dc-inv-zoom,1));color:${DC.subtitle};line-height:calc(22px * var(--dc-inv-zoom,1))">${esc(subtitle)}</div>`
|
|
703
|
+
: ''
|
|
704
|
+
}
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
<div style="display:flex;gap:${px(gap)};padding:0 60px;align-items:flex-start;width:max-content">
|
|
708
|
+
<slot></slot>
|
|
709
|
+
</div>
|
|
710
|
+
</div>`;
|
|
711
|
+
wireEditable(this.shadowRoot, '.dc-sectionhead .dc-editable', (v) =>
|
|
712
|
+
this.emit('dc:rename-section', { sectionId: this.sectionId, value: v }),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ───────────────────────────────────────────────────────────
|
|
718
|
+
// Single-artboard export (png / html). Both paths share one self-contained clone:
|
|
719
|
+
// computed styles baked into style attributes, and every @font-face plus <img> /
|
|
720
|
+
// inline background-image url inlined as a data URI.
|
|
721
|
+
// PNG puts the clone through foreignObject -> canvas at 3x the artboard's natural
|
|
722
|
+
// size; HTML wraps a minimal standalone document. Neither depends on canvas zoom.
|
|
723
|
+
// ───────────────────────────────────────────────────────────
|
|
724
|
+
async function dcExport(node, w, h, name, kind) {
|
|
725
|
+
try {
|
|
726
|
+
await document.fonts.ready;
|
|
727
|
+
} catch {}
|
|
728
|
+
const toDataURL = (url) =>
|
|
729
|
+
fetch(url)
|
|
730
|
+
.then((r) => r.blob())
|
|
731
|
+
.then(
|
|
732
|
+
(b) =>
|
|
733
|
+
new Promise((res) => {
|
|
734
|
+
const fr = new FileReader();
|
|
735
|
+
fr.onload = () => res(fr.result);
|
|
736
|
+
fr.onerror = () => res(url);
|
|
737
|
+
fr.readAsDataURL(b);
|
|
738
|
+
}),
|
|
739
|
+
)
|
|
740
|
+
.catch(() => url);
|
|
741
|
+
|
|
742
|
+
// Reading cssRules from a cross-origin stylesheet throws SecurityError (e.g.
|
|
743
|
+
// fonts.googleapis.com); fall back to fetching the CSS text and pulling
|
|
744
|
+
// @font-face out with a regex. @import and @media have to be walked too.
|
|
745
|
+
const fontRules = [],
|
|
746
|
+
pending = [],
|
|
747
|
+
seen = new Set();
|
|
748
|
+
const scrapeCss = (href) => {
|
|
749
|
+
if (seen.has(href)) return;
|
|
750
|
+
seen.add(href);
|
|
751
|
+
pending.push(
|
|
752
|
+
fetch(href)
|
|
753
|
+
.then((r) => r.text())
|
|
754
|
+
.then((css) => {
|
|
755
|
+
for (const m of css.match(/@font-face\s*{[^}]*}/g) || [])
|
|
756
|
+
fontRules.push({ css: m, base: href });
|
|
757
|
+
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
|
758
|
+
scrapeCss(new URL(m[1], href).href);
|
|
759
|
+
})
|
|
760
|
+
.catch(() => {}),
|
|
761
|
+
);
|
|
762
|
+
};
|
|
763
|
+
const walk = (rules, base) => {
|
|
764
|
+
for (const r of rules) {
|
|
765
|
+
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
|
766
|
+
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
|
767
|
+
const ibase = r.styleSheet.href || base;
|
|
768
|
+
try {
|
|
769
|
+
walk(r.styleSheet.cssRules, ibase);
|
|
770
|
+
} catch {
|
|
771
|
+
scrapeCss(ibase);
|
|
772
|
+
}
|
|
773
|
+
} else if (r.cssRules) walk(r.cssRules, base);
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
for (const ss of document.styleSheets) {
|
|
777
|
+
const base = ss.href || location.href;
|
|
778
|
+
try {
|
|
779
|
+
walk(ss.cssRules, base);
|
|
780
|
+
} catch {
|
|
781
|
+
if (ss.href) scrapeCss(ss.href);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
while (pending.length) await pending.shift();
|
|
785
|
+
const fontCss = (
|
|
786
|
+
await Promise.all(
|
|
787
|
+
fontRules.map(async (rule) => {
|
|
788
|
+
let out = rule.css,
|
|
789
|
+
m;
|
|
790
|
+
const re = /url\((['"]?)([^'")]+)\1\)/g;
|
|
791
|
+
while ((m = re.exec(rule.css))) {
|
|
792
|
+
if (m[2].indexOf('data:') === 0) continue;
|
|
793
|
+
let abs;
|
|
794
|
+
try {
|
|
795
|
+
abs = new URL(m[2], rule.base).href;
|
|
796
|
+
} catch {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
out = out.split(m[0]).join('url("' + (await toDataURL(abs)) + '")');
|
|
800
|
+
}
|
|
801
|
+
return out;
|
|
802
|
+
}),
|
|
803
|
+
)
|
|
804
|
+
).join('\n');
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Cloning has to pierce shadow roots: artboard content arrives through a slot,
|
|
808
|
+
* so a plain cloneNode yields nothing but an empty <slot>. This is one of the
|
|
809
|
+
* things becoming custom elements forced — the React version had no such
|
|
810
|
+
* boundary.
|
|
811
|
+
*/
|
|
812
|
+
const cloneStyled = (src) => {
|
|
813
|
+
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT'))
|
|
814
|
+
return document.createTextNode('');
|
|
815
|
+
if (src.nodeType === 1 && src.localName === 'slot') {
|
|
816
|
+
// The slot itself never appears in the export; substitute the nodes it
|
|
817
|
+
// actually projects.
|
|
818
|
+
const frag = document.createDocumentFragment();
|
|
819
|
+
for (const n of src.assignedNodes({ flatten: true })) frag.appendChild(cloneStyled(n));
|
|
820
|
+
return frag;
|
|
821
|
+
}
|
|
822
|
+
const dst = src.cloneNode(false);
|
|
823
|
+
if (src.nodeType === 1) {
|
|
824
|
+
const cs = getComputedStyle(src);
|
|
825
|
+
let txt = '';
|
|
826
|
+
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
|
827
|
+
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
|
828
|
+
if (src.tagName === 'CANVAS')
|
|
829
|
+
try {
|
|
830
|
+
const im = document.createElement('img');
|
|
831
|
+
im.src = src.toDataURL();
|
|
832
|
+
im.setAttribute('style', txt);
|
|
833
|
+
return im;
|
|
834
|
+
} catch {}
|
|
835
|
+
}
|
|
836
|
+
// A custom element's visible content lives in its shadow root; the light DOM
|
|
837
|
+
// is only the data source.
|
|
838
|
+
const inner = src.shadowRoot ?? src;
|
|
839
|
+
for (let c = inner.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
|
840
|
+
return dst;
|
|
841
|
+
};
|
|
842
|
+
const clone = cloneStyled(node);
|
|
843
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
|
844
|
+
// Drop the card's own shadow and corner radius — an export is a flush w×h block.
|
|
845
|
+
clone.style.boxShadow = 'none';
|
|
846
|
+
clone.style.borderRadius = '0';
|
|
847
|
+
|
|
848
|
+
const jobs = [];
|
|
849
|
+
clone.querySelectorAll('img').forEach((el) => {
|
|
850
|
+
const s = el.getAttribute('src');
|
|
851
|
+
if (s && s.indexOf('data:') !== 0)
|
|
852
|
+
jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
|
853
|
+
});
|
|
854
|
+
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
|
855
|
+
const bg = el.style.backgroundImage;
|
|
856
|
+
if (!bg) return;
|
|
857
|
+
let m;
|
|
858
|
+
const re = /url\(["']?([^"')]+)["']?\)/g;
|
|
859
|
+
while ((m = re.exec(bg))) {
|
|
860
|
+
const tok = m[0],
|
|
861
|
+
url = m[1];
|
|
862
|
+
if (url.indexOf('data:') === 0) continue;
|
|
863
|
+
jobs.push(
|
|
864
|
+
toDataURL(url).then((d) => {
|
|
865
|
+
el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")');
|
|
866
|
+
}),
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
await Promise.all(jobs);
|
|
871
|
+
|
|
872
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
873
|
+
const save = (blob, ext) => {
|
|
874
|
+
if (!blob) return;
|
|
875
|
+
const a = document.createElement('a');
|
|
876
|
+
a.href = URL.createObjectURL(blob);
|
|
877
|
+
a.download = name + '.' + ext;
|
|
878
|
+
a.click();
|
|
879
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
if (kind === 'html') {
|
|
883
|
+
const html =
|
|
884
|
+
'<!doctype html><html><head><meta charset="utf-8"><title>' +
|
|
885
|
+
name +
|
|
886
|
+
'</title>' +
|
|
887
|
+
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
|
888
|
+
'</head><body style="margin:0">' +
|
|
889
|
+
xml +
|
|
890
|
+
'</body></html>';
|
|
891
|
+
return save(new Blob([html], { type: 'text/html' }), 'html');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// PNG: the SVG's own width/height ARE the output resolution — an SVG loaded
|
|
895
|
+
// through <img> rasterises at its intrinsic size, so rendering at 1x and then
|
|
896
|
+
// ctx.scale-ing up would just magnify a 1x bitmap.
|
|
897
|
+
const scale = 3;
|
|
898
|
+
const svg =
|
|
899
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
|
|
900
|
+
w * scale +
|
|
901
|
+
'" height="' +
|
|
902
|
+
h * scale +
|
|
903
|
+
'" viewBox="0 0 ' +
|
|
904
|
+
w +
|
|
905
|
+
' ' +
|
|
906
|
+
h +
|
|
907
|
+
'"><foreignObject width="' +
|
|
908
|
+
w +
|
|
909
|
+
'" height="' +
|
|
910
|
+
h +
|
|
911
|
+
'">' +
|
|
912
|
+
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') +
|
|
913
|
+
xml +
|
|
914
|
+
'</foreignObject></svg>';
|
|
915
|
+
const img = new Image();
|
|
916
|
+
await new Promise((res, rej) => {
|
|
917
|
+
img.onload = res;
|
|
918
|
+
img.onerror = () => rej(new Error('svg load failed'));
|
|
919
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
920
|
+
});
|
|
921
|
+
const cv = document.createElement('canvas');
|
|
922
|
+
cv.width = w * scale;
|
|
923
|
+
cv.height = h * scale;
|
|
924
|
+
cv.getContext('2d').drawImage(img, 0, 0);
|
|
925
|
+
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const DC_STATE_FILE = '.design-canvas.state.json';
|
|
929
|
+
const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
|
|
930
|
+
|
|
931
|
+
/** Hand the sidecar content back to the host to save, via postMessage. With no
|
|
932
|
+
* parent, do nothing. */
|
|
933
|
+
function miaodaWriteFile(path, content) {
|
|
934
|
+
try {
|
|
935
|
+
window.parent.postMessage({ type: 'miaoda:bridge:write-file', path, content }, '*');
|
|
936
|
+
} catch {
|
|
937
|
+
/* no parent — ignore */
|
|
938
|
+
}
|
|
939
|
+
return Promise.resolve();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ───────────────────────────────────────────────────────────
|
|
943
|
+
// Focus overlay — blows up a single artboard. ←/→ moves within the section, ↑/↓
|
|
944
|
+
// across sections, Esc or a backdrop click exits.
|
|
945
|
+
// Mounted on document.body rather than inside a shadow root, so position:fixed
|
|
946
|
+
// resolves against the real viewport — inside, the canvas's own transform would
|
|
947
|
+
// turn it into a containing block.
|
|
948
|
+
// ───────────────────────────────────────────────────────────
|
|
949
|
+
class DCFocusOverlay {
|
|
950
|
+
constructor(canvas) {
|
|
951
|
+
this.canvas = canvas;
|
|
952
|
+
this.root = null;
|
|
953
|
+
}
|
|
954
|
+
close() {
|
|
955
|
+
if (!this.root) return;
|
|
956
|
+
document.removeEventListener('keydown', this._onKey);
|
|
957
|
+
window.removeEventListener('resize', this._onResize);
|
|
958
|
+
this.root.remove();
|
|
959
|
+
this.root = null;
|
|
960
|
+
}
|
|
961
|
+
open(slotId) {
|
|
962
|
+
this.close();
|
|
963
|
+
const { sectionId, artboard } = this.canvas.registry[slotId] || {};
|
|
964
|
+
if (!artboard) return;
|
|
965
|
+
const meta = this.canvas.sectionMeta[sectionId];
|
|
966
|
+
const peers = meta.slotIds;
|
|
967
|
+
const idx = peers.indexOf(artboard.slotId);
|
|
968
|
+
|
|
969
|
+
const chromeless = artboard.hasAttribute('chromeless');
|
|
970
|
+
const hasHeight = artboard.hasAttribute('height');
|
|
971
|
+
const width = numAttr(artboard, 'width', 260);
|
|
972
|
+
// Auto-sized cards (chromeless, or no explicit height) are measured against
|
|
973
|
+
// their content first and scaled from that measurement; otherwise they get
|
|
974
|
+
// stuffed into an undersized clip box and the magnified result is a cropped
|
|
975
|
+
// slice.
|
|
976
|
+
const autoSize = chromeless || !hasHeight;
|
|
977
|
+
const card = artboard.shadowRoot.querySelector('.dc-card');
|
|
978
|
+
const measured = autoSize ? { w: card.offsetWidth, h: card.offsetHeight } : null;
|
|
979
|
+
// Hide an auto-sized card that could not be measured — one frame scaled from
|
|
980
|
+
// the fallback size flashes visibly wrong content.
|
|
981
|
+
const needsMeasure = autoSize && !(measured.w > 0);
|
|
982
|
+
const cw = autoSize ? measured.w || width : width;
|
|
983
|
+
const ch = autoSize ? measured.h || 480 : numAttr(artboard, 'height', 480);
|
|
984
|
+
|
|
985
|
+
const root = document.createElement('div');
|
|
986
|
+
root.className = 'dc-focus-root';
|
|
987
|
+
root.innerHTML = `<style>
|
|
988
|
+
.dc-focus-content *{scrollbar-width:none}
|
|
989
|
+
.dc-focus-content *::-webkit-scrollbar{display:none}
|
|
990
|
+
</style>`;
|
|
991
|
+
Object.assign(root.style, {
|
|
992
|
+
position: 'fixed',
|
|
993
|
+
inset: '0',
|
|
994
|
+
zIndex: '100',
|
|
995
|
+
background: 'rgba(0,0,0,.8)',
|
|
996
|
+
backdropFilter: 'blur(4px)',
|
|
997
|
+
fontFamily: DC.font,
|
|
998
|
+
display: 'flex',
|
|
999
|
+
alignItems: 'center',
|
|
1000
|
+
justifyContent: 'center',
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
const content = document.createElement('div');
|
|
1004
|
+
content.className = 'dc-focus-content';
|
|
1005
|
+
content.style.position = 'relative';
|
|
1006
|
+
// Set explicitly: stay hidden until the natural size is known, so no frame is
|
|
1007
|
+
// painted at the wrong size.
|
|
1008
|
+
content.style.visibility = needsMeasure ? 'hidden' : 'visible';
|
|
1009
|
+
const inner = document.createElement('div');
|
|
1010
|
+
// The content is the artboard's real light DOM, not a clone, and goes back
|
|
1011
|
+
// when the overlay closes — so interaction state (scroll position, typed
|
|
1012
|
+
// input) survives entering and leaving focus.
|
|
1013
|
+
this._borrowed = [...artboard.childNodes];
|
|
1014
|
+
this._returnTo = artboard;
|
|
1015
|
+
for (const n of this._borrowed) inner.appendChild(n);
|
|
1016
|
+
content.appendChild(inner);
|
|
1017
|
+
root.appendChild(content);
|
|
1018
|
+
|
|
1019
|
+
const layout = () => {
|
|
1020
|
+
// Fill the viewport minus a safe margin (70 left/right, 32 top/bottom), taking
|
|
1021
|
+
// the smaller of the width and height ratios so the whole board stays visible.
|
|
1022
|
+
const MARGIN_X = 70,
|
|
1023
|
+
MARGIN_Y = 32;
|
|
1024
|
+
const scale = Math.max(
|
|
1025
|
+
0.05,
|
|
1026
|
+
Math.min(
|
|
1027
|
+
(window.innerWidth - MARGIN_X * 2) / cw,
|
|
1028
|
+
(window.innerHeight - MARGIN_Y * 2) / ch,
|
|
1029
|
+
),
|
|
1030
|
+
);
|
|
1031
|
+
content.style.width = cw * scale + 'px';
|
|
1032
|
+
content.style.height = ch * scale + 'px';
|
|
1033
|
+
Object.assign(inner.style, {
|
|
1034
|
+
width: cw + 'px',
|
|
1035
|
+
height: ch + 'px',
|
|
1036
|
+
transform: `scale(${scale})`,
|
|
1037
|
+
transformOrigin: 'top left',
|
|
1038
|
+
});
|
|
1039
|
+
if (!chromeless) {
|
|
1040
|
+
Object.assign(inner.style, {
|
|
1041
|
+
background: '#fff',
|
|
1042
|
+
borderRadius: '2px',
|
|
1043
|
+
overflow: 'auto',
|
|
1044
|
+
boxShadow: '0 20px 80px rgba(0,0,0,.4)',
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
layout();
|
|
1049
|
+
this._onResize = layout;
|
|
1050
|
+
window.addEventListener('resize', this._onResize);
|
|
1051
|
+
|
|
1052
|
+
// The chrome (arrows / close / dots) only shows while the pointer is over the
|
|
1053
|
+
// dim backdrop; when hidden it also goes pointer-events:none so it cannot
|
|
1054
|
+
// block content interaction or a backdrop click.
|
|
1055
|
+
let chromeVisible = false;
|
|
1056
|
+
const chromeEls = [];
|
|
1057
|
+
const setChrome = (v) => {
|
|
1058
|
+
if (chromeVisible === v) return;
|
|
1059
|
+
chromeVisible = v;
|
|
1060
|
+
for (const el of chromeEls) {
|
|
1061
|
+
el.style.opacity = v ? '1' : '0';
|
|
1062
|
+
el.style.pointerEvents = v ? 'auto' : 'none';
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
const btn = (extra, svg, onClick) => {
|
|
1066
|
+
const b = document.createElement('button');
|
|
1067
|
+
b.innerHTML = svg;
|
|
1068
|
+
Object.assign(b.style, {
|
|
1069
|
+
position: 'absolute',
|
|
1070
|
+
border: 'none',
|
|
1071
|
+
background: 'rgba(255,255,255,.08)',
|
|
1072
|
+
color: 'rgb(255,255,255)',
|
|
1073
|
+
width: '36px',
|
|
1074
|
+
height: '36px',
|
|
1075
|
+
borderRadius: '22px',
|
|
1076
|
+
fontSize: '16px',
|
|
1077
|
+
cursor: 'pointer',
|
|
1078
|
+
display: 'flex',
|
|
1079
|
+
alignItems: 'center',
|
|
1080
|
+
justifyContent: 'center',
|
|
1081
|
+
transition: 'background .15s, opacity .18s ease',
|
|
1082
|
+
opacity: '0',
|
|
1083
|
+
pointerEvents: 'none',
|
|
1084
|
+
...extra,
|
|
1085
|
+
});
|
|
1086
|
+
b.addEventListener('mouseenter', () => (b.style.background = 'rgba(255,255,255,.18)'));
|
|
1087
|
+
b.addEventListener('mouseleave', () => (b.style.background = 'rgba(255,255,255,.08)'));
|
|
1088
|
+
b.addEventListener('click', (e) => {
|
|
1089
|
+
e.stopPropagation();
|
|
1090
|
+
onClick();
|
|
1091
|
+
});
|
|
1092
|
+
chromeEls.push(b);
|
|
1093
|
+
root.appendChild(b);
|
|
1094
|
+
return b;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const go = (d) => {
|
|
1098
|
+
const n = peers[(idx + d + peers.length) % peers.length];
|
|
1099
|
+
if (n) this.canvas.setFocus(`${sectionId}/${n}`);
|
|
1100
|
+
};
|
|
1101
|
+
const goSection = (d) => {
|
|
1102
|
+
// A section whose artboards were all deleted has empty slotIds — skip it so
|
|
1103
|
+
// ↑/↓ never lands on a dead end.
|
|
1104
|
+
const order = this.canvas.sectionOrder;
|
|
1105
|
+
const n = order.length;
|
|
1106
|
+
const secIdx = order.indexOf(sectionId);
|
|
1107
|
+
for (let i = 1; i < n; i++) {
|
|
1108
|
+
const ns = order[(((secIdx + d * i) % n) + n) % n];
|
|
1109
|
+
const first = this.canvas.sectionMeta[ns] && this.canvas.sectionMeta[ns].slotIds[0];
|
|
1110
|
+
if (first) {
|
|
1111
|
+
this.canvas.setFocus(`${ns}/${first}`);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
const ARROW_L =
|
|
1118
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.86 1.53c.26.26.26.68 0 .94L5.33 8l5.53 5.53c.26.26.26.68 0 .94a.67.67 0 0 1-.94 0L4.39 8.94a1.33 1.33 0 0 1 0-1.89l5.53-5.52c.26-.26.68-.26.94 0Z" fill="currentColor"/></svg>';
|
|
1119
|
+
const ARROW_R =
|
|
1120
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.14 14.47a.67.67 0 0 1 0-.94L10.67 8 5.14 2.47a.67.67 0 1 1 .94-.94l5.53 5.53c.52.52.52 1.37 0 1.89l-5.53 5.52a.67.67 0 0 1-.94 0Z" fill="currentColor"/></svg>';
|
|
1121
|
+
const CLOSE =
|
|
1122
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.47 13.47a.66.66 0 0 0 0-.93L8.94 8l4.53-4.54a.66.66 0 1 0-.93-.93L8 7.06 3.46 2.53a.66.66 0 0 0-.93.93L7.06 8l-4.53 4.54a.66.66 0 0 0 .93.93L8 8.94l4.54 4.53c.26.26.68.26.93 0Z" fill="currentColor"/></svg>';
|
|
1123
|
+
|
|
1124
|
+
btn({ top: '50%', left: '20px', transform: 'translateY(-50%)' }, ARROW_L, () => go(-1));
|
|
1125
|
+
btn({ top: '50%', right: '20px', transform: 'translateY(-50%)' }, ARROW_R, () => go(1));
|
|
1126
|
+
btn({ top: '20px', right: '24px' }, CLOSE, () => this.canvas.setFocus(null));
|
|
1127
|
+
|
|
1128
|
+
const dots = document.createElement('div');
|
|
1129
|
+
Object.assign(dots.style, {
|
|
1130
|
+
position: 'absolute',
|
|
1131
|
+
bottom: '13px',
|
|
1132
|
+
left: '50%',
|
|
1133
|
+
transform: 'translateX(-50%)',
|
|
1134
|
+
display: 'flex',
|
|
1135
|
+
gap: '8px',
|
|
1136
|
+
transition: 'opacity .18s ease',
|
|
1137
|
+
opacity: '0',
|
|
1138
|
+
pointerEvents: 'none',
|
|
1139
|
+
});
|
|
1140
|
+
dots.addEventListener('click', (e) => e.stopPropagation());
|
|
1141
|
+
peers.forEach((p, i) => {
|
|
1142
|
+
const d = document.createElement('button');
|
|
1143
|
+
Object.assign(d.style, {
|
|
1144
|
+
border: 'none',
|
|
1145
|
+
padding: '0',
|
|
1146
|
+
cursor: 'pointer',
|
|
1147
|
+
width: '6px',
|
|
1148
|
+
height: '6px',
|
|
1149
|
+
borderRadius: '3px',
|
|
1150
|
+
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)',
|
|
1151
|
+
});
|
|
1152
|
+
d.addEventListener('click', () => this.canvas.setFocus(`${sectionId}/${p}`));
|
|
1153
|
+
dots.appendChild(d);
|
|
1154
|
+
});
|
|
1155
|
+
chromeEls.push(dots);
|
|
1156
|
+
root.appendChild(dots);
|
|
1157
|
+
|
|
1158
|
+
content.addEventListener('click', (e) => e.stopPropagation());
|
|
1159
|
+
root.addEventListener('click', () => this.canvas.setFocus(null));
|
|
1160
|
+
root.addEventListener('mousemove', (e) => setChrome(!content.contains(e.target)));
|
|
1161
|
+
root.addEventListener('mouseleave', () => setChrome(false));
|
|
1162
|
+
root.addEventListener('wheel', (e) => {
|
|
1163
|
+
if (!content.contains(e.target)) e.preventDefault();
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
this._onKey = (e) => {
|
|
1167
|
+
if (e.key === 'ArrowLeft') {
|
|
1168
|
+
e.preventDefault();
|
|
1169
|
+
go(-1);
|
|
1170
|
+
} else if (e.key === 'ArrowRight') {
|
|
1171
|
+
e.preventDefault();
|
|
1172
|
+
go(1);
|
|
1173
|
+
} else if (e.key === 'ArrowUp') {
|
|
1174
|
+
e.preventDefault();
|
|
1175
|
+
goSection(-1);
|
|
1176
|
+
} else if (e.key === 'ArrowDown') {
|
|
1177
|
+
e.preventDefault();
|
|
1178
|
+
goSection(1);
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
document.addEventListener('keydown', this._onKey);
|
|
1182
|
+
|
|
1183
|
+
document.body.appendChild(root);
|
|
1184
|
+
this.root = root;
|
|
1185
|
+
}
|
|
1186
|
+
/** Put the borrowed content back on the artboard, unchanged. */
|
|
1187
|
+
restore() {
|
|
1188
|
+
if (!this._returnTo) return;
|
|
1189
|
+
for (const n of this._borrowed) this._returnTo.appendChild(n);
|
|
1190
|
+
this._returnTo = null;
|
|
1191
|
+
this._borrowed = [];
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ───────────────────────────────────────────────────────────
|
|
1196
|
+
// <design-canvas> — the pan/zoom viewport and the owner of all state.
|
|
1197
|
+
//
|
|
1198
|
+
// Input mapping (Figma's):
|
|
1199
|
+
// trackpad pinch / Ctrl·⌘+wheel -> zoom; unmodified wheel -> pan;
|
|
1200
|
+
// middle-drag or left-drag on the background -> pan; on mobile, one finger pans
|
|
1201
|
+
// and two fingers pinch to zoom.
|
|
1202
|
+
// The transform lives in instance fields and is written straight to the DOM
|
|
1203
|
+
// (translate3d + will-change) rather than driving a re-render.
|
|
1204
|
+
// ───────────────────────────────────────────────────────────
|
|
1205
|
+
class DesignCanvas extends Base {
|
|
1206
|
+
static get observedAttributes() {
|
|
1207
|
+
return ['min-scale', 'max-scale'];
|
|
1208
|
+
}
|
|
1209
|
+
constructor() {
|
|
1210
|
+
super();
|
|
1211
|
+
this.tf = { x: 0, y: 0, scale: 0.5 };
|
|
1212
|
+
this.state = { sections: {}, focus: null };
|
|
1213
|
+
this.registry = {};
|
|
1214
|
+
this.sectionMeta = {};
|
|
1215
|
+
this.sectionOrder = [];
|
|
1216
|
+
this.overlay = new DCFocusOverlay(this);
|
|
1217
|
+
this._tfKey = 'dc-viewport:' + location.pathname;
|
|
1218
|
+
this._saveT = 0;
|
|
1219
|
+
this._writeT = 0;
|
|
1220
|
+
this._didRead = false;
|
|
1221
|
+
this._ready = false;
|
|
1222
|
+
this._skipNextWrite = false;
|
|
1223
|
+
// Magic Share echo guards, one per mirrored piece of state: a remote replay has
|
|
1224
|
+
// to land locally without re-broadcasting as if the user did it here.
|
|
1225
|
+
this._skipNextMirrorEmit = false;
|
|
1226
|
+
this._skipNextFocusEmit = false;
|
|
1227
|
+
this._applyingRemoteView = false;
|
|
1228
|
+
this._viewIsRemote = false;
|
|
1229
|
+
this._mirrorSeeded = false;
|
|
1230
|
+
this._mirrorEmitT = 0;
|
|
1231
|
+
this._mirrorOffs = [];
|
|
1232
|
+
this._didFit = false;
|
|
1233
|
+
this._restoredView = false;
|
|
1234
|
+
this._lastPostedScale = undefined;
|
|
1235
|
+
}
|
|
1236
|
+
get minScale() {
|
|
1237
|
+
return numAttr(this, 'min-scale', 0.1);
|
|
1238
|
+
}
|
|
1239
|
+
get maxScale() {
|
|
1240
|
+
return numAttr(this, 'max-scale', 8);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
connectedCallback() {
|
|
1244
|
+
this._render();
|
|
1245
|
+
this._readSidecar();
|
|
1246
|
+
this._bindHost();
|
|
1247
|
+
this._bindGestures();
|
|
1248
|
+
this._bindChildEvents();
|
|
1249
|
+
this.addEventListener('dc:edit-start', (ev) => {
|
|
1250
|
+
this._editing = pathOf(ev)[0];
|
|
1251
|
+
});
|
|
1252
|
+
this.addEventListener('dc:edit-end', () => {
|
|
1253
|
+
this._editing = null;
|
|
1254
|
+
});
|
|
1255
|
+
// Any section or artboard the author adds or removes rebuilds the registry
|
|
1256
|
+
this._mo = new MutationObserver(() => this.rebuild());
|
|
1257
|
+
this._mo.observe(this, {
|
|
1258
|
+
childList: true,
|
|
1259
|
+
subtree: true,
|
|
1260
|
+
attributeFilter: ['id', 'label', 'title'],
|
|
1261
|
+
});
|
|
1262
|
+
// Order matters: write the transform into the DOM first, then rebuild the
|
|
1263
|
+
// registry and fit. The other way round, the first measurement happens while
|
|
1264
|
+
// the world still has no transform, so world coordinates come out divided by
|
|
1265
|
+
// scale=1 — off by the whole zoom factor.
|
|
1266
|
+
this._restoreView();
|
|
1267
|
+
this.rebuild();
|
|
1268
|
+
// The first-visit fit has to happen at a deterministic moment. During
|
|
1269
|
+
// connectedCallback the descendant custom elements have not upgraded yet, so
|
|
1270
|
+
// there is nothing to measure; waiting for MutationObserver delivery instead
|
|
1271
|
+
// lets the fit drift across hundreds of milliseconds with how fast the page's
|
|
1272
|
+
// own scripts load — and content measures differently before and after a web
|
|
1273
|
+
// font lands, so the opening framing jitters with it. On the microtask tick
|
|
1274
|
+
// every descendant inserted in the same task has upgraded synchronously: the
|
|
1275
|
+
// moment is fixed, and earlier than any network resource. This matches the
|
|
1276
|
+
// semantics of the React version's useLayoutEffect.
|
|
1277
|
+
queueMicrotask(() => this.fitToContent());
|
|
1278
|
+
this._onDocKey = (e) => {
|
|
1279
|
+
if (e.key === 'Escape') this.setFocus(null);
|
|
1280
|
+
};
|
|
1281
|
+
this._onDocDown = (e) => {
|
|
1282
|
+
const ae = this._editing;
|
|
1283
|
+
if (!ae) return;
|
|
1284
|
+
// A click on the element being edited is not a click outside. Decide via
|
|
1285
|
+
// composedPath: e.target alone resolves to the host element across a shadow
|
|
1286
|
+
// boundary.
|
|
1287
|
+
if (pathOf(e).includes(ae)) return;
|
|
1288
|
+
// blur triggers a focus recalculation, and some implementations throw there
|
|
1289
|
+
// when the element sits inside a shadow root. Commit the value anyway —
|
|
1290
|
+
// content must not be lost to a difference in the focus mechanism.
|
|
1291
|
+
try {
|
|
1292
|
+
ae.blur();
|
|
1293
|
+
} catch {}
|
|
1294
|
+
ae.__dcCommit?.();
|
|
1295
|
+
};
|
|
1296
|
+
document.addEventListener('keydown', this._onDocKey);
|
|
1297
|
+
this.addEventListener('pointerdown', this._onDocDown);
|
|
1298
|
+
this._bindMirror();
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Magic Share replay entries. They claim exactly the types this component emits,
|
|
1303
|
+
* so the emit-and-apply contract stays in one place.
|
|
1304
|
+
* Each answers applied or ignored — 'failed' means "cannot apply yet, retry",
|
|
1305
|
+
* and none of these three has such a state; answering it by mistake makes the
|
|
1306
|
+
* host retry forever.
|
|
1307
|
+
*/
|
|
1308
|
+
_bindMirror() {
|
|
1309
|
+
if (dcCaptureMode) return;
|
|
1310
|
+
this._mirrorOffs = [
|
|
1311
|
+
window.__miaodaMirror.onApply('miaoda:canvas:structure-changed', (m) => {
|
|
1312
|
+
// Per-field shape check, not just typeof: a malformed snapshot (order that
|
|
1313
|
+
// is not an array, say) would assign cleanly, be answered 'applied', and
|
|
1314
|
+
// only then blow up the whole tree on rebuild — an unrecoverable failure
|
|
1315
|
+
// the retry protocol cannot see.
|
|
1316
|
+
if (!m.sections || typeof m.sections !== 'object')
|
|
1317
|
+
return { type: 'ignored', reason: 'no sections' };
|
|
1318
|
+
for (const sec of Object.values(m.sections)) {
|
|
1319
|
+
if (
|
|
1320
|
+
!sec ||
|
|
1321
|
+
typeof sec !== 'object' ||
|
|
1322
|
+
(sec.order !== undefined && !Array.isArray(sec.order)) ||
|
|
1323
|
+
(sec.hidden !== undefined && !Array.isArray(sec.hidden)) ||
|
|
1324
|
+
(sec.labels !== undefined && (typeof sec.labels !== 'object' || sec.labels === null))
|
|
1325
|
+
) {
|
|
1326
|
+
return { type: 'ignored', reason: 'malformed sections' };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
this._skipNextMirrorEmit = true;
|
|
1330
|
+
// A replayed arrangement is the presenter's state, not a local edit — never
|
|
1331
|
+
// persist it back through this end's write bridge.
|
|
1332
|
+
this._skipNextWrite = true;
|
|
1333
|
+
// Once set, a slower sidecar read cannot clobber it. _didRead goes up with
|
|
1334
|
+
// it, or the guard at the top of _writeSidecar would strand both flags in
|
|
1335
|
+
// the raised position and swallow the next real broadcast and write.
|
|
1336
|
+
this._mirrorSeeded = true;
|
|
1337
|
+
this._didRead = true;
|
|
1338
|
+
this.state.sections = m.sections;
|
|
1339
|
+
this.rebuild();
|
|
1340
|
+
this._writeSidecar();
|
|
1341
|
+
return { type: 'applied' };
|
|
1342
|
+
}),
|
|
1343
|
+
window.__miaodaMirror.onApply('miaoda:canvas:focus-changed', (m) => {
|
|
1344
|
+
const next = typeof m.focus === 'string' ? m.focus : null;
|
|
1345
|
+
// With an unchanged value setFocus returns early and emits nothing, so
|
|
1346
|
+
// raising the guard here would strand it and swallow the next real focus
|
|
1347
|
+
// broadcast.
|
|
1348
|
+
if (next === this.state.focus) return { type: 'applied' };
|
|
1349
|
+
this._skipNextFocusEmit = true;
|
|
1350
|
+
this.setFocus(next);
|
|
1351
|
+
return { type: 'applied' };
|
|
1352
|
+
}),
|
|
1353
|
+
window.__miaodaMirror.onApply('miaoda:canvas:viewport-changed', (m) => {
|
|
1354
|
+
if (![m.x, m.y, m.scale].every(Number.isFinite))
|
|
1355
|
+
return { type: 'ignored', reason: 'bad snapshot' };
|
|
1356
|
+
// Kill the pending local-emit timer: it was armed before this replay and
|
|
1357
|
+
// would fire 120ms later with tf already holding the remote state — echoing
|
|
1358
|
+
// the presenter's framing back as our own.
|
|
1359
|
+
clearTimeout(this._mirrorEmitT);
|
|
1360
|
+
this._mirrorEmitT = 0;
|
|
1361
|
+
// Same staleness for the persist timer: armed by a local gesture, but it reads
|
|
1362
|
+
// tf at fire time and would save the remote framing as this viewer's.
|
|
1363
|
+
clearTimeout(this._saveT);
|
|
1364
|
+
// _viewIsRemote is raised by apply(), the single fan-out for both local
|
|
1365
|
+
// gestures and replays.
|
|
1366
|
+
// A replayed framing IS this canvas's framing now — mark the first-visit fit
|
|
1367
|
+
// as satisfied, or it re-solves as soon as content mounts and overrides the
|
|
1368
|
+
// presenter's viewport.
|
|
1369
|
+
this._didFit = true;
|
|
1370
|
+
this._applyingRemoteView = true;
|
|
1371
|
+
this.tf = {
|
|
1372
|
+
x: m.x,
|
|
1373
|
+
y: m.y,
|
|
1374
|
+
scale: Math.min(this.maxScale, Math.max(this.minScale, m.scale)),
|
|
1375
|
+
};
|
|
1376
|
+
this.apply();
|
|
1377
|
+
this._applyingRemoteView = false;
|
|
1378
|
+
return { type: 'applied' };
|
|
1379
|
+
}),
|
|
1380
|
+
];
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
disconnectedCallback() {
|
|
1384
|
+
for (const off of this._mirrorOffs || []) off();
|
|
1385
|
+
this._mirrorOffs = [];
|
|
1386
|
+
clearTimeout(this._mirrorEmitT);
|
|
1387
|
+
this._mirrorEmitT = 0;
|
|
1388
|
+
this._mo?.disconnect();
|
|
1389
|
+
this._mo = null;
|
|
1390
|
+
document.removeEventListener('keydown', this._onDocKey);
|
|
1391
|
+
this.removeEventListener('pointerdown', this._onDocDown);
|
|
1392
|
+
window.removeEventListener('message', this._onHostMsg);
|
|
1393
|
+
window.removeEventListener('pagehide', this._flushView);
|
|
1394
|
+
this._flushView?.();
|
|
1395
|
+
this.overlay.close();
|
|
1396
|
+
for (const off of this._offs || []) off();
|
|
1397
|
+
this._offs = [];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** While moving DOM ourselves, do not read our own changes as the author's. */
|
|
1401
|
+
pauseObserving() {
|
|
1402
|
+
this._mo?.disconnect();
|
|
1403
|
+
}
|
|
1404
|
+
resumeObserving() {
|
|
1405
|
+
// takeRecords discards the batch we just produced; without it, resuming
|
|
1406
|
+
// observation fires again immediately.
|
|
1407
|
+
this._mo?.takeRecords?.();
|
|
1408
|
+
this._mo?.observe(this, {
|
|
1409
|
+
childList: true,
|
|
1410
|
+
subtree: true,
|
|
1411
|
+
attributeFilter: ['id', 'label', 'title'],
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
_render() {
|
|
1416
|
+
this.shadowRoot.innerHTML = `<style>${boxRule(this)}${CANVAS_CSS}</style>
|
|
1417
|
+
<div class="design-canvas${dcIsMobile ? ' dc-mobile' : ''}" part="frame" style="height:100vh;width:100vw;
|
|
1418
|
+
background:${DC.bg};overflow:hidden;overscroll-behavior:none;touch-action:none;
|
|
1419
|
+
position:relative;font-family:${DC.font};box-sizing:border-box">
|
|
1420
|
+
<div class="dc-world" part="world" style="position:absolute;top:0;left:0;transform-origin:0 0;
|
|
1421
|
+
will-change:transform;width:max-content;min-width:100%;min-height:100%;padding:60px 0 80px">
|
|
1422
|
+
<slot></slot>
|
|
1423
|
+
</div>
|
|
1424
|
+
<!-- Things that sit above the canvas without panning or zooming with it
|
|
1425
|
+
(panels and such) go through a named slot. The React version picked
|
|
1426
|
+
them out via a static dcOverlay marker on the component, which does not
|
|
1427
|
+
survive the custom-element boundary. -->
|
|
1428
|
+
<slot name="overlay"></slot>
|
|
1429
|
+
</div>`;
|
|
1430
|
+
this.vp = this.shadowRoot.querySelector('.design-canvas');
|
|
1431
|
+
this.world = this.shadowRoot.querySelector('.dc-world');
|
|
1432
|
+
// Mobile zooms by pinching, so the zoom control is not rendered. The host's
|
|
1433
|
+
// set-zoom message still works — the protocol is independent of the control.
|
|
1434
|
+
if (!dcIsMobile) this._renderZoom();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// ── Zoom control (bottom right) ─────────────────────────
|
|
1438
|
+
_renderZoom() {
|
|
1439
|
+
const wrap = document.createElement('div');
|
|
1440
|
+
wrap.className = 'dc-zoom';
|
|
1441
|
+
wrap.setAttribute('data-miaoda-chrome', '');
|
|
1442
|
+
wrap.innerHTML = `
|
|
1443
|
+
<button type="button" class="dc-zoom-btn dc-zoom-in">
|
|
1444
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.4957 11.5517L14.9105 13.9668C15.1702 14.2266 15.1684 14.648 14.9087 14.9077C14.649 15.1674 14.2275 15.1692 13.9678 14.9095L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM6.66699 6.66602V5.33268C6.66699 4.96449 6.96547 4.66602 7.33366 4.66602C7.70185 4.66602 8.00033 4.96449 8.00033 5.33268V6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H8.00033V9.33268C8.00033 9.70087 7.70185 9.99935 7.33366 9.99935C6.96547 9.99935 6.66699 9.70087 6.66699 9.33268V7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H6.66699ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666Z" fill="#1F2329"/></svg>
|
|
1445
|
+
</button>
|
|
1446
|
+
<div class="dc-zoom-toggle">
|
|
1447
|
+
<button type="button" class="dc-zoom-btn dc-zoom-trigger">
|
|
1448
|
+
<span class="dc-zoom-pct">50%</span>
|
|
1449
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
1450
|
+
</button>
|
|
1451
|
+
</div>
|
|
1452
|
+
<button type="button" class="dc-zoom-btn dc-zoom-out">
|
|
1453
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.4957 11.5517L14.9111 13.9674C15.1707 14.227 15.1697 14.6496 14.9102 14.9092C14.6506 15.1688 14.228 15.1697 13.9684 14.9101L11.5527 12.4943C10.3631 13.4696 8.87188 14.0016 7.33366 13.9993C3.65166 13.9993 0.666992 11.0147 0.666992 7.33268C0.666992 3.65068 3.65166 0.666016 7.33366 0.666016C11.0157 0.666016 14.0003 3.65068 14.0003 7.33268C14.0003 8.93368 13.436 10.4027 12.4957 11.5517ZM7.33366 12.666C10.2793 12.666 12.667 10.2783 12.667 7.33268C12.667 4.38702 10.2793 1.99935 7.33366 1.99935C4.38799 1.99935 2.00033 4.38702 2.00033 7.33268C2.00033 10.2783 4.38799 12.666 7.33366 12.666ZM4.66699 7.33268C4.66699 6.96449 4.96547 6.66602 5.33366 6.66602H9.33366C9.70185 6.66602 10.0003 6.96449 10.0003 7.33268C10.0003 7.70087 9.70185 7.99935 9.33366 7.99935H5.33366C4.96547 7.99935 4.66699 7.70087 4.66699 7.33268Z" fill="#1F2329"/></svg>
|
|
1454
|
+
</button>`;
|
|
1455
|
+
this.vp.appendChild(wrap);
|
|
1456
|
+
this._zoomWrap = wrap;
|
|
1457
|
+
// Swallow pointerdown so clicking the control does not start a background drag
|
|
1458
|
+
// (data-miaoda-chrome is the second line of defence).
|
|
1459
|
+
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
1460
|
+
wrap.querySelector('.dc-zoom-in').addEventListener('click', () => {
|
|
1461
|
+
const maxPct = Math.round(this.maxScale * 100);
|
|
1462
|
+
this.zoomToPct(Math.min(maxPct, (Math.floor(this._pct() / 10) + 1) * 10));
|
|
1463
|
+
});
|
|
1464
|
+
wrap.querySelector('.dc-zoom-out').addEventListener('click', () => {
|
|
1465
|
+
const minPct = Math.round(this.minScale * 100);
|
|
1466
|
+
this.zoomToPct(Math.max(minPct, (Math.ceil(this._pct() / 10) - 1) * 10));
|
|
1467
|
+
});
|
|
1468
|
+
wrap
|
|
1469
|
+
.querySelector('.dc-zoom-trigger')
|
|
1470
|
+
.addEventListener('click', () => this._toggleZoomMenu());
|
|
1471
|
+
this._syncZoom();
|
|
1472
|
+
}
|
|
1473
|
+
_pct() {
|
|
1474
|
+
return Math.round(this.tf.scale * 100);
|
|
1475
|
+
}
|
|
1476
|
+
_syncZoom() {
|
|
1477
|
+
if (!this._zoomWrap) return;
|
|
1478
|
+
const pct = this._pct();
|
|
1479
|
+
const el = this._zoomWrap.querySelector('.dc-zoom-pct');
|
|
1480
|
+
if (el) el.textContent = pct + '%';
|
|
1481
|
+
this._zoomWrap.querySelector('.dc-zoom-in').disabled = pct >= Math.round(this.maxScale * 100);
|
|
1482
|
+
this._zoomWrap.querySelector('.dc-zoom-out').disabled =
|
|
1483
|
+
pct <= Math.round(this.minScale * 100);
|
|
1484
|
+
}
|
|
1485
|
+
_toggleZoomMenu() {
|
|
1486
|
+
const host = this._zoomWrap.querySelector('.dc-zoom-toggle');
|
|
1487
|
+
const open = host.querySelector('.dc-zoom-menu');
|
|
1488
|
+
if (open) {
|
|
1489
|
+
open.remove();
|
|
1490
|
+
document.removeEventListener('pointerdown', this._offZoomMenu, true);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const menu = document.createElement('div');
|
|
1494
|
+
menu.className = 'dc-menu dc-zoom-menu';
|
|
1495
|
+
menu.innerHTML = DC_ZOOM_PRESETS.map(
|
|
1496
|
+
(p) =>
|
|
1497
|
+
`<button type="button" data-pct="${p}"><span>${p}%</span>${
|
|
1498
|
+
p === this._pct()
|
|
1499
|
+
? '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.00002 5.64639C2.09378 5.55266 2.22094 5.5 2.35352 5.5C2.4861 5.5 2.61325 5.55266 2.70702 5.64639L4.74277 7.68189L9.27827 3.14639C9.37203 3.05266 9.49919 3 9.63177 3C9.76435 3 9.8915 3.05266 9.98527 3.14639L10.3388 3.49989C10.3852 3.54633 10.4221 3.60145 10.4472 3.66213C10.4723 3.72281 10.4853 3.78784 10.4853 3.85352C10.4853 3.9192 10.4723 3.98423 10.4472 4.04491C10.4221 4.10558 10.3852 4.16071 10.3388 4.20714L5.09627 9.44965C5.04984 9.4961 4.99471 9.53295 4.93403 9.55808C4.87336 9.58322 4.80832 9.59616 4.74264 9.59616C4.67697 9.59616 4.61193 9.58322 4.55125 9.55808C4.49058 9.53295 4.43545 9.4961 4.38902 9.44965L1.64652 6.70714C1.60007 6.66071 1.56322 6.60558 1.53808 6.54491C1.51294 6.48423 1.5 6.4192 1.5 6.35352C1.5 6.28784 1.51294 6.22281 1.53808 6.16213C1.56322 6.10145 1.60007 6.04633 1.64652 5.99989L2.00002 5.64639Z" fill="#1F2329"/></svg>'
|
|
1500
|
+
: ''
|
|
1501
|
+
}</button>`,
|
|
1502
|
+
).join('');
|
|
1503
|
+
menu.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
1504
|
+
menu.addEventListener('click', (e) => {
|
|
1505
|
+
const p = e.target.closest('button')?.dataset.pct;
|
|
1506
|
+
if (!p) return;
|
|
1507
|
+
this.zoomToPct(Number(p));
|
|
1508
|
+
this._toggleZoomMenu();
|
|
1509
|
+
});
|
|
1510
|
+
host.appendChild(menu);
|
|
1511
|
+
this._offZoomMenu = (e) => {
|
|
1512
|
+
const inside = pathOf(e).some((n) => n === this._zoomWrap || this._zoomWrap.contains(n));
|
|
1513
|
+
if (!inside) this._toggleZoomMenu();
|
|
1514
|
+
};
|
|
1515
|
+
// Attach immediately, never deferred: the menu opens on click and this listens
|
|
1516
|
+
// for pointerdown, so it cannot close itself. Deferring would never fire under
|
|
1517
|
+
// fake timers, leaving the listener unattached and the menu permanently open.
|
|
1518
|
+
document.addEventListener('pointerdown', this._offZoomMenu, true);
|
|
1519
|
+
}
|
|
1520
|
+
zoomToPct(pct) {
|
|
1521
|
+
const r = this.vp.getBoundingClientRect();
|
|
1522
|
+
const target = Math.min(this.maxScale, Math.max(this.minScale, pct / 100));
|
|
1523
|
+
this.zoomAt(r.left + r.width / 2, r.top + r.height / 2, target / this.tf.scale);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// ── Transform ───────────────────────────────────────────
|
|
1527
|
+
apply() {
|
|
1528
|
+
const { x, y, scale } = this.tf;
|
|
1529
|
+
if (!this.world) return;
|
|
1530
|
+
this.world.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
|
1531
|
+
// Consumed by the counter-scaled chrome (labels, buttons, section gaps)
|
|
1532
|
+
this.world.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
|
1533
|
+
// The same variable also goes on the host element: sections and artboards read
|
|
1534
|
+
// it inside their own shadow roots, and custom properties inherit down the
|
|
1535
|
+
// light DOM — set on the world it would never reach children projected out
|
|
1536
|
+
// through a slot.
|
|
1537
|
+
this.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
|
1538
|
+
if (!dcIsMobile) this._syncZoom();
|
|
1539
|
+
// A pan leaves scale unchanged — skip the cross-frame post
|
|
1540
|
+
if (this._lastPostedScale !== scale) {
|
|
1541
|
+
this._lastPostedScale = scale;
|
|
1542
|
+
try {
|
|
1543
|
+
window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
|
|
1544
|
+
} catch {}
|
|
1545
|
+
}
|
|
1546
|
+
// A replayed framing is the presenter's, not this viewer's — it must not
|
|
1547
|
+
// overwrite the viewport this end restores when it opens the canvas on its own
|
|
1548
|
+
// later. Any local gesture re-owns the view (and its persistence); apply() is
|
|
1549
|
+
// the single fan-out for both, so this distinction belongs here and nowhere
|
|
1550
|
+
// else.
|
|
1551
|
+
if (this._applyingRemoteView) {
|
|
1552
|
+
this._viewIsRemote = true;
|
|
1553
|
+
} else {
|
|
1554
|
+
this._viewIsRemote = false;
|
|
1555
|
+
clearTimeout(this._saveT);
|
|
1556
|
+
this._saveT = setTimeout(() => {
|
|
1557
|
+
try {
|
|
1558
|
+
localStorage.setItem(this._tfKey, JSON.stringify(this.tf));
|
|
1559
|
+
} catch {}
|
|
1560
|
+
}, 200);
|
|
1561
|
+
}
|
|
1562
|
+
// Magic Share: mirror the whole viewport (scale AND pan) as one snapshot,
|
|
1563
|
+
// trailing-throttled at 120ms rather than per call — apply() runs at pointer
|
|
1564
|
+
// rate and a per-frame cross-window post is too expensive. Snapshot semantics
|
|
1565
|
+
// make the dropped intermediate frames harmless.
|
|
1566
|
+
if (!dcCaptureMode && !this._applyingRemoteView && !this._mirrorEmitT) {
|
|
1567
|
+
this._mirrorEmitT = setTimeout(() => {
|
|
1568
|
+
this._mirrorEmitT = 0;
|
|
1569
|
+
window.__miaodaMirror.emit(
|
|
1570
|
+
{
|
|
1571
|
+
type: 'miaoda:canvas:viewport-changed',
|
|
1572
|
+
x: this.tf.x,
|
|
1573
|
+
y: this.tf.y,
|
|
1574
|
+
scale: this.tf.scale,
|
|
1575
|
+
},
|
|
1576
|
+
{ key: 'canvas:viewport', throttle: 60 },
|
|
1577
|
+
);
|
|
1578
|
+
}, 120);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
zoomAt(cx, cy, factor) {
|
|
1583
|
+
const r = this.vp.getBoundingClientRect();
|
|
1584
|
+
const px2 = cx - r.left,
|
|
1585
|
+
py = cy - r.top;
|
|
1586
|
+
const t = this.tf;
|
|
1587
|
+
const next = Math.min(this.maxScale, Math.max(this.minScale, t.scale * factor));
|
|
1588
|
+
const k = next / t.scale;
|
|
1589
|
+
/**
|
|
1590
|
+
* The consumers of --dc-inv-zoom (a section heading's calc()'d font metrics,
|
|
1591
|
+
* the gaps between sections) reflow after every scale change, so a world point
|
|
1592
|
+
* that is mathematically pinned under the cursor drifts anyway. Anchor the DOM
|
|
1593
|
+
* element under the cursor instead: record its screen Y, apply the transform,
|
|
1594
|
+
* then cancel whatever vertical drift the reflow introduced.
|
|
1595
|
+
*/
|
|
1596
|
+
let marker = null,
|
|
1597
|
+
markerY0 = 0;
|
|
1598
|
+
if (k !== 1) {
|
|
1599
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
1600
|
+
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
|
1601
|
+
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
|
1602
|
+
}
|
|
1603
|
+
t.x = px2 - (px2 - t.x) * k;
|
|
1604
|
+
t.y = py - (py - t.y) * k;
|
|
1605
|
+
t.scale = next;
|
|
1606
|
+
this.apply();
|
|
1607
|
+
if (marker) {
|
|
1608
|
+
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
|
1609
|
+
if (Math.abs(drift) > 0.1) {
|
|
1610
|
+
t.y -= drift;
|
|
1611
|
+
this.apply();
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
_restoreView() {
|
|
1617
|
+
this._flushView = () => {
|
|
1618
|
+
clearTimeout(this._saveT);
|
|
1619
|
+
// With a replayed framing on screen, persist nothing — the saved viewport must
|
|
1620
|
+
// stay this viewer's own.
|
|
1621
|
+
if (this._viewIsRemote) return;
|
|
1622
|
+
try {
|
|
1623
|
+
localStorage.setItem(this._tfKey, JSON.stringify(this.tf));
|
|
1624
|
+
} catch {}
|
|
1625
|
+
};
|
|
1626
|
+
try {
|
|
1627
|
+
const s = JSON.parse(localStorage.getItem(this._tfKey) || 'null');
|
|
1628
|
+
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
|
1629
|
+
this.tf = {
|
|
1630
|
+
x: s.x,
|
|
1631
|
+
y: s.y,
|
|
1632
|
+
scale: Math.min(this.maxScale, Math.max(this.minScale, s.scale)),
|
|
1633
|
+
};
|
|
1634
|
+
this._restoredView = true;
|
|
1635
|
+
}
|
|
1636
|
+
} catch {}
|
|
1637
|
+
this.apply();
|
|
1638
|
+
window.addEventListener('pagehide', this._flushView);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* First-visit fit: scale and centre the whole canvas with at least PAD of safe
|
|
1643
|
+
* margin on every side. Skipped when a viewport was restored, so a returning
|
|
1644
|
+
* viewer keeps their own view.
|
|
1645
|
+
*/
|
|
1646
|
+
fitToContent() {
|
|
1647
|
+
if (this._didFit || this._restoredView) return;
|
|
1648
|
+
const vp = this.vp,
|
|
1649
|
+
world = this.world;
|
|
1650
|
+
if (!vp || !world) return;
|
|
1651
|
+
const slots = this.querySelectorAll('[data-dc-slot]');
|
|
1652
|
+
if (!slots.length) return; // content has not mounted yet — wait for the next pass
|
|
1653
|
+
// The React version fits inside useLayoutEffect, after the whole tree has
|
|
1654
|
+
// committed. Here rebuild() also runs from connectedCallback, before the
|
|
1655
|
+
// descendant custom elements have upgraded one by one, and the bounding box
|
|
1656
|
+
// then misses the headings that have not appeared yet — which is exactly what
|
|
1657
|
+
// made the opening framing jitter between loads. Do not fit until every
|
|
1658
|
+
// heading is present; the next MutationObserver pass will come.
|
|
1659
|
+
const secs = [...this.querySelectorAll('dc-section')];
|
|
1660
|
+
const heads = secs.map((s) => s.shadowRoot?.querySelector('.dc-sectionhead')).filter(Boolean);
|
|
1661
|
+
if (heads.length < secs.length) return;
|
|
1662
|
+
// Mobile screens are narrow — 64px a side eats a third of the width, so fit
|
|
1663
|
+
// flush-ish at 16px there.
|
|
1664
|
+
const PAD = dcIsMobile ? 16 : 64;
|
|
1665
|
+
|
|
1666
|
+
const measure = () => {
|
|
1667
|
+
const vpr = vp.getBoundingClientRect();
|
|
1668
|
+
const t = this.tf;
|
|
1669
|
+
let minX = Infinity,
|
|
1670
|
+
minY = Infinity,
|
|
1671
|
+
maxX = -Infinity,
|
|
1672
|
+
maxY = -Infinity;
|
|
1673
|
+
slots.forEach((el) => {
|
|
1674
|
+
const r = el.getBoundingClientRect();
|
|
1675
|
+
minX = Math.min(minX, (r.left - vpr.left - t.x) / t.scale);
|
|
1676
|
+
maxX = Math.max(maxX, (r.right - vpr.left - t.x) / t.scale);
|
|
1677
|
+
minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
|
|
1678
|
+
maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
|
|
1679
|
+
});
|
|
1680
|
+
heads.forEach((el) => {
|
|
1681
|
+
const r = el.getBoundingClientRect();
|
|
1682
|
+
minY = Math.min(minY, (r.top - vpr.top - t.y) / t.scale);
|
|
1683
|
+
maxY = Math.max(maxY, (r.bottom - vpr.top - t.y) / t.scale);
|
|
1684
|
+
});
|
|
1685
|
+
return { vpr, minX, minY, w: maxX - minX, h: maxY - minY };
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Content SCREEN width is linear in scale (screenW = s·Wworld) and SCREEN
|
|
1690
|
+
* height is affine (screenH = s·P + Q): headings and inter-section gaps
|
|
1691
|
+
* counter-scale with zoom, holding a constant screen size while cards scale.
|
|
1692
|
+
* Sample two scales to solve the coefficients, then take the largest scale that
|
|
1693
|
+
* keeps both axes inside the viewport minus PAD. Exact — no iterate-to-converge.
|
|
1694
|
+
*/
|
|
1695
|
+
const s1 = this.tf.scale;
|
|
1696
|
+
const m1 = measure();
|
|
1697
|
+
if (!(m1.w > 0) || !(m1.h > 0)) {
|
|
1698
|
+
this._didFit = true;
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
const s2 = s1 * 0.7;
|
|
1702
|
+
this.tf.scale = s2;
|
|
1703
|
+
this.apply();
|
|
1704
|
+
const m2 = measure();
|
|
1705
|
+
|
|
1706
|
+
const { vpr } = m1;
|
|
1707
|
+
const avW = vpr.width - PAD * 2,
|
|
1708
|
+
avH = vpr.height - PAD * 2;
|
|
1709
|
+
const Wworld = m1.w;
|
|
1710
|
+
const P = (s1 * m1.h - s2 * m2.h) / (s1 - s2);
|
|
1711
|
+
const Q = s1 * m1.h - s1 * P;
|
|
1712
|
+
const fitW = avW / Wworld;
|
|
1713
|
+
const fitH = P > 0 ? (avH - Q) / P : Infinity;
|
|
1714
|
+
const next = Math.max(this.minScale, Math.min(this.maxScale, Math.min(fitW, fitH)));
|
|
1715
|
+
|
|
1716
|
+
// Measure again at the solved scale and centre from that, so the margins come
|
|
1717
|
+
// out symmetric.
|
|
1718
|
+
this.tf.scale = next;
|
|
1719
|
+
this.apply();
|
|
1720
|
+
const mf = measure();
|
|
1721
|
+
this.tf.x = (vpr.width - mf.w * next) / 2 - mf.minX * next;
|
|
1722
|
+
this.tf.y = (vpr.height - mf.h * next) / 2 - mf.minY * next;
|
|
1723
|
+
this.apply();
|
|
1724
|
+
this._didFit = true;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ── Gestures ────────────────────────────────────────────
|
|
1728
|
+
_bindGestures() {
|
|
1729
|
+
const vp = this.vp;
|
|
1730
|
+
this._offs = [];
|
|
1731
|
+
const on = (t, h, o) => {
|
|
1732
|
+
vp.addEventListener(t, h, o);
|
|
1733
|
+
this._offs.push(() => vp.removeEventListener(t, h, o));
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Zoom is keyed on modifiers only: browsers set ctrlKey for a trackpad pinch,
|
|
1738
|
+
* and Ctrl/⌘+wheel is an explicit mouse zoom gesture; an unmodified wheel always
|
|
1739
|
+
* pans. That is Figma's mapping, and it sidesteps a classification that cannot
|
|
1740
|
+
* be made reliably — the peak frame of a fast trackpad flick and one mouse
|
|
1741
|
+
* wheel notch are indistinguishable at the event level.
|
|
1742
|
+
* isNotchedWheel now only picks the zoom STEP, so getting it wrong changes
|
|
1743
|
+
* granularity and can never flip pan into zoom.
|
|
1744
|
+
*/
|
|
1745
|
+
const isNotchedWheel = (e) =>
|
|
1746
|
+
e.deltaMode !== 0 ||
|
|
1747
|
+
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
|
1748
|
+
|
|
1749
|
+
let isGesturing = false;
|
|
1750
|
+
let gsBase = 1;
|
|
1751
|
+
|
|
1752
|
+
on(
|
|
1753
|
+
'wheel',
|
|
1754
|
+
(e) => {
|
|
1755
|
+
e.preventDefault();
|
|
1756
|
+
if (isGesturing) return; // Safari: gesture* owns this pinch — drop concurrent wheels
|
|
1757
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1758
|
+
this.zoomAt(
|
|
1759
|
+
e.clientX,
|
|
1760
|
+
e.clientY,
|
|
1761
|
+
isNotchedWheel(e)
|
|
1762
|
+
? Math.exp(-Math.sign(e.deltaY) * 0.18)
|
|
1763
|
+
: Math.exp(-e.deltaY * 0.01),
|
|
1764
|
+
);
|
|
1765
|
+
} else {
|
|
1766
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? vp.clientHeight : 1;
|
|
1767
|
+
this.tf.x -= e.deltaX * unit;
|
|
1768
|
+
this.tf.y -= e.deltaY * unit;
|
|
1769
|
+
this.apply();
|
|
1770
|
+
}
|
|
1771
|
+
},
|
|
1772
|
+
{ passive: false },
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
// Safari routes trackpad pinches through native gesture* events, where e.scale
|
|
1776
|
+
// is continuous and feels far better than the ctrl+wheel fallback.
|
|
1777
|
+
// Two-finger pinch on mobile is already handled by the pointer state machine
|
|
1778
|
+
// below, so here we only preventDefault — otherwise it would zoom twice.
|
|
1779
|
+
on(
|
|
1780
|
+
'gesturestart',
|
|
1781
|
+
(e) => {
|
|
1782
|
+
e.preventDefault();
|
|
1783
|
+
isGesturing = true;
|
|
1784
|
+
gsBase = this.tf.scale;
|
|
1785
|
+
},
|
|
1786
|
+
{ passive: false },
|
|
1787
|
+
);
|
|
1788
|
+
on(
|
|
1789
|
+
'gesturechange',
|
|
1790
|
+
(e) => {
|
|
1791
|
+
e.preventDefault();
|
|
1792
|
+
if (dcIsMobile) return;
|
|
1793
|
+
this.zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / this.tf.scale);
|
|
1794
|
+
},
|
|
1795
|
+
{ passive: false },
|
|
1796
|
+
);
|
|
1797
|
+
on(
|
|
1798
|
+
'gestureend',
|
|
1799
|
+
(e) => {
|
|
1800
|
+
e.preventDefault();
|
|
1801
|
+
isGesturing = false;
|
|
1802
|
+
},
|
|
1803
|
+
{ passive: false },
|
|
1804
|
+
);
|
|
1805
|
+
|
|
1806
|
+
// Desktop: middle-drag anywhere, or left-drag on the canvas background — not on
|
|
1807
|
+
// an artboard, an inline editor, or chrome.
|
|
1808
|
+
let drag = null;
|
|
1809
|
+
const onPointerDown = (e) => {
|
|
1810
|
+
const onBg = !pathOf(e).some(
|
|
1811
|
+
(n) =>
|
|
1812
|
+
n.matches &&
|
|
1813
|
+
(n.matches('[data-dc-slot]') ||
|
|
1814
|
+
n.matches('.dc-editable') ||
|
|
1815
|
+
n.matches('[data-miaoda-chrome]')),
|
|
1816
|
+
);
|
|
1817
|
+
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
|
1818
|
+
e.preventDefault();
|
|
1819
|
+
try {
|
|
1820
|
+
this.setPointerCapture(e.pointerId);
|
|
1821
|
+
} catch {}
|
|
1822
|
+
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
|
1823
|
+
vp.style.cursor = 'grabbing';
|
|
1824
|
+
};
|
|
1825
|
+
const onPointerMove = (e) => {
|
|
1826
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
1827
|
+
this.tf.x += e.clientX - drag.lx;
|
|
1828
|
+
this.tf.y += e.clientY - drag.ly;
|
|
1829
|
+
drag.lx = e.clientX;
|
|
1830
|
+
drag.ly = e.clientY;
|
|
1831
|
+
this.apply();
|
|
1832
|
+
};
|
|
1833
|
+
const onPointerUp = (e) => {
|
|
1834
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
1835
|
+
try {
|
|
1836
|
+
this.releasePointerCapture(e.pointerId);
|
|
1837
|
+
} catch {}
|
|
1838
|
+
drag = null;
|
|
1839
|
+
vp.style.cursor = '';
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Mobile gestures, replacing the desktop mapping above: one finger pans — cards
|
|
1844
|
+
* fill a phone viewport and background is scarce, so a drag that starts on an
|
|
1845
|
+
* artboard counts too. Two fingers pinch to zoom about their midpoint, and the
|
|
1846
|
+
* midpoint's own movement pans at the same time (Figma semantics: one gesture
|
|
1847
|
+
* frames the view). The tap/pan slop keeps label taps working.
|
|
1848
|
+
* No inertial flick: motion stops when the fingers lift.
|
|
1849
|
+
*/
|
|
1850
|
+
const touches = new Map();
|
|
1851
|
+
let mobMode = null;
|
|
1852
|
+
let tapStart = null;
|
|
1853
|
+
let pinchDist = 0;
|
|
1854
|
+
let pinchMid = null;
|
|
1855
|
+
let panApplied = null;
|
|
1856
|
+
const MOB_SLOP = 9;
|
|
1857
|
+
const pinchGeom = () => {
|
|
1858
|
+
const [a, b] = [...touches.values()];
|
|
1859
|
+
return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
|
|
1860
|
+
};
|
|
1861
|
+
let mobRaf = 0;
|
|
1862
|
+
const mobFlushFrame = () => {
|
|
1863
|
+
mobRaf = 0;
|
|
1864
|
+
if (mobMode === 'pan') {
|
|
1865
|
+
const [t] = [...touches.values()];
|
|
1866
|
+
if (!t) return;
|
|
1867
|
+
this.tf.x += t.x - panApplied.x;
|
|
1868
|
+
this.tf.y += t.y - panApplied.y;
|
|
1869
|
+
panApplied = { x: t.x, y: t.y };
|
|
1870
|
+
this.apply();
|
|
1871
|
+
} else if (mobMode === 'pinch' && touches.size >= 2) {
|
|
1872
|
+
const g = pinchGeom();
|
|
1873
|
+
this.tf.x += g.mx - pinchMid.x;
|
|
1874
|
+
this.tf.y += g.my - pinchMid.y;
|
|
1875
|
+
pinchMid = { x: g.mx, y: g.my };
|
|
1876
|
+
if (pinchDist > 0 && g.d > 0) this.zoomAt(g.mx, g.my, g.d / pinchDist);
|
|
1877
|
+
else this.apply();
|
|
1878
|
+
pinchDist = g.d;
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
// Coalesce into rAF: move handlers only record positions, and geometry is
|
|
1882
|
+
// applied at most once per frame. pointermove fires well above the render rate
|
|
1883
|
+
// (120Hz sampling, fast pinches), and running zoomAt on every event produces
|
|
1884
|
+
// garbage faster than mobile WebKit collects it — which shows up as unbounded
|
|
1885
|
+
// memory growth. Only the last position in a frame is ever painted anyway.
|
|
1886
|
+
const mobSchedule = () => {
|
|
1887
|
+
if (!mobRaf) mobRaf = requestAnimationFrame(mobFlushFrame);
|
|
1888
|
+
};
|
|
1889
|
+
const mobFlushNow = () => {
|
|
1890
|
+
if (mobRaf) {
|
|
1891
|
+
cancelAnimationFrame(mobRaf);
|
|
1892
|
+
mobFlushFrame();
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
const onTouchDown = (e) => {
|
|
1896
|
+
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
1897
|
+
if (touches.size === 1) {
|
|
1898
|
+
mobMode = 'tap';
|
|
1899
|
+
tapStart = { x: e.clientX, y: e.clientY };
|
|
1900
|
+
} else if (touches.size === 2) {
|
|
1901
|
+
mobFlushNow();
|
|
1902
|
+
mobMode = 'pinch';
|
|
1903
|
+
const g = pinchGeom();
|
|
1904
|
+
pinchDist = g.d;
|
|
1905
|
+
pinchMid = { x: g.mx, y: g.my };
|
|
1906
|
+
try {
|
|
1907
|
+
vp.setPointerCapture(e.pointerId);
|
|
1908
|
+
} catch {}
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
const onTouchMove = (e) => {
|
|
1912
|
+
const t = touches.get(e.pointerId);
|
|
1913
|
+
if (!t) return;
|
|
1914
|
+
t.x = e.clientX;
|
|
1915
|
+
t.y = e.clientY;
|
|
1916
|
+
if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
|
|
1917
|
+
mobMode = 'pan';
|
|
1918
|
+
panApplied = { x: t.x, y: t.y };
|
|
1919
|
+
try {
|
|
1920
|
+
vp.setPointerCapture(e.pointerId);
|
|
1921
|
+
} catch {}
|
|
1922
|
+
}
|
|
1923
|
+
if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
|
|
1924
|
+
};
|
|
1925
|
+
const onTouchUp = (e) => {
|
|
1926
|
+
mobFlushNow();
|
|
1927
|
+
if (!touches.delete(e.pointerId)) return;
|
|
1928
|
+
try {
|
|
1929
|
+
vp.releasePointerCapture(e.pointerId);
|
|
1930
|
+
} catch {}
|
|
1931
|
+
if (touches.size === 0) {
|
|
1932
|
+
mobMode = null;
|
|
1933
|
+
} else if (touches.size === 1) {
|
|
1934
|
+
// A pinch collapsing to one finger: rebase on the surviving finger and take
|
|
1935
|
+
// over panning without a jump.
|
|
1936
|
+
mobMode = 'pan';
|
|
1937
|
+
const [t] = [...touches.values()];
|
|
1938
|
+
panApplied = { x: t.x, y: t.y };
|
|
1939
|
+
} else if (mobMode === 'pinch') {
|
|
1940
|
+
const g = pinchGeom();
|
|
1941
|
+
pinchDist = g.d;
|
|
1942
|
+
pinchMid = { x: g.mx, y: g.my };
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
const pd = dcIsMobile ? onTouchDown : onPointerDown;
|
|
1947
|
+
const pm = dcIsMobile ? onTouchMove : onPointerMove;
|
|
1948
|
+
const pu = dcIsMobile ? onTouchUp : onPointerUp;
|
|
1949
|
+
const onHost = (t, h) => {
|
|
1950
|
+
this.addEventListener(t, h);
|
|
1951
|
+
this._offs.push(() => this.removeEventListener(t, h));
|
|
1952
|
+
};
|
|
1953
|
+
onHost('pointerdown', pd);
|
|
1954
|
+
onHost('pointermove', pm);
|
|
1955
|
+
onHost('pointerup', pu);
|
|
1956
|
+
onHost('pointercancel', pu);
|
|
1957
|
+
this._offs.push(() => {
|
|
1958
|
+
if (mobRaf) cancelAnimationFrame(mobRaf);
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// ── Host protocol ───────────────────────────────────────
|
|
1963
|
+
_bindHost() {
|
|
1964
|
+
this._onHostMsg = (e) => {
|
|
1965
|
+
const d = e.data;
|
|
1966
|
+
if (d && d.type === 'miaoda:canvas:set-zoom' && typeof d.scale === 'number') {
|
|
1967
|
+
const r = this.vp.getBoundingClientRect();
|
|
1968
|
+
this.zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / this.tf.scale);
|
|
1969
|
+
} else if (d && d.type === 'miaoda:canvas:probe') {
|
|
1970
|
+
// The host's readyGen reset asks "is there a canvas?" on the iframe's native
|
|
1971
|
+
// load event, which for a canvas with images or fonts happens after our
|
|
1972
|
+
// mount-time announce — so announce again.
|
|
1973
|
+
try {
|
|
1974
|
+
window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
|
|
1975
|
+
window.parent.postMessage(
|
|
1976
|
+
{ type: 'miaoda:upgrade:available', kind: 'design-canvas' },
|
|
1977
|
+
'*',
|
|
1978
|
+
);
|
|
1979
|
+
} catch {}
|
|
1980
|
+
this._lastPostedScale = undefined;
|
|
1981
|
+
this.apply();
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
window.addEventListener('message', this._onHostMsg);
|
|
1985
|
+
try {
|
|
1986
|
+
window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
|
|
1987
|
+
window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*');
|
|
1988
|
+
} catch {}
|
|
1989
|
+
this._lastPostedScale = undefined;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// ── Sidecar persistence ─────────────────────────────────
|
|
1993
|
+
/**
|
|
1994
|
+
* Reads go through a plain fetch, so a saved arrangement shows up anywhere the
|
|
1995
|
+
* HTML and its sidecar are served together (preview, direct link, downloaded zip).
|
|
1996
|
+
* Writes hand the content back to the host over postMessage — editing needs the
|
|
1997
|
+
* miaoda runtime.
|
|
1998
|
+
*/
|
|
1999
|
+
_readSidecar() {
|
|
2000
|
+
// Backstop: render even if the read hangs — the canvas must not stay blank
|
|
2001
|
+
this._readTimer = setTimeout(() => {
|
|
2002
|
+
if (!this._ready) {
|
|
2003
|
+
this._ready = true;
|
|
2004
|
+
this.rebuild();
|
|
2005
|
+
}
|
|
2006
|
+
}, 150);
|
|
2007
|
+
fetch('./' + DC_STATE_FILE)
|
|
2008
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
2009
|
+
.then((saved) => {
|
|
2010
|
+
// A late joiner's replay races this fetch, and the replay is the fresher
|
|
2011
|
+
// truth (it IS the presenter's current arrangement, and it was already
|
|
2012
|
+
// answered 'applied' so the host will not resend) — a slower sidecar must
|
|
2013
|
+
// not clobber it.
|
|
2014
|
+
if (!saved || !saved.sections || this._mirrorSeeded) return;
|
|
2015
|
+
// The write that immediately follows hydration is an echo — skip it, or the
|
|
2016
|
+
// empty initial state overwrites the slower read.
|
|
2017
|
+
this._skipNextWrite = true;
|
|
2018
|
+
this.state.sections = saved.sections;
|
|
2019
|
+
this.rebuild();
|
|
2020
|
+
// Hydration is itself an arrangement settling, so it must be broadcast.
|
|
2021
|
+
// _didRead is normally raised in finally; raise it early here or the guard
|
|
2022
|
+
// at the top of _writeSidecar would swallow this broadcast.
|
|
2023
|
+
this._didRead = true;
|
|
2024
|
+
this._writeSidecar();
|
|
2025
|
+
})
|
|
2026
|
+
.catch(() => {})
|
|
2027
|
+
.finally(() => {
|
|
2028
|
+
this._didRead = true;
|
|
2029
|
+
clearTimeout(this._readTimer);
|
|
2030
|
+
if (!this._ready) {
|
|
2031
|
+
this._ready = true;
|
|
2032
|
+
this.rebuild();
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
_writeSidecar() {
|
|
2037
|
+
if (!this._didRead) return;
|
|
2038
|
+
// Magic Share: broadcast the full snapshot whenever the arrangement settles,
|
|
2039
|
+
// hydration included — that is exactly the baseline a late joiner needs.
|
|
2040
|
+
// Only a remote replay must not re-broadcast, or the two ends trigger each
|
|
2041
|
+
// other in an echo.
|
|
2042
|
+
if (this._skipNextMirrorEmit) this._skipNextMirrorEmit = false;
|
|
2043
|
+
else if (!dcCaptureMode)
|
|
2044
|
+
window.__miaodaMirror.emit(
|
|
2045
|
+
{ type: 'miaoda:canvas:structure-changed', sections: this.state.sections },
|
|
2046
|
+
{ key: 'canvas:structure' },
|
|
2047
|
+
);
|
|
2048
|
+
if (this._skipNextWrite) {
|
|
2049
|
+
this._skipNextWrite = false;
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
clearTimeout(this._writeT);
|
|
2053
|
+
this._writeT = setTimeout(() => {
|
|
2054
|
+
miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: this.state.sections })).catch(
|
|
2055
|
+
() => {},
|
|
2056
|
+
);
|
|
2057
|
+
}, 250);
|
|
2058
|
+
}
|
|
2059
|
+
patchSection(id, patch) {
|
|
2060
|
+
const cur = this.state.sections[id] || {};
|
|
2061
|
+
this.state.sections = {
|
|
2062
|
+
...this.state.sections,
|
|
2063
|
+
[id]: { ...cur, ...(typeof patch === 'function' ? patch(cur) : patch) },
|
|
2064
|
+
};
|
|
2065
|
+
this.rebuild();
|
|
2066
|
+
this._writeSidecar();
|
|
2067
|
+
}
|
|
2068
|
+
setFocus(slotId) {
|
|
2069
|
+
if (this.state.focus === slotId) return;
|
|
2070
|
+
this.overlay.restore();
|
|
2071
|
+
this.state.focus = slotId;
|
|
2072
|
+
if (slotId) this.overlay.open(slotId);
|
|
2073
|
+
else this.overlay.close();
|
|
2074
|
+
// Magic Share: focus is ephemeral view state, mirrored under its own key so a
|
|
2075
|
+
// follower enters and leaves the focus overlay in step with the presenter.
|
|
2076
|
+
// Leaving broadcasts null — the empty value is itself a state.
|
|
2077
|
+
if (this._skipNextFocusEmit) this._skipNextFocusEmit = false;
|
|
2078
|
+
else if (!dcCaptureMode)
|
|
2079
|
+
window.__miaodaMirror.emit(
|
|
2080
|
+
{ type: 'miaoda:canvas:focus-changed', focus: slotId || null },
|
|
2081
|
+
{ key: 'canvas:focus' },
|
|
2082
|
+
);
|
|
2083
|
+
// The host and author scripts need to observe focus changes, and only a
|
|
2084
|
+
// composed event crosses the shadow boundary.
|
|
2085
|
+
this.dispatchEvent(
|
|
2086
|
+
new CustomEvent('dcfocuschange', { detail: { slotId }, bubbles: true, composed: true }),
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
_bindChildEvents() {
|
|
2091
|
+
this.addEventListener('dc:rename-section', (e) => {
|
|
2092
|
+
e.stopPropagation();
|
|
2093
|
+
this.patchSection(e.detail.sectionId, { title: e.detail.value });
|
|
2094
|
+
});
|
|
2095
|
+
this.addEventListener('dc:rename-label', (e) => {
|
|
2096
|
+
e.stopPropagation();
|
|
2097
|
+
const sec = e.target.closest('dc-section');
|
|
2098
|
+
if (!sec) return;
|
|
2099
|
+
const k = e.detail.slotId;
|
|
2100
|
+
this.patchSection(sec.sectionId, (x) => ({ labels: { ...x.labels, [k]: e.detail.value } }));
|
|
2101
|
+
});
|
|
2102
|
+
this.addEventListener('dc:reorder', (e) => {
|
|
2103
|
+
e.stopPropagation();
|
|
2104
|
+
const sec = e.target.closest('dc-section');
|
|
2105
|
+
if (sec) this.patchSection(sec.sectionId, { order: e.detail.order });
|
|
2106
|
+
});
|
|
2107
|
+
this.addEventListener('dc:delete', (e) => {
|
|
2108
|
+
e.stopPropagation();
|
|
2109
|
+
const sec = e.target.closest('dc-section');
|
|
2110
|
+
if (!sec) return;
|
|
2111
|
+
const srcKey = sec.srcOrder().join('\x1f');
|
|
2112
|
+
const k = e.detail.slotId;
|
|
2113
|
+
this.patchSection(sec.sectionId, (x) => ({
|
|
2114
|
+
hidden: [...(x.srcKey === srcKey ? x.hidden || [] : []), k],
|
|
2115
|
+
srcKey,
|
|
2116
|
+
}));
|
|
2117
|
+
});
|
|
2118
|
+
this.addEventListener('dc:focus', (e) => {
|
|
2119
|
+
e.stopPropagation();
|
|
2120
|
+
const sec = e.target.closest('dc-section');
|
|
2121
|
+
if (sec) this.setFocus(`${sec.sectionId}/${e.detail.slotId}`);
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Rebuild the registry from the light DOM children and push state down to each
|
|
2127
|
+
* section.
|
|
2128
|
+
* `hidden` only applies to the same source content: once the content is
|
|
2129
|
+
* regenerated (the set of artboard IDs changes), earlier deletions must not carry
|
|
2130
|
+
* over to the new content.
|
|
2131
|
+
*/
|
|
2132
|
+
rebuild() {
|
|
2133
|
+
this.registry = {};
|
|
2134
|
+
this.sectionMeta = {};
|
|
2135
|
+
this.sectionOrder = [];
|
|
2136
|
+
for (const sec of this.querySelectorAll(':scope > dc-section')) {
|
|
2137
|
+
const sid = sec.sectionId;
|
|
2138
|
+
if (!sid) continue;
|
|
2139
|
+
this.sectionOrder.push(sid);
|
|
2140
|
+
const persisted = this.state.sections[sid] || {};
|
|
2141
|
+
const allIds = sec.srcOrder();
|
|
2142
|
+
const srcKey = allIds.join('\x1f');
|
|
2143
|
+
const hidden = persisted.srcKey === srcKey ? persisted.hidden || [] : [];
|
|
2144
|
+
const srcIds = allIds.filter((k) => !hidden.includes(k));
|
|
2145
|
+
for (const ab of sec.querySelectorAll(':scope > dc-artboard')) {
|
|
2146
|
+
if (!srcIds.includes(ab.slotId)) continue;
|
|
2147
|
+
this.registry[`${sid}/${ab.slotId}`] = { sectionId: sid, artboard: ab };
|
|
2148
|
+
}
|
|
2149
|
+
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
|
2150
|
+
const meta = {
|
|
2151
|
+
ready: this._ready,
|
|
2152
|
+
title: persisted.title ?? sec.getAttribute('title'),
|
|
2153
|
+
labels: persisted.labels || {},
|
|
2154
|
+
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
|
2155
|
+
};
|
|
2156
|
+
this.sectionMeta[sid] = meta;
|
|
2157
|
+
sec.applyState(meta);
|
|
2158
|
+
}
|
|
2159
|
+
// If the focused artboard is gone (deleted, or content regenerated), leave focus.
|
|
2160
|
+
// This is a LOCAL correction, not a user action, so it must not broadcast: in a
|
|
2161
|
+
// Magic Share session one follower whose content lacks the artboard would
|
|
2162
|
+
// otherwise push focus:null back and kick the presenter out of the overlay.
|
|
2163
|
+
// The React version has no equivalent broadcast — it simply declines to render
|
|
2164
|
+
// the overlay for an unknown id and leaves its own state alone.
|
|
2165
|
+
if (this.state.focus && !this.registry[this.state.focus]) {
|
|
2166
|
+
this._skipNextFocusEmit = true;
|
|
2167
|
+
this.setFocus(null);
|
|
2168
|
+
// setFocus bails out on an unchanged value without emitting, which would leave
|
|
2169
|
+
// the guard raised and swallow the next real broadcast.
|
|
2170
|
+
this._skipNextFocusEmit = false;
|
|
2171
|
+
}
|
|
2172
|
+
// Fit as soon as the content is in place, synchronously, matching React's
|
|
2173
|
+
// useLayoutEffect. Deferring to rAF is one tick late and flashes an unfitted
|
|
2174
|
+
// first frame.
|
|
2175
|
+
this.fitToContent();
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// A duplicate define throws — copies of this file can coexist on one page, so the
|
|
2180
|
+
// guard is mandatory.
|
|
2181
|
+
for (const [tag, ctor] of [
|
|
2182
|
+
['dc-postit', DCPostIt],
|
|
2183
|
+
['dc-artboard', DCArtboard],
|
|
2184
|
+
['dc-section', DCSection],
|
|
2185
|
+
['design-canvas', DesignCanvas],
|
|
2186
|
+
]) {
|
|
2187
|
+
if (!customElements.get(tag)) customElements.define(tag, ctor);
|
|
2188
|
+
}
|
|
2189
|
+
})();
|