@lark-apaas/coding-registry-buildless 0.1.0 → 0.1.2-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -11
- package/components/android-frame.jsx +189 -0
- package/components/animations.jsx +680 -0
- package/components/browser-window.jsx +122 -0
- package/components/design-canvas.jsx +1125 -0
- package/components/ios-frame.jsx +270 -0
- package/components/macos-window.jsx +197 -0
- package/components/tweaks-panel.jsx +724 -71
- package/package.json +14 -3
- package/scripts/deck-stage.js +1795 -85
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
/* BEGIN USAGE */
|
|
2
|
+
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
|
3
|
+
// Sections + Artboards + PostIt notes.
|
|
4
|
+
// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt.
|
|
5
|
+
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
|
6
|
+
// inline-editable, and any artboard can be opened in a fullscreen focus
|
|
7
|
+
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
|
8
|
+
// via the host bridge. No assets, no deps.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// <DesignCanvas>
|
|
12
|
+
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
|
13
|
+
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
|
14
|
+
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
|
15
|
+
// </DCSection>
|
|
16
|
+
// </DesignCanvas>
|
|
17
|
+
//
|
|
18
|
+
// Artboards are static design frames, not scroll regions — never use
|
|
19
|
+
// height: 100% + overflow: auto/scroll on inner elements; size each artboard
|
|
20
|
+
// to fit its content (explicit pixel height, or let it grow).
|
|
21
|
+
//
|
|
22
|
+
// When placing a device frame inside a DCArtboard, pass chromeless to
|
|
23
|
+
// suppress the card chrome and let the artboard size to its content:
|
|
24
|
+
// <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
|
|
25
|
+
/* END USAGE */
|
|
26
|
+
|
|
27
|
+
const DC = {
|
|
28
|
+
bg: '#FFFFFF',
|
|
29
|
+
label: '#8F959E',
|
|
30
|
+
title: '#1F2329',
|
|
31
|
+
subtitle: '#646A73',
|
|
32
|
+
postitBg: '#fef4a8',
|
|
33
|
+
postitText: '#5a4a2a',
|
|
34
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
|
38
|
+
// the hosted design's own styles).
|
|
39
|
+
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
|
40
|
+
const s = document.createElement('style');
|
|
41
|
+
s.id = 'dc-styles';
|
|
42
|
+
s.textContent = [
|
|
43
|
+
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:4px;padding:0 2px;margin:0 -2px}',
|
|
44
|
+
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1px #336DF4}',
|
|
45
|
+
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
|
46
|
+
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
|
47
|
+
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #336DF4;transform:scale(1.02)}',
|
|
48
|
+
// isolation:isolate contains artboard content's z-indexes so a
|
|
49
|
+
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
|
50
|
+
// the .dc-menu popover that drops into the top of the card.
|
|
51
|
+
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
|
52
|
+
'.dc-card *{scrollbar-width:none}',
|
|
53
|
+
'.dc-card *::-webkit-scrollbar{display:none}',
|
|
54
|
+
// Per-artboard header: grip + label on the left, delete/expand on the
|
|
55
|
+
// right. Single flex row; when the artboard's on-screen width is too
|
|
56
|
+
// narrow for both the label yields (ellipsis, then hidden entirely below
|
|
57
|
+
// ~4ch via the container query) and the buttons stay on the row.
|
|
58
|
+
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
|
59
|
+
' display:flex;align-items:center;container-type:inline-size}',
|
|
60
|
+
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
|
61
|
+
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
|
62
|
+
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
|
63
|
+
'.dc-grip:active{cursor:grabbing}',
|
|
64
|
+
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
|
65
|
+
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
|
66
|
+
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
|
67
|
+
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
|
68
|
+
// until the card is moused.
|
|
69
|
+
'@container (max-width: 110px){',
|
|
70
|
+
' .dc-labeltext{display:none}',
|
|
71
|
+
' .dc-grip{opacity:0}',
|
|
72
|
+
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
|
73
|
+
'}',
|
|
74
|
+
'.dc-labeltext:hover{background:rgba(31,35,41,0.05)}',
|
|
75
|
+
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
|
76
|
+
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
|
77
|
+
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
|
78
|
+
'[data-dc-slot]:hover .dc-header,[data-dc-slot]:hover .dc-header .dc-editable{color:#646A73!important}',
|
|
79
|
+
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
|
80
|
+
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
|
|
81
|
+
' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
|
|
82
|
+
' font:inherit;transition:background .12s,color .12s}',
|
|
83
|
+
'.dc-expand:hover,.dc-kebab:hover{background:rgba(31,35,41,0.05);color:#646A73}',
|
|
84
|
+
// Slot hosting an open menu floats above later siblings (which otherwise
|
|
85
|
+
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
|
86
|
+
// clipped by the next card.
|
|
87
|
+
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
|
88
|
+
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;border:1px solid #DEE0E3;',
|
|
89
|
+
' 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}',
|
|
90
|
+
'.dc-menu button{display:block;width:100%;padding:4px 8px;border:0;background:transparent;',
|
|
91
|
+
' border-radius:6px;font-family:inherit;font-size:14px;font-weight:400;line-height:24px;',
|
|
92
|
+
' color:#1F2329;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
|
93
|
+
'.dc-menu button:hover{background:rgba(31,35,41,0.05)}',
|
|
94
|
+
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
|
95
|
+
'.dc-menu .dc-danger{color:#F54A45}',
|
|
96
|
+
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
|
97
|
+
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
|
98
|
+
// DCViewport on every transform update and inherits to all descendants —
|
|
99
|
+
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
|
100
|
+
// it the same way.
|
|
101
|
+
//
|
|
102
|
+
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
|
103
|
+
// matter) with its world-space width set to card-width / inv-zoom so that
|
|
104
|
+
// after counter-scaling its on-screen width exactly matches the card's —
|
|
105
|
+
// that's what lets the container query + text-overflow behave against the
|
|
106
|
+
// card's visible edge at every zoom level.
|
|
107
|
+
//
|
|
108
|
+
// The section head uses CSS zoom instead of transform so its layout box
|
|
109
|
+
// grows with the counter-scale, pushing the card row down — otherwise the
|
|
110
|
+
// constant-screen-size title would overflow into the (shrinking) world-
|
|
111
|
+
// space gap and overlap the artboard headers at low zoom.
|
|
112
|
+
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
|
113
|
+
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
|
114
|
+
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
|
115
|
+
// On-canvas zoom control (bottom-right pill). Stays screen-fixed because
|
|
116
|
+
// it renders outside worldRef. z-index 50 keeps it above world content but
|
|
117
|
+
// below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
|
|
118
|
+
// only overrides its positioning to open upward (.dc-zoom-menu).
|
|
119
|
+
'.dc-zoom{position:absolute;bottom:16px;right:20px;z-index:50;display:flex;align-items:center;',
|
|
120
|
+
' background:#fff;border-radius:8px;padding:4px;font-family:inherit;',
|
|
121
|
+
' 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)}',
|
|
122
|
+
'.dc-zoom-btn{height:24px;min-width:24px;border-radius:6px;border:none;cursor:pointer;padding:0;',
|
|
123
|
+
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
|
124
|
+
' font:inherit;transition:background .12s,color .12s}',
|
|
125
|
+
'.dc-zoom-btn:hover{background:#1f23290d}',
|
|
126
|
+
'.dc-zoom-btn:disabled{opacity:.35;cursor:default;background:transparent;color:rgba(60,50,40,.7)}',
|
|
127
|
+
'.dc-zoom-toggle{position:relative;display:flex}',
|
|
128
|
+
'.dc-zoom-trigger{padding:0 6px;gap:4px}',
|
|
129
|
+
'.dc-zoom-pct{min-width:36px;text-align:center;font-size:12px;font-weight:400;color:#1F2329}',
|
|
130
|
+
'.dc-zoom-menu{top:auto;bottom:100%;left:50%;transform:translateX(-50%);margin-top:0;margin-bottom:6px;min-width:114px}',
|
|
131
|
+
'.dc-zoom-menu button{display:flex;align-items:center;justify-content:space-between;gap:8px}',
|
|
132
|
+
].join('\n');
|
|
133
|
+
document.head.appendChild(s);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const DCCtx = React.createContext(null);
|
|
137
|
+
|
|
138
|
+
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
|
|
139
|
+
// DCSection/DCArtboard children from the type-based walks below.
|
|
140
|
+
function dcFlatten(children) {
|
|
141
|
+
const out = [];
|
|
142
|
+
React.Children.forEach(children, (c) => {
|
|
143
|
+
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
|
|
144
|
+
else out.push(c);
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─────────────────────────────────────────────────────────────
|
|
150
|
+
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
|
151
|
+
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
|
152
|
+
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
|
153
|
+
// .design-canvas.state.json
|
|
154
|
+
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
|
155
|
+
// arrangement is visible anywhere the HTML + sidecar are served together
|
|
156
|
+
// (miaoda preview, direct link, downloaded zip). Writes go through the
|
|
157
|
+
// host's window.miaoda bridge — editing requires the miaoda runtime.
|
|
158
|
+
// Focus is ephemeral.
|
|
159
|
+
// ─────────────────────────────────────────────────────────────
|
|
160
|
+
const DC_STATE_FILE = '.design-canvas.state.json';
|
|
161
|
+
|
|
162
|
+
function DesignCanvas({ children, minScale, maxScale, style }) {
|
|
163
|
+
const [state, setState] = React.useState({ sections: {}, focus: null });
|
|
164
|
+
// Hold rendering until the sidecar read settles so the saved order/titles
|
|
165
|
+
// appear on first paint (no source-order flash). didRead gates writes until
|
|
166
|
+
// the read settles so the empty initial state can't clobber a slow read;
|
|
167
|
+
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
|
168
|
+
// hydration.
|
|
169
|
+
const [ready, setReady] = React.useState(false);
|
|
170
|
+
const didRead = React.useRef(false);
|
|
171
|
+
const skipNextWrite = React.useRef(false);
|
|
172
|
+
|
|
173
|
+
React.useEffect(() => {
|
|
174
|
+
let off = false;
|
|
175
|
+
fetch('./' + DC_STATE_FILE)
|
|
176
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
177
|
+
.then((saved) => {
|
|
178
|
+
if (off || !saved || !saved.sections) return;
|
|
179
|
+
skipNextWrite.current = true;
|
|
180
|
+
setState((s) => ({ ...s, sections: saved.sections }));
|
|
181
|
+
})
|
|
182
|
+
.catch(() => {})
|
|
183
|
+
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
|
184
|
+
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
|
185
|
+
return () => { off = true; clearTimeout(t); };
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
React.useEffect(() => {
|
|
189
|
+
if (!didRead.current) return;
|
|
190
|
+
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
|
191
|
+
const t = setTimeout(() => {
|
|
192
|
+
window.miaoda?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
|
193
|
+
}, 250);
|
|
194
|
+
return () => clearTimeout(t);
|
|
195
|
+
}, [state.sections]);
|
|
196
|
+
|
|
197
|
+
// Build registries synchronously from children so FocusOverlay can read
|
|
198
|
+
// them in the same render. Fragments are flattened; wrapping in other
|
|
199
|
+
// elements still opts out of focus/reorder.
|
|
200
|
+
const registry = {}; // slotId -> { sectionId, artboard }
|
|
201
|
+
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
|
202
|
+
const sectionOrder = [];
|
|
203
|
+
dcFlatten(children).forEach((sec) => {
|
|
204
|
+
if (!sec || sec.type !== DCSection) return;
|
|
205
|
+
const sid = sec.props.id ?? sec.props.title;
|
|
206
|
+
if (!sid) return;
|
|
207
|
+
sectionOrder.push(sid);
|
|
208
|
+
const persisted = state.sections[sid] || {};
|
|
209
|
+
const abs = [];
|
|
210
|
+
dcFlatten(sec.props.children).forEach((ab) => {
|
|
211
|
+
if (!ab || ab.type !== DCArtboard) return;
|
|
212
|
+
const aid = ab.props.id ?? ab.props.label;
|
|
213
|
+
if (aid) abs.push([aid, ab]);
|
|
214
|
+
});
|
|
215
|
+
// hidden is scoped to one source revision — when the agent regenerates
|
|
216
|
+
// (artboard-ID set changes), prior deletes don't apply to new content.
|
|
217
|
+
const srcKey = abs.map(([k]) => k).join('\x1f');
|
|
218
|
+
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
|
219
|
+
const srcIds = [];
|
|
220
|
+
abs.forEach(([aid, ab]) => {
|
|
221
|
+
if (hidden.includes(aid)) return;
|
|
222
|
+
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
|
223
|
+
srcIds.push(aid);
|
|
224
|
+
});
|
|
225
|
+
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
|
226
|
+
sectionMeta[sid] = {
|
|
227
|
+
title: persisted.title ?? sec.props.title,
|
|
228
|
+
subtitle: sec.props.subtitle,
|
|
229
|
+
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const api = React.useMemo(() => ({
|
|
234
|
+
state,
|
|
235
|
+
section: (id) => state.sections[id] || {},
|
|
236
|
+
patchSection: (id, p) => setState((s) => ({
|
|
237
|
+
...s,
|
|
238
|
+
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
|
239
|
+
})),
|
|
240
|
+
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
|
241
|
+
}), [state]);
|
|
242
|
+
|
|
243
|
+
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
|
244
|
+
React.useEffect(() => {
|
|
245
|
+
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
|
246
|
+
const onPd = (e) => {
|
|
247
|
+
const ae = document.activeElement;
|
|
248
|
+
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
|
249
|
+
};
|
|
250
|
+
document.addEventListener('keydown', onKey);
|
|
251
|
+
document.addEventListener('pointerdown', onPd, true);
|
|
252
|
+
return () => {
|
|
253
|
+
document.removeEventListener('keydown', onKey);
|
|
254
|
+
document.removeEventListener('pointerdown', onPd, true);
|
|
255
|
+
};
|
|
256
|
+
}, [api]);
|
|
257
|
+
|
|
258
|
+
// dcOverlay opt-in: render outside DCViewport so the component is free from
|
|
259
|
+
// worldRef's transform (pan/zoom). Unmarked children stay in the canvas.
|
|
260
|
+
const flat = dcFlatten(children);
|
|
261
|
+
const overlayChildren = flat.filter(c => c && c.type?.dcOverlay);
|
|
262
|
+
const worldChildren = flat.filter(c => !c || !c.type?.dcOverlay);
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<DCCtx.Provider value={api}>
|
|
266
|
+
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && worldChildren}</DCViewport>
|
|
267
|
+
{ready && overlayChildren}
|
|
268
|
+
{state.focus && registry[state.focus] && (
|
|
269
|
+
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
|
270
|
+
)}
|
|
271
|
+
</DCCtx.Provider>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────────────────────────────────────────
|
|
276
|
+
// DCZoomControl — bottom-right zoom pill (zoom-in / % + presets / zoom-out)
|
|
277
|
+
//
|
|
278
|
+
// Renders outside worldRef so it stays screen-fixed. data-miaoda-chrome marks
|
|
279
|
+
// it as non-background so the viewport's pointerdown doesn't start a pan on it
|
|
280
|
+
// (the native vp listener can't be stopped by React's synthetic propagation,
|
|
281
|
+
// so the attribute — not stopPropagation — is what prevents the pan). Buttons
|
|
282
|
+
// step to the nearest 10% grid, clamped to the canvas scale range; the dropdown
|
|
283
|
+
// offers common presets and opens upward.
|
|
284
|
+
// ─────────────────────────────────────────────────────────────
|
|
285
|
+
const DC_ZOOM_PRESETS = [20, 35, 50, 75, 100, 125, 150, 175, 200];
|
|
286
|
+
|
|
287
|
+
function DCZoomControl({ scalePct, minScale, maxScale, onZoomTo }) {
|
|
288
|
+
const [open, setOpen] = React.useState(false);
|
|
289
|
+
const rootRef = React.useRef(null);
|
|
290
|
+
const minPct = Math.round(minScale * 100);
|
|
291
|
+
const maxPct = Math.round(maxScale * 100);
|
|
292
|
+
const snapUp = Math.min(maxPct, (Math.floor(scalePct / 10) + 1) * 10);
|
|
293
|
+
const snapDown = Math.max(minPct, (Math.ceil(scalePct / 10) - 1) * 10);
|
|
294
|
+
|
|
295
|
+
React.useEffect(() => {
|
|
296
|
+
if (!open) return;
|
|
297
|
+
const onDocDown = (e) => {
|
|
298
|
+
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
|
299
|
+
};
|
|
300
|
+
document.addEventListener('pointerdown', onDocDown);
|
|
301
|
+
return () => document.removeEventListener('pointerdown', onDocDown);
|
|
302
|
+
}, [open]);
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div
|
|
306
|
+
ref={rootRef}
|
|
307
|
+
className="dc-zoom"
|
|
308
|
+
data-miaoda-chrome=""
|
|
309
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
310
|
+
>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
className="dc-zoom-btn"
|
|
314
|
+
disabled={scalePct >= maxPct}
|
|
315
|
+
onClick={() => onZoomTo(snapUp)}
|
|
316
|
+
>
|
|
317
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
318
|
+
<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"/>
|
|
319
|
+
</svg>
|
|
320
|
+
</button>
|
|
321
|
+
|
|
322
|
+
<div className="dc-zoom-toggle">
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
className="dc-zoom-btn dc-zoom-trigger"
|
|
326
|
+
onClick={() => setOpen((o) => !o)}
|
|
327
|
+
>
|
|
328
|
+
<span className="dc-zoom-pct">{scalePct}%</span>
|
|
329
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
330
|
+
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
331
|
+
</svg>
|
|
332
|
+
</button>
|
|
333
|
+
{open && (
|
|
334
|
+
<div className="dc-menu dc-zoom-menu" onPointerDown={(e) => e.stopPropagation()}>
|
|
335
|
+
{DC_ZOOM_PRESETS.map((p) => (
|
|
336
|
+
<button type="button" key={p} onClick={() => { onZoomTo(p); setOpen(false); }}>
|
|
337
|
+
<span>{p}%</span>
|
|
338
|
+
{p === scalePct && (
|
|
339
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
340
|
+
<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"/>
|
|
341
|
+
</svg>
|
|
342
|
+
)}
|
|
343
|
+
</button>
|
|
344
|
+
))}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
className="dc-zoom-btn"
|
|
352
|
+
disabled={scalePct <= minPct}
|
|
353
|
+
onClick={() => onZoomTo(snapDown)}
|
|
354
|
+
>
|
|
355
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
356
|
+
<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"/>
|
|
357
|
+
</svg>
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─────────────────────────────────────────────────────────────
|
|
364
|
+
// DCViewport — transform-based pan/zoom (internal)
|
|
365
|
+
//
|
|
366
|
+
// Input mapping (Figma-style):
|
|
367
|
+
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
|
368
|
+
// • trackpad scroll → pan (two-finger)
|
|
369
|
+
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
|
370
|
+
// • middle-drag / primary-drag-on-bg → pan
|
|
371
|
+
//
|
|
372
|
+
// Transform state lives in a ref and is written straight to the DOM
|
|
373
|
+
// (translate3d + will-change) so wheel ticks don't go through React —
|
|
374
|
+
// keeps pans at 60fps on dense canvases.
|
|
375
|
+
// ─────────────────────────────────────────────────────────────
|
|
376
|
+
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
|
377
|
+
const vpRef = React.useRef(null);
|
|
378
|
+
const worldRef = React.useRef(null);
|
|
379
|
+
// Default to 50% so a fresh canvas opens zoomed-out enough to see the
|
|
380
|
+
// overall layout; localStorage restore (below) overrides this on revisits.
|
|
381
|
+
const tf = React.useRef({ x: 0, y: 0, scale: 0.5 });
|
|
382
|
+
// Persist viewport across reloads so the user lands back where they were
|
|
383
|
+
// after an agent edit or browser refresh. The sandbox origin is already
|
|
384
|
+
// per-project; pathname keeps multiple canvas files in one project apart.
|
|
385
|
+
const tfKey = 'dc-viewport:' + location.pathname;
|
|
386
|
+
const saveT = React.useRef(0);
|
|
387
|
+
|
|
388
|
+
const lastPostedScale = React.useRef();
|
|
389
|
+
// Imperative handle for the on-canvas zoom control to zoom around the
|
|
390
|
+
// viewport centre (same path as the host's set-zoom message). Assigned in
|
|
391
|
+
// the pointer/wheel effect below where zoomAt is in scope.
|
|
392
|
+
const zoomCenteredRef = React.useRef(null);
|
|
393
|
+
// Percentage shown by the zoom control. Synced from apply() on every scale
|
|
394
|
+
// change — React bails out when the rounded value is unchanged, so pan ticks
|
|
395
|
+
// (scale unchanged) cost nothing. Lazily seeded from the persisted transform
|
|
396
|
+
// so the first paint already shows the restored — or default 50% — zoom.
|
|
397
|
+
const [scalePct, setScalePct] = React.useState(() => {
|
|
398
|
+
try {
|
|
399
|
+
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
|
400
|
+
if (s && Number.isFinite(s.scale)) {
|
|
401
|
+
return Math.round(Math.min(maxScale, Math.max(minScale, s.scale)) * 100);
|
|
402
|
+
}
|
|
403
|
+
} catch {}
|
|
404
|
+
return Math.round(tf.current.scale * 100);
|
|
405
|
+
});
|
|
406
|
+
const apply = React.useCallback(() => {
|
|
407
|
+
const { x, y, scale } = tf.current;
|
|
408
|
+
const el = worldRef.current;
|
|
409
|
+
if (!el) return;
|
|
410
|
+
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
|
411
|
+
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
|
412
|
+
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
|
413
|
+
// Keep the on-canvas zoom control's % readout in sync. React bails out on
|
|
414
|
+
// an unchanged rounded value, so pan ticks (scale unchanged) don't render.
|
|
415
|
+
setScalePct(Math.round(scale * 100));
|
|
416
|
+
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
|
417
|
+
// ticks leave scale unchanged — skip the cross-frame post for those.
|
|
418
|
+
if (lastPostedScale.current !== scale) {
|
|
419
|
+
lastPostedScale.current = scale;
|
|
420
|
+
window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
|
|
421
|
+
}
|
|
422
|
+
clearTimeout(saveT.current);
|
|
423
|
+
saveT.current = setTimeout(() => {
|
|
424
|
+
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
|
425
|
+
}, 200);
|
|
426
|
+
}, [tfKey]);
|
|
427
|
+
|
|
428
|
+
React.useLayoutEffect(() => {
|
|
429
|
+
const flush = () => {
|
|
430
|
+
clearTimeout(saveT.current);
|
|
431
|
+
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
|
432
|
+
};
|
|
433
|
+
try {
|
|
434
|
+
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
|
435
|
+
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
|
436
|
+
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
|
437
|
+
}
|
|
438
|
+
} catch {}
|
|
439
|
+
// Apply before first paint whether we restored or fell back to the default
|
|
440
|
+
// (scale 0.5), so the canvas never flashes 100% before snapping to 50%.
|
|
441
|
+
apply();
|
|
442
|
+
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
|
443
|
+
// window doesn't drop the last pan/zoom.
|
|
444
|
+
window.addEventListener('pagehide', flush);
|
|
445
|
+
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
|
446
|
+
}, []);
|
|
447
|
+
|
|
448
|
+
React.useEffect(() => {
|
|
449
|
+
const vp = vpRef.current;
|
|
450
|
+
if (!vp) return;
|
|
451
|
+
|
|
452
|
+
const zoomAt = (cx, cy, factor) => {
|
|
453
|
+
const r = vp.getBoundingClientRect();
|
|
454
|
+
const px = cx - r.left, py = cy - r.top;
|
|
455
|
+
const t = tf.current;
|
|
456
|
+
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
|
457
|
+
const k = next / t.scale;
|
|
458
|
+
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
|
|
459
|
+
// marginBottom) reflow on every scale change, vertically shifting the
|
|
460
|
+
// world layout — so a world point mathematically pinned under the cursor
|
|
461
|
+
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
|
|
462
|
+
// Anchor the DOM element under the cursor instead: record its screen Y,
|
|
463
|
+
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
|
|
464
|
+
// drift the reflow introduced so it stays put on screen.
|
|
465
|
+
let marker = null, markerY0 = 0;
|
|
466
|
+
if (k !== 1) {
|
|
467
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
468
|
+
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
|
469
|
+
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
|
470
|
+
}
|
|
471
|
+
// keep the world point under the cursor fixed
|
|
472
|
+
t.x = px - (px - t.x) * k;
|
|
473
|
+
t.y = py - (py - t.y) * k;
|
|
474
|
+
t.scale = next;
|
|
475
|
+
apply();
|
|
476
|
+
if (marker) {
|
|
477
|
+
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
|
|
478
|
+
// departure after the --dc-inv-zoom reflow is the layout drift.
|
|
479
|
+
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
|
480
|
+
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// On-canvas zoom control: zoom to a target scale around the viewport
|
|
485
|
+
// centre, matching the host set-zoom path (onHostMsg). Closes over the
|
|
486
|
+
// live zoomAt / vp / tf, so it always uses the current transform.
|
|
487
|
+
zoomCenteredRef.current = (targetScale) => {
|
|
488
|
+
const r = vp.getBoundingClientRect();
|
|
489
|
+
const clamped = Math.min(maxScale, Math.max(minScale, targetScale));
|
|
490
|
+
zoomAt(r.left + r.width / 2, r.top + r.height / 2, clamped / tf.current.scale);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
|
494
|
+
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
|
495
|
+
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
|
496
|
+
// two-finger scroll sends small/fractional pixel deltas, often with
|
|
497
|
+
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
|
498
|
+
const isMouseWheel = (e) =>
|
|
499
|
+
e.deltaMode !== 0 ||
|
|
500
|
+
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
|
501
|
+
|
|
502
|
+
const onWheel = (e) => {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
|
505
|
+
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
|
506
|
+
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
|
507
|
+
// wheels fall through to the fixed-step branch below.
|
|
508
|
+
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
|
509
|
+
} else if (isMouseWheel(e)) {
|
|
510
|
+
// notched mouse wheel — fixed-ratio step per click
|
|
511
|
+
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
|
512
|
+
} else {
|
|
513
|
+
// trackpad two-finger scroll — pan
|
|
514
|
+
tf.current.x -= e.deltaX;
|
|
515
|
+
tf.current.y -= e.deltaY;
|
|
516
|
+
apply();
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// Safari sends native gesture* events for trackpad pinch with a smooth
|
|
521
|
+
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
|
522
|
+
// better feel there. No-ops on other browsers. Safari also fires
|
|
523
|
+
// ctrlKey wheel events during the same pinch — isGesturing makes
|
|
524
|
+
// onWheel drop those entirely so they neither zoom nor pan.
|
|
525
|
+
let gsBase = 1;
|
|
526
|
+
let isGesturing = false;
|
|
527
|
+
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
|
528
|
+
const onGestureChange = (e) => {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
|
531
|
+
};
|
|
532
|
+
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
|
533
|
+
|
|
534
|
+
// Drag-pan: middle button anywhere, or primary button on canvas
|
|
535
|
+
// background (anything that isn't an artboard or an inline editor).
|
|
536
|
+
let drag = null;
|
|
537
|
+
const onPointerDown = (e) => {
|
|
538
|
+
const onBg = !e.target.closest('[data-dc-slot], .dc-editable, [data-miaoda-chrome]');
|
|
539
|
+
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
vp.setPointerCapture(e.pointerId);
|
|
542
|
+
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
|
543
|
+
vp.style.cursor = 'grabbing';
|
|
544
|
+
};
|
|
545
|
+
const onPointerMove = (e) => {
|
|
546
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
547
|
+
tf.current.x += e.clientX - drag.lx;
|
|
548
|
+
tf.current.y += e.clientY - drag.ly;
|
|
549
|
+
drag.lx = e.clientX; drag.ly = e.clientY;
|
|
550
|
+
apply();
|
|
551
|
+
};
|
|
552
|
+
const onPointerUp = (e) => {
|
|
553
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
554
|
+
vp.releasePointerCapture(e.pointerId);
|
|
555
|
+
drag = null;
|
|
556
|
+
vp.style.cursor = '';
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
|
560
|
+
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
|
561
|
+
const onHostMsg = (e) => {
|
|
562
|
+
const d = e.data;
|
|
563
|
+
if (d && d.type === 'miaoda:canvas:set-zoom' && typeof d.scale === 'number') {
|
|
564
|
+
const r = vp.getBoundingClientRect();
|
|
565
|
+
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
|
566
|
+
} else if (d && d.type === 'miaoda:canvas:probe') {
|
|
567
|
+
// Host's [readyGen] reset asks whether a canvas is present; it
|
|
568
|
+
// fires on the iframe's native 'load', which for canvases with
|
|
569
|
+
// images/fonts is after our mount-time announce, so re-announce.
|
|
570
|
+
// Clear the pan-tick guard so apply() re-posts the current scale
|
|
571
|
+
// even if it's unchanged — the host just reset dcScale to 1.
|
|
572
|
+
window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
|
|
573
|
+
lastPostedScale.current = undefined;
|
|
574
|
+
apply();
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
window.addEventListener('message', onHostMsg);
|
|
578
|
+
// Announce canvas mode so the host toolbar proxies its % control here
|
|
579
|
+
// instead of scaling the iframe element (which would just shrink the
|
|
580
|
+
// viewport window of an infinite canvas). The apply() that follows emits
|
|
581
|
+
// the initial miaoda:canvas:zoom so the toolbar % is correct before first pinch.
|
|
582
|
+
// lastPostedScale reset mirrors the miaoda:canvas:probe handler: the layout
|
|
583
|
+
// effect's restore-path apply() may already have posted the restored
|
|
584
|
+
// scale (before miaoda:canvas:present), so clear the guard to re-post it in order.
|
|
585
|
+
window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
|
|
586
|
+
lastPostedScale.current = undefined;
|
|
587
|
+
apply();
|
|
588
|
+
|
|
589
|
+
vp.addEventListener('wheel', onWheel, { passive: false });
|
|
590
|
+
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
|
591
|
+
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
|
592
|
+
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
|
593
|
+
vp.addEventListener('pointerdown', onPointerDown);
|
|
594
|
+
vp.addEventListener('pointermove', onPointerMove);
|
|
595
|
+
vp.addEventListener('pointerup', onPointerUp);
|
|
596
|
+
vp.addEventListener('pointercancel', onPointerUp);
|
|
597
|
+
return () => {
|
|
598
|
+
window.removeEventListener('message', onHostMsg);
|
|
599
|
+
vp.removeEventListener('wheel', onWheel);
|
|
600
|
+
vp.removeEventListener('gesturestart', onGestureStart);
|
|
601
|
+
vp.removeEventListener('gesturechange', onGestureChange);
|
|
602
|
+
vp.removeEventListener('gestureend', onGestureEnd);
|
|
603
|
+
vp.removeEventListener('pointerdown', onPointerDown);
|
|
604
|
+
vp.removeEventListener('pointermove', onPointerMove);
|
|
605
|
+
vp.removeEventListener('pointerup', onPointerUp);
|
|
606
|
+
vp.removeEventListener('pointercancel', onPointerUp);
|
|
607
|
+
};
|
|
608
|
+
}, [apply, minScale, maxScale]);
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<div
|
|
612
|
+
ref={vpRef}
|
|
613
|
+
className="design-canvas"
|
|
614
|
+
style={{
|
|
615
|
+
height: '100vh', width: '100vw',
|
|
616
|
+
background: DC.bg,
|
|
617
|
+
overflow: 'hidden',
|
|
618
|
+
overscrollBehavior: 'none',
|
|
619
|
+
touchAction: 'none',
|
|
620
|
+
position: 'relative',
|
|
621
|
+
fontFamily: DC.font,
|
|
622
|
+
boxSizing: 'border-box',
|
|
623
|
+
...style,
|
|
624
|
+
}}
|
|
625
|
+
>
|
|
626
|
+
<div
|
|
627
|
+
ref={worldRef}
|
|
628
|
+
style={{
|
|
629
|
+
position: 'absolute', top: 0, left: 0,
|
|
630
|
+
transformOrigin: '0 0',
|
|
631
|
+
willChange: 'transform',
|
|
632
|
+
width: 'max-content', minWidth: '100%',
|
|
633
|
+
minHeight: '100%',
|
|
634
|
+
padding: '60px 0 80px',
|
|
635
|
+
}}
|
|
636
|
+
>
|
|
637
|
+
{children}
|
|
638
|
+
</div>
|
|
639
|
+
<DCZoomControl
|
|
640
|
+
scalePct={scalePct}
|
|
641
|
+
minScale={minScale}
|
|
642
|
+
maxScale={maxScale}
|
|
643
|
+
onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ─────────────────────────────────────────────────────────────
|
|
650
|
+
// DCSection — editable title + h-row of artboards in persisted order
|
|
651
|
+
// ─────────────────────────────────────────────────────────────
|
|
652
|
+
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
|
653
|
+
const ctx = React.useContext(DCCtx);
|
|
654
|
+
const sid = id ?? title;
|
|
655
|
+
const all = React.Children.toArray(dcFlatten(children));
|
|
656
|
+
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
|
657
|
+
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
|
658
|
+
const sec = (ctx && sid && ctx.section(sid)) || {};
|
|
659
|
+
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
|
660
|
+
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
|
661
|
+
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
|
662
|
+
const srcKey = allIds.join('\x1f');
|
|
663
|
+
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
|
664
|
+
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
|
665
|
+
|
|
666
|
+
const order = React.useMemo(() => {
|
|
667
|
+
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
|
668
|
+
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
|
669
|
+
}, [sec.order, srcOrder.join('|')]);
|
|
670
|
+
|
|
671
|
+
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
|
672
|
+
|
|
673
|
+
// marginBottom counter-scales so the on-screen gap between sections stays
|
|
674
|
+
// constant — otherwise at low zoom the (world-space) gap collapses while
|
|
675
|
+
// the screen-constant sectionhead below it doesn't, and the title reads as
|
|
676
|
+
// belonging to the section above. paddingBottom below is just enough for
|
|
677
|
+
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
|
678
|
+
// the title sits tight against its own row at every zoom.
|
|
679
|
+
return (
|
|
680
|
+
<div data-dc-section={sid}
|
|
681
|
+
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
|
682
|
+
<div style={{ padding: '0 60px' }}>
|
|
683
|
+
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
|
684
|
+
<DCEditable tag="div" value={sec.title ?? title}
|
|
685
|
+
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
|
686
|
+
style={{ fontSize: 20, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 2, display: 'inline-block', lineHeight: '28px' }} />
|
|
687
|
+
{subtitle && <div style={{ fontSize: 14, color: DC.subtitle, lineHeight: '22px' }}>{subtitle}</div>}
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
|
691
|
+
{order.map((k) => (
|
|
692
|
+
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
|
693
|
+
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
|
694
|
+
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
|
695
|
+
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
|
696
|
+
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
|
697
|
+
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
|
698
|
+
srcKey,
|
|
699
|
+
}))}
|
|
700
|
+
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
|
701
|
+
))}
|
|
702
|
+
</div>
|
|
703
|
+
{rest}
|
|
704
|
+
</div>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
|
709
|
+
function DCArtboard() { return null; }
|
|
710
|
+
|
|
711
|
+
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
|
712
|
+
// self-contained clone: computed styles baked in, @font-face / <img> /
|
|
713
|
+
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
|
714
|
+
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
|
715
|
+
// (same pipeline the host uses for page captures); HTML wraps it in a
|
|
716
|
+
// minimal standalone document. Both are independent of viewport zoom.
|
|
717
|
+
async function dcExport(node, w, h, name, kind) {
|
|
718
|
+
try { await document.fonts.ready; } catch {}
|
|
719
|
+
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
|
720
|
+
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
|
721
|
+
})).catch(() => url);
|
|
722
|
+
|
|
723
|
+
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
|
724
|
+
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
|
725
|
+
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
|
726
|
+
// the blocks. @import and @media/@supports are walked so nested
|
|
727
|
+
// @font-face rules aren't missed.
|
|
728
|
+
const fontRules = [], pending = [], seen = new Set();
|
|
729
|
+
const scrapeCss = (href) => {
|
|
730
|
+
if (seen.has(href)) return; seen.add(href);
|
|
731
|
+
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
|
732
|
+
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
|
733
|
+
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
|
734
|
+
scrapeCss(new URL(m[1], href).href);
|
|
735
|
+
}).catch(() => {}));
|
|
736
|
+
};
|
|
737
|
+
const walk = (rules, base) => {
|
|
738
|
+
for (const r of rules) {
|
|
739
|
+
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
|
740
|
+
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
|
741
|
+
const ibase = r.styleSheet.href || base;
|
|
742
|
+
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
|
743
|
+
} else if (r.cssRules) walk(r.cssRules, base);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
for (const ss of document.styleSheets) {
|
|
747
|
+
const base = ss.href || location.href;
|
|
748
|
+
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
|
749
|
+
}
|
|
750
|
+
while (pending.length) await pending.shift();
|
|
751
|
+
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
|
752
|
+
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
|
753
|
+
while ((m = re.exec(rule.css))) {
|
|
754
|
+
if (m[2].indexOf('data:') === 0) continue;
|
|
755
|
+
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
|
756
|
+
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}))).join('\n');
|
|
760
|
+
|
|
761
|
+
const cloneStyled = (src) => {
|
|
762
|
+
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
|
763
|
+
const dst = src.cloneNode(false);
|
|
764
|
+
if (src.nodeType === 1) {
|
|
765
|
+
const cs = getComputedStyle(src); let txt = '';
|
|
766
|
+
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
|
767
|
+
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
|
768
|
+
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
|
769
|
+
}
|
|
770
|
+
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
|
771
|
+
return dst;
|
|
772
|
+
};
|
|
773
|
+
const clone = cloneStyled(node);
|
|
774
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
|
775
|
+
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
|
776
|
+
// the artboard's own background (if any) is already in the computed style.
|
|
777
|
+
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
|
778
|
+
|
|
779
|
+
const jobs = [];
|
|
780
|
+
clone.querySelectorAll('img').forEach((el) => {
|
|
781
|
+
const s = el.getAttribute('src');
|
|
782
|
+
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
|
783
|
+
});
|
|
784
|
+
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
|
785
|
+
const bg = el.style.backgroundImage; if (!bg) return;
|
|
786
|
+
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
|
787
|
+
while ((m = re.exec(bg))) {
|
|
788
|
+
const tok = m[0], url = m[1];
|
|
789
|
+
if (url.indexOf('data:') === 0) continue;
|
|
790
|
+
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
await Promise.all(jobs);
|
|
794
|
+
|
|
795
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
796
|
+
const save = (blob, ext) => {
|
|
797
|
+
if (!blob) return;
|
|
798
|
+
const a = document.createElement('a');
|
|
799
|
+
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
|
800
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
if (kind === 'html') {
|
|
804
|
+
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
|
805
|
+
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
|
806
|
+
'</head><body style="margin:0">' + xml + '</body></html>';
|
|
807
|
+
return save(new Blob([html], { type: 'text/html' }), 'html');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// PNG: the SVG's own width/height must be the output resolution — an
|
|
811
|
+
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
|
812
|
+
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
|
813
|
+
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
|
814
|
+
// the HTML at full resolution.
|
|
815
|
+
const px = 3;
|
|
816
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
|
817
|
+
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
|
818
|
+
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
|
819
|
+
const img = new Image();
|
|
820
|
+
await new Promise((res, rej) => {
|
|
821
|
+
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
|
822
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
823
|
+
});
|
|
824
|
+
const cv = document.createElement('canvas');
|
|
825
|
+
cv.width = w * px; cv.height = h * px;
|
|
826
|
+
cv.getContext('2d').drawImage(img, 0, 0);
|
|
827
|
+
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
|
831
|
+
const { id: rawId, label: rawLabel, width = 260, height = 480, chromeless = false, children, style = {} } = artboard.props;
|
|
832
|
+
const id = rawId ?? rawLabel;
|
|
833
|
+
const ref = React.useRef(null);
|
|
834
|
+
const cardRef = React.useRef(null);
|
|
835
|
+
const menuRef = React.useRef(null);
|
|
836
|
+
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
837
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
838
|
+
|
|
839
|
+
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
|
840
|
+
// the menu — first click arms the row, second commits; closing disarms.
|
|
841
|
+
React.useEffect(() => {
|
|
842
|
+
if (!menuOpen) { setConfirming(false); return; }
|
|
843
|
+
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
|
844
|
+
document.addEventListener('pointerdown', off, true);
|
|
845
|
+
return () => document.removeEventListener('pointerdown', off, true);
|
|
846
|
+
}, [menuOpen]);
|
|
847
|
+
|
|
848
|
+
const doExport = (kind) => {
|
|
849
|
+
setMenuOpen(false);
|
|
850
|
+
if (!cardRef.current) return;
|
|
851
|
+
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
|
852
|
+
const ew = cardRef.current.offsetWidth || width;
|
|
853
|
+
const eh = cardRef.current.offsetHeight || height;
|
|
854
|
+
dcExport(cardRef.current, ew, eh, name, kind)
|
|
855
|
+
.catch((e) => console.error('[design-canvas] export failed:', e));
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
|
859
|
+
// their would-be slots in real time via transforms. DOM order only
|
|
860
|
+
// changes on drop.
|
|
861
|
+
const onGripDown = (e) => {
|
|
862
|
+
e.preventDefault(); e.stopPropagation();
|
|
863
|
+
const me = ref.current;
|
|
864
|
+
// translateX is applied in local (pre-scale) space but pointer deltas and
|
|
865
|
+
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
|
866
|
+
// current scale so the dragged card tracks the cursor at any zoom level.
|
|
867
|
+
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
|
868
|
+
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
|
869
|
+
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
|
870
|
+
const slotXs = homes.map((h) => h.x);
|
|
871
|
+
const startIdx = order.indexOf(id);
|
|
872
|
+
const startX = e.clientX;
|
|
873
|
+
let liveOrder = order.slice();
|
|
874
|
+
me.classList.add('dc-dragging');
|
|
875
|
+
|
|
876
|
+
const layout = () => {
|
|
877
|
+
for (const h of homes) {
|
|
878
|
+
if (h.id === id) continue;
|
|
879
|
+
const slot = liveOrder.indexOf(h.id);
|
|
880
|
+
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const move = (ev) => {
|
|
885
|
+
const dx = ev.clientX - startX;
|
|
886
|
+
me.style.transform = `translateX(${dx / scale}px)`;
|
|
887
|
+
const cur = homes[startIdx].x + dx;
|
|
888
|
+
let nearest = 0, best = Infinity;
|
|
889
|
+
for (let i = 0; i < slotXs.length; i++) {
|
|
890
|
+
const d = Math.abs(slotXs[i] - cur);
|
|
891
|
+
if (d < best) { best = d; nearest = i; }
|
|
892
|
+
}
|
|
893
|
+
if (liveOrder.indexOf(id) !== nearest) {
|
|
894
|
+
liveOrder = order.filter((k) => k !== id);
|
|
895
|
+
liveOrder.splice(nearest, 0, id);
|
|
896
|
+
layout();
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const up = () => {
|
|
901
|
+
document.removeEventListener('pointermove', move);
|
|
902
|
+
document.removeEventListener('pointerup', up);
|
|
903
|
+
const finalSlot = liveOrder.indexOf(id);
|
|
904
|
+
me.classList.remove('dc-dragging');
|
|
905
|
+
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
|
906
|
+
// After the settle transition, kill transitions + clear transforms +
|
|
907
|
+
// commit the reorder in the same frame so there's no visual snap-back.
|
|
908
|
+
setTimeout(() => {
|
|
909
|
+
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
|
910
|
+
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
|
911
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
912
|
+
for (const h of homes) h.el.style.transition = '';
|
|
913
|
+
}));
|
|
914
|
+
}, 180);
|
|
915
|
+
};
|
|
916
|
+
document.addEventListener('pointermove', move);
|
|
917
|
+
document.addEventListener('pointerup', up);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
return (
|
|
921
|
+
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
|
922
|
+
<div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
|
923
|
+
<div className="dc-labelrow">
|
|
924
|
+
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
|
925
|
+
<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>
|
|
926
|
+
</div>
|
|
927
|
+
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
|
928
|
+
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
|
929
|
+
style={{ fontSize: 14, fontWeight: 400, color: DC.label, lineHeight: '22px' }} />
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
<div className="dc-btns">
|
|
933
|
+
<div ref={menuRef} style={{ position: 'relative' }}>
|
|
934
|
+
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
|
935
|
+
<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>
|
|
936
|
+
</button>
|
|
937
|
+
{menuOpen && (
|
|
938
|
+
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
|
939
|
+
<button onClick={() => doExport('png')}>下载 PNG</button>
|
|
940
|
+
<button onClick={() => doExport('html')}>下载 HTML</button>
|
|
941
|
+
<button className="dc-danger"
|
|
942
|
+
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
|
943
|
+
{confirming ? '确认删除' : '删除'}
|
|
944
|
+
</button>
|
|
945
|
+
</div>
|
|
946
|
+
)}
|
|
947
|
+
</div>
|
|
948
|
+
<button className="dc-expand" onClick={onFocus} title="Focus">
|
|
949
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
|
950
|
+
</button>
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
<div ref={cardRef} className="dc-card"
|
|
954
|
+
style={{ ...(chromeless ? {} : { width, height, borderRadius: 4, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', background: '#fff' }), ...style }}>
|
|
955
|
+
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Inline rename — commits on blur or Enter.
|
|
962
|
+
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
|
963
|
+
const T = tag;
|
|
964
|
+
return (
|
|
965
|
+
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
|
966
|
+
onClick={onClick}
|
|
967
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
968
|
+
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
|
969
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
|
970
|
+
style={style}>{value}</T>
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ─────────────────────────────────────────────────────────────
|
|
975
|
+
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
|
976
|
+
// sections, Esc or backdrop click to exit.
|
|
977
|
+
// ─────────────────────────────────────────────────────────────
|
|
978
|
+
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
|
979
|
+
const ctx = React.useContext(DCCtx);
|
|
980
|
+
const { sectionId, artboard } = entry;
|
|
981
|
+
const sec = ctx.section(sectionId);
|
|
982
|
+
const meta = sectionMeta[sectionId];
|
|
983
|
+
const peers = meta.slotIds;
|
|
984
|
+
const aid = artboard.props.id ?? artboard.props.label;
|
|
985
|
+
const idx = peers.indexOf(aid);
|
|
986
|
+
const secIdx = sectionOrder.indexOf(sectionId);
|
|
987
|
+
|
|
988
|
+
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
|
989
|
+
const goSection = (d) => {
|
|
990
|
+
// Sections whose artboards are all deleted have slotIds:[] — step past
|
|
991
|
+
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
|
992
|
+
const n = sectionOrder.length;
|
|
993
|
+
for (let i = 1; i < n; i++) {
|
|
994
|
+
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
|
995
|
+
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
|
996
|
+
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
React.useEffect(() => {
|
|
1001
|
+
const k = (e) => {
|
|
1002
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
|
1003
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
|
1004
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
|
1005
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
|
1006
|
+
};
|
|
1007
|
+
document.addEventListener('keydown', k);
|
|
1008
|
+
return () => document.removeEventListener('keydown', k);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const { width = 260, height = 480, children } = artboard.props;
|
|
1012
|
+
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
|
1013
|
+
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
|
1014
|
+
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
|
1015
|
+
|
|
1016
|
+
const [ddOpen, setDd] = React.useState(false);
|
|
1017
|
+
const Arrow = ({ dir, onClick }) => (
|
|
1018
|
+
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
|
1019
|
+
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
|
1020
|
+
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
|
1021
|
+
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
|
1022
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
|
1023
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
|
1024
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
|
1025
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
1026
|
+
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
|
1027
|
+
</button>
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
// Portal to body so position:fixed is the real viewport regardless of any
|
|
1031
|
+
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
|
1032
|
+
return ReactDOM.createPortal(
|
|
1033
|
+
<div onClick={() => ctx.setFocus(null)}
|
|
1034
|
+
onWheel={(e) => e.preventDefault()}
|
|
1035
|
+
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
|
1036
|
+
fontFamily: DC.font, color: '#fff' }}>
|
|
1037
|
+
|
|
1038
|
+
{/* top bar: section dropdown (left) · close (right) */}
|
|
1039
|
+
<div onClick={(e) => e.stopPropagation()}
|
|
1040
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
|
1041
|
+
<div style={{ position: 'relative' }}>
|
|
1042
|
+
<button onClick={() => setDd((o) => !o)}
|
|
1043
|
+
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
|
1044
|
+
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
|
1045
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
1046
|
+
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
|
1047
|
+
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
|
1048
|
+
</span>
|
|
1049
|
+
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
|
1050
|
+
</button>
|
|
1051
|
+
{ddOpen && (
|
|
1052
|
+
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
|
1053
|
+
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
|
1054
|
+
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
|
1055
|
+
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
|
1056
|
+
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
|
1057
|
+
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
|
1058
|
+
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
|
1059
|
+
{sectionMeta[sid].title}
|
|
1060
|
+
</button>
|
|
1061
|
+
))}
|
|
1062
|
+
</div>
|
|
1063
|
+
)}
|
|
1064
|
+
</div>
|
|
1065
|
+
<div style={{ flex: 1 }} />
|
|
1066
|
+
<button onClick={() => ctx.setFocus(null)}
|
|
1067
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
|
1068
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
|
1069
|
+
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
|
1070
|
+
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
|
1071
|
+
</div>
|
|
1072
|
+
|
|
1073
|
+
{/* card centered, label + index below — only the card itself stops
|
|
1074
|
+
propagation so any backdrop click (including the margins around
|
|
1075
|
+
the card) exits focus */}
|
|
1076
|
+
<div
|
|
1077
|
+
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
|
1078
|
+
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
|
1079
|
+
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
|
1080
|
+
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
|
1081
|
+
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
|
1085
|
+
{(sec.labels || {})[aid] ?? artboard.props.label}
|
|
1086
|
+
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
|
1087
|
+
</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
|
|
1090
|
+
<Arrow dir="left" onClick={() => go(-1)} />
|
|
1091
|
+
<Arrow dir="right" onClick={() => go(1)} />
|
|
1092
|
+
|
|
1093
|
+
{/* dots */}
|
|
1094
|
+
<div onClick={(e) => e.stopPropagation()}
|
|
1095
|
+
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
|
1096
|
+
{peers.map((p, i) => (
|
|
1097
|
+
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
|
1098
|
+
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
|
1099
|
+
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
|
1100
|
+
))}
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>,
|
|
1103
|
+
document.body,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ─────────────────────────────────────────────────────────────
|
|
1108
|
+
// Post-it — absolute-positioned sticky note
|
|
1109
|
+
// ─────────────────────────────────────────────────────────────
|
|
1110
|
+
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
|
1111
|
+
return (
|
|
1112
|
+
<div style={{
|
|
1113
|
+
position: 'absolute', top, left, right, bottom, width,
|
|
1114
|
+
background: DC.postitBg, padding: '14px 16px',
|
|
1115
|
+
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
|
1116
|
+
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
|
1117
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
|
1118
|
+
transform: `rotate(${rotate}deg)`,
|
|
1119
|
+
zIndex: 5,
|
|
1120
|
+
}}>{children}</div>
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
|
1125
|
+
|