@seliseblocks/mailcraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +557 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/mailcraft-editor.bundle.js +519 -0
- package/dist/mailcraft-editor.bundle.js.map +7 -0
- package/examples/templates/activate-your-account.html +54 -0
- package/examples/templates/back-in-stock.html +63 -0
- package/examples/templates/cart-left-behind.html +74 -0
- package/examples/templates/community-giveaway.html +81 -0
- package/examples/templates/frontend-futures-invite.html +69 -0
- package/examples/templates/give-25-get-25.html +69 -0
- package/examples/templates/invoice-paid.html +77 -0
- package/examples/templates/meet-nova-launch.html +69 -0
- package/examples/templates/mega-weekend-sale.html +57 -0
- package/examples/templates/order-confirmed.html +80 -0
- package/examples/templates/rate-your-headphones.html +71 -0
- package/examples/templates/reset-your-password.html +54 -0
- package/examples/templates/thankyou-promo-code.html +68 -0
- package/examples/templates/the-sunday-brief.html +47 -0
- package/examples/templates/welcome-to-your-workspace.html +62 -0
- package/examples/templates/your-order-shipped.html +74 -0
- package/examples/templates/your-password-was-changed.html +58 -0
- package/examples/templates/your-signin-code.html +58 -0
- package/examples/vanilla.html +1574 -0
- package/package.json +66 -0
- package/src/core/accent.js +194 -0
- package/src/core/assets.js +15 -0
- package/src/core/binder.js +119 -0
- package/src/core/blocks.js +256 -0
- package/src/core/css-cascade.js +117 -0
- package/src/core/editor-core.js +1492 -0
- package/src/core/export.js +58 -0
- package/src/core/footer.js +73 -0
- package/src/core/i18n/ar.js +177 -0
- package/src/core/i18n/bg.js +152 -0
- package/src/core/i18n/bn.js +176 -0
- package/src/core/i18n/ca.js +152 -0
- package/src/core/i18n/cs.js +152 -0
- package/src/core/i18n/da.js +152 -0
- package/src/core/i18n/de-CH.js +152 -0
- package/src/core/i18n/de.js +152 -0
- package/src/core/i18n/dz.js +179 -0
- package/src/core/i18n/el.js +152 -0
- package/src/core/i18n/en.js +269 -0
- package/src/core/i18n/es.js +152 -0
- package/src/core/i18n/et.js +152 -0
- package/src/core/i18n/fi.js +152 -0
- package/src/core/i18n/fr.js +152 -0
- package/src/core/i18n/hr.js +152 -0
- package/src/core/i18n/hu.js +152 -0
- package/src/core/i18n/index.js +83 -0
- package/src/core/i18n/it.js +152 -0
- package/src/core/i18n/lt.js +152 -0
- package/src/core/i18n/lv.js +152 -0
- package/src/core/i18n/nb.js +152 -0
- package/src/core/i18n/nl.js +152 -0
- package/src/core/i18n/pl.js +152 -0
- package/src/core/i18n/pt.js +152 -0
- package/src/core/i18n/ro.js +152 -0
- package/src/core/i18n/ru.js +152 -0
- package/src/core/i18n/sk.js +152 -0
- package/src/core/i18n/sl.js +152 -0
- package/src/core/i18n/sv.js +152 -0
- package/src/core/i18n/tables.js +50 -0
- package/src/core/i18n/tr.js +152 -0
- package/src/core/i18n/uk.js +152 -0
- package/src/core/icons.js +235 -0
- package/src/core/ids.js +1 -0
- package/src/core/import-html.js +959 -0
- package/src/core/layout-style.js +115 -0
- package/src/core/parse.js +10 -0
- package/src/core/placeholder.js +15 -0
- package/src/core/sanitize.js +141 -0
- package/src/core/storage-limits.js +184 -0
- package/src/core/storage.js +85 -0
- package/src/core/theme.js +1 -0
- package/src/core/toolbar.js +67 -0
- package/src/core/variables.js +11 -0
- package/src/create-editor.js +109 -0
- package/src/index.js +9 -0
- package/src/mailcraft-editor.js +1862 -0
- package/src/render/block-body.js +295 -0
- package/src/render/canvas.js +284 -0
- package/src/render/fields.js +609 -0
- package/src/render/focus-preserve.js +158 -0
- package/src/render/rte.js +212 -0
- package/src/render/screenshot.js +132 -0
- package/src/render/story.js +415 -0
- package/src/render/style.js +452 -0
|
@@ -0,0 +1,1862 @@
|
|
|
1
|
+
import { EditorCore, AI_GOALS, AI_TONES } from './core/editor-core.js';
|
|
2
|
+
import { GROUPS, LAYOUTS, DEF, PALETTE } from './core/blocks.js';
|
|
3
|
+
import { KB } from './core/assets.js';
|
|
4
|
+
import { acceptAttribute } from './core/storage-limits.js';
|
|
5
|
+
import { renderDoc } from './render/canvas.js';
|
|
6
|
+
import { renderRte } from './render/rte.js';
|
|
7
|
+
import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
|
|
8
|
+
import { icon } from './core/icons.js';
|
|
9
|
+
import { TOKEN } from './core/variables.js';
|
|
10
|
+
import { hl, cssUrl } from './core/sanitize.js';
|
|
11
|
+
import { withFocusPreserved } from './render/focus-preserve.js';
|
|
12
|
+
import { captureTemplatePng } from './render/screenshot.js';
|
|
13
|
+
import { resolveToolbar, toolbarKey } from './core/toolbar.js';
|
|
14
|
+
import { resolveFooter } from './core/footer.js';
|
|
15
|
+
import { createStoryViewer } from './render/story.js';
|
|
16
|
+
import { STYLE } from './render/style.js';
|
|
17
|
+
import { createTranslator, isRtl } from './core/i18n/index.js';
|
|
18
|
+
import { ACCENT_VARS, accentTokens, parseColor } from './core/accent.js';
|
|
19
|
+
import { LOCALE_TABLES } from './core/i18n/tables.js';
|
|
20
|
+
|
|
21
|
+
/** `elS` mirrors the template's literal `style="..."` attribute strings verbatim -- copied, not re-derived, to keep the port pixel-exact. */
|
|
22
|
+
function elS(tag, styleStr, attrs = {}, ...children) {
|
|
23
|
+
const node = document.createElement(tag);
|
|
24
|
+
if (styleStr) node.style.cssText = styleStr;
|
|
25
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
26
|
+
if (v === undefined) continue;
|
|
27
|
+
if (k === 'text') node.textContent = v;
|
|
28
|
+
else if (k === 'html') node.innerHTML = v;
|
|
29
|
+
else if (k === 'on') for (const [ev, fn] of Object.entries(v)) node.addEventListener(ev, fn);
|
|
30
|
+
else node.setAttribute(k, v);
|
|
31
|
+
}
|
|
32
|
+
children.flat().forEach((c) => { if (c != null && c !== false) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Built from `t` at render time (not a module-level const) since the active translator can change at runtime via `.messages`/`.locale`. Icon-only tabs: `label` carries the full name for aria-label/tooltip, `title` is the native-title fallback/hint. */
|
|
37
|
+
function tabsFor(t) {
|
|
38
|
+
return [
|
|
39
|
+
{ key: 'design', label: t('tab.design'), title: t('tab.designHint'), iconName: 'paint' },
|
|
40
|
+
{ key: 'blocks', label: t('tab.blocks'), title: t('tab.blocksHint'), iconName: 'grid' },
|
|
41
|
+
{ key: 'rows', label: t('tab.rows'), title: t('tab.rowsHint'), iconName: 'table' },
|
|
42
|
+
{ key: 'files', label: t('tab.files'), title: t('tab.filesHint'), iconName: 'image' },
|
|
43
|
+
{ key: 'layers', label: t('tab.layers'), title: t('tab.layersHint'), iconName: 'list' },
|
|
44
|
+
{ key: 'theme', label: t('tab.settings'), title: t('tab.settingsHint'), iconName: 'sliders' },
|
|
45
|
+
{ key: 'data', label: t('tab.data'), title: t('tab.dataHint'), iconName: 'data' },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Appends a shared CSS-only tooltip bubble (`.mc-tooltip`, render/style.js) to
|
|
51
|
+
* `node` and marks it `data-tip` so `:hover`/`:focus-visible`
|
|
52
|
+
* on the node itself reveals it -- no JS show/hide logic. `dir` picks which
|
|
53
|
+
* side it renders on ('down' for tabs/segments, 'up' for anything hugging the
|
|
54
|
+
* bottom of the header). Also sets `aria-label` (icon-only controls have no
|
|
55
|
+
* visible text) and keeps `title` as the native-tooltip fallback.
|
|
56
|
+
*/
|
|
57
|
+
function tip(node, label, dir, align) {
|
|
58
|
+
node.style.position = node.style.position || 'relative';
|
|
59
|
+
node.setAttribute('data-tip', '1');
|
|
60
|
+
if (!node.hasAttribute('aria-label')) node.setAttribute('aria-label', label);
|
|
61
|
+
// The bubble replaces the native tooltip -- with `title` left on the node
|
|
62
|
+
// both would pop on hover.
|
|
63
|
+
node.removeAttribute('title');
|
|
64
|
+
// align 'end' pins the bubble to the control's right edge instead of
|
|
65
|
+
// centering it -- for controls hugging a right edge, where a centered
|
|
66
|
+
// bubble would overflow its scroll container (see .mc-tooltip-end).
|
|
67
|
+
const direction = dir || 'down';
|
|
68
|
+
node.tipNode = elS('span', '', { class: `mc-tooltip mc-tooltip-${direction}${direction === 'down' ? ' mc-tab-tooltip' : ''}${align === 'end' ? ' mc-tooltip-end' : ''}`, text: label });
|
|
69
|
+
node.appendChild(node.tipNode);
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `<mailcraft-editor>` -- a pixel-faithful port of the MailCraft prototype to
|
|
75
|
+
* a zero-dependency Web Component. Shadow DOM for isolation; no bundled
|
|
76
|
+
* framework -- `core/editor-core.js` owns all state and logic (ported
|
|
77
|
+
* near-verbatim from the original `Component` class), this file only builds
|
|
78
|
+
* and re-builds DOM from it.
|
|
79
|
+
*
|
|
80
|
+
* Attributes: `variables`, `locale` (picks the shipped UI language
|
|
81
|
+
* and, via `dir` defaulting, RTL), `dir`, `theme` ("light"/"dark" -- host-owned),
|
|
82
|
+
* `ui-font` (a CSS font-family value; use "inherit" for the host app's font),
|
|
83
|
+
* `accent` (the host's brand color -- a CSS color, `var(--your-token)`, or
|
|
84
|
+
* "inherit" to read the host's `accent-color`; the whole accent token set is
|
|
85
|
+
* derived from it, contrast-corrected per chrome),
|
|
86
|
+
* chrome; while present the built-in toggle is hidden), `footer` ("none" to
|
|
87
|
+
* remove the attribution strip, or any string to replace its text).
|
|
88
|
+
* Properties: `.variables`, `.toolbar` (which parts of the top bar are shown),
|
|
89
|
+
* `.aiProvider` (optional async fn, replaces the original's `window.claude.complete`),
|
|
90
|
+
* `.iconProvider` (optional social-icon override), `.storageProvider` (host-supplied
|
|
91
|
+
* file storage -- see `core/storage.js`), `.storageLimits` (host-set upload ceilings,
|
|
92
|
+
* required whenever a provider is set), `.messages` (UI string overrides --
|
|
93
|
+
* a host's own table, an imported locale from `core/i18n/`, or both merged via
|
|
94
|
+
* `defineMessages`; see `core/i18n/index.js`).
|
|
95
|
+
* Methods: `getContent()`, `setContent(doc)`, `importHtml(html)`, `exportHtml()`,
|
|
96
|
+
* `loadTemplate(tpl)`, `screenshotPng()`
|
|
97
|
+
* (full-template PNG as a Blob), `previewScreenshot()` (story-style viewer),
|
|
98
|
+
* `downloadScreenshot()`, `copyScreenshot()`, `undo()`, `redo()`.
|
|
99
|
+
* Events: `change` (detail: doc), `export` (detail: html string).
|
|
100
|
+
*/
|
|
101
|
+
// `extends HTMLElement` is evaluated at module scope, so a bare
|
|
102
|
+
// `import 'mailcraft-editor'` threw ReferenceError anywhere there is no DOM --
|
|
103
|
+
// an SSR render pass, or a Node test runner reaching for a named export. The
|
|
104
|
+
// stand-in keeps the module importable there; the element is only ever
|
|
105
|
+
// constructed by the browser, which always has the real base class.
|
|
106
|
+
const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
|
|
107
|
+
|
|
108
|
+
export class MailCraftEditor extends ElementBase {
|
|
109
|
+
static get observedAttributes() { return ['variables', 'locale', 'dir', 'theme', 'ui-font', 'accent', 'toolbar', 'footer']; }
|
|
110
|
+
|
|
111
|
+
constructor() {
|
|
112
|
+
super();
|
|
113
|
+
this.attachShadow({ mode: 'open' });
|
|
114
|
+
// Created here (not connectedCallback) so `.variables` and the document
|
|
115
|
+
// work even before the element is inserted into the document -- a common pattern
|
|
116
|
+
// (`document.createElement(...)`, configure, then append).
|
|
117
|
+
this.core = new EditorCore({
|
|
118
|
+
variables: this.hasAttribute('variables') ? this.getAttribute('variables') : undefined,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
connectedCallback() {
|
|
123
|
+
this.buildShell();
|
|
124
|
+
this.applyUiFont();
|
|
125
|
+
this.applyAccent();
|
|
126
|
+
this.applyDir();
|
|
127
|
+
this._toolbarKey = toolbarKey(this.toolbarConfig());
|
|
128
|
+
this.core.mount(this.shadowRoot);
|
|
129
|
+
// After mount() on purpose: mount restores the persisted chrome/doc, and
|
|
130
|
+
// a host-supplied `theme`/`locale` must win over what was persisted.
|
|
131
|
+
this.applyTheme();
|
|
132
|
+
this.refreshTranslator();
|
|
133
|
+
this.core.mountKeyboard((target) => {
|
|
134
|
+
const tag = (target && target.tagName || '').toLowerCase();
|
|
135
|
+
return tag === 'input' || tag === 'textarea' || (target && target.isContentEditable);
|
|
136
|
+
});
|
|
137
|
+
this.core.onFormatChange = () => this.scheduleRteRefresh();
|
|
138
|
+
this.core.onCodeSourceChange = () => this.refreshCodeSource();
|
|
139
|
+
this.core.onSavedChange = () => this.refreshSavedLabel();
|
|
140
|
+
this.core.onToast = () => this.refreshToast();
|
|
141
|
+
this.core.onTick = () => this.refreshCountdowns();
|
|
142
|
+
this.unsubscribe = this.core.subscribe(() => {
|
|
143
|
+
withFocusPreserved(this.shadowRoot, () => this.render());
|
|
144
|
+
this.dispatchEvent(new CustomEvent('change', { detail: this.core.state.doc }));
|
|
145
|
+
});
|
|
146
|
+
this.render();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
disconnectedCallback() {
|
|
150
|
+
this.unsubscribe?.();
|
|
151
|
+
this.core.onFormatChange = null;
|
|
152
|
+
this.core.onCodeSourceChange = null;
|
|
153
|
+
this.core.onSavedChange = null;
|
|
154
|
+
this.core.onToast = null;
|
|
155
|
+
this.core.onTick = null;
|
|
156
|
+
if (this._rteFrame != null) cancelAnimationFrame(this._rteFrame);
|
|
157
|
+
this._rteFrame = null;
|
|
158
|
+
this.core.unmount();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
attributeChangedCallback(name, _old, value) {
|
|
162
|
+
if (!this.core) return;
|
|
163
|
+
if (name === 'variables') { this.core.variablesRaw = value; this.core.emit(); }
|
|
164
|
+
if (name === 'locale' || name === 'dir') this.applyDir();
|
|
165
|
+
if (name === 'locale') this.refreshTranslator();
|
|
166
|
+
if (name === 'theme') this.applyTheme();
|
|
167
|
+
if (name === 'ui-font') this.applyUiFont();
|
|
168
|
+
// Forced: setting the attribute is also how a host asks for a re-derive
|
|
169
|
+
// after its own `--token` changed -- see applyAccent.
|
|
170
|
+
if (name === 'accent') this.applyAccent(true);
|
|
171
|
+
if (name === 'toolbar') this.applyToolbar();
|
|
172
|
+
if (name === 'footer') this.applyFooter();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Editor-chrome typography only. The default lives in `--ed-font`; a host
|
|
177
|
+
* may pass any valid CSS font-family string, or `inherit` to snapshot the
|
|
178
|
+
* host element's computed family. Email-document typography remains owned
|
|
179
|
+
* by the document theme and is deliberately unaffected.
|
|
180
|
+
*/
|
|
181
|
+
applyUiFont() {
|
|
182
|
+
if (!this.mc) return;
|
|
183
|
+
const requested = (this.getAttribute('ui-font') || '').trim();
|
|
184
|
+
if (!requested) {
|
|
185
|
+
this.mc.style.removeProperty('--ed-font');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const resolved = requested.toLowerCase() === 'inherit'
|
|
189
|
+
? getComputedStyle(this).fontFamily
|
|
190
|
+
: requested;
|
|
191
|
+
this.mc.style.setProperty('--ed-font', resolved || 'inherit');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolves the `accent` attribute to a plain color string. Three spellings,
|
|
196
|
+
* because a host's brand color lives in a different place in each app:
|
|
197
|
+
*
|
|
198
|
+
* accent="#e11d48" a literal color
|
|
199
|
+
* accent="var(--brand)" a design-system token, read off the host
|
|
200
|
+
* element (custom properties inherit through
|
|
201
|
+
* the shadow boundary, so the host's value is
|
|
202
|
+
* the one that resolves), with the optional
|
|
203
|
+
* `var(--brand, #fallback)` fallback honored
|
|
204
|
+
* accent="inherit" the host's computed `accent-color` -- the
|
|
205
|
+
* standard CSS property for exactly this, so a
|
|
206
|
+
* host that already sets it gets the editor for
|
|
207
|
+
* free
|
|
208
|
+
*
|
|
209
|
+
* Returns '' when there is nothing usable, which leaves the built-in accent
|
|
210
|
+
* in place rather than guessing.
|
|
211
|
+
*/
|
|
212
|
+
resolveAccentValue(requested) {
|
|
213
|
+
if (typeof getComputedStyle !== 'function') return requested;
|
|
214
|
+
const hostStyle = getComputedStyle(this);
|
|
215
|
+
if (requested.toLowerCase() === 'inherit') {
|
|
216
|
+
const declared = (hostStyle.accentColor || '').trim();
|
|
217
|
+
return !declared || declared === 'auto' ? '' : declared;
|
|
218
|
+
}
|
|
219
|
+
const ref = /^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)$/.exec(requested);
|
|
220
|
+
if (!ref) return requested;
|
|
221
|
+
return (hostStyle.getPropertyValue(ref[1]) || '').trim() || (ref[2] || '').trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Host brand color for the editor chrome. One attribute drives all four
|
|
226
|
+
* accent tokens (`core/accent.js` does the derivation) instead of asking a
|
|
227
|
+
* host to hand-pick a hover shade, a legible ink color and a wash alpha --
|
|
228
|
+
* and it re-derives per chrome, since the shade that reads on white panels
|
|
229
|
+
* is not the one that reads on the dark ones.
|
|
230
|
+
*
|
|
231
|
+
* Applied as inline custom properties on `#mc`: the stylesheet declares the
|
|
232
|
+
* defaults on that same element, so only an inline value can outrank them.
|
|
233
|
+
* Email-document colors are untouched -- those belong to the template, not
|
|
234
|
+
* to the app hosting the editor.
|
|
235
|
+
*
|
|
236
|
+
* `force` skips the memo. Nothing notifies an element that a CSS custom
|
|
237
|
+
* property it reads has changed, so a host whose own `--brand` moved (a
|
|
238
|
+
* brand switcher, a tenant theme) re-sets the attribute to the same string
|
|
239
|
+
* to ask for a re-derive -- and the memo would otherwise swallow that.
|
|
240
|
+
*/
|
|
241
|
+
applyAccent(force) {
|
|
242
|
+
if (!this.mc) return;
|
|
243
|
+
const requested = (this.getAttribute('accent') || '').trim();
|
|
244
|
+
const chrome = this.core.state?.chrome || 'light';
|
|
245
|
+
// Both halves matter: the same brand color resolves to different tokens in
|
|
246
|
+
// light and dark chrome, so a toggle has to re-derive.
|
|
247
|
+
const key = requested + '|' + chrome;
|
|
248
|
+
if (!force && key === this._accentKey) return;
|
|
249
|
+
this._accentKey = key;
|
|
250
|
+
const clear = () => ACCENT_VARS.forEach((name) => this.mc.style.removeProperty(name));
|
|
251
|
+
if (!requested) { clear(); return; }
|
|
252
|
+
|
|
253
|
+
const value = this.resolveAccentValue(requested);
|
|
254
|
+
// Named colors ("rebeccapurple") and anything else only a browser can
|
|
255
|
+
// resolve go through a throwaway probe: set it as a color, read back what
|
|
256
|
+
// the engine computed. Parsing first keeps the common case DOM-free.
|
|
257
|
+
const color = parseColor(value) || parseColor(this.probeColor(value));
|
|
258
|
+
if (!color) {
|
|
259
|
+
clear();
|
|
260
|
+
console.warn('[mailcraft] accent="' + requested + '" is not a usable color -- keeping the built-in accent.');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const tokens = accentTokens(color, chrome);
|
|
264
|
+
for (const [name, v] of Object.entries(tokens)) this.mc.style.setProperty(name, v);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Asks the engine what a color string computes to. Returns '' when there is no layout to ask (SSR, a test DOM) or the value was rejected. */
|
|
268
|
+
probeColor(value) {
|
|
269
|
+
if (!value || typeof getComputedStyle !== 'function' || typeof document === 'undefined') return '';
|
|
270
|
+
const probe = document.createElement('span');
|
|
271
|
+
probe.style.display = 'none';
|
|
272
|
+
probe.style.color = value;
|
|
273
|
+
// CSSOM drops a value it cannot parse, leaving `color` empty -- and a
|
|
274
|
+
// probe with no color of its own computes to the *inherited* one, which
|
|
275
|
+
// would sail through parseColor and repaint the chrome in --ed-text.
|
|
276
|
+
if (!probe.style.color) return '';
|
|
277
|
+
// A detached node computes to nothing in some engines -- park it inside
|
|
278
|
+
// the shadow root, where it can neither be seen nor affect layout.
|
|
279
|
+
this.mc.appendChild(probe);
|
|
280
|
+
const computed = (getComputedStyle(probe).color || '').trim();
|
|
281
|
+
probe.remove();
|
|
282
|
+
return computed;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Host-controlled chrome: a `theme` attribute ("light"/"dark") wins over
|
|
287
|
+
* whatever chrome was persisted, and while it's present the built-in
|
|
288
|
+
* light/dark toggle is hidden (see renderInner) -- the host owns the
|
|
289
|
+
* control, so the two can never fight. Removing the attribute hands
|
|
290
|
+
* control back to the built-in toggle.
|
|
291
|
+
*/
|
|
292
|
+
applyTheme() {
|
|
293
|
+
const v = this.getAttribute('theme');
|
|
294
|
+
if (v) this.core.setState({ chrome: v === 'dark' ? 'dark' : 'light' });
|
|
295
|
+
else if (this.core.state) this.core.emit();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolves the `locale` attribute to its shipped message table
|
|
300
|
+
* (core/i18n/tables.js) and overlays any host-set `.messages` on top --
|
|
301
|
+
* attribute picks the language, `.messages` stays the documented way to
|
|
302
|
+
* override individual strings. With no locale attribute this degrades to
|
|
303
|
+
* the old behavior: `.messages` alone over built-in English.
|
|
304
|
+
*/
|
|
305
|
+
refreshTranslator() {
|
|
306
|
+
const table = LOCALE_TABLES[this.getAttribute('locale')] || null;
|
|
307
|
+
const overrides = this.core.messages;
|
|
308
|
+
this.core.t = createTranslator(table ? Object.assign({}, table, overrides || {}) : overrides);
|
|
309
|
+
this.story?.retranslate();
|
|
310
|
+
this.core.emit();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it never affects rendered content, only the CSS `dir`). */
|
|
314
|
+
applyDir() {
|
|
315
|
+
if (!this.mc) return;
|
|
316
|
+
const explicit = this.getAttribute('dir');
|
|
317
|
+
this.mc.setAttribute('dir', explicit || (isRtl(this.getAttribute('locale')) ? 'rtl' : 'ltr'));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- public API ----------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Internal, and deliberately undocumented for hosts: the document object is
|
|
324
|
+
* this package's own representation, not a format anyone else should have to
|
|
325
|
+
* store, version or migrate. The host-facing contract is HTML in, HTML out
|
|
326
|
+
* (`loadTemplate({ html })` / `exportHtml()`), and exported HTML is valid
|
|
327
|
+
* input to the importer, so round-tripping through it is how a draft is
|
|
328
|
+
* saved and restored.
|
|
329
|
+
*
|
|
330
|
+
* These stay because the editor needs them internally (undo, autosave, the
|
|
331
|
+
* tests) and because a host that has genuinely outgrown HTML has somewhere
|
|
332
|
+
* to go -- but nothing in README.md points here, and the shape is free to
|
|
333
|
+
* change between versions.
|
|
334
|
+
*/
|
|
335
|
+
getContent() { return JSON.parse(JSON.stringify(this.core.state.doc)); }
|
|
336
|
+
setContent(doc) { this.core.loadDoc(doc); }
|
|
337
|
+
/** Parses arbitrary HTML (a full email, a fragment, MailCraft's own exported output) into native blocks wherever the shape is recognizable, replacing the current document. See `core/import-html.js` for the per-shape rules. */
|
|
338
|
+
importHtml(html) { return this.core.importHtml(html); }
|
|
339
|
+
get variables() { return this.core.vars(); }
|
|
340
|
+
set variables(value) { this.core.variablesRaw = value; this.core.emit(); }
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Which parts of the editor's own top bar are shown -- `false` for no bar at
|
|
344
|
+
* all, or an object switching individual parts off:
|
|
345
|
+
*
|
|
346
|
+
* editor.toolbar = false; // no bar
|
|
347
|
+
* editor.toolbar = { logo: false, ai: false }; // keep the rest
|
|
348
|
+
* <mailcraft-editor toolbar="none"> // markup, no bar
|
|
349
|
+
* <mailcraft-editor toolbar="undo,redo,export"> // markup, only these
|
|
350
|
+
*
|
|
351
|
+
* Switchable parts: logo, status, device, undo, redo, theme, ai, code,
|
|
352
|
+
* preview, export. Hiding a control does not remove the capability -- every
|
|
353
|
+
* one of them is also a method here, so a host that renders its own bar
|
|
354
|
+
* keeps all of it. Keyboard shortcuts are unaffected either way.
|
|
355
|
+
*
|
|
356
|
+
* The property wins over the attribute once set, the same way `.messages`
|
|
357
|
+
* wins over `locale`.
|
|
358
|
+
*/
|
|
359
|
+
get toolbar() { return this._toolbar !== undefined ? this._toolbar : (this.getAttribute('toolbar') || ''); }
|
|
360
|
+
set toolbar(value) { this._toolbar = value; this.applyToolbar(); }
|
|
361
|
+
|
|
362
|
+
/** The resolved `{ item: boolean }` map, or null when there is to be no bar. */
|
|
363
|
+
toolbarConfig() { return resolveToolbar(this._toolbar !== undefined ? this._toolbar : this.getAttribute('toolbar')); }
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Rebuilds the shell when, and only when, the resolved config actually
|
|
367
|
+
* changed. The header is built once (it is not part of the per-state render
|
|
368
|
+
* path), so switching a part on or off has to go through buildShell -- and
|
|
369
|
+
* that also re-creates every modal and the story viewer, which is not
|
|
370
|
+
* something to do on a set that changes nothing.
|
|
371
|
+
*/
|
|
372
|
+
applyToolbar() {
|
|
373
|
+
if (!this.core || !this.shadowRoot.firstChild) return;
|
|
374
|
+
const key = toolbarKey(this.toolbarConfig());
|
|
375
|
+
if (key === this._toolbarKey) return;
|
|
376
|
+
this._toolbarKey = key;
|
|
377
|
+
this.buildShell();
|
|
378
|
+
this.applyUiFont();
|
|
379
|
+
this.applyAccent();
|
|
380
|
+
this.applyDir();
|
|
381
|
+
this.applyTheme();
|
|
382
|
+
this.render();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The attribution strip: `false`/`"none"` removes it, a string replaces the
|
|
387
|
+
* line, `{ text, href, target }` gives it a link. Unset shows the built-in
|
|
388
|
+
* "Powered by SELISE Blocks" line, which follows `locale` and `.messages`
|
|
389
|
+
* (key `footer.poweredBy`) like every other label. See core/footer.js.
|
|
390
|
+
*/
|
|
391
|
+
get footer() { return this._footer !== undefined ? this._footer : (this.getAttribute('footer') || ''); }
|
|
392
|
+
set footer(value) { this._footer = value; this.applyFooter(); }
|
|
393
|
+
|
|
394
|
+
get uiFont() { return this.getAttribute('ui-font') || ''; }
|
|
395
|
+
set uiFont(value) {
|
|
396
|
+
if (value == null || String(value).trim() === '') this.removeAttribute('ui-font');
|
|
397
|
+
else this.setAttribute('ui-font', String(value));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The host's brand color for the editor chrome -- a CSS color, `var(--your-token)`, or `inherit` (the host's `accent-color`). Setting '' or null hands the chrome back to the built-in accent. */
|
|
401
|
+
get accent() { return this.getAttribute('accent') || ''; }
|
|
402
|
+
set accent(value) {
|
|
403
|
+
if (value == null || String(value).trim() === '') this.removeAttribute('accent');
|
|
404
|
+
else this.setAttribute('accent', String(value));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get aiProvider() { return this.core.aiProvider; }
|
|
408
|
+
set aiProvider(fn) { this.core.aiProvider = fn; }
|
|
409
|
+
|
|
410
|
+
/** UI string overrides -- an overlay on the built-in English table (or whichever locale table the host also passed in), never a replacement, so a partial translation shows English where it's incomplete rather than a gap. Import a locale from `core/i18n/<tag>.js`, or merge one with your own overrides via `defineMessages` (`core/i18n/index.js`). */
|
|
411
|
+
get messages() { return this.core.messages; }
|
|
412
|
+
set messages(value) { this.core.messages = value; this.refreshTranslator(); }
|
|
413
|
+
|
|
414
|
+
/** Optional `(platformKey, {label, size, color}) => Node` -- lets a host app supply its own social-icon art (e.g. brand SVGs) instead of the built-in set. Falls back to the built-in icon whenever it's unset, throws, or returns something that isn't a DOM node. */
|
|
415
|
+
get iconProvider() { return this.core.iconProvider; }
|
|
416
|
+
set iconProvider(fn) { this.core.iconProvider = fn; this.core.emit(); }
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Host-supplied file storage -- see `core/storage.js` for the contract, and
|
|
420
|
+
* README.md for a worked example. Setting one replaces the seeded demo
|
|
421
|
+
* library with the backend's own: folders, paging,
|
|
422
|
+
* upload and delete all go through it, and nothing is written to
|
|
423
|
+
* localStorage. Set `null` to go back to the built-in behaviour.
|
|
424
|
+
*/
|
|
425
|
+
get storageProvider() { return this.core.storageProvider; }
|
|
426
|
+
set storageProvider(provider) { this.core.setStorageProvider(provider); }
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
|
|
430
|
+
* Required alongside a provider and deliberately undefaulted -- the ceilings
|
|
431
|
+
* that suit an email template depend on the sending platform, the audience's
|
|
432
|
+
* mail clients and the host's own rules, none of which this package can know.
|
|
433
|
+
* See `core/storage-limits.js`.
|
|
434
|
+
*/
|
|
435
|
+
get storageLimits() { return this.core.storageLimits; }
|
|
436
|
+
set storageLimits(limits) { this.core.setStorageLimits(limits); }
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Applies one host-supplied template as an undoable edit. Template galleries
|
|
440
|
+
* are host UI -- the editor has no tab for them and ships no catalogue --
|
|
441
|
+
* so this is the whole seam: the host renders its own picker and pushes the
|
|
442
|
+
* chosen template in. `tpl` carries one of three sources: `doc` (a document
|
|
443
|
+
* object), `build()` (one made per use), or `html` (a raw email string, run
|
|
444
|
+
* through the importer). `examples/templates/` holds ready-made HTML ones.
|
|
445
|
+
* The other direction is `getContent()`, to capture the current document
|
|
446
|
+
* back out as a template of the host's own.
|
|
447
|
+
*/
|
|
448
|
+
loadTemplate(tpl) { this.core.loadTemplate(tpl); }
|
|
449
|
+
|
|
450
|
+
exportHtml() {
|
|
451
|
+
const html = this.core.buildHtml();
|
|
452
|
+
this.dispatchEvent(new CustomEvent('export', { detail: html }));
|
|
453
|
+
return html;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Full-template screenshot as a PNG Blob (2x resolution, desktop width, independent of the current device/zoom view). See render/screenshot.js for the technique and its limits. */
|
|
457
|
+
screenshotPng() { return captureTemplatePng(this.core, this.mc); }
|
|
458
|
+
|
|
459
|
+
/** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. Nothing touches the filesystem until the user picks Download inside it. */
|
|
460
|
+
previewScreenshot() { this.story.open(); }
|
|
461
|
+
|
|
462
|
+
/** `screenshotPng()` plus the same save-a-file flow as the HTML export download, with success/failure toasts. Pass an already-captured blob (the story viewer does) to save that one instead of rendering a second time. */
|
|
463
|
+
async downloadScreenshot(blob) {
|
|
464
|
+
const t = this.core.t;
|
|
465
|
+
try {
|
|
466
|
+
const png = blob || await this.screenshotPng();
|
|
467
|
+
const a = document.createElement('a');
|
|
468
|
+
a.href = URL.createObjectURL(png);
|
|
469
|
+
a.download = 'email.png';
|
|
470
|
+
a.click();
|
|
471
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1500);
|
|
472
|
+
this.core.flash(t('toast.pngSaved'));
|
|
473
|
+
} catch {
|
|
474
|
+
this.core.flash(t('toast.pngFailed'));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The screenshot onto the system clipboard as an image. Needs a secure context and `ClipboardItem`; where either is missing this reports the failure rather than appearing to succeed. */
|
|
479
|
+
async copyScreenshot(blob) {
|
|
480
|
+
const t = this.core.t;
|
|
481
|
+
try {
|
|
482
|
+
const png = blob || await this.screenshotPng();
|
|
483
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
|
|
484
|
+
this.core.flash(t('toast.pngCopied'));
|
|
485
|
+
} catch {
|
|
486
|
+
this.core.flash(t('toast.pngCopyFailed'));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
undo() { this.core.undo(); }
|
|
491
|
+
redo() { this.core.redo(); }
|
|
492
|
+
|
|
493
|
+
// ---- shell (built once) ---------------------------------------------
|
|
494
|
+
|
|
495
|
+
buildShell() {
|
|
496
|
+
const root = this.shadowRoot;
|
|
497
|
+
root.innerHTML = '';
|
|
498
|
+
root.appendChild(elS('style', '', { text: STYLE }));
|
|
499
|
+
|
|
500
|
+
// A rebuild throws away the `#mc` that carried the inline accent tokens,
|
|
501
|
+
// so the memo of what was last applied has to go with it.
|
|
502
|
+
this._accentKey = null;
|
|
503
|
+
this.mc = elS('div', 'height: 100%; padding: 16px; box-sizing: border-box; background: radial-gradient(circle at 8% 0%, var(--ed-soft), transparent 28%), var(--ed-bg); color: var(--ed-text); font-family: var(--ed-font); font-size: 14px; overflow-x: auto; overflow-y: hidden;', { id: 'mc' });
|
|
504
|
+
root.appendChild(this.mc);
|
|
505
|
+
|
|
506
|
+
const outer = elS('div', 'position: relative; height: 100%; min-width: 1180px;');
|
|
507
|
+
this.mc.appendChild(outer);
|
|
508
|
+
|
|
509
|
+
// Header nodes are re-created below (or not at all). Clearing them first
|
|
510
|
+
// means a rebuild that drops the bar cannot leave the refreshers pointing
|
|
511
|
+
// at detached nodes they would then keep writing to.
|
|
512
|
+
this.savedLabel = this.savedDot = this.deviceSeg = null;
|
|
513
|
+
this.undoBtn = this.redoBtn = this.chromeBtn = null;
|
|
514
|
+
this.aiBtn = this.codeBtn = this.previewBtn = this.exportBtn = null;
|
|
515
|
+
|
|
516
|
+
const bar = this.toolbarConfig();
|
|
517
|
+
this.frame = elS('div', 'position: relative; height: 100%; border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); background: var(--ed-panel); display: grid; grid-template-rows: ' + (bar ? '54px minmax(0, 1fr)' : 'minmax(0, 1fr)') + '; overflow: hidden; box-shadow: var(--ed-shadow-sm), var(--ed-shadow-md);', { class: 'mc-shell' + (bar ? '' : ' mc-no-header') });
|
|
518
|
+
outer.appendChild(this.frame);
|
|
519
|
+
|
|
520
|
+
if (bar) this.frame.appendChild(this.buildHeader(bar));
|
|
521
|
+
|
|
522
|
+
this.footerBar = this.footerText = this.footerLink = null;
|
|
523
|
+
|
|
524
|
+
this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0;', { class: 'mc-layout' });
|
|
525
|
+
this.frame.appendChild(this.body);
|
|
526
|
+
this.body.appendChild(this.buildMain());
|
|
527
|
+
this.body.appendChild(this.buildAside());
|
|
528
|
+
this.body.appendChild(this.buildFooter());
|
|
529
|
+
|
|
530
|
+
this.libraryModal = this.buildLibraryModal();
|
|
531
|
+
this.exportModal = this.buildExportModal();
|
|
532
|
+
this.codeModal = this.buildCodeModal();
|
|
533
|
+
this.aiModal = this.buildAiModal();
|
|
534
|
+
this.previewModal = this.buildPreviewModal();
|
|
535
|
+
// Not a state-driven modal like the others: the story viewer is opened
|
|
536
|
+
// imperatively and runs its own timers, so an editor re-render behind it
|
|
537
|
+
// (an autosave, a toast) can never interrupt playback mid-screen.
|
|
538
|
+
this.story = createStoryViewer(this.core, {
|
|
539
|
+
capture: () => this.screenshotPng(),
|
|
540
|
+
download: (blob) => this.downloadScreenshot(blob),
|
|
541
|
+
copy: (blob) => this.copyScreenshot(blob),
|
|
542
|
+
});
|
|
543
|
+
this.toastEl = elS('div', "position: absolute; bottom: 74px; left: 50%; transform: translateX(-50%); z-index: 80; background: var(--ed-text); color: var(--ed-bg); padding: 9px 16px; border-radius: var(--ed-radius-sm); font-family: ui-monospace, monospace; font-size: 10.5px; letter-spacing: 0.08em; box-shadow: var(--ed-shadow-md); animation: mcIn 0.18s ease; display: none;", { class: 'mc-toast' });
|
|
544
|
+
outer.append(this.libraryModal, this.exportModal, this.codeModal, this.aiModal, this.previewModal, this.story.node, this.toastEl);
|
|
545
|
+
|
|
546
|
+
this.fileInput = elS('input', 'display: none;', { type: 'file', accept: 'image/*', multiple: 'true' });
|
|
547
|
+
this.fileInput.addEventListener('change', (e) => { this.core.addFiles(e.target.files); e.target.value = ''; });
|
|
548
|
+
outer.appendChild(this.fileInput);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---- header -----------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
buildHeader(on) {
|
|
554
|
+
const t = this.core.t;
|
|
555
|
+
const header = elS('header', "display: flex; align-items: center; gap: 14px; padding: 0 16px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2)); position: relative; z-index: 30;", { class: 'mc-header' });
|
|
556
|
+
|
|
557
|
+
const brandMark = elS('span', '', { class: 'mc-brand-mark' });
|
|
558
|
+
const markSvg = icon('mailSpark', 20);
|
|
559
|
+
markSvg.setAttribute('stroke-width', '1.8');
|
|
560
|
+
brandMark.appendChild(markSvg);
|
|
561
|
+
const brand = elS('div', 'display: flex; align-items: center; gap: 9px;');
|
|
562
|
+
brand.append(
|
|
563
|
+
brandMark,
|
|
564
|
+
elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; letter-spacing: -0.01em; line-height: 1;', { text: 'MailCraft', class: 'mc-brand-name' }),
|
|
565
|
+
);
|
|
566
|
+
if (on.logo) header.append(brand, elS('div', 'width: 1px; height: 22px; background: var(--ed-line);'));
|
|
567
|
+
|
|
568
|
+
this.savedLabel = elS('span', 'display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
|
|
569
|
+
// No infinite pulse: a forever-running animation keeps the compositor
|
|
570
|
+
// producing frames while the editor is idle -- constant CPU on low-end
|
|
571
|
+
// hardware for a decorative effect. A solid dot still reads "autosave on".
|
|
572
|
+
this.savedDot = elS('span', 'width: 5px; height: 5px; border-radius: 50%; background: var(--ed-accent);');
|
|
573
|
+
this.savedLabel.appendChild(this.savedDot);
|
|
574
|
+
this.savedLabel.appendChild(document.createTextNode(''));
|
|
575
|
+
if (on.status) header.appendChild(this.savedLabel);
|
|
576
|
+
else this.savedLabel = this.savedDot = null;
|
|
577
|
+
|
|
578
|
+
header.appendChild(elS('div', 'flex: 1;'));
|
|
579
|
+
|
|
580
|
+
if (on.device) {
|
|
581
|
+
this.deviceSeg = elS('div', '', { class: 'mc-segment mc-device-segment' });
|
|
582
|
+
header.appendChild(this.deviceSeg);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const iconButtons = elS('div', 'display: flex; align-items: center; gap: 3px;');
|
|
586
|
+
if (on.undo) this.undoBtn = this.iconBtn('undo', t('action.undoHint'), () => this.core.undo(), 'action.undoHint');
|
|
587
|
+
if (on.redo) this.redoBtn = this.iconBtn('redo', t('action.redoHint'), () => this.core.redo(), 'action.redoHint');
|
|
588
|
+
if (on.theme) this.chromeBtn = this.labelIconBtn('moon', t('action.chromeToDark'), t('action.chromeHint'), () => {
|
|
589
|
+
const chrome = this.core.state.chrome === 'light' ? 'dark' : 'light';
|
|
590
|
+
this.core.setState({ chrome }, () => this.core.persist(null, null, chrome));
|
|
591
|
+
}, 'action.chromeHint');
|
|
592
|
+
if (this.chromeBtn) {
|
|
593
|
+
this.chromeBtn.i18nTipKey = this.core.state.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
|
|
594
|
+
this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
|
|
595
|
+
}
|
|
596
|
+
if (on.ai) this.aiBtn = this.labelIconBtn('spark', t('action.aiDraft'), t('action.aiDraftHint'), () => this.core.setState({ aiOpen: true }), 'action.aiDraftHint', 'action.aiDraft');
|
|
597
|
+
if (on.code) this.codeBtn = this.labelIconBtn('code', t('action.code'), t('action.codeHint'), () => this.core.openCode(), 'action.codeHint', 'action.code');
|
|
598
|
+
if (on.preview) this.previewBtn = this.labelIconBtn('eye', t('action.preview'), t('action.previewHint') || '', () => this.core.setState({ previewOpen: true }), 'action.previewHint', 'action.preview');
|
|
599
|
+
if (on.export) this.exportBtn = this.labelIconBtn('download', t('action.export'), t('action.exportHint'), () => this.core.openExport(), 'action.exportHint', 'action.export');
|
|
600
|
+
|
|
601
|
+
const actions = [this.undoBtn, this.redoBtn, this.chromeBtn, this.aiBtn, this.codeBtn, this.previewBtn, this.exportBtn].filter(Boolean);
|
|
602
|
+
if (actions.length) {
|
|
603
|
+
iconButtons.append(...actions);
|
|
604
|
+
header.appendChild(iconButtons);
|
|
605
|
+
}
|
|
606
|
+
return header;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** `titleKey` (when given) is written as `data-i18n-title` so `refreshStrings()` keeps the tooltip in sync with the active translator after `.messages` changes post-mount -- `title` itself only sets the initial value. */
|
|
610
|
+
iconBtn(name, title, onClick, titleKey) {
|
|
611
|
+
const btn = elS('button', 'border: 1px solid var(--ed-line); background: var(--ed-panel); color: var(--ed-text); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title, 'data-i18n-title': titleKey || undefined, class: 'mc-icon-button' });
|
|
612
|
+
btn.appendChild(icon(name, 15));
|
|
613
|
+
btn.addEventListener('mouseenter', () => { if (!btn.disabled) { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; } });
|
|
614
|
+
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel)'; });
|
|
615
|
+
btn.addEventListener('click', onClick);
|
|
616
|
+
tip(btn, title, 'down');
|
|
617
|
+
return btn;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Always creates a real text node for the label (even if empty) and exposes it as `btn.labelNode`, so a caller that updates the label later never has to guess at `lastChild` -- guessing wrong there once already corrupted the chrome-toggle icon (its `lastChild` was the `<svg>`, and `.textContent = ...` on it wiped out the icon's paths).
|
|
622
|
+
* `labelKey`/`titleKey` (when given) are stashed on the button itself (not as `data-i18n`, since a generic textContent sweep would wipe the icon the same way `lastChild` guessing used to) so `refreshStrings()` can target `labelNode`/`title` directly.
|
|
623
|
+
*/
|
|
624
|
+
labelIconBtn(iconName, label, title, onClick, titleKey, labelKey) {
|
|
625
|
+
const btn = elS('button', 'border: 1px solid var(--ed-line); background: var(--ed-panel); color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 10px; display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: border-color 0.16s, background 0.16s;', { type: 'button', title, class: 'mc-icon-label' });
|
|
626
|
+
btn.i18nTitleKey = titleKey || null;
|
|
627
|
+
btn.i18nLabelKey = labelKey || null;
|
|
628
|
+
if (iconName) {
|
|
629
|
+
btn.iconNode = icon(iconName, 14);
|
|
630
|
+
btn.iconName = iconName;
|
|
631
|
+
btn.appendChild(btn.iconNode);
|
|
632
|
+
}
|
|
633
|
+
btn.labelNode = document.createTextNode(label || '');
|
|
634
|
+
btn.appendChild(btn.labelNode);
|
|
635
|
+
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
|
|
636
|
+
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel)'; });
|
|
637
|
+
btn.addEventListener('click', onClick);
|
|
638
|
+
return tip(btn, title, 'down');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---- shared panel-design helpers (flat-accordion system) ----------------
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Flat-accordion section chrome, identical to the Design/Settings tabs'
|
|
645
|
+
* (render/fields.js renderFieldCards): a full-width uppercase bar and a
|
|
646
|
+
* body directly on the panel surface. This replaced the floating white
|
|
647
|
+
* cards on a gray gutter -- three panel languages across the tabs read as
|
|
648
|
+
* three different products, and the card-in-card nesting was exactly the
|
|
649
|
+
* boxes-in-boxes the inspector redesign removed. `right` (element or
|
|
650
|
+
* string) sits flush right for counts/meta. The mc-section-* classes keep
|
|
651
|
+
* these restylable panel-wide (render/style.js).
|
|
652
|
+
*/
|
|
653
|
+
sectionBar(label, right) {
|
|
654
|
+
const bar = elS('div', 'display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 11px 14px; background: var(--ed-panel-2); border-top: 1px solid var(--ed-line); border-bottom: 1px solid var(--ed-line); user-select: none;', { class: 'mc-section-bar' });
|
|
655
|
+
bar.appendChild(elS('span', 'font-family: var(--ed-font); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-muted);', { text: label, class: 'mc-section-label' }));
|
|
656
|
+
if (right) bar.appendChild(typeof right === 'string' ? elS('span', 'font-size: 10px; font-weight: 600; color: var(--ed-faint); flex: none;', { text: right }) : right);
|
|
657
|
+
return bar;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The section's content surface, matching renderFieldCards' body. */
|
|
661
|
+
sectionBody(extraStyle) {
|
|
662
|
+
return elS('div', 'padding: 12px 14px 16px; background: var(--ed-panel);' + (extraStyle || ''), { class: 'mc-section-body' });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Every non-field tab shares this shell: white panel to the bottom, sections stacked flush. */
|
|
666
|
+
tabSurface() {
|
|
667
|
+
return elS('div', 'background: var(--ed-panel); min-height: 100%; padding-bottom: 28px;', { class: 'mc-tab-surface' });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The soft accent action chip -- the panel's secondary button (field `btn` fields share the recipe in render/fields.js). */
|
|
671
|
+
softBtn(label, iconName, onClick) {
|
|
672
|
+
const btn = elS('button', 'width: 100%; border: 0; border-radius: 8px; background: var(--ed-soft); color: var(--ed-accent); cursor: pointer; padding: 8px 10px; display: flex; align-items: center; justify-content: center; gap: 6px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, color 0.16s;', { type: 'button' });
|
|
673
|
+
if (iconName) btn.appendChild(icon(iconName, 13));
|
|
674
|
+
btn.appendChild(elS('span', '', { text: label }));
|
|
675
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent-ink)'; });
|
|
676
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; });
|
|
677
|
+
btn.addEventListener('click', onClick);
|
|
678
|
+
return btn;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Show/hide the toast -- driven surgically by core.onToast so its 1.9s
|
|
682
|
+
* auto-dismissal never costs (or interrupts anything with) a full render. */
|
|
683
|
+
refreshToast() {
|
|
684
|
+
const s = this.core.state;
|
|
685
|
+
this.toastEl.style.display = s.toast ? 'block' : 'none';
|
|
686
|
+
this.toastEl.textContent = s.toast || '';
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Repaint just the countdown digits on the canvas -- driven by the core's
|
|
690
|
+
* 1s tick (core.onTick), which used to re-render the whole editor. */
|
|
691
|
+
refreshCountdowns() {
|
|
692
|
+
const now = this.core.state.now;
|
|
693
|
+
this.canvasSlot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
|
|
694
|
+
const ms = Math.max(0, new Date(wrap.getAttribute('data-mc-countdown')).getTime() - now);
|
|
695
|
+
const vals = { days: Math.floor(ms / 86400000), hrs: Math.floor(ms / 3600000) % 24, min: Math.floor(ms / 60000) % 60, sec: Math.floor(ms / 1000) % 60 };
|
|
696
|
+
wrap.querySelectorAll('[data-mc-count]').forEach((n) => {
|
|
697
|
+
n.textContent = String(vals[n.getAttribute('data-mc-count')] ?? 0).padStart(2, '0');
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The header's "Saved HH:MM" label -- autosave updates it through
|
|
703
|
+
* core.onSavedChange, surgically: it is the only thing autosave changes
|
|
704
|
+
* on screen, and a full render for it destroyed open dropdowns. */
|
|
705
|
+
refreshSavedLabel() {
|
|
706
|
+
if (!this.savedLabel) return;
|
|
707
|
+
const s = this.core.state; const t = this.core.t;
|
|
708
|
+
this.savedLabel.lastChild.textContent = s.savedStatus === 'error' ? t('status.saveFailed')
|
|
709
|
+
: s.savedStatus === 'saved' ? t('status.saved', { time: s.savedAt }) : t('status.autosaveOn');
|
|
710
|
+
this.savedDot.style.background = s.savedStatus === 'error' ? 'var(--ed-danger)' : s.savedStatus === 'saved' ? 'var(--ed-success)' : 'var(--ed-accent)';
|
|
711
|
+
this.savedLabel.style.color = s.savedStatus === 'error' ? 'var(--ed-danger)' : 'var(--ed-faint)';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Refresh only the floating rich-text toolbar after a caret/format change.
|
|
715
|
+
* The editable block itself remains mounted, so native selection, IME,
|
|
716
|
+
* pointer capture and scroll position stay untouched. */
|
|
717
|
+
scheduleRteRefresh() {
|
|
718
|
+
if (this._rteFrame != null) return;
|
|
719
|
+
this._rteFrame = requestAnimationFrame(() => {
|
|
720
|
+
this._rteFrame = null;
|
|
721
|
+
if (this.core.rendering || !this.core.state.editing) return;
|
|
722
|
+
const current = this.canvasSlot?.querySelector('[data-mc-rte="1"]');
|
|
723
|
+
const found = this.core.find(this.core.state.doc, this.core.state.editing);
|
|
724
|
+
if (!current || !found.block) return;
|
|
725
|
+
// Skip the rebuild when nothing the toolbar displays changed -- see
|
|
726
|
+
// formatFingerprint (editor-core.js). A drag selection fires this every
|
|
727
|
+
// frame, and rebuilding the toolbar each frame stuttered the selection.
|
|
728
|
+
const print = this.core.formatFingerprint(found.block);
|
|
729
|
+
if (print === this._rtePrint) return;
|
|
730
|
+
this._rtePrint = print;
|
|
731
|
+
current.replaceWith(renderRte(this.core, found.block));
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ---- footer (attribution strip) -----------------------------------------
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Built once with the rest of the shell, then only refreshed -- like the
|
|
739
|
+
* header, it is not part of the state-driven render tree. Both a plain
|
|
740
|
+
* `<span>` and an `<a>` are created up front and toggled in `refreshFooter`:
|
|
741
|
+
* a host can add or drop a link at runtime, and swapping tag names mid-flight
|
|
742
|
+
* would strand whichever node the previous render had left in place.
|
|
743
|
+
*/
|
|
744
|
+
buildFooter() {
|
|
745
|
+
this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; border-top: 1px solid var(--ed-line); background: var(--ed-panel-2); color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
|
|
746
|
+
this.footerText = elS('span', '', { class: 'mc-footer-text' });
|
|
747
|
+
this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
|
|
748
|
+
this.footerBar.appendChild(this.footerText);
|
|
749
|
+
this.footerBar.appendChild(this.footerLink);
|
|
750
|
+
return this.footerBar;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** The resolved `{ text, href, target }`, or null when there is to be no strip. */
|
|
754
|
+
footerConfig() { return resolveFooter(this._footer !== undefined ? this._footer : this.getAttribute('footer')); }
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Text, link and visibility in one pass, run every render: it is three
|
|
758
|
+
* property writes, and memoizing it would have to account for the
|
|
759
|
+
* translator too -- the default line follows `locale`/`.messages`, so a
|
|
760
|
+
* config-shaped memo would freeze the wrong string.
|
|
761
|
+
*/
|
|
762
|
+
refreshFooter() {
|
|
763
|
+
if (!this.footerBar) return;
|
|
764
|
+
const cfg = this.footerConfig();
|
|
765
|
+
if (!cfg) { this.footerBar.style.display = 'none'; return; }
|
|
766
|
+
this.footerBar.style.display = 'flex';
|
|
767
|
+
this.footerBar.style.width = (this.core.state.device === 'mobile' ? 375 : this.core.state.doc.theme.width) + 'px';
|
|
768
|
+
|
|
769
|
+
const text = cfg.text == null ? this.core.t('footer.poweredBy') : cfg.text;
|
|
770
|
+
const linked = !!cfg.href;
|
|
771
|
+
this.footerText.style.display = linked ? 'none' : 'inline';
|
|
772
|
+
this.footerLink.style.display = linked ? 'inline' : 'none';
|
|
773
|
+
// The idle node is emptied, not just hidden: display:none still counts
|
|
774
|
+
// toward textContent, and a stale line there would reach anything reading
|
|
775
|
+
// the strip -- a screen reader's text pass included.
|
|
776
|
+
this.footerText.textContent = linked ? '' : text;
|
|
777
|
+
this.footerLink.textContent = linked ? text : '';
|
|
778
|
+
if (linked) {
|
|
779
|
+
this.footerLink.setAttribute('href', cfg.href);
|
|
780
|
+
// A footer link leaves the editor, and the editor is embedded in the
|
|
781
|
+
// host's page -- opening in place would take the user's unsaved work
|
|
782
|
+
// with it. `rel` goes with the new tab, not as an afterthought.
|
|
783
|
+
this.footerLink.setAttribute('target', cfg.target || '_blank');
|
|
784
|
+
this.footerLink.setAttribute('rel', 'noopener noreferrer');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Re-reads the config after an attribute or property change. Never rebuilds: the strip is one node whose contents are cheap to rewrite. */
|
|
789
|
+
applyFooter() { this.refreshFooter(); }
|
|
790
|
+
|
|
791
|
+
// ---- main (canvas) ------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
buildMain() {
|
|
794
|
+
const main = elS('main', '', { class: 'mc-workspace' });
|
|
795
|
+
this.mainEl = main;
|
|
796
|
+
main.style.cssText = 'grid-column: 1; grid-row: 1; position: relative; overflow-y: auto; min-height: 0;';
|
|
797
|
+
main.addEventListener('click', () => { if (this.core.state.sel) this.core.setState({ sel: null }); });
|
|
798
|
+
|
|
799
|
+
const pad = elS('div', 'padding: 28px 40px 150px; display: flex; justify-content: center;', { class: 'mc-canvas-stage' });
|
|
800
|
+
this.zoomFrame = elS('div', 'position: relative; transform-origin: top center; transition: transform 0.22s cubic-bezier(0.22,0.61,0.36,1);');
|
|
801
|
+
this.canvasSlot = elS('div', '', { class: 'mc-sheet-wrap' });
|
|
802
|
+
this.zoomFrame.appendChild(this.canvasSlot);
|
|
803
|
+
pad.appendChild(this.zoomFrame);
|
|
804
|
+
main.appendChild(pad);
|
|
805
|
+
|
|
806
|
+
const dock = elS('div', 'position: sticky; bottom: 0; display: flex; justify-content: center; pointer-events: none; padding-bottom: 16px;');
|
|
807
|
+
const pill = elS('div', 'pointer-events: auto; display: flex; align-items: center; gap: 12px; border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); background: var(--ed-panel); padding: 6px 8px 6px 12px; box-shadow: var(--ed-shadow-md);', { class: 'mc-zoom' });
|
|
808
|
+
this.canvasMetaEl = elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
|
|
809
|
+
this.zoomSeg = elS('div', 'display: flex; gap: 1px;');
|
|
810
|
+
pill.append(this.canvasMetaEl, elS('span', 'width: 1px; height: 18px; background: var(--ed-line);'), this.zoomSeg);
|
|
811
|
+
dock.appendChild(pill);
|
|
812
|
+
main.appendChild(dock);
|
|
813
|
+
return main;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ---- aside (tabbed inspector) -------------------------------------------
|
|
817
|
+
|
|
818
|
+
buildAside() {
|
|
819
|
+
const aside = elS('aside', 'grid-column: 2; grid-row: 1 / span 2; border-left: 1px solid var(--ed-line); background: var(--ed-panel); display: grid; grid-template-rows: auto 1fr; min-height: 0;', { class: 'mc-inspector' });
|
|
820
|
+
this.tabBar = elS('div', 'display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr);', { class: 'mc-tabbar', role: 'tablist' });
|
|
821
|
+
aside.appendChild(this.tabBar);
|
|
822
|
+
// overflow-x hidden: `overflow-y: auto` alone computes overflow-x to
|
|
823
|
+
// auto, so anything a pixel too wide (a tooltip's hidden box, a long
|
|
824
|
+
// unbreakable value) grew a horizontal scrollbar under the panel.
|
|
825
|
+
this.tabBody = elS('div', 'overflow-y: auto; overflow-x: hidden; min-height: 0;');
|
|
826
|
+
aside.appendChild(this.tabBody);
|
|
827
|
+
return aside;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---- per-state render ---------------------------------------------------
|
|
831
|
+
|
|
832
|
+
render() {
|
|
833
|
+
// The RTE-edited block's own props (e.g. `props.html`) only update on
|
|
834
|
+
// blur (see `blockCtx.onBlur`, canvas.js) -- that's what lets typing stay
|
|
835
|
+
// fast (no per-keystroke commit/undo-history entry). But a render still
|
|
836
|
+
// rebuilds that block's DOM node from `props.html` regardless, and
|
|
837
|
+
// renders can happen while an edit is active (for example when another
|
|
838
|
+
// editor control changes) -- so without syncing first, the next
|
|
839
|
+
// keystroke's re-render would blow away whatever was just typed, reverting
|
|
840
|
+
// it to the last-committed (pre-edit) value. Sync copies the live,
|
|
841
|
+
// uncommitted DOM content into the model right before the rebuild reads
|
|
842
|
+
// it -- a plain mutation, not `setProp`, so it doesn't push an undo entry.
|
|
843
|
+
this.syncLiveEdit();
|
|
844
|
+
// Marks the canvas rebuild below so `blockCtx.onBlur` (canvas.js) can tell
|
|
845
|
+
// a real blur apart from the spurious one fired when this method removes
|
|
846
|
+
// the still-focused block node while tearing down `canvasSlot`.
|
|
847
|
+
this.core.rendering = true;
|
|
848
|
+
// The full render below rebuilds the RTE toolbar on its own path, so the
|
|
849
|
+
// fingerprint that lets scheduleRteRefresh skip no-op rebuilds is stale.
|
|
850
|
+
this._rtePrint = null;
|
|
851
|
+
// The workspace <main> and the inspector tab body are the two scroll
|
|
852
|
+
// containers that survive a render (built once in buildShell) -- but the
|
|
853
|
+
// rebuild below can still zero them: if anything forces synchronous
|
|
854
|
+
// layout while a slot sits momentarily empty, the container's
|
|
855
|
+
// scrollHeight collapses and the browser clamps its scrollTop to 0, and
|
|
856
|
+
// re-appending the new tree never scrolls back. On a long template that
|
|
857
|
+
// read as "click a text block (or touch the RTE at all) and the canvas
|
|
858
|
+
// yanks to the top". Snapshot both here and put them back once the new
|
|
859
|
+
// tree is in place -- focus restoration (focus-preserve.js) already uses
|
|
860
|
+
// `preventScroll`, so nothing later in the same render fights this.
|
|
861
|
+
const mainTop = this.mainEl ? this.mainEl.scrollTop : 0;
|
|
862
|
+
const mainLeft = this.mainEl ? this.mainEl.scrollLeft : 0;
|
|
863
|
+
const tabBefore = this._renderedTab;
|
|
864
|
+
const tabTop = this.tabBody ? this.tabBody.scrollTop : 0;
|
|
865
|
+
try {
|
|
866
|
+
this.renderInner();
|
|
867
|
+
} finally {
|
|
868
|
+
this.core.rendering = false;
|
|
869
|
+
}
|
|
870
|
+
if (this.mainEl) {
|
|
871
|
+
if (this.mainEl.scrollTop !== mainTop) this.mainEl.scrollTop = mainTop;
|
|
872
|
+
if (this.mainEl.scrollLeft !== mainLeft) this.mainEl.scrollLeft = mainLeft;
|
|
873
|
+
}
|
|
874
|
+
// A genuine tab switch should start at the top -- only same-tab rebuilds
|
|
875
|
+
// (e.g. per-keystroke commits from a Design-tab field) restore.
|
|
876
|
+
if (this.tabBody && this.core.state.tab === tabBefore && this.tabBody.scrollTop !== tabTop) this.tabBody.scrollTop = tabTop;
|
|
877
|
+
this._renderedTab = this.core.state.tab;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
syncLiveEdit() {
|
|
881
|
+
const c = this.core;
|
|
882
|
+
if (!c.state.editing || !c.editEl || !c.editEl.isConnected || !c.editKey) return;
|
|
883
|
+
const found = c.find(c.state.doc, c.state.editing);
|
|
884
|
+
if (!found.block) return;
|
|
885
|
+
const val = c.editPlain ? c.editEl.textContent : c.editEl.innerHTML;
|
|
886
|
+
if (val !== found.block.props[c.editKey]) found.block.props[c.editKey] = val;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Refreshes every static header/modal string tagged `data-i18n*` at build time, plus the handful of labelIconBtn buttons whose label lives in a `labelNode` text node (a generic textContent sweep would wipe their icon -- see `labelIconBtn`'s doc comment). Runs every render (cheap: property assignments only, no rebuild) so a `.messages`/`.locale` change after mount relabels chrome that was only ever built once. */
|
|
890
|
+
refreshStrings() {
|
|
891
|
+
const t = this.core.t;
|
|
892
|
+
const root = this.shadowRoot;
|
|
893
|
+
root.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.getAttribute('data-i18n')); });
|
|
894
|
+
root.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.getAttribute('data-i18n-title')); });
|
|
895
|
+
root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); });
|
|
896
|
+
[this.aiBtn, this.codeBtn, this.previewBtn, this.exportBtn].forEach((btn) => {
|
|
897
|
+
if (!btn) return;
|
|
898
|
+
if (btn.i18nTitleKey) btn.title = t(btn.i18nTitleKey);
|
|
899
|
+
if (btn.i18nLabelKey) btn.labelNode.textContent = t(btn.i18nLabelKey);
|
|
900
|
+
if (btn.i18nTitleKey && btn.tipNode) btn.tipNode.textContent = t(btn.i18nTitleKey);
|
|
901
|
+
});
|
|
902
|
+
if (this.chromeBtn?.i18nTitleKey) this.chromeBtn.title = t(this.chromeBtn.i18nTitleKey);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
renderInner() {
|
|
906
|
+
const s = this.core.state;
|
|
907
|
+
const t = this.core.t;
|
|
908
|
+
this.refreshStrings();
|
|
909
|
+
|
|
910
|
+
this.mc.dataset.chrome = s.chrome;
|
|
911
|
+
// Re-derives only when the brand color or the chrome actually changed --
|
|
912
|
+
// the accent that clears contrast on white panels is not the one that
|
|
913
|
+
// clears it on the dark palette.
|
|
914
|
+
this.applyAccent();
|
|
915
|
+
this.refreshFooter();
|
|
916
|
+
this.refreshSavedLabel();
|
|
917
|
+
|
|
918
|
+
if (this.undoBtn) this.undoBtn.disabled = !s.history.length;
|
|
919
|
+
if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
|
|
920
|
+
// A host-supplied `theme` attribute owns light/dark (see applyTheme) --
|
|
921
|
+
// hide the built-in toggle rather than let it fight the host's control.
|
|
922
|
+
if (this.chromeBtn) {
|
|
923
|
+
this.chromeBtn.style.display = this.hasAttribute('theme') ? 'none' : 'flex';
|
|
924
|
+
this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
|
|
925
|
+
this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
|
|
926
|
+
this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
|
|
927
|
+
this.chromeBtn.setAttribute('aria-label', t(this.chromeBtn.i18nTipKey));
|
|
928
|
+
this.chromeBtn.title = t(this.chromeBtn.i18nTipKey);
|
|
929
|
+
const chromeIcon = s.chrome === 'light' ? 'moon' : 'sun';
|
|
930
|
+
if (this.chromeBtn.iconName !== chromeIcon) {
|
|
931
|
+
const nextIcon = icon(chromeIcon, 14);
|
|
932
|
+
this.chromeBtn.iconNode.replaceWith(nextIcon);
|
|
933
|
+
this.chromeBtn.iconNode = nextIcon;
|
|
934
|
+
this.chromeBtn.iconName = chromeIcon;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
this.renderDeviceSeg();
|
|
939
|
+
this.renderZoomSeg();
|
|
940
|
+
|
|
941
|
+
this.zoomFrame.style.transform = `scale(${s.zoom})`;
|
|
942
|
+
// Build the new canvas tree *before* detaching the old one, and swap the
|
|
943
|
+
// two atomically (`replaceChildren` evaluates its argument first): while
|
|
944
|
+
// a block is being edited, `renderDoc` builds the RTE toolbar mid-tree,
|
|
945
|
+
// and its `document.queryCommandState` calls force a synchronous layout.
|
|
946
|
+
// With the previous `innerHTML = ''`-then-append order, that layout ran
|
|
947
|
+
// while the slot was empty -- the workspace's scrollHeight collapsed for
|
|
948
|
+
// an instant and the browser clamped its scrollTop to 0, which is what
|
|
949
|
+
// scrolled a long template to the top on every RTE interaction.
|
|
950
|
+
this.canvasSlot.replaceChildren(renderDoc(this.core, true));
|
|
951
|
+
|
|
952
|
+
const blockCount = s.doc.rows.reduce((n, r) => n + r.cols.reduce((m, c) => m + c.blocks.length, 0), 0);
|
|
953
|
+
this.canvasMetaEl.textContent = t('canvas.meta', { rows: s.doc.rows.length, blocks: blockCount, width: s.device === 'mobile' ? 375 : s.doc.theme.width });
|
|
954
|
+
|
|
955
|
+
this.renderTabs();
|
|
956
|
+
this.renderTabBody();
|
|
957
|
+
|
|
958
|
+
this.renderLibraryModal();
|
|
959
|
+
this.renderExportModal();
|
|
960
|
+
this.renderCodeModal();
|
|
961
|
+
this.renderAiModal();
|
|
962
|
+
this.renderPreviewModal(false);
|
|
963
|
+
|
|
964
|
+
this.refreshToast();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
segBg(on) { return on ? 'var(--ed-accent)' : 'transparent'; }
|
|
968
|
+
segFg(on) { return on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)'; }
|
|
969
|
+
|
|
970
|
+
renderDeviceSeg() {
|
|
971
|
+
if (!this.deviceSeg) return;
|
|
972
|
+
const s = this.core.state;
|
|
973
|
+
const t = this.core.t;
|
|
974
|
+
this.deviceSeg.innerHTML = '';
|
|
975
|
+
[
|
|
976
|
+
{ label: t('device.desktop'), title: t('device.desktopHint'), v: 'desktop', iconName: 'monitor' },
|
|
977
|
+
{ label: t('device.mobile'), title: t('device.mobileHint'), v: 'mobile', iconName: 'phone' },
|
|
978
|
+
].forEach((d) => {
|
|
979
|
+
const on = s.device === d.v;
|
|
980
|
+
const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)}; transition: background 0.16s, color 0.16s;`, { type: 'button', title: d.title, 'aria-pressed': String(on) });
|
|
981
|
+
btn.appendChild(icon(d.iconName, 15));
|
|
982
|
+
btn.addEventListener('mouseenter', () => { if (!on) { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; } else { btn.style.background = 'var(--ed-accent-strong)'; } });
|
|
983
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = this.segBg(on); btn.style.color = this.segFg(on); });
|
|
984
|
+
btn.addEventListener('click', () => this.core.setState({ device: d.v }));
|
|
985
|
+
tip(btn, d.label, 'down');
|
|
986
|
+
this.deviceSeg.appendChild(btn);
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
renderZoomSeg() {
|
|
991
|
+
const s = this.core.state;
|
|
992
|
+
this.zoomSeg.innerHTML = '';
|
|
993
|
+
[0.75, 1, 1.25].forEach((z) => {
|
|
994
|
+
const on = s.zoom === z;
|
|
995
|
+
const btn = elS('button', `border: 0; border-radius: 7px; cursor: pointer; background: ${this.segBg(on)}; color: ${this.segFg(on)}; font-family: var(--ed-font); font-size: 10px; font-weight: 600; padding: 5px 9px; transition: background 0.16s, color 0.16s;`, { type: 'button', text: `${Math.round(z * 100)}%`, 'aria-pressed': String(on) });
|
|
996
|
+
btn.addEventListener('click', () => this.core.setState({ zoom: z }));
|
|
997
|
+
this.zoomSeg.appendChild(btn);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
renderTabs() {
|
|
1002
|
+
const s = this.core.state;
|
|
1003
|
+
this.tabBar.innerHTML = '';
|
|
1004
|
+
tabsFor(this.core.t).forEach((tabDef) => {
|
|
1005
|
+
const on = s.tab === tabDef.key;
|
|
1006
|
+
const btn = elS('button', `border: 0; border-radius: 8px 8px 0 0; border-bottom: 2px solid ${on ? 'var(--ed-accent)' : 'transparent'}; background: ${on ? 'var(--ed-panel)' : 'transparent'}; color: ${on ? 'var(--ed-accent)' : 'var(--ed-muted)'}; cursor: pointer; height: 39px; display: flex; align-items: center; justify-content: center; transition: color 0.16s, background 0.16s;`, { type: 'button', title: tabDef.title, role: 'tab', 'aria-selected': String(on) });
|
|
1007
|
+
btn.appendChild(icon(tabDef.iconName, 16));
|
|
1008
|
+
btn.addEventListener('mouseenter', () => { if (!on) { btn.style.color = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; } });
|
|
1009
|
+
btn.addEventListener('mouseleave', () => { btn.style.color = on ? 'var(--ed-accent)' : 'var(--ed-muted)'; btn.style.background = on ? 'var(--ed-panel)' : 'transparent'; });
|
|
1010
|
+
btn.addEventListener('click', () => this.core.setState({ tab: tabDef.key }));
|
|
1011
|
+
tip(btn, tabDef.label, 'down');
|
|
1012
|
+
this.tabBar.appendChild(btn);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
renderTabBody() {
|
|
1017
|
+
this.tabBody.innerHTML = '';
|
|
1018
|
+
const tab = this.core.state.tab;
|
|
1019
|
+
if (tab === 'design') this.tabBody.appendChild(this.renderDesignTab());
|
|
1020
|
+
else if (tab === 'blocks') this.tabBody.appendChild(this.renderBlocksTab());
|
|
1021
|
+
else if (tab === 'rows') this.tabBody.appendChild(this.renderRowsTab());
|
|
1022
|
+
else if (tab === 'files') this.tabBody.appendChild(this.renderFilesTab());
|
|
1023
|
+
else if (tab === 'layers') this.tabBody.appendChild(this.renderLayersTab());
|
|
1024
|
+
else if (tab === 'theme') this.tabBody.appendChild(this.renderThemeTab());
|
|
1025
|
+
else if (tab === 'data') this.tabBody.appendChild(this.renderDataTab());
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ---- Design tab ----------------------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
renderDesignTab() {
|
|
1031
|
+
const s = this.core.state;
|
|
1032
|
+
const t = this.core.t;
|
|
1033
|
+
const selO = this.core.selObj();
|
|
1034
|
+
// min-height so the workspace-toned body paints to the bottom of the
|
|
1035
|
+
// panel even when the field list is short (tabBody gives the 100%).
|
|
1036
|
+
const wrap = elS('div', 'min-height: 100%; display: flex; flex-direction: column;', { class: 'mc-tab-surface mc-design-panel' });
|
|
1037
|
+
// "Card groups" inspector (the design direction the user picked): the
|
|
1038
|
+
// sticky header carries a block-type icon chip; the body sits on the
|
|
1039
|
+
// workspace tone with each property group rendered as a white card
|
|
1040
|
+
// (renderFieldCards, render/fields.js).
|
|
1041
|
+
const head = elS('div', 'padding: 12px 14px; border-bottom: 1px solid var(--ed-line); display: flex; align-items: center; gap: 10px; position: sticky; top: 0; background: var(--ed-panel); z-index: 5;', { class: 'mc-inspector-head' });
|
|
1042
|
+
if (s.sel) {
|
|
1043
|
+
const chip = elS('div', 'width: 36px; height: 36px; flex: none; border-radius: 10px; background: var(--ed-soft); color: var(--ed-accent); display: flex; align-items: center; justify-content: center;');
|
|
1044
|
+
chip.appendChild(icon(selO.block ? selO.block.type : 'product', 18));
|
|
1045
|
+
head.appendChild(chip);
|
|
1046
|
+
}
|
|
1047
|
+
const headText = elS('div', 'flex: 1; min-width: 0;');
|
|
1048
|
+
headText.append(
|
|
1049
|
+
elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: selO.block ? DEF(selO.block.type).label : (selO.row ? t('inspector.sectionLabel') : t('inspector.nothingSelected')) }),
|
|
1050
|
+
elS('div', 'font-size: 11px; color: var(--ed-muted); margin-top: 1px;', { text: selO.block ? t('inspector.blockProperties') : (selO.row ? t('inspector.sectionProperties') : t('inspector.title')) }),
|
|
1051
|
+
);
|
|
1052
|
+
head.appendChild(headText);
|
|
1053
|
+
if (s.sel) {
|
|
1054
|
+
const actions = elS('div', 'display: flex; gap: 4px;');
|
|
1055
|
+
const dup = elS('button', 'border: 0; border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-muted); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: background 0.16s, color 0.16s;', { type: 'button', title: t('action.duplicateHint'), class: 'mc-icon-button' });
|
|
1056
|
+
dup.appendChild(icon('copy', 14));
|
|
1057
|
+
dup.addEventListener('mouseenter', () => { dup.style.background = 'var(--ed-soft)'; dup.style.color = 'var(--ed-accent)'; });
|
|
1058
|
+
dup.addEventListener('mouseleave', () => { dup.style.background = 'var(--ed-panel-2)'; dup.style.color = 'var(--ed-muted)'; });
|
|
1059
|
+
dup.addEventListener('click', () => this.core.dupSel());
|
|
1060
|
+
tip(dup, t('action.duplicateHint'), 'down', 'end');
|
|
1061
|
+
const del = elS('button', 'border: 0; border-radius: 8px; background: var(--ed-danger-soft); color: var(--ed-danger); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: filter 0.16s;', { type: 'button', title: t('action.deleteHint'), class: 'mc-icon-button' });
|
|
1062
|
+
del.appendChild(icon('trash', 14));
|
|
1063
|
+
del.addEventListener('mouseenter', () => { del.style.filter = 'brightness(0.94)'; });
|
|
1064
|
+
del.addEventListener('mouseleave', () => { del.style.filter = ''; });
|
|
1065
|
+
del.addEventListener('click', () => this.core.delSel());
|
|
1066
|
+
tip(del, t('action.deleteHint'), 'down', 'end');
|
|
1067
|
+
actions.append(dup, del);
|
|
1068
|
+
head.appendChild(actions);
|
|
1069
|
+
}
|
|
1070
|
+
wrap.appendChild(head);
|
|
1071
|
+
|
|
1072
|
+
// Flat accordion surface (Beefree-style): the fields render straight on
|
|
1073
|
+
// the panel with full-width section bars -- no workspace-toned gutter,
|
|
1074
|
+
// no floating cards.
|
|
1075
|
+
const body = elS('div', 'background: var(--ed-panel); flex: 1; padding-bottom: 28px;', { class: 'mc-panel-content' });
|
|
1076
|
+
if (!s.sel) {
|
|
1077
|
+
const empty = elS('div', 'margin: 12px 14px; background: var(--ed-panel); border: 1px solid var(--ed-line); border-radius: 12px; padding: 14px; font-size: 12.5px; color: var(--ed-muted); line-height: 1.6;');
|
|
1078
|
+
const p = elS('p', 'margin: 0 0 14px;');
|
|
1079
|
+
p.innerHTML = t('inspector.emptyBody');
|
|
1080
|
+
empty.appendChild(p);
|
|
1081
|
+
const box = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: 10px; padding: 12px;', { class: 'mc-shortcuts' });
|
|
1082
|
+
box.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); margin-bottom: 8px;', { text: t('inspector.shortcuts') }));
|
|
1083
|
+
const grid = elS('div', 'display: grid; gap: 5px; font-family: ui-monospace, monospace; font-size: 10.5px; color: var(--ed-muted);');
|
|
1084
|
+
[t('shortcut.undoRedo'), t('shortcut.duplicate'), t('shortcut.delete'), t('shortcut.export'), t('shortcut.escape')].forEach((txt) => grid.appendChild(elS('div', '', { text: txt })));
|
|
1085
|
+
box.appendChild(grid);
|
|
1086
|
+
empty.appendChild(box);
|
|
1087
|
+
body.appendChild(empty);
|
|
1088
|
+
}
|
|
1089
|
+
const fields = this.core.fields().concat(this.core.boxFields());
|
|
1090
|
+
renderFieldCards(body, fields);
|
|
1091
|
+
wrap.appendChild(body);
|
|
1092
|
+
return wrap;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ---- Blocks tab ------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
renderBlocksTab() {
|
|
1098
|
+
const t = this.core.t;
|
|
1099
|
+
const wrap = this.tabSurface();
|
|
1100
|
+
wrap.appendChild(this.sectionBar(t('blocks.dragHint'), t('blocks.count', { count: PALETTE.length })));
|
|
1101
|
+
const body = this.sectionBody();
|
|
1102
|
+
|
|
1103
|
+
const grid = elS('div', 'display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;');
|
|
1104
|
+
paletteTiles(this.core).forEach((tile) => {
|
|
1105
|
+
const cell = elS('div', 'border: 1px solid var(--ed-line); border-radius: 10px; background: var(--ed-panel-2); cursor: grab; padding: 11px 4px 8px; display: flex; flex-direction: column; align-items: center; gap: 7px; user-select: none; transition: border-color 0.16s, transform 0.16s, background 0.16s, box-shadow 0.16s;', { draggable: 'true', title: tile.hint });
|
|
1106
|
+
const applyHover = () => { cell.style.borderColor = 'var(--ed-accent)'; cell.style.background = 'var(--ed-soft)'; cell.style.transform = 'translateY(-2px)'; cell.style.boxShadow = 'var(--ed-shadow-sm)'; };
|
|
1107
|
+
cell.addEventListener('mouseenter', applyHover);
|
|
1108
|
+
cell.addEventListener('mouseleave', () => { cell.style.borderColor = 'var(--ed-line)'; cell.style.background = 'var(--ed-panel-2)'; cell.style.transform = ''; cell.style.boxShadow = ''; });
|
|
1109
|
+
cell.addEventListener('mousedown', () => { cell.style.cursor = 'grabbing'; cell.style.transform = ''; });
|
|
1110
|
+
cell.addEventListener('mouseup', () => { cell.style.cursor = 'grab'; applyHover(); });
|
|
1111
|
+
cell.addEventListener('dragstart', tile.onDragStart);
|
|
1112
|
+
cell.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
|
|
1113
|
+
cell.addEventListener('dblclick', tile.onAdd);
|
|
1114
|
+
const iconSpan = elS('span', 'color: var(--ed-accent); display: block;');
|
|
1115
|
+
iconSpan.appendChild(tile.icon());
|
|
1116
|
+
cell.appendChild(iconSpan);
|
|
1117
|
+
cell.appendChild(elS('span', 'font-size: 10.5px; color: var(--ed-muted); text-align: center; line-height: 1.2;', { text: tile.label }));
|
|
1118
|
+
grid.appendChild(cell);
|
|
1119
|
+
});
|
|
1120
|
+
body.appendChild(grid);
|
|
1121
|
+
wrap.appendChild(body);
|
|
1122
|
+
return wrap;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ---- Rows tab --------------------------------------------------------
|
|
1126
|
+
|
|
1127
|
+
renderRowsTab() {
|
|
1128
|
+
const s = this.core.state;
|
|
1129
|
+
const t = this.core.t;
|
|
1130
|
+
const wrap = this.tabSurface();
|
|
1131
|
+
wrap.appendChild(this.sectionBar(t('rows.sectionLayouts')));
|
|
1132
|
+
const layoutBody = this.sectionBody();
|
|
1133
|
+
|
|
1134
|
+
const grid = elS('div', 'display: grid; gap: 6px;');
|
|
1135
|
+
LAYOUTS.forEach((l) => {
|
|
1136
|
+
const cell = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; background: var(--ed-panel-2); cursor: grab; padding: 10px; display: flex; align-items: center; gap: 12px; user-select: none; transition: border-color 0.16s, background 0.16s;', { draggable: 'true' });
|
|
1137
|
+
cell.addEventListener('mouseenter', () => { cell.style.borderColor = 'var(--ed-accent)'; cell.style.background = 'var(--ed-soft)'; });
|
|
1138
|
+
cell.addEventListener('mouseleave', () => { cell.style.borderColor = 'var(--ed-line)'; cell.style.background = 'var(--ed-panel-2)'; });
|
|
1139
|
+
cell.addEventListener('dragstart', this.core.startDrag({ kind: 'row', spans: l.spans }));
|
|
1140
|
+
cell.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
|
|
1141
|
+
cell.addEventListener('dblclick', () => this.core.insertRow(l.spans, s.doc.rows.length));
|
|
1142
|
+
const cellsWrap = elS('div', 'display: flex; gap: 3px; flex: 1; height: 26px;');
|
|
1143
|
+
l.spans.forEach((sp) => cellsWrap.appendChild(elS('div', `flex: ${sp}; border: 1px solid var(--ed-line-2); background: repeating-linear-gradient(45deg, transparent, transparent 4px, var(--ed-line) 4px, var(--ed-line) 5px);`)));
|
|
1144
|
+
cell.append(cellsWrap, elS('span', 'font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-muted); white-space: nowrap;', { text: l.label }));
|
|
1145
|
+
grid.appendChild(cell);
|
|
1146
|
+
});
|
|
1147
|
+
layoutBody.appendChild(grid);
|
|
1148
|
+
wrap.appendChild(layoutBody);
|
|
1149
|
+
|
|
1150
|
+
wrap.appendChild(this.sectionBar(t('rows.customMarkup')));
|
|
1151
|
+
const markupBody = this.sectionBody();
|
|
1152
|
+
const htmlRow = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; background: var(--ed-panel-2); cursor: grab; padding: 11px 12px; display: flex; align-items: center; gap: 11px; user-select: none; transition: border-color 0.16s, background 0.16s;', { draggable: 'true', title: t('rows.htmlRowHint') });
|
|
1153
|
+
htmlRow.addEventListener('mouseenter', () => { htmlRow.style.borderColor = 'var(--ed-accent)'; htmlRow.style.background = 'var(--ed-soft)'; });
|
|
1154
|
+
htmlRow.addEventListener('mouseleave', () => { htmlRow.style.borderColor = 'var(--ed-line)'; htmlRow.style.background = 'var(--ed-panel-2)'; });
|
|
1155
|
+
htmlRow.addEventListener('dragstart', this.core.startDrag({ kind: 'row', spans: [100], html: true }));
|
|
1156
|
+
htmlRow.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
|
|
1157
|
+
htmlRow.addEventListener('click', () => this.core.insertRow([100], s.doc.rows.length, true));
|
|
1158
|
+
const htmlIcon = elS('span', 'color: var(--ed-accent); display: block;');
|
|
1159
|
+
htmlIcon.appendChild(icon('code', 20));
|
|
1160
|
+
const htmlText = elS('span', 'flex: 1;');
|
|
1161
|
+
htmlText.append(
|
|
1162
|
+
elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 13px; line-height: 1.2;', { text: t('rows.rawHtmlSection') }),
|
|
1163
|
+
elS('span', 'display: block; font-size: 11px; color: var(--ed-muted); line-height: 1.4;', { text: t('rows.rawHtmlDesc') }),
|
|
1164
|
+
);
|
|
1165
|
+
htmlRow.append(htmlIcon, htmlText);
|
|
1166
|
+
markupBody.appendChild(htmlRow);
|
|
1167
|
+
wrap.appendChild(markupBody);
|
|
1168
|
+
|
|
1169
|
+
wrap.appendChild(this.sectionBar(t('rows.canvasModel')));
|
|
1170
|
+
const modelBody = this.sectionBody();
|
|
1171
|
+
const modeSeg = elS('div', '', { class: 'mc-segment' });
|
|
1172
|
+
[{ label: t('rows.modeRowBased'), v: 'rows' }, { label: t('rows.modeFreeStack'), v: 'stack' }].forEach((m) => {
|
|
1173
|
+
const on = s.mode === m.v;
|
|
1174
|
+
const btn = elS('button', `flex: 1; background: ${this.segBg(on)}; color: ${this.segFg(on)}; font-family: var(--ed-font); font-size: 10px; font-weight: 600; padding: 8px 4px; transition: background 0.16s, color 0.16s;`, { type: 'button', text: m.label, 'aria-pressed': String(on) });
|
|
1175
|
+
btn.addEventListener('click', () => this.core.setState({ mode: m.v }));
|
|
1176
|
+
modeSeg.appendChild(btn);
|
|
1177
|
+
});
|
|
1178
|
+
modelBody.appendChild(modeSeg);
|
|
1179
|
+
const hint = s.mode === 'rows' ? t('rows.hintRows') : t('rows.hintStack');
|
|
1180
|
+
modelBody.appendChild(elS('p', 'margin: 10px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: hint }));
|
|
1181
|
+
wrap.appendChild(modelBody);
|
|
1182
|
+
return wrap;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ---- Files tab -------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
renderFilesTab() {
|
|
1188
|
+
const s = this.core.state;
|
|
1189
|
+
const t = this.core.t;
|
|
1190
|
+
const wrap = this.tabSurface();
|
|
1191
|
+
wrap.appendChild(this.sectionBar(t('files.libraryCount', { count: s.assets.length })));
|
|
1192
|
+
const body = this.sectionBody();
|
|
1193
|
+
const openBtn = elS('button', 'width: 100%; border: 0; border-radius: 8px; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; padding: 9px 12px; display: flex; align-items: center; justify-content: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
|
|
1194
|
+
openBtn.append(icon('folder', 14), elS('span', '', { text: t('files.openManager') }));
|
|
1195
|
+
openBtn.addEventListener('mouseenter', () => { openBtn.style.filter = 'brightness(1.1)'; });
|
|
1196
|
+
openBtn.addEventListener('mouseleave', () => { openBtn.style.filter = ''; });
|
|
1197
|
+
openBtn.addEventListener('click', () => this.core.openLibrary());
|
|
1198
|
+
body.appendChild(openBtn);
|
|
1199
|
+
|
|
1200
|
+
const grid = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px;');
|
|
1201
|
+
s.assets.slice(0, 6).forEach((a) => {
|
|
1202
|
+
const tile = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; overflow: hidden; cursor: grab; background: var(--ed-panel-2); transition: border-color 0.16s;', { draggable: 'true', title: a.name });
|
|
1203
|
+
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--ed-accent)'; });
|
|
1204
|
+
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; });
|
|
1205
|
+
tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
|
|
1206
|
+
tile.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
|
|
1207
|
+
tile.appendChild(elS('div', `width: 100%; height: 64px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work);`, { role: 'img', 'aria-label': a.name }));
|
|
1208
|
+
tile.appendChild(elS('div', 'font-family: ui-monospace, monospace; font-size: 9px; color: var(--ed-muted); padding: 4px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
|
|
1209
|
+
grid.appendChild(tile);
|
|
1210
|
+
});
|
|
1211
|
+
body.appendChild(grid);
|
|
1212
|
+
body.appendChild(elS('p', 'margin: 12px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('files.dragToCanvas') }));
|
|
1213
|
+
wrap.appendChild(body);
|
|
1214
|
+
return wrap;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ---- Layers tab --------------------------------------------------------
|
|
1218
|
+
|
|
1219
|
+
renderLayersTab() {
|
|
1220
|
+
const s = this.core.state;
|
|
1221
|
+
const t = this.core.t;
|
|
1222
|
+
const wrap = this.tabSurface();
|
|
1223
|
+
wrap.appendChild(this.sectionBar(t('layers.structure')));
|
|
1224
|
+
const body = this.sectionBody('display: grid; gap: 0;');
|
|
1225
|
+
body.classList.add('mc-layer-tree');
|
|
1226
|
+
// Mirror the document model as a real tree: section -> column -> block.
|
|
1227
|
+
// Keeping columns visible matters for multi-column rows; flattening every
|
|
1228
|
+
// block beneath the section made the structure ambiguous.
|
|
1229
|
+
s.doc.rows.forEach((r, i) => {
|
|
1230
|
+
const rSel = s.sel && s.sel.id === r.id;
|
|
1231
|
+
const card = elS('div', 'background: transparent; padding: 0 0 8px;', { class: `mc-tree-section${rSel ? ' is-selected' : ''}` });
|
|
1232
|
+
const blockN = r.cols.reduce((n, c) => n + c.blocks.length, 0);
|
|
1233
|
+
const head = elS('div', `display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 6px 7px; border-radius: 7px; background: ${rSel ? 'var(--ed-soft)' : 'transparent'}; transition: background 0.16s;`, { class: 'mc-tree-node mc-tree-section-node' });
|
|
1234
|
+
head.addEventListener('mouseenter', () => { head.style.background = 'var(--ed-soft)'; });
|
|
1235
|
+
head.addEventListener('mouseleave', () => { head.style.background = rSel ? 'var(--ed-soft)' : 'transparent'; });
|
|
1236
|
+
head.addEventListener('click', () => this.core.select('row', r.id));
|
|
1237
|
+
head.append(
|
|
1238
|
+
elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-family: ui-monospace, monospace; font-size: 10px; font-weight: 600; background: ${rSel ? 'var(--ed-accent)' : 'var(--ed-panel-2)'}; color: ${rSel ? 'var(--ed-accent-ink)' : 'var(--ed-muted)'};`, { text: String(i + 1).padStart(2, '0'), class: 'mc-tree-index' }),
|
|
1239
|
+
elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; color: var(--ed-text);', { text: r.cols.length === 1 ? t('layers.sectionSingle') : t('layers.sectionMulti', { count: r.cols.length }), class: 'mc-tree-title' }),
|
|
1240
|
+
elS('span', 'flex: 1;'),
|
|
1241
|
+
elS('span', 'font-family: var(--ed-font); font-size: 10px; font-weight: 500; color: var(--ed-muted);', { text: t('layers.blockCount', { count: blockN }), class: 'mc-tree-count' }),
|
|
1242
|
+
);
|
|
1243
|
+
card.appendChild(head);
|
|
1244
|
+
const columns = elS('div', '', { class: 'mc-tree-children mc-tree-columns' });
|
|
1245
|
+
r.cols.forEach((c, colIndex) => {
|
|
1246
|
+
const branch = elS('div', '', { class: 'mc-tree-branch' });
|
|
1247
|
+
const colHead = elS('div', 'display: flex; align-items: center; gap: 7px; min-height: 28px; padding: 3px 7px; color: var(--ed-muted);', { class: 'mc-tree-node mc-tree-column-node' });
|
|
1248
|
+
const colChip = elS('span', 'width: 20px; height: 20px; flex: none; border-radius: 5px; display: flex; align-items: center; justify-content: center; background: var(--ed-panel-2); color: var(--ed-faint);');
|
|
1249
|
+
colChip.appendChild(icon('table', 11));
|
|
1250
|
+
colHead.append(
|
|
1251
|
+
colChip,
|
|
1252
|
+
elS('span', 'font-family: var(--ed-font); font-size: 11.5px; font-weight: 600;', { text: t('layers.column', { index: colIndex + 1 }), class: 'mc-tree-title' }),
|
|
1253
|
+
elS('span', 'flex: 1;'),
|
|
1254
|
+
elS('span', 'font-family: var(--ed-font); font-size: 10px; font-weight: 500; color: var(--ed-muted);', { text: t('layers.blockCount', { count: c.blocks.length }), class: 'mc-tree-count' }),
|
|
1255
|
+
);
|
|
1256
|
+
branch.appendChild(colHead);
|
|
1257
|
+
if (c.blocks.length) {
|
|
1258
|
+
const list = elS('div', '', { class: 'mc-tree-children mc-tree-blocks' });
|
|
1259
|
+
c.blocks.forEach((b) => {
|
|
1260
|
+
const bSel = s.sel && s.sel.id === b.id;
|
|
1261
|
+
const typeName = DEF(b.type).label;
|
|
1262
|
+
const text = b.type === 'text' ? String(b.props.html || '').replace(/<[^>]+>/g, '').trim().slice(0, 34) : (b.props.label || b.props.title || b.props.caption || '');
|
|
1263
|
+
const line = elS('div', `display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 4px 7px; border-radius: 8px; background: ${bSel ? 'var(--ed-soft)' : 'transparent'}; box-shadow: ${bSel ? 'inset 0 0 0 1px var(--ed-accent)' : 'none'}; transition: background 0.16s;`, { class: 'mc-tree-node mc-tree-block-node' });
|
|
1264
|
+
line.addEventListener('mouseenter', () => { if (!(s.sel && s.sel.id === b.id)) line.style.background = 'var(--ed-panel)'; });
|
|
1265
|
+
line.addEventListener('mouseleave', () => { line.style.background = bSel ? 'var(--ed-soft)' : 'transparent'; });
|
|
1266
|
+
line.addEventListener('click', () => this.core.select('block', b.id));
|
|
1267
|
+
const chip = elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: ${bSel ? 'var(--ed-accent)' : 'var(--ed-soft)'}; color: ${bSel ? 'var(--ed-accent-ink)' : 'var(--ed-accent)'};`);
|
|
1268
|
+
chip.appendChild(icon(b.type, 12));
|
|
1269
|
+
line.append(chip, elS('span', 'font-family: var(--ed-font); font-size: 12px; font-weight: 500; color: var(--ed-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: text || typeName, class: 'mc-tree-title' }));
|
|
1270
|
+
if (text) {
|
|
1271
|
+
line.append(
|
|
1272
|
+
elS('span', 'flex: 1; min-width: 8px;'),
|
|
1273
|
+
elS('span', 'flex: none; font-family: var(--ed-font); font-size: 9.5px; font-weight: 600; letter-spacing: 0.035em; text-transform: uppercase; color: var(--ed-muted);', { text: typeName, class: 'mc-tree-type' }),
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
list.appendChild(line);
|
|
1277
|
+
});
|
|
1278
|
+
branch.appendChild(list);
|
|
1279
|
+
}
|
|
1280
|
+
columns.appendChild(branch);
|
|
1281
|
+
});
|
|
1282
|
+
card.appendChild(columns);
|
|
1283
|
+
body.appendChild(card);
|
|
1284
|
+
});
|
|
1285
|
+
wrap.appendChild(body);
|
|
1286
|
+
return wrap;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ---- Theme tab -------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
renderThemeTab() {
|
|
1292
|
+
// Same flat-accordion treatment as the Design tab, so the two style
|
|
1293
|
+
// surfaces read as one system; the section bars carry the structure.
|
|
1294
|
+
const wrap = this.tabSurface();
|
|
1295
|
+
renderFieldCards(wrap, this.core.themeFields());
|
|
1296
|
+
return wrap;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ---- Data (variables) tab ----------------------------------------------
|
|
1300
|
+
|
|
1301
|
+
renderDataTab() {
|
|
1302
|
+
const s = this.core.state;
|
|
1303
|
+
const t = this.core.t;
|
|
1304
|
+
const wrap = this.tabSurface();
|
|
1305
|
+
wrap.appendChild(this.sectionBar(t('vars.title'), t('vars.fromCode', { count: this.core.vars().length })));
|
|
1306
|
+
const card = this.sectionBody();
|
|
1307
|
+
card.appendChild(elS('p', 'margin: 0 0 12px; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.declaredNote') }));
|
|
1308
|
+
const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query' });
|
|
1309
|
+
search.value = s.varQuery || '';
|
|
1310
|
+
search.addEventListener('focus', () => { search.style.borderColor = 'var(--ed-accent)'; search.style.outline = 'none'; });
|
|
1311
|
+
search.addEventListener('blur', () => { search.style.borderColor = 'var(--ed-line)'; });
|
|
1312
|
+
// Debounced: each setState is a full re-render (see typeCommit, fields.js).
|
|
1313
|
+
const varSearchCommit = typeCommit((v) => this.core.setState({ varQuery: v }));
|
|
1314
|
+
search.addEventListener('input', (e) => varSearchCommit.call(e.target.value));
|
|
1315
|
+
card.appendChild(search);
|
|
1316
|
+
|
|
1317
|
+
const all = this.core.vars();
|
|
1318
|
+
const filtered = all.filter((v) => !s.varQuery || v.toLowerCase().includes(String(s.varQuery).toLowerCase()));
|
|
1319
|
+
const grid = elS('div', 'display: grid; gap: 3px;');
|
|
1320
|
+
filtered.forEach((v) => {
|
|
1321
|
+
const name = v.replace(/[_.]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1322
|
+
const btn = elS('button', 'display: flex; align-items: center; gap: 9px; text-align: left; border: 1px solid var(--ed-line); border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-text); cursor: pointer; padding: 8px 10px; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('vars.insertHint') });
|
|
1323
|
+
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
|
|
1324
|
+
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel-2)'; });
|
|
1325
|
+
btn.addEventListener('click', () => this.core.insertTag(v));
|
|
1326
|
+
const textSpan = elS('span', 'flex: 1; min-width: 0;');
|
|
1327
|
+
textSpan.append(
|
|
1328
|
+
elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; line-height: 1.2;', { text: name }),
|
|
1329
|
+
elS('span', 'display: block; font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-accent); margin-top: 2px;', { text: TOKEN(v) }),
|
|
1330
|
+
);
|
|
1331
|
+
btn.append(textSpan, elS('span', 'font-family: var(--ed-font); font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-faint);', { text: t('vars.insert') }));
|
|
1332
|
+
grid.appendChild(btn);
|
|
1333
|
+
});
|
|
1334
|
+
card.appendChild(grid);
|
|
1335
|
+
if (!filtered.length) {
|
|
1336
|
+
card.appendChild(elS('p', 'margin: 10px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: all.length ? t('vars.noMatch') : t('vars.noneDeclared') }));
|
|
1337
|
+
}
|
|
1338
|
+
card.appendChild(elS('p', 'margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--ed-line); font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.rteHint') }));
|
|
1339
|
+
wrap.appendChild(card);
|
|
1340
|
+
return wrap;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ---- modals: library ----------------------------------------------------
|
|
1344
|
+
|
|
1345
|
+
buildLibraryModal() {
|
|
1346
|
+
const t = this.core.t;
|
|
1347
|
+
const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 34px; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
|
|
1348
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
|
|
1349
|
+
const modal = elS('div', 'width: 100%; height: 100%; max-width: 1000px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; display: grid; grid-template-rows: auto 1fr auto; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
|
|
1350
|
+
modal.addEventListener('click', (e) => e.stopPropagation());
|
|
1351
|
+
overlay.appendChild(modal);
|
|
1352
|
+
|
|
1353
|
+
const head = elS('div', 'display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
|
|
1354
|
+
this.libraryTitleEl = elS('div', 'flex: 1;');
|
|
1355
|
+
this.libraryTitleEl.append(
|
|
1356
|
+
elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.fileManager'), 'data-i18n': 'modal.fileManager' }),
|
|
1357
|
+
(this.libraryTitleText = elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;')),
|
|
1358
|
+
);
|
|
1359
|
+
this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query' });
|
|
1360
|
+
this.assetSearch.addEventListener('focus', () => { this.assetSearch.style.borderColor = 'var(--ed-accent)'; this.assetSearch.style.outline = 'none'; });
|
|
1361
|
+
this.assetSearch.addEventListener('blur', () => { this.assetSearch.style.borderColor = 'var(--ed-line)'; });
|
|
1362
|
+
// Debounced re-render; setAssetQuery separately debounces the backend call.
|
|
1363
|
+
const assetSearchCommit = typeCommit((v) => this.core.setAssetQuery(v));
|
|
1364
|
+
this.assetSearch.addEventListener('input', (e) => assetSearchCommit.call(e.target.value));
|
|
1365
|
+
const uploadBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 32px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
|
|
1366
|
+
uploadBtn.append(icon('upload', 14), elS('span', '', { text: t('action.upload'), 'data-i18n': 'action.upload' }));
|
|
1367
|
+
uploadBtn.addEventListener('mouseenter', () => { uploadBtn.style.filter = 'brightness(1.1)'; });
|
|
1368
|
+
uploadBtn.addEventListener('mouseleave', () => { uploadBtn.style.filter = ''; });
|
|
1369
|
+
uploadBtn.addEventListener('click', () => this.fileInput.click());
|
|
1370
|
+
const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
|
|
1371
|
+
closeBtn.appendChild(icon('x', 14));
|
|
1372
|
+
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1373
|
+
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
|
|
1374
|
+
closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
|
|
1375
|
+
tip(closeBtn, t('action.close'), 'down', 'end');
|
|
1376
|
+
head.append(this.libraryTitleEl, this.assetSearch, uploadBtn, closeBtn);
|
|
1377
|
+
modal.appendChild(head);
|
|
1378
|
+
|
|
1379
|
+
const body = elS('div', 'display: grid; grid-template-columns: 186px 1fr; min-height: 0;');
|
|
1380
|
+
this.folderList = elS('div', 'border-right: 1px solid var(--ed-line); padding: 14px; overflow-y: auto;');
|
|
1381
|
+
const foldersHead = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); margin-bottom: 10px;', { text: t('library.folders'), 'data-i18n': 'library.folders' });
|
|
1382
|
+
this.folderGrid = elS('div', 'display: grid; gap: 2px;');
|
|
1383
|
+
this.folderList.append(foldersHead, this.folderGrid);
|
|
1384
|
+
this.assetGridWrap = elS('div', 'overflow-y: auto; padding: 16px; transition: background 0.16s;');
|
|
1385
|
+
this.assetGridWrap.addEventListener('dragover', (e) => { if (this.core.drag) return; e.preventDefault(); if (!this.core.state.libHot) this.core.setState({ libHot: true }); });
|
|
1386
|
+
this.assetGridWrap.addEventListener('drop', (e) => { e.preventDefault(); this.core.setState({ libHot: false }); if (e.dataTransfer?.files?.length) this.core.addFiles(e.dataTransfer.files); });
|
|
1387
|
+
this.assetStatus = elS('div', 'font-size: 12.5px; color: var(--ed-muted); padding: 0 2px 12px;');
|
|
1388
|
+
this.assetGrid = elS('div', 'display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 14px;');
|
|
1389
|
+
this.assetEmpty = elS('div', 'padding: 28px 2px; color: var(--ed-muted); font-size: 12.5px;', { text: t('library.empty'), 'data-i18n': 'library.empty' });
|
|
1390
|
+
this.assetMore = elS('button', 'margin-top: 16px; border: 0; border-radius: 8px; background: var(--ed-soft); color: var(--ed-accent); cursor: pointer; height: 30px; padding: 0 14px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, color 0.16s;', { type: 'button', text: t('library.loadMore'), 'data-i18n': 'library.loadMore' });
|
|
1391
|
+
this.assetMore.addEventListener('mouseenter', () => { this.assetMore.style.background = 'var(--ed-accent)'; this.assetMore.style.color = 'var(--ed-accent-ink)'; });
|
|
1392
|
+
this.assetMore.addEventListener('mouseleave', () => { this.assetMore.style.background = 'var(--ed-soft)'; this.assetMore.style.color = 'var(--ed-accent)'; });
|
|
1393
|
+
this.assetMore.addEventListener('click', () => this.core.loadMoreAssets());
|
|
1394
|
+
this.assetGridWrap.append(this.assetStatus, this.assetGrid, this.assetEmpty, this.assetMore);
|
|
1395
|
+
this.assetGridWrap.appendChild(elS('div', 'margin-top: 20px; border: 1px dashed var(--ed-line-2); padding: 22px; text-align: center; color: var(--ed-muted); font-size: 12.5px;', { text: t('library.dropHint'), 'data-i18n': 'library.dropHint' }));
|
|
1396
|
+
body.append(this.folderList, this.assetGridWrap);
|
|
1397
|
+
modal.appendChild(body);
|
|
1398
|
+
|
|
1399
|
+
const foot = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); display: flex; justify-content: space-between; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
|
|
1400
|
+
this.libraryHintEl = elS('span');
|
|
1401
|
+
this.storageLabelEl = elS('span');
|
|
1402
|
+
foot.append(this.libraryHintEl, this.storageLabelEl);
|
|
1403
|
+
modal.appendChild(foot);
|
|
1404
|
+
return overlay;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
renderLibraryModal() {
|
|
1408
|
+
const s = this.core.state;
|
|
1409
|
+
const t = this.core.t;
|
|
1410
|
+
this.libraryModal.style.display = s.libraryOpen ? 'flex' : 'none';
|
|
1411
|
+
if (!s.libraryOpen) return;
|
|
1412
|
+
this.libraryTitleText.textContent = s.assetTarget ? t('modal.replaceImage') : t('modal.assetsTitle');
|
|
1413
|
+
this.libraryHintEl.textContent = s.assetTarget ? t('library.clickToReplace') : t('library.clickToPlace');
|
|
1414
|
+
this.storageLabelEl.textContent = t('library.storageLabel', { count: s.assets.length, size: KB(s.assets.reduce((n, a) => n + (a.size || 0), 0)) });
|
|
1415
|
+
if (this.assetSearch.value !== (s.assetQuery || '')) this.assetSearch.value = s.assetQuery || '';
|
|
1416
|
+
// The OS file dialog greys out what validation would refuse anyway, so the
|
|
1417
|
+
// accept list tracks whatever limits the host has set.
|
|
1418
|
+
this.fileInput.accept = acceptAttribute(this.core.limits());
|
|
1419
|
+
|
|
1420
|
+
this.folderGrid.innerHTML = '';
|
|
1421
|
+
this.core.folderOptions().forEach((f) => {
|
|
1422
|
+
const on = s.assetFolder === f.id;
|
|
1423
|
+
const btn = elS('button', `text-align: left; border: 0; border-left: 2px solid ${on ? 'var(--ed-accent)' : 'transparent'}; background: ${on ? 'var(--ed-soft)' : 'transparent'}; color: ${on ? 'var(--ed-accent)' : 'var(--ed-text)'}; cursor: pointer; padding: 8px 10px; font-size: 12.5px; display: flex; justify-content: space-between; gap: 8px; transition: background 0.16s;`, { type: 'button' });
|
|
1424
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-soft)'; });
|
|
1425
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = on ? 'var(--ed-soft)' : 'transparent'; });
|
|
1426
|
+
btn.addEventListener('click', () => this.core.setAssetFolder(f.id));
|
|
1427
|
+
btn.append(
|
|
1428
|
+
elS('span', '', { text: f.name }),
|
|
1429
|
+
elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint);', { text: f.count == null ? '' : String(f.count) }),
|
|
1430
|
+
);
|
|
1431
|
+
this.folderGrid.appendChild(btn);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// One line carries all three transient states -- they can't co-occur, and a
|
|
1435
|
+
// separate row for each would push the grid down every time one appeared.
|
|
1436
|
+
const status = s.uploading ? t('storage.uploading', { count: s.uploading })
|
|
1437
|
+
: s.assetsError ? t('storage.errLoadFailed', { reason: s.assetsError })
|
|
1438
|
+
: s.assetsLoading ? t('storage.loading') : '';
|
|
1439
|
+
this.assetStatus.textContent = status;
|
|
1440
|
+
this.assetStatus.style.display = status ? 'block' : 'none';
|
|
1441
|
+
this.assetStatus.style.color = s.assetsError ? 'var(--ed-danger)' : 'var(--ed-muted)';
|
|
1442
|
+
|
|
1443
|
+
this.assetGridWrap.style.background = s.libHot ? 'var(--ed-soft)' : 'transparent';
|
|
1444
|
+
const visible = this.core.visibleAssets();
|
|
1445
|
+
this.assetGrid.innerHTML = '';
|
|
1446
|
+
visible.forEach((a) => {
|
|
1447
|
+
const tile = elS('div', 'border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); overflow: hidden; background: var(--ed-panel-2); cursor: pointer; transition: border-color 0.16s, transform 0.16s, box-shadow 0.16s;', { draggable: 'true' });
|
|
1448
|
+
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--ed-accent)'; tile.style.transform = 'translateY(-1px)'; tile.style.boxShadow = 'var(--ed-shadow-md)'; });
|
|
1449
|
+
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; tile.style.transform = ''; tile.style.boxShadow = ''; });
|
|
1450
|
+
tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
|
|
1451
|
+
tile.addEventListener('click', () => this.core.useAsset(a));
|
|
1452
|
+
tile.appendChild(elS('div', `width: 100%; height: 120px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work); border-bottom: 1px solid var(--ed-line);`, { role: 'img', 'aria-label': a.name }));
|
|
1453
|
+
const meta = elS('div', 'padding: 8px 9px;');
|
|
1454
|
+
meta.appendChild(elS('div', 'font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
|
|
1455
|
+
const metaRow = elS('div', 'display: flex; justify-content: space-between; gap: 6px; margin-top: 3px;');
|
|
1456
|
+
metaRow.appendChild(elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint);', { text: `${a.w ? a.w + '×' + a.ht + ' · ' : ''}${KB(a.size || 0)}` }));
|
|
1457
|
+
const del = elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint); cursor: pointer;', { title: t('library.deleteFileHint'), text: t('library.del') });
|
|
1458
|
+
del.addEventListener('mouseenter', () => { del.style.color = 'var(--ed-danger)'; });
|
|
1459
|
+
del.addEventListener('mouseleave', () => { del.style.color = 'var(--ed-faint)'; });
|
|
1460
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); this.core.removeAsset(a); });
|
|
1461
|
+
metaRow.appendChild(del);
|
|
1462
|
+
meta.appendChild(metaRow);
|
|
1463
|
+
tile.appendChild(meta);
|
|
1464
|
+
this.assetGrid.appendChild(tile);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
// "Nothing here" only once there is an answer -- during the first load, and
|
|
1468
|
+
// after a failure, an empty grid means "not known yet", not "no files".
|
|
1469
|
+
this.assetEmpty.style.display = (!visible.length && !s.assetsLoading && !s.assetsError) ? 'block' : 'none';
|
|
1470
|
+
this.assetMore.style.display = s.assetCursor ? 'block' : 'none';
|
|
1471
|
+
this.assetMore.disabled = !!s.assetsLoading;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ---- modals: export ----------------------------------------------------
|
|
1475
|
+
|
|
1476
|
+
buildExportModal() {
|
|
1477
|
+
const t = this.core.t;
|
|
1478
|
+
const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 34px; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
|
|
1479
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
|
|
1480
|
+
const modal = elS('div', 'width: 100%; height: 100%; max-width: 900px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; display: grid; grid-template-rows: auto 1fr auto; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
|
|
1481
|
+
modal.addEventListener('click', (e) => e.stopPropagation());
|
|
1482
|
+
overlay.appendChild(modal);
|
|
1483
|
+
|
|
1484
|
+
const head = elS('div', 'display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
|
|
1485
|
+
const headText = elS('div', 'flex: 1;');
|
|
1486
|
+
headText.append(
|
|
1487
|
+
elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.export'), 'data-i18n': 'modal.export' }),
|
|
1488
|
+
elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;', { text: t('modal.exportTitle'), 'data-i18n': 'modal.exportTitle' }),
|
|
1489
|
+
);
|
|
1490
|
+
this.copyBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 12px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', class: 'mc-icon-label' });
|
|
1491
|
+
this.copyBtn.iconNode = icon('copy', 14);
|
|
1492
|
+
this.copyBtn.labelNode = elS('span');
|
|
1493
|
+
this.copyBtn.append(this.copyBtn.iconNode, this.copyBtn.labelNode);
|
|
1494
|
+
this.copyBtn.addEventListener('mouseenter', () => { this.copyBtn.style.borderColor = 'var(--ed-accent)'; this.copyBtn.style.background = 'var(--ed-soft)'; });
|
|
1495
|
+
this.copyBtn.addEventListener('mouseleave', () => { this.copyBtn.style.borderColor = 'var(--ed-line)'; this.copyBtn.style.background = 'transparent'; });
|
|
1496
|
+
this.copyBtn.addEventListener('click', () => this.core.copyExport());
|
|
1497
|
+
this.shotBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 12px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.screenshotHint'), 'data-i18n-title': 'action.screenshotHint', class: 'mc-icon-label' });
|
|
1498
|
+
this.shotBtn.append(icon('image', 14), elS('span', '', { text: t('action.screenshot'), 'data-i18n': 'action.screenshot' }));
|
|
1499
|
+
this.shotBtn.addEventListener('mouseenter', () => { this.shotBtn.style.borderColor = 'var(--ed-accent)'; this.shotBtn.style.background = 'var(--ed-soft)'; });
|
|
1500
|
+
this.shotBtn.addEventListener('mouseleave', () => { this.shotBtn.style.borderColor = 'var(--ed-line)'; this.shotBtn.style.background = 'transparent'; });
|
|
1501
|
+
// Opens the viewer first and renders into it: the capture takes a beat on
|
|
1502
|
+
// a long template, and a modal that is already up with a skeleton reads as
|
|
1503
|
+
// progress, where a button that just sits there reads as a dead click.
|
|
1504
|
+
this.shotBtn.addEventListener('click', () => this.previewScreenshot());
|
|
1505
|
+
const downloadBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 32px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
|
|
1506
|
+
downloadBtn.append(icon('download', 14), elS('span', '', { text: t('action.download'), 'data-i18n': 'action.download' }));
|
|
1507
|
+
downloadBtn.addEventListener('mouseenter', () => { downloadBtn.style.filter = 'brightness(1.1)'; });
|
|
1508
|
+
downloadBtn.addEventListener('mouseleave', () => { downloadBtn.style.filter = ''; });
|
|
1509
|
+
downloadBtn.addEventListener('click', () => this.core.downloadExport());
|
|
1510
|
+
const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
|
|
1511
|
+
closeBtn.appendChild(icon('x', 14));
|
|
1512
|
+
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1513
|
+
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
|
|
1514
|
+
closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
|
|
1515
|
+
tip(closeBtn, t('action.close'), 'down', 'end');
|
|
1516
|
+
head.append(headText, this.copyBtn, this.shotBtn, downloadBtn, closeBtn);
|
|
1517
|
+
modal.appendChild(head);
|
|
1518
|
+
|
|
1519
|
+
this.exportTextarea = elS('textarea', 'width: 100%; height: 100%; box-sizing: border-box; border: 0; background: var(--ed-work); color: var(--ed-text); font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.55; padding: 18px 20px; resize: none;', { readonly: 'true' });
|
|
1520
|
+
modal.appendChild(this.exportTextarea);
|
|
1521
|
+
this.exportMetaEl = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
|
|
1522
|
+
modal.appendChild(this.exportMetaEl);
|
|
1523
|
+
return overlay;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
renderExportModal() {
|
|
1527
|
+
const s = this.core.state;
|
|
1528
|
+
const t = this.core.t;
|
|
1529
|
+
this.exportModal.style.display = s.exportOpen ? 'flex' : 'none';
|
|
1530
|
+
if (!s.exportOpen) return;
|
|
1531
|
+
if (this.exportTextarea.value !== s.exportCode) this.exportTextarea.value = s.exportCode;
|
|
1532
|
+
this.copyBtn.labelNode.textContent = s.copied ? t('export.copied') : t('export.copy');
|
|
1533
|
+
this.exportMetaEl.textContent = t('export.meta', { kb: (s.exportCode.length / 1024).toFixed(1) });
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ---- modals: code view --------------------------------------------------
|
|
1537
|
+
|
|
1538
|
+
buildCodeModal() {
|
|
1539
|
+
const t = this.core.t;
|
|
1540
|
+
const overlay = elS('div', 'position: absolute; inset: 0; background: var(--ed-panel); z-index: 55; display: grid; grid-template-rows: 58px minmax(0, 1fr) auto; animation: mcFade 0.16s ease; display: none;', { class: 'mc-fullscreen-panel mc-code-panel' });
|
|
1541
|
+
const head = elS('div', 'display: flex; align-items: center; gap: 10px; padding: 0 18px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2));', { class: 'mc-code-toolbar' });
|
|
1542
|
+
const headIcon = elS('span', 'width: 32px; height: 32px; border-radius: 9px; background: var(--ed-soft); color: var(--ed-accent); display: flex; align-items: center; justify-content: center; flex: none;', { class: 'mc-code-badge' });
|
|
1543
|
+
headIcon.appendChild(icon('code', 17));
|
|
1544
|
+
const headText = elS('div', 'flex: 1; min-width: 0;');
|
|
1545
|
+
this.codeStatusEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-code-kicker' });
|
|
1546
|
+
headText.append(this.codeStatusEl, elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: t('modal.rawHtmlTitle'), 'data-i18n': 'modal.rawHtmlTitle', class: 'mc-code-heading' }));
|
|
1547
|
+
this.codeWidthSeg = elS('div', '', { class: 'mc-segment' });
|
|
1548
|
+
const reloadBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
|
|
1549
|
+
reloadBtn.appendChild(icon('refresh', 14));
|
|
1550
|
+
reloadBtn.appendChild(elS('span', '', { text: t('action.reload'), 'data-i18n': 'action.reload' }));
|
|
1551
|
+
reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.borderColor = 'var(--ed-accent)'; reloadBtn.style.background = 'var(--ed-soft)'; });
|
|
1552
|
+
reloadBtn.addEventListener('mouseleave', () => { reloadBtn.style.borderColor = 'var(--ed-line)'; reloadBtn.style.background = 'transparent'; });
|
|
1553
|
+
reloadBtn.addEventListener('click', () => {
|
|
1554
|
+
const src = this.core.buildHtml();
|
|
1555
|
+
this.core.setState({ codeSrc: src, codeLive: src, codeDirty: false });
|
|
1556
|
+
this.core.flash(this.core.t('toast.sourceReloaded'));
|
|
1557
|
+
});
|
|
1558
|
+
const applyBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 30px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 11px; transition: filter 0.16s;', { type: 'button', title: t('action.applyToCanvasHint'), 'data-i18n-title': 'action.applyToCanvasHint' });
|
|
1559
|
+
applyBtn.appendChild(icon('check', 14));
|
|
1560
|
+
applyBtn.appendChild(elS('span', '', { text: t('action.applyToCanvas'), 'data-i18n': 'action.applyToCanvas' }));
|
|
1561
|
+
applyBtn.addEventListener('mouseenter', () => { applyBtn.style.filter = 'brightness(1.1)'; });
|
|
1562
|
+
applyBtn.addEventListener('mouseleave', () => { applyBtn.style.filter = ''; });
|
|
1563
|
+
applyBtn.addEventListener('click', () => this.core.applyCode());
|
|
1564
|
+
const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s;', { type: 'button', title: t('action.closeWithoutApplyingHint'), 'data-i18n-title': 'action.closeWithoutApplyingHint', class: 'mc-icon-button' });
|
|
1565
|
+
closeBtn.appendChild(icon('x', 14));
|
|
1566
|
+
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1567
|
+
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
|
|
1568
|
+
closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
|
|
1569
|
+
tip(closeBtn, t('action.closeWithoutApplyingHint'), 'down', 'end');
|
|
1570
|
+
head.append(headIcon, headText, this.codeWidthSeg, reloadBtn, applyBtn, closeBtn);
|
|
1571
|
+
overlay.appendChild(head);
|
|
1572
|
+
|
|
1573
|
+
const cols = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; min-height: 0; background: var(--ed-work);', { class: 'mc-code-split' });
|
|
1574
|
+
const left = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-source' });
|
|
1575
|
+
const leftHead = elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); display: flex; justify-content: space-between;', { class: 'mc-pane-label' });
|
|
1576
|
+
this.codeMetaEl = elS('span');
|
|
1577
|
+
leftHead.append(elS('span', '', { text: t('code.source'), 'data-i18n': 'code.source' }), this.codeMetaEl);
|
|
1578
|
+
left.appendChild(leftHead);
|
|
1579
|
+
const editorWrap = elS('div', 'position: relative; min-height: 0;');
|
|
1580
|
+
editorWrap.appendChild(this.buildCodeEditor());
|
|
1581
|
+
left.appendChild(editorWrap);
|
|
1582
|
+
cols.appendChild(left);
|
|
1583
|
+
|
|
1584
|
+
const right = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-preview' });
|
|
1585
|
+
right.appendChild(elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('code.livePreview'), 'data-i18n': 'code.livePreview', class: 'mc-pane-label' }));
|
|
1586
|
+
const previewWrap = elS('div', 'overflow: auto; padding: 20px; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
|
|
1587
|
+
this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 1px solid var(--ed-line-2); border-radius: 6px; background: #ffffff; box-shadow: 0 6px 18px rgba(15,23,42,0.10); transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
|
|
1588
|
+
this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
|
|
1589
|
+
previewWrap.appendChild(this.codeFrame);
|
|
1590
|
+
right.appendChild(previewWrap);
|
|
1591
|
+
cols.appendChild(right);
|
|
1592
|
+
overlay.appendChild(cols);
|
|
1593
|
+
|
|
1594
|
+
overlay.appendChild(elS('div', 'padding: 8px 18px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);', { text: t('code.applyNote'), 'data-i18n': 'code.applyNote', class: 'mc-code-footer' }));
|
|
1595
|
+
return overlay;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** Ported from `codeEditorEl()`: numbered syntax-highlighted rows sit behind
|
|
1599
|
+
* a transparent-text/visible-caret `<textarea>`. Both layers share the same
|
|
1600
|
+
* fixed width and wrapping rules, so long HTML stays on screen without the
|
|
1601
|
+
* highlight or caret drifting away from the editable text. */
|
|
1602
|
+
buildCodeEditor() {
|
|
1603
|
+
const MONO = 'font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 20px; tab-size: 2;';
|
|
1604
|
+
const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);');
|
|
1605
|
+
const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
|
|
1606
|
+
this.codePre = elS('pre', `${MONO} margin: 0; padding: 14px 0 80px; width: 100%; min-width: 0; min-height: 100%; white-space: normal; color: var(--ed-text); pointer-events: none; background: linear-gradient(to right, var(--ed-panel-2) 0 52px, var(--ed-work) 52px);`, { 'aria-hidden': 'true' });
|
|
1607
|
+
this.codeTextarea = elS('textarea', `${MONO} position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 14px 18px 80px 70px; border: 0; background: transparent; color: transparent; caret-color: var(--ed-accent); white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; overflow: hidden; resize: none; outline: none;`, { spellcheck: 'false', wrap: 'soft', 'data-focus-key': 'code-src' });
|
|
1608
|
+
this.codeTextarea.addEventListener('input', (e) => this.core.setCodeSrc(e.target.value));
|
|
1609
|
+
this.codeTextarea.addEventListener('keydown', (e) => {
|
|
1610
|
+
if (e.key !== 'Tab') return;
|
|
1611
|
+
e.preventDefault();
|
|
1612
|
+
const ta = e.target; const a = ta.selectionStart; const b = ta.selectionEnd;
|
|
1613
|
+
const next = ta.value.slice(0, a) + ' ' + ta.value.slice(b);
|
|
1614
|
+
this.core.setCodeSrc(next);
|
|
1615
|
+
ta.value = next;
|
|
1616
|
+
ta.selectionStart = ta.selectionEnd = a + 2;
|
|
1617
|
+
});
|
|
1618
|
+
inner.append(this.codePre, this.codeTextarea);
|
|
1619
|
+
root.appendChild(inner);
|
|
1620
|
+
return root;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** The iframe owns a separate document, so the Shadow DOM scrollbar rules
|
|
1624
|
+
* cannot reach its viewport. Mirror the editor scrollbar as preview chrome
|
|
1625
|
+
* only; the source string and the HTML applied/exported by the host remain
|
|
1626
|
+
* byte-for-byte untouched. */
|
|
1627
|
+
syncCodePreviewScrollbar() {
|
|
1628
|
+
const doc = this.codeFrame?.contentDocument;
|
|
1629
|
+
if (!doc?.head) return;
|
|
1630
|
+
const view = this.ownerDocument?.defaultView;
|
|
1631
|
+
const thumb = view?.getComputedStyle(this.mc).getPropertyValue('--ed-line-2').trim() || 'rgba(15,23,42,0.16)';
|
|
1632
|
+
let style = doc.head.querySelector('[data-mc-preview-scrollbar]');
|
|
1633
|
+
if (!style) {
|
|
1634
|
+
style = doc.createElement('style');
|
|
1635
|
+
style.setAttribute('data-mc-preview-scrollbar', '');
|
|
1636
|
+
doc.head.appendChild(style);
|
|
1637
|
+
}
|
|
1638
|
+
style.textContent = `html { scrollbar-width: thin !important; scrollbar-color: ${thumb} transparent !important; } *::-webkit-scrollbar { width: 8px !important; height: 8px !important; } *::-webkit-scrollbar-thumb { background: ${thumb} !important; border-radius: 999px !important; } *::-webkit-scrollbar-track { background: transparent !important; } *::-webkit-scrollbar-button { display: none !important; width: 0 !important; height: 0 !important; }`;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
renderCodeModal() {
|
|
1642
|
+
const s = this.core.state;
|
|
1643
|
+
const t = this.core.t;
|
|
1644
|
+
this.codeModal.style.display = s.codeOpen ? 'grid' : 'none';
|
|
1645
|
+
if (!s.codeOpen) return;
|
|
1646
|
+
this.refreshCodeSource();
|
|
1647
|
+
if (this._codeFrameSrc !== s.codeLive) {
|
|
1648
|
+
this.codeFrame.srcdoc = s.codeLive;
|
|
1649
|
+
this._codeFrameSrc = s.codeLive;
|
|
1650
|
+
}
|
|
1651
|
+
this.syncCodePreviewScrollbar();
|
|
1652
|
+
this.codeWidthSeg.innerHTML = '';
|
|
1653
|
+
[{ label: t('device.desktop'), v: 'desktop', iconName: 'monitor' }, { label: t('device.mobile'), v: 'mobile', iconName: 'phone' }].forEach((w) => {
|
|
1654
|
+
const on = s.codeDevice === w.v;
|
|
1655
|
+
const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)};`, { type: 'button', title: w.label, 'aria-pressed': String(on) });
|
|
1656
|
+
btn.appendChild(icon(w.iconName, 15));
|
|
1657
|
+
btn.addEventListener('click', () => this.core.setState({ codeDevice: w.v }));
|
|
1658
|
+
tip(btn, w.label, 'down');
|
|
1659
|
+
this.codeWidthSeg.appendChild(btn);
|
|
1660
|
+
});
|
|
1661
|
+
this.codeFrame.style.width = s.codeDevice === 'mobile' ? '390px' : '100%';
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Repaint the syntax layer, gutter and metadata without touching the
|
|
1665
|
+
* canvas, inspector, iframe, or focused textarea node. */
|
|
1666
|
+
refreshCodeSource() {
|
|
1667
|
+
if (!this.core.state.codeOpen || !this.codeTextarea) return;
|
|
1668
|
+
const s = this.core.state;
|
|
1669
|
+
const t = this.core.t;
|
|
1670
|
+
this.codeStatusEl.textContent = s.codeDirty ? t('code.statusEdited') : t('code.statusSynced');
|
|
1671
|
+
if (this.codeTextarea.value !== s.codeSrc) this.codeTextarea.value = s.codeSrc;
|
|
1672
|
+
const lines = s.codeSrc.split('\n');
|
|
1673
|
+
this.codePre.innerHTML = lines.map((line, index) => `<span style="display:grid;grid-template-columns:52px minmax(0,1fr);min-height:20px"><span style="padding-right:10px;text-align:right;color:var(--ed-faint);border-right:1px solid var(--ed-line);user-select:none">${index + 1}</span><span style="min-width:0;padding:0 18px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word">${hl(line) || '​'}</span></span>`).join('');
|
|
1674
|
+
const lineCount = lines.length;
|
|
1675
|
+
this.codeMetaEl.textContent = t('code.meta', { kb: (s.codeSrc.length / 1024).toFixed(1), lines: lineCount });
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// ---- modals: AI copy --------------------------------------------------
|
|
1679
|
+
|
|
1680
|
+
buildAiModal() {
|
|
1681
|
+
const t = this.core.t;
|
|
1682
|
+
const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: flex-start; justify-content: center; padding: 34px; overflow-y: auto; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
|
|
1683
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
|
|
1684
|
+
const modal = elS('div', 'width: 100%; max-width: 660px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
|
|
1685
|
+
modal.addEventListener('click', (e) => e.stopPropagation());
|
|
1686
|
+
overlay.appendChild(modal);
|
|
1687
|
+
|
|
1688
|
+
const head = elS('div', 'display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
|
|
1689
|
+
const headText = elS('div', 'flex: 1;');
|
|
1690
|
+
headText.append(
|
|
1691
|
+
elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.aiDraft'), 'data-i18n': 'modal.aiDraft' }),
|
|
1692
|
+
elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;', { text: t('modal.aiDraftTitle'), 'data-i18n': 'modal.aiDraftTitle' }),
|
|
1693
|
+
);
|
|
1694
|
+
const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
|
|
1695
|
+
closeBtn.appendChild(icon('x', 14));
|
|
1696
|
+
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1697
|
+
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
|
|
1698
|
+
closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
|
|
1699
|
+
tip(closeBtn, t('action.close'), 'down', 'end');
|
|
1700
|
+
head.append(headText, closeBtn);
|
|
1701
|
+
modal.appendChild(head);
|
|
1702
|
+
|
|
1703
|
+
const body = elS('div', 'padding: 20px; display: grid; gap: 14px;');
|
|
1704
|
+
body.appendChild(elS('p', 'margin: 0; padding: 10px 12px; border: 1px solid var(--ed-line); border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-muted); font-size: 12px; line-height: 1.5;', { text: t('ai.disclosure'), 'data-i18n': 'ai.disclosure' }));
|
|
1705
|
+
const row = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 14px;');
|
|
1706
|
+
|
|
1707
|
+
const goalWrap = elS('div');
|
|
1708
|
+
goalWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.goal'), 'data-i18n': 'ai.goal' }));
|
|
1709
|
+
this.aiGoalSelect = elS('select', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 8px;');
|
|
1710
|
+
this.aiGoalSelect.addEventListener('change', (e) => this.core.setState({ aiGoal: e.target.value }));
|
|
1711
|
+
// Filled once here rather than on every render: the lists are static, and
|
|
1712
|
+
// sixteen goals under two <optgroup> headings stay scannable where one flat
|
|
1713
|
+
// run of sixteen would not.
|
|
1714
|
+
AI_GOALS.forEach((g) => {
|
|
1715
|
+
const grp = document.createElement('optgroup');
|
|
1716
|
+
grp.label = g.group;
|
|
1717
|
+
g.items.forEach((name) => { const o = document.createElement('option'); o.value = name; o.textContent = name; grp.appendChild(o); });
|
|
1718
|
+
this.aiGoalSelect.appendChild(grp);
|
|
1719
|
+
});
|
|
1720
|
+
goalWrap.appendChild(this.aiGoalSelect);
|
|
1721
|
+
|
|
1722
|
+
const toneWrap = elS('div');
|
|
1723
|
+
toneWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.tone'), 'data-i18n': 'ai.tone' }));
|
|
1724
|
+
this.aiToneSelect = elS('select', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 8px;');
|
|
1725
|
+
this.aiToneSelect.addEventListener('change', (e) => this.core.setState({ aiTone: e.target.value }));
|
|
1726
|
+
AI_TONES.forEach((tone) => { const o = document.createElement('option'); o.value = tone; o.textContent = tone; this.aiToneSelect.appendChild(o); });
|
|
1727
|
+
toneWrap.appendChild(this.aiToneSelect);
|
|
1728
|
+
row.append(goalWrap, toneWrap);
|
|
1729
|
+
body.appendChild(row);
|
|
1730
|
+
|
|
1731
|
+
const briefWrap = elS('div');
|
|
1732
|
+
briefWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.briefLabel'), 'data-i18n': 'ai.briefLabel' }));
|
|
1733
|
+
this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief' });
|
|
1734
|
+
this.aiBriefInput.addEventListener('focus', () => { this.aiBriefInput.style.borderColor = 'var(--ed-accent)'; this.aiBriefInput.style.outline = 'none'; });
|
|
1735
|
+
// Debounced with a blur flush: runAi reads state.aiBrief, and clicking
|
|
1736
|
+
// Generate blurs this field first -- the flush lands the last keystrokes
|
|
1737
|
+
// before the read.
|
|
1738
|
+
const briefCommit = typeCommit((v) => this.core.setState({ aiBrief: v }));
|
|
1739
|
+
this.aiBriefInput.addEventListener('blur', () => { this.aiBriefInput.style.borderColor = 'var(--ed-line)'; briefCommit.flush(); });
|
|
1740
|
+
this.aiBriefInput.addEventListener('input', (e) => briefCommit.call(e.target.value));
|
|
1741
|
+
briefWrap.appendChild(this.aiBriefInput);
|
|
1742
|
+
body.appendChild(briefWrap);
|
|
1743
|
+
|
|
1744
|
+
this.aiRunBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; min-height: 36px; padding: 9px 12px; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
|
|
1745
|
+
this.aiRunBtn.iconNode = icon('spark', 14);
|
|
1746
|
+
this.aiRunBtn.labelNode = elS('span');
|
|
1747
|
+
this.aiRunBtn.append(this.aiRunBtn.iconNode, this.aiRunBtn.labelNode);
|
|
1748
|
+
this.aiRunBtn.addEventListener('mouseenter', () => { this.aiRunBtn.style.filter = 'brightness(1.1)'; });
|
|
1749
|
+
this.aiRunBtn.addEventListener('mouseleave', () => { this.aiRunBtn.style.filter = ''; });
|
|
1750
|
+
this.aiRunBtn.addEventListener('click', () => this.core.runAi());
|
|
1751
|
+
body.appendChild(this.aiRunBtn);
|
|
1752
|
+
|
|
1753
|
+
this.aiResultsEl = elS('div');
|
|
1754
|
+
body.appendChild(this.aiResultsEl);
|
|
1755
|
+
modal.appendChild(body);
|
|
1756
|
+
return overlay;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
renderAiModal() {
|
|
1760
|
+
const s = this.core.state;
|
|
1761
|
+
const t = this.core.t;
|
|
1762
|
+
this.aiModal.style.display = s.aiOpen ? 'flex' : 'none';
|
|
1763
|
+
if (!s.aiOpen) return;
|
|
1764
|
+
// Options are built once in buildAiModal; render only reflects state.
|
|
1765
|
+
this.aiGoalSelect.value = s.aiGoal;
|
|
1766
|
+
this.aiToneSelect.value = s.aiTone;
|
|
1767
|
+
if (this.aiBriefInput.value !== s.aiBrief) this.aiBriefInput.value = s.aiBrief;
|
|
1768
|
+
this.aiRunBtn.disabled = s.aiBusy;
|
|
1769
|
+
this.aiRunBtn.labelNode.textContent = s.aiBusy ? t('ai.writing') : t('ai.generate');
|
|
1770
|
+
|
|
1771
|
+
this.aiResultsEl.innerHTML = '';
|
|
1772
|
+
s.aiResults.forEach((r) => {
|
|
1773
|
+
const card = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); padding: 15px;');
|
|
1774
|
+
card.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-accent); margin-bottom: 7px;', { text: r.kind }));
|
|
1775
|
+
card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text }));
|
|
1776
|
+
const useBtn = elS('button', 'margin-top: 12px; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; padding: 6px 11px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: background 0.16s, color 0.16s, border-color 0.16s;', { type: 'button', text: r.action });
|
|
1777
|
+
useBtn.addEventListener('mouseenter', () => { useBtn.style.background = 'var(--ed-accent)'; useBtn.style.color = 'var(--ed-accent-ink)'; useBtn.style.borderColor = 'var(--ed-accent)'; });
|
|
1778
|
+
useBtn.addEventListener('mouseleave', () => { useBtn.style.background = 'transparent'; useBtn.style.color = 'var(--ed-text)'; useBtn.style.borderColor = 'var(--ed-line)'; });
|
|
1779
|
+
useBtn.addEventListener('click', r.onUse);
|
|
1780
|
+
card.appendChild(useBtn);
|
|
1781
|
+
this.aiResultsEl.appendChild(card);
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// ---- modals: preview ----------------------------------------------------
|
|
1786
|
+
|
|
1787
|
+
buildPreviewModal() {
|
|
1788
|
+
const t = this.core.t;
|
|
1789
|
+
const overlay = elS('div', '', { class: 'mc-fullscreen-panel mc-preview-panel' });
|
|
1790
|
+
overlay.style.cssText = 'position: absolute; inset: 0; z-index: 60; display: grid; grid-template-rows: 58px minmax(0, 1fr); animation: mcFade 0.16s ease; display: none;';
|
|
1791
|
+
const head = elS('div', 'display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel);', { class: 'mc-preview-toolbar' });
|
|
1792
|
+
const headText = elS('div', 'flex: 1; min-width: 0;');
|
|
1793
|
+
this.previewKickerEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-preview-kicker' });
|
|
1794
|
+
headText.append(this.previewKickerEl);
|
|
1795
|
+
this.previewDeviceSeg = elS('div', '', { class: 'mc-segment' });
|
|
1796
|
+
const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 13px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button' });
|
|
1797
|
+
closeBtn.appendChild(icon('x', 14));
|
|
1798
|
+
closeBtn.appendChild(elS('span', '', { text: t('action.close'), 'data-i18n': 'action.close' }));
|
|
1799
|
+
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; closeBtn.style.background = 'var(--ed-soft)'; });
|
|
1800
|
+
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; closeBtn.style.background = 'transparent'; });
|
|
1801
|
+
closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
|
|
1802
|
+
head.append(headText, this.previewDeviceSeg, closeBtn);
|
|
1803
|
+
overlay.appendChild(head);
|
|
1804
|
+
const body = elS('div', 'overflow-y: auto; padding: 32px 24px;', { class: 'mc-preview-body' });
|
|
1805
|
+
this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
|
|
1806
|
+
this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
|
|
1807
|
+
body.appendChild(this.previewSlot);
|
|
1808
|
+
overlay.appendChild(body);
|
|
1809
|
+
return overlay;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
renderPreviewModal() {
|
|
1813
|
+
const s = this.core.state;
|
|
1814
|
+
const t = this.core.t;
|
|
1815
|
+
this.previewModal.style.display = s.previewOpen ? 'grid' : 'none';
|
|
1816
|
+
if (!s.previewOpen) return;
|
|
1817
|
+
this.previewKickerEl.textContent = s.device === 'mobile' ? t('preview.kickerMobile') : t('preview.kickerDesktop', { width: s.doc.theme.width });
|
|
1818
|
+
this.previewDeviceSeg.innerHTML = '';
|
|
1819
|
+
[
|
|
1820
|
+
{ label: t('device.desktop'), v: 'desktop', iconName: 'monitor' }, { label: t('device.mobile'), v: 'mobile', iconName: 'phone' },
|
|
1821
|
+
].forEach((d) => {
|
|
1822
|
+
const on = s.device === d.v;
|
|
1823
|
+
const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)};`, { type: 'button', title: d.label, 'aria-pressed': String(on) });
|
|
1824
|
+
btn.appendChild(icon(d.iconName, 15));
|
|
1825
|
+
btn.addEventListener('click', () => this.core.setState({ device: d.v }));
|
|
1826
|
+
tip(btn, d.label, 'down');
|
|
1827
|
+
this.previewDeviceSeg.appendChild(btn);
|
|
1828
|
+
});
|
|
1829
|
+
this.previewSlot.innerHTML = '';
|
|
1830
|
+
this.previewSlot.appendChild(renderDoc(this.core, false));
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** Ported from `blockTiles` inside `renderVals()` -- `icon` is a factory (not a built node) since a DOM node can only ever be attached in one place. */
|
|
1835
|
+
function paletteTiles(core) {
|
|
1836
|
+
const rowCount = () => core.state.doc.rows.length;
|
|
1837
|
+
return PALETTE.map((e) => {
|
|
1838
|
+
if (e.g) {
|
|
1839
|
+
const gr = GROUPS[e.g];
|
|
1840
|
+
return {
|
|
1841
|
+
label: gr.label, icon: () => icon(gr.icon, 20),
|
|
1842
|
+
hint: gr.label + ' — drops a row of ordinary blocks you can edit and rearrange one by one',
|
|
1843
|
+
onDragStart: core.startDrag({ kind: 'group', id: e.g }),
|
|
1844
|
+
onAdd: () => core.insertGroup(e.g, null, 0, rowCount()),
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
const b = DEF(e.t); if (!b) return null;
|
|
1848
|
+
return {
|
|
1849
|
+
label: b.label, icon: () => icon(b.type, 20), hint: b.hint + ' — drag onto the canvas, or double-click to append',
|
|
1850
|
+
onDragStart: core.startDrag({ kind: 'block', type: b.type }),
|
|
1851
|
+
onAdd: () => core.insertBlock(b.type, null, 0, rowCount()),
|
|
1852
|
+
};
|
|
1853
|
+
}).filter(Boolean);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Guarded on both sides. `customElements` is absent under SSR (Next.js, Nuxt,
|
|
1857
|
+
// Angular Universal) and in any Node-based test runner, where importing the
|
|
1858
|
+
// package must not throw; and `define` throws NotSupportedError if the module
|
|
1859
|
+
// is evaluated twice -- HMR, or a bundled and an ESM copy loaded side by side.
|
|
1860
|
+
if (typeof customElements !== 'undefined' && !customElements.get('mailcraft-editor')) {
|
|
1861
|
+
customElements.define('mailcraft-editor', MailCraftEditor);
|
|
1862
|
+
}
|