@vibe-x/dsh-html-workbench 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.
@@ -0,0 +1,901 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>HTML 可视化编辑台</title>
7
+ <link rel="stylesheet" href="/vendor/grapesjs/css/grapes.min.css">
8
+ <style data-bundle="workbench">
9
+ /* Source stylesheet. The build step inlines this file into workbench.html. */
10
+ :root {
11
+ font-family: Geist, "SF Pro Display", "PingFang SC", system-ui, sans-serif;
12
+ color: #18181b;
13
+ --surface: rgba(255, 255, 255, .94);
14
+ --panel: #fbfbfa;
15
+ --canvas: #ececea;
16
+ --line: rgba(24, 24, 27, .12);
17
+ --line-strong: rgba(24, 24, 27, .2);
18
+ --muted: #71717a;
19
+ --ink: #20201f;
20
+ --success: #278455;
21
+ --warning: #b7791f;
22
+ --danger: #c2413b;
23
+ --shadow: 0 16px 45px rgba(38, 38, 35, .13), 0 2px 8px rgba(38, 38, 35, .07);
24
+ }
25
+
26
+ * { box-sizing: border-box; }
27
+ [hidden] { display: none !important; }
28
+ html, body { width: 100%; height: 100%; margin: 0; }
29
+ body { min-width: 0; overflow: hidden; background: var(--canvas); }
30
+ button, input { font: inherit; }
31
+ button { -webkit-tap-highlight-color: transparent; }
32
+
33
+ .workbench { position: relative; width: 100%; height: 100%; overflow: hidden; }
34
+ .topbar {
35
+ position: absolute;
36
+ z-index: 30;
37
+ top: 10px;
38
+ left: 58px;
39
+ right: 10px;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ gap: 10px;
44
+ pointer-events: none;
45
+ }
46
+ .toolbar-cluster {
47
+ display: flex;
48
+ align-items: center;
49
+ min-width: 0;
50
+ height: 42px;
51
+ padding: 4px;
52
+ border: 1px solid rgba(255, 255, 255, .85);
53
+ border-radius: 11px;
54
+ background: var(--surface);
55
+ box-shadow: 0 8px 28px rgba(38, 38, 35, .12), inset 0 0 0 1px var(--line);
56
+ backdrop-filter: blur(18px) saturate(1.15);
57
+ pointer-events: auto;
58
+ }
59
+ .file-cluster { position: relative; max-width: min(320px, 32vw); padding: 0; }
60
+ .file-menu-button {
61
+ display: grid;
62
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
63
+ align-items: center;
64
+ gap: 8px;
65
+ width: 100%;
66
+ height: 40px;
67
+ padding: 0 10px 0 6px;
68
+ border: 0;
69
+ color: var(--ink);
70
+ background: transparent;
71
+ cursor: pointer;
72
+ }
73
+ .file-menu-button > strong { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
74
+ .file-menu-button > svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; transition: transform .18s ease; }
75
+ .file-menu-button[aria-expanded="true"] > svg { transform: rotate(180deg); }
76
+ .product-mark {
77
+ padding: 5px 6px 4px;
78
+ border-radius: 5px;
79
+ color: #fff;
80
+ background: var(--ink);
81
+ font-size: 9px;
82
+ font-weight: 700;
83
+ letter-spacing: .08em;
84
+ }
85
+ .sync-status { display: flex; align-items: center; min-width: 8px; color: var(--muted); }
86
+ .sync-status i { width: 7px; height: 7px; border-radius: 50%; background: #a1a1aa; box-shadow: 0 0 0 3px rgba(161, 161, 170, .12); }
87
+ .sync-status b { width: 0; overflow: hidden; font-size: 10px; font-weight: 500; opacity: 0; white-space: nowrap; }
88
+ .sync-status[data-state="saved"] i { background: var(--success); box-shadow: 0 0 0 3px rgba(39, 132, 85, .12); }
89
+ .sync-status[data-state="dirty"] i, .sync-status[data-state="saving"] i { background: var(--warning); }
90
+ .sync-status[data-state="conflict"] i, .sync-status[data-state="error"] i { background: var(--danger); }
91
+
92
+ .file-popover {
93
+ position: absolute;
94
+ top: 50px;
95
+ left: 0;
96
+ width: min(520px, calc(100vw - 80px));
97
+ padding: 16px;
98
+ border: 1px solid var(--line);
99
+ border-radius: 13px;
100
+ background: rgba(255, 255, 255, .98);
101
+ box-shadow: var(--shadow);
102
+ }
103
+ .file-popover label { display: block; margin-bottom: 8px; color: #3f3f46; font-size: 11px; font-weight: 650; }
104
+ .file-open-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
105
+ .file-open-row input { min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 7px; outline: 0; color: #27272a; background: #fafafa; font-size: 11px; }
106
+ .file-open-row input:focus { border-color: #52525b; box-shadow: 0 0 0 3px rgba(63, 63, 70, .1); }
107
+ .file-popover p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
108
+
109
+ .viewport-controls { flex: none; gap: 2px; }
110
+ .toolbar-separator { width: 1px; height: 20px; margin: 0 3px; background: var(--line); }
111
+ .toolbar-cluster button, .panel-actions button {
112
+ min-height: 32px;
113
+ border: 0;
114
+ border-radius: 7px;
115
+ color: #52525b;
116
+ background: transparent;
117
+ font-size: 11px;
118
+ font-variant-numeric: tabular-nums;
119
+ cursor: pointer;
120
+ transition: color .18s ease, background .18s ease, transform .12s ease, box-shadow .18s ease;
121
+ }
122
+ .toolbar-cluster button:hover, .panel-actions button:hover { color: #18181b; background: rgba(24, 24, 27, .06); }
123
+ .toolbar-cluster button:active, .panel-actions button:active, .tool-rail button:active { transform: translateY(1px) scale(.98); }
124
+ .toolbar-cluster button:focus-visible, .panel-actions button:focus-visible, .tool-rail button:focus-visible { outline: 2px solid #3f3f46; outline-offset: 2px; }
125
+ .viewport-controls button { min-width: 42px; padding: 0 8px; }
126
+ .viewport-controls .mode-button { min-width: 44px; }
127
+ .viewport-controls .fit-button { min-width: 48px; }
128
+ .viewport-controls button.active { color: #fff; background: var(--ink); box-shadow: 0 2px 7px rgba(24, 24, 27, .22); }
129
+ .actions { flex: none; gap: 2px; }
130
+ .icon-button { display: inline-grid; place-items: center; width: 32px; padding: 0; }
131
+ .icon-button svg, .save-button svg, .tool-rail svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.55; }
132
+ .actions .save-button { display: flex; align-items: center; gap: 6px; min-width: 70px; margin-left: 2px; padding: 0 10px; color: #fff; background: var(--ink); }
133
+ .actions .save-button:hover { color: #fff; background: #353532; }
134
+ .actions button:disabled { opacity: .45; cursor: not-allowed; }
135
+
136
+ .tool-rail {
137
+ position: absolute;
138
+ z-index: 28;
139
+ top: 64px;
140
+ left: 10px;
141
+ display: grid;
142
+ gap: 5px;
143
+ width: 40px;
144
+ padding: 4px;
145
+ border: 1px solid rgba(255, 255, 255, .85);
146
+ border-radius: 11px;
147
+ background: var(--surface);
148
+ box-shadow: 0 8px 26px rgba(38, 38, 35, .11), inset 0 0 0 1px var(--line);
149
+ backdrop-filter: blur(18px);
150
+ }
151
+ .tool-rail button {
152
+ display: grid;
153
+ place-items: center;
154
+ gap: 2px;
155
+ width: 32px;
156
+ min-height: 43px;
157
+ padding: 5px 2px 4px;
158
+ border: 0;
159
+ border-radius: 7px;
160
+ color: #71717a;
161
+ background: transparent;
162
+ cursor: pointer;
163
+ transition: color .18s ease, background .18s ease, transform .12s ease;
164
+ }
165
+ .tool-rail button span { font-size: 8px; font-weight: 600; letter-spacing: .03em; }
166
+ .tool-rail button:hover { color: #18181b; background: rgba(24, 24, 27, .06); }
167
+ .tool-rail button.active { color: #fff; background: var(--ink); }
168
+
169
+ .control-panel {
170
+ position: absolute;
171
+ z-index: 25;
172
+ top: 62px;
173
+ bottom: 12px;
174
+ left: 60px;
175
+ display: flex;
176
+ flex-direction: column;
177
+ width: 288px;
178
+ overflow: hidden;
179
+ border: 1px solid rgba(255, 255, 255, .9);
180
+ border-radius: 13px;
181
+ background: rgba(251, 251, 250, .97);
182
+ box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
183
+ backdrop-filter: blur(20px) saturate(1.1);
184
+ }
185
+ .panel-header { display: flex; align-items: center; justify-content: space-between; min-height: 58px; padding: 10px 10px 9px 14px; border-bottom: 1px solid var(--line); }
186
+ .panel-header > div:first-child { display: grid; gap: 2px; }
187
+ .panel-header small { color: var(--muted); font-size: 9px; letter-spacing: .08em; }
188
+ .panel-header strong { font-size: 13px; font-weight: 650; }
189
+ .panel-actions { display: flex; gap: 2px; }
190
+ .panel-actions .icon-button[aria-pressed="true"] { color: #fff; background: var(--ink); }
191
+ .panel-content { flex: 1; min-height: 0; overflow: auto; padding: 14px; }
192
+ .section-label { margin: 0 0 10px; color: var(--muted); font-size: 9px; font-weight: 650; letter-spacing: .08em; }
193
+
194
+ #blocks-panel { display: block; }
195
+ #blocks-panel .gjs-block-category { display: contents; }
196
+ #blocks-panel .gjs-title { display: none; }
197
+ #blocks-panel .gjs-blocks-c { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
198
+ #blocks-panel .gjs-block {
199
+ width: auto;
200
+ min-height: 64px;
201
+ margin: 0;
202
+ padding: 10px 5px;
203
+ border: 1px solid var(--line);
204
+ border-radius: 8px;
205
+ color: #3f3f46;
206
+ background: #fff;
207
+ box-shadow: none;
208
+ font-size: 10px;
209
+ transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
210
+ }
211
+ #blocks-panel .gjs-block:hover { border-color: var(--line-strong); transform: translateY(-1px); box-shadow: 0 6px 14px rgba(38, 38, 35, .08); }
212
+ #styles-panel { color: #3f3f46; background: transparent; }
213
+ #styles-panel .gjs-sm-sector-title { padding: 9px 0; border-bottom: 1px solid var(--line); background: transparent; font-size: 11px; }
214
+ #styles-panel .gjs-sm-properties { padding: 10px 0; }
215
+ #styles-panel .gjs-sm-property { width: 100%; margin-bottom: 9px; }
216
+ #styles-panel .gjs-sm-label { color: var(--muted); font-size: 10px; }
217
+ #styles-panel .gjs-field { border: 1px solid var(--line); border-radius: 6px; color: #18181b; background: #fff; }
218
+ .attributes-panel { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
219
+ .attributes-panel label { display: grid; gap: 5px; margin: 9px 0; color: #52525b; font-size: 10px; }
220
+ .attributes-panel input { width: 100%; height: 32px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; outline: 0; }
221
+ .attributes-panel input:focus { border-color: #52525b; box-shadow: 0 0 0 3px rgba(63, 63, 70, .1); }
222
+
223
+ .canvas-area {
224
+ position: absolute;
225
+ inset: 0;
226
+ min-width: 0;
227
+ min-height: 0;
228
+ overflow: auto;
229
+ padding: 62px 12px 12px 58px;
230
+ background: radial-gradient(circle at 52% 0%, rgba(255, 255, 255, .72), transparent 38%), var(--canvas);
231
+ transition: padding-left .22s ease;
232
+ }
233
+ .workbench.panel-pinned .canvas-area { padding-left: 360px; }
234
+ .workbench[data-mode="preview"] .tool-rail,
235
+ .workbench[data-mode="preview"] .control-panel,
236
+ .workbench[data-mode="preview"] .edit-action { display: none !important; }
237
+ .workbench[data-mode="preview"] .canvas-area { padding-left: 12px; }
238
+ .workbench[data-mode="preview"] .canvas-viewport { box-shadow: 0 12px 38px rgba(38, 38, 35, .1); }
239
+ .canvas-stage { display: flex; justify-content: center; width: max-content; min-width: 100%; height: 100%; min-height: 320px; }
240
+ .canvas-viewport {
241
+ flex: none;
242
+ height: 100%;
243
+ min-width: 320px;
244
+ overflow: hidden;
245
+ border: 1px solid rgba(63, 63, 70, .22);
246
+ border-radius: 3px;
247
+ background: #fff;
248
+ box-shadow: 0 18px 50px rgba(38, 38, 35, .12);
249
+ transform-origin: top center;
250
+ }
251
+ #gjs, .preview-frame { width: 100%; height: 100%; }
252
+ .preview-frame { display: block; border: 0; background: #fff; }
253
+ .workbench[data-mode="preview"] #gjs { display: none; }
254
+ .gjs-one-bg { background-color: #fff; }
255
+ .gjs-two-color { color: #3f3f46; }
256
+ .gjs-three-bg { background-color: var(--ink); }
257
+ .gjs-four-color, .gjs-four-color-h:hover { color: var(--ink); }
258
+ .gjs-cv-canvas { top: 0; width: 100%; height: 100%; background: #dededb; }
259
+ .gjs-frame-wrapper { background: #fff; }
260
+ .gjs-toolbar, .gjs-badge { color: #fff; background: var(--ink); }
261
+ .gjs-highlighter, .gjs-highlighter-sel { outline-color: var(--ink); }
262
+
263
+ @media (max-width: 820px) {
264
+ .topbar { left: 54px; gap: 6px; }
265
+ .file-cluster { max-width: 190px; }
266
+ .viewport-controls button { min-width: 36px; padding-inline: 6px; }
267
+ .viewport-controls .mode-button { min-width: 38px; }
268
+ .actions .save-button { min-width: 34px; padding: 0 8px; }
269
+ .actions .save-button span { display: none; }
270
+ }
271
+
272
+ @media (max-width: 650px) {
273
+ .file-cluster { max-width: 150px; }
274
+ .viewport-controls [data-width="375"], .viewport-controls [data-width="1280"] { display: none; }
275
+ .toolbar-separator { margin-inline: 1px; }
276
+ .actions #replace-image-button { display: none; }
277
+ }
278
+
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <main class="workbench" data-grapesjs-workbench="true">
283
+ <header class="topbar" aria-label="编辑器工具栏">
284
+ <div class="toolbar-cluster file-cluster">
285
+ <button id="file-menu-button" class="file-menu-button" type="button" aria-expanded="false">
286
+ <span class="product-mark">HTML</span>
287
+ <strong id="file-name">未打开文件</strong>
288
+ <span id="status" class="sync-status" data-state="idle"><i></i><b>等待打开文件</b></span>
289
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6.5 8 3.5 3.5L13.5 8"/></svg>
290
+ </button>
291
+ <div id="file-popover" class="file-popover" hidden>
292
+ <label for="file-input">本地 HTML 文件</label>
293
+ <div class="file-open-row">
294
+ <input id="file-input" aria-label="HTML 文件绝对路径" placeholder="/absolute/path/page.html">
295
+ <button id="open-button" type="button">打开</button>
296
+ </div>
297
+ <p>输入绝对路径,工作台会直接读取并同步保存。</p>
298
+ </div>
299
+ </div>
300
+
301
+ <div class="toolbar-cluster viewport-controls" aria-label="工作台模式和画布尺寸">
302
+ <button type="button" data-mode="edit" class="mode-button active" aria-pressed="true">编辑</button>
303
+ <button type="button" data-mode="preview" class="mode-button" aria-pressed="false">预览</button>
304
+ <span class="toolbar-separator" aria-hidden="true"></span>
305
+ <button id="fit-button" class="fit-button active" type="button" aria-pressed="true">适应</button>
306
+ <button type="button" data-width="375">375</button>
307
+ <button type="button" data-width="768" class="active">768</button>
308
+ <button type="button" data-width="1280">1280</button>
309
+ </div>
310
+
311
+ <div class="toolbar-cluster actions">
312
+ <button id="undo-button" class="icon-button edit-action" type="button" aria-label="撤销" title="撤销">
313
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 5 3.5 9l4 4"/><path d="M4 9h7a5 5 0 0 1 5 5"/></svg>
314
+ </button>
315
+ <button id="redo-button" class="icon-button edit-action" type="button" aria-label="重做" title="重做">
316
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 5 4 4-4 4"/><path d="M16 9H9a5 5 0 0 0-5 5"/></svg>
317
+ </button>
318
+ <button id="replace-image-button" class="icon-button edit-action" type="button" aria-label="替换图片" title="替换图片">
319
+ <svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="12" rx="2"/><circle cx="7" cy="8" r="1.2"/><path d="m5 14 3.5-3.5 2.5 2 2-2 2 2"/></svg>
320
+ </button>
321
+ <button id="save-button" class="primary save-button edit-action" type="button">
322
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 3h10l2 2v12H4z"/><path d="M7 3v5h6V3M7 17v-5h6v5"/></svg>
323
+ <span>保存</span>
324
+ </button>
325
+ </div>
326
+ </header>
327
+
328
+ <nav class="tool-rail" aria-label="编辑工具">
329
+ <button type="button" data-panel="blocks" aria-label="添加内容" title="添加内容">
330
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"/></svg>
331
+ <span>添加</span>
332
+ </button>
333
+ <button type="button" data-panel="styles" aria-label="调整样式" title="调整样式">
334
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 6h8M15 6h1M4 14h1M8 14h8"/><circle cx="13.5" cy="6" r="1.5"/><circle cx="6.5" cy="14" r="1.5"/></svg>
335
+ <span>样式</span>
336
+ </button>
337
+ </nav>
338
+
339
+ <aside id="control-panel" class="control-panel" hidden>
340
+ <header class="panel-header">
341
+ <div><small>编辑工具</small><strong id="panel-title">添加内容</strong></div>
342
+ <div class="panel-actions">
343
+ <button id="pin-panel-button" class="icon-button" type="button" aria-label="固定面板" aria-pressed="false" title="固定面板">
344
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 3 6 2-1 4 3 3-3 1-2 4-1-5-4-1 3-3z"/></svg>
345
+ </button>
346
+ <button id="close-panel-button" class="icon-button" type="button" aria-label="关闭面板" title="关闭面板">
347
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15"/></svg>
348
+ </button>
349
+ </div>
350
+ </header>
351
+ <section class="panel-content" data-panel-content="blocks">
352
+ <p class="section-label">拖入画布</p>
353
+ <div id="blocks-panel"></div>
354
+ </section>
355
+ <section class="panel-content" data-panel-content="styles" hidden>
356
+ <p class="section-label">所选元素</p>
357
+ <div id="styles-panel"></div>
358
+ <section id="attributes-panel" class="attributes-panel" hidden>
359
+ <p class="section-label">元素属性</p>
360
+ <label>图片地址<input id="src-input"></label>
361
+ <label>替代文本<input id="alt-input"></label>
362
+ <label>链接地址<input id="href-input"></label>
363
+ </section>
364
+ </section>
365
+ </aside>
366
+
367
+ <section class="canvas-area">
368
+ <div id="canvas-stage" class="canvas-stage">
369
+ <div id="canvas-viewport" class="canvas-viewport" style="width:768px">
370
+ <div id="gjs"></div>
371
+ <iframe id="preview-frame" class="preview-frame" title="页面交互预览" hidden></iframe>
372
+ </div>
373
+ </div>
374
+ </section>
375
+ </main>
376
+ <script src="/vendor/grapesjs/grapes.min.js"></script>
377
+ <script type="module" data-bundle="workbench">
378
+ 'use strict'
379
+
380
+ const grapesjs = window.grapesjs
381
+ const fileInput = document.querySelector('#file-input')
382
+ const fileName = document.querySelector('#file-name')
383
+ const status = document.querySelector('#status')
384
+ const saveButton = document.querySelector('#save-button')
385
+ const canvasArea = document.querySelector('.canvas-area')
386
+ const canvasStage = document.querySelector('#canvas-stage')
387
+ const viewport = document.querySelector('#canvas-viewport')
388
+ const fitButton = document.querySelector('#fit-button')
389
+ const fileMenuButton = document.querySelector('#file-menu-button')
390
+ const filePopover = document.querySelector('#file-popover')
391
+ const controlPanel = document.querySelector('#control-panel')
392
+ const closePanelButton = document.querySelector('#close-panel-button')
393
+ const pinPanelButton = document.querySelector('#pin-panel-button')
394
+ const panelTitle = document.querySelector('#panel-title')
395
+ const panelButtons = [...document.querySelectorAll('[data-panel]')]
396
+ const panelContents = [...document.querySelectorAll('[data-panel-content]')]
397
+ const attributesPanel = document.querySelector('#attributes-panel')
398
+ const srcInput = document.querySelector('#src-input')
399
+ const altInput = document.querySelector('#alt-input')
400
+ const hrefInput = document.querySelector('#href-input')
401
+ const workbench = document.querySelector('.workbench')
402
+ const modeButtons = [...document.querySelectorAll('[data-mode]')]
403
+ const previewFrame = document.querySelector('#preview-frame')
404
+
405
+ let editor
406
+ let activeFile = ''
407
+ let assetBase = ''
408
+ let revision = ''
409
+ let bodyScripts = []
410
+ let sourceCss = ''
411
+ let links = []
412
+ let headScripts = []
413
+ let htmlAttributes = {}
414
+ let bodyAttributes = {}
415
+ let appliedHtmlAttributes = new Set()
416
+ let appliedBodyAttributes = new Set()
417
+ let eventSource
418
+ let saveTimer
419
+ let applyingDocument = false
420
+ let dirty = false
421
+ let canvasWidth = 768
422
+ let fitCanvas = true
423
+ let activePanel = ''
424
+ let panelPinned = false
425
+ let workbenchMode = 'edit'
426
+
427
+ function setStatus(text, state = 'idle') {
428
+ status.querySelector('b').textContent = text
429
+ status.dataset.state = state
430
+ saveButton.disabled = state === 'saving'
431
+ }
432
+
433
+ function api(route) {
434
+ return `${route}?file=${encodeURIComponent(activeFile)}`
435
+ }
436
+
437
+ function setFilePopover(open) {
438
+ filePopover.hidden = !open
439
+ fileMenuButton.setAttribute('aria-expanded', String(open))
440
+ }
441
+
442
+ function setPanelPinned(pinned) {
443
+ panelPinned = pinned
444
+ pinPanelButton.setAttribute('aria-pressed', String(pinned))
445
+ pinPanelButton.setAttribute('aria-label', pinned ? '取消固定面板' : '固定面板')
446
+ pinPanelButton.title = pinned ? '取消固定' : '固定面板'
447
+ document.querySelector('.workbench').classList.toggle('panel-pinned', pinned && !controlPanel.hidden)
448
+ requestAnimationFrame(updateCanvasScale)
449
+ }
450
+
451
+ function closePanel() {
452
+ controlPanel.hidden = true
453
+ activePanel = ''
454
+ panelButtons.forEach((button) => button.classList.remove('active'))
455
+ setPanelPinned(false)
456
+ }
457
+
458
+ function openPanel(name) {
459
+ if (activePanel === name && !controlPanel.hidden) return closePanel()
460
+ activePanel = name
461
+ controlPanel.hidden = false
462
+ panelTitle.textContent = name === 'styles' ? '调整样式' : '添加内容'
463
+ panelButtons.forEach((button) => button.classList.toggle('active', button.dataset.panel === name))
464
+ panelContents.forEach((content) => { content.hidden = content.dataset.panelContent !== name })
465
+ setPanelPinned(name === 'styles')
466
+ requestAnimationFrame(() => {
467
+ updateCanvasScale()
468
+ editor?.refresh()
469
+ })
470
+ }
471
+
472
+ function escapeAttribute(value) {
473
+ return String(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
474
+ }
475
+
476
+ function serializeAttributes(attributes = {}) {
477
+ return Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('')
478
+ }
479
+
480
+ function serializeHeadScript(source) {
481
+ return `<script${serializeAttributes(source.attributes)}>${source.content || ''}<\/script>`
482
+ }
483
+
484
+ function getBodyHtml() {
485
+ // GrapesJS 的 wrapper 组件 tagName 为 'body',editor.getHtml() 返回
486
+ // <body>...</body>。服务端校验禁止 bodyHtml 包含 html/head/body/script
487
+ // 标签,预览文档也已有 <body> 包装,因此这里取 wrapper 的 innerHTML。
488
+ const wrapper = editor.getWrapper()
489
+ return wrapper.getInnerHTML ? wrapper.getInnerHTML() : editor.getHtml().replace(/^<body\b[^>]*>/i, '').replace(/<\/body>\s*$/i, '')
490
+ }
491
+
492
+ function buildPreviewDocument() {
493
+ const previewBase = new URL(assetBase, location.origin).href
494
+ const stylesheets = links.map((href) => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join('\n')
495
+ const scripts = headScripts.map(serializeHeadScript).join('\n')
496
+ const overrideCss = editor.getCss()
497
+ const previewHelpers = `<script>
498
+ document.addEventListener('click', function (event) {
499
+ const link = event.target.closest && event.target.closest('a[href^="#"]')
500
+ if (!link) return
501
+ const fragment = link.getAttribute('href').slice(1)
502
+ if (!fragment) return
503
+ const target = document.getElementById(decodeURIComponent(fragment))
504
+ if (!target) return
505
+ event.preventDefault()
506
+ target.scrollIntoView()
507
+ }, false)
508
+ <\/script>`
509
+ return `<!doctype html>
510
+ <html${serializeAttributes(htmlAttributes)}>
511
+ <head>
512
+ <meta charset="utf-8">
513
+ <base href="${escapeAttribute(previewBase)}">
514
+ ${stylesheets}
515
+ <style>${sourceCss}</style>
516
+ <style data-grapesjs-overrides>${overrideCss}</style>
517
+ ${scripts}
518
+ </head>
519
+ <body${serializeAttributes(bodyAttributes)}>
520
+ ${getBodyHtml()}
521
+ ${bodyScripts.join('\n')}
522
+ ${previewHelpers}
523
+ </body>
524
+ </html>`
525
+ }
526
+
527
+ function stateSelectorFor(element) {
528
+ const id = element.getAttribute('id')
529
+ if (id) return `#${CSS.escape(id)}`
530
+ const workbenchId = element.getAttribute('data-wb-id')
531
+ if (workbenchId) return `[data-wb-id="${CSS.escape(workbenchId)}"]`
532
+ const mode = element.getAttribute('data-mode')
533
+ if (mode) return `[data-mode="${CSS.escape(mode)}"]`
534
+ return null
535
+ }
536
+
537
+ function syncPreviewStateToEditor() {
538
+ const previewDocument = previewFrame.contentDocument
539
+ const editDocument = editor?.Canvas.getDocument()
540
+ if (!previewDocument || !editDocument) return
541
+
542
+ // Preview contains runtime DOM while Edit is GrapesJS's source model. Only
543
+ // copy a narrow, semantic state allowlist for elements with stable identity;
544
+ // arbitrary runtime styles, animations and framework DOM must not leak into
545
+ // the editable source on every mode switch.
546
+ const stateAttributes = ['class', 'hidden', 'aria-selected', 'aria-expanded', 'aria-hidden']
547
+ previewDocument.querySelectorAll('[id], [data-wb-id], [data-mode]').forEach((previewElement) => {
548
+ const selector = stateSelectorFor(previewElement)
549
+ if (!selector) return
550
+ const editElement = editDocument.querySelector(selector)
551
+ const component = componentFromCanvasElement(editElement)
552
+ if (!component) return
553
+
554
+ const attributes = { ...component.getAttributes() }
555
+ for (const attribute of stateAttributes) {
556
+ if (previewElement.hasAttribute(attribute)) {
557
+ attributes[attribute] = previewElement.getAttribute(attribute) || ''
558
+ } else {
559
+ delete attributes[attribute]
560
+ }
561
+ }
562
+ component.setAttributes(attributes)
563
+ })
564
+ }
565
+
566
+ function setWorkbenchMode(nextMode) {
567
+ if (!editor || nextMode === workbenchMode || !['edit', 'preview'].includes(nextMode)) return
568
+ workbenchMode = nextMode
569
+ const previewing = workbenchMode === 'preview'
570
+ workbench.dataset.mode = workbenchMode
571
+ modeButtons.forEach((button) => {
572
+ const active = button.dataset.mode === workbenchMode
573
+ button.classList.toggle('active', active)
574
+ button.setAttribute('aria-pressed', String(active))
575
+ })
576
+ if (previewing) {
577
+ closePanel()
578
+ setFilePopover(false)
579
+ editor.select(null)
580
+ previewFrame.srcdoc = buildPreviewDocument()
581
+ previewFrame.hidden = false
582
+ } else {
583
+ syncPreviewStateToEditor()
584
+ previewFrame.hidden = true
585
+ previewFrame.srcdoc = ''
586
+ }
587
+ requestAnimationFrame(() => {
588
+ updateCanvasScale()
589
+ editor.refresh()
590
+ })
591
+ }
592
+
593
+ function updateCanvasScale() {
594
+ if (!canvasArea || !viewport) return
595
+ const styles = getComputedStyle(canvasArea)
596
+ const availableWidth = canvasArea.clientWidth - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight)
597
+ const availableHeight = canvasArea.clientHeight - parseFloat(styles.paddingTop) - parseFloat(styles.paddingBottom)
598
+ const scale = fitCanvas ? Math.min(1, availableWidth / canvasWidth) : 1
599
+ viewport.style.width = `${canvasWidth}px`
600
+ viewport.style.height = `${Math.max(320, availableHeight / scale)}px`
601
+ viewport.style.zoom = String(scale)
602
+ viewport.dataset.scale = scale.toFixed(3)
603
+ canvasStage.style.minHeight = `${Math.max(320, availableHeight)}px`
604
+ requestAnimationFrame(() => editor?.refresh())
605
+ }
606
+
607
+ function applySourceAttributes(element, attributes, appliedNames) {
608
+ for (const name of appliedNames) element.removeAttribute(name)
609
+ const nextNames = new Set()
610
+ for (const [name, value] of Object.entries(attributes || {})) {
611
+ element.setAttribute(name, value)
612
+ nextNames.add(name)
613
+ }
614
+ return nextNames
615
+ }
616
+
617
+ function componentFromCanvasElement(element) {
618
+ const node = element?.closest?.('[data-gjs-type][id]')
619
+ if (!node) return null
620
+ return editor.getWrapper().find(`#${node.id}`)[0] || null
621
+ }
622
+
623
+ async function injectSourceAssets() {
624
+ const document = editor?.Canvas.getDocument()
625
+ if (!document) return
626
+ document.querySelectorAll('[data-source-asset]').forEach((node) => node.remove())
627
+ appliedHtmlAttributes = applySourceAttributes(document.documentElement, htmlAttributes, appliedHtmlAttributes)
628
+ appliedBodyAttributes = applySourceAttributes(document.body, bodyAttributes, appliedBodyAttributes)
629
+ const base = document.createElement('base')
630
+ base.href = new URL(assetBase, location.origin).href
631
+ base.dataset.sourceAsset = 'true'
632
+ document.head.prepend(base)
633
+
634
+ for (const source of headScripts) {
635
+ const script = document.createElement('script')
636
+ for (const [name, value] of Object.entries(source.attributes || {})) script.setAttribute(name, value)
637
+ script.dataset.sourceAsset = 'true'
638
+ if (!script.src) {
639
+ script.textContent = source.content || ''
640
+ document.head.append(script)
641
+ continue
642
+ }
643
+ await new Promise((resolve) => {
644
+ script.addEventListener('load', resolve, { once: true })
645
+ script.addEventListener('error', resolve, { once: true })
646
+ document.head.append(script)
647
+ })
648
+ }
649
+ for (const href of links) {
650
+ const link = document.createElement('link')
651
+ link.rel = 'stylesheet'
652
+ link.href = href
653
+ link.dataset.sourceAsset = 'true'
654
+ document.head.append(link)
655
+ }
656
+ const style = document.createElement('style')
657
+ style.dataset.sourceAsset = 'true'
658
+ style.textContent = sourceCss
659
+ document.head.append(style)
660
+ const interactionStyle = document.createElement('style')
661
+ interactionStyle.dataset.sourceAsset = 'true'
662
+ interactionStyle.textContent = '[data-gjs-type="default"]:empty{pointer-events:none!important}'
663
+ document.head.append(interactionStyle)
664
+
665
+ if (!document.documentElement.dataset.workbenchInteractionBound) {
666
+ document.documentElement.dataset.workbenchInteractionBound = 'true'
667
+ document.addEventListener('click', (event) => {
668
+ if (workbenchMode !== 'edit') return
669
+ const component = componentFromCanvasElement(event.target)
670
+ if (component) editor.select(component)
671
+ }, true)
672
+ document.addEventListener('dblclick', (event) => {
673
+ if (workbenchMode !== 'edit') return
674
+ const component = componentFromCanvasElement(event.target)
675
+ if (!component) return
676
+ editor.select(component)
677
+ if (component.get('editable')) component.trigger('active', event)
678
+ }, true)
679
+ }
680
+
681
+ }
682
+
683
+ function initEditor() {
684
+ editor = grapesjs.init({
685
+ container: '#gjs',
686
+ height: '100%',
687
+ width: 'auto',
688
+ storageManager: false,
689
+ jsInHtml: false,
690
+ panels: { defaults: [] },
691
+ blockManager: {
692
+ appendTo: '#blocks-panel',
693
+ blocks: [
694
+ { id: 'text', label: '文本', category: '基础', content: '<p data-gjs-type="text">双击编辑这段文字</p>' },
695
+ { id: 'section', label: '区块', category: '基础', content: '<section style="padding:32px"><h2>新章节</h2><p>在这里补充内容。</p></section>' },
696
+ { id: 'image', label: '图片', category: '基础', content: { type: 'image' }, select: true, activate: true },
697
+ ],
698
+ },
699
+ styleManager: {
700
+ appendTo: '#styles-panel',
701
+ sectors: [
702
+ { name: '排版', open: true, properties: ['font-family', 'font-size', 'font-weight', 'color', 'line-height', 'letter-spacing', 'text-align'] },
703
+ { name: '空间', open: false, properties: ['margin', 'padding'] },
704
+ { name: '尺寸', open: false, properties: ['width', 'height', 'max-width', 'min-height'] },
705
+ { name: '外观', open: false, properties: ['background-color', 'border', 'border-radius', 'opacity'] },
706
+ { name: '布局', open: false, properties: ['display', 'flex-direction', 'justify-content', 'align-items', 'gap'] },
707
+ ],
708
+ },
709
+ selectorManager: { componentFirst: true },
710
+ canvas: { frameStyle: 'html{background:#f4f4f5}body{min-height:100vh}' },
711
+ })
712
+ window.__GRAPESJS_WORKBENCH__ = { editor }
713
+ editor.on('load', () => void injectSourceAssets())
714
+ editor.on('update', scheduleSave)
715
+ // GrapesJS 的 RTE 文本编辑走 storeData → sync:content(noCount:true)路径,
716
+ // 不触发 editor 的 'update' 事件(changesCount 不增加)。但组件模型的
717
+ // Backbone change 事件不受 noCount 影响,会转发为 component:update,
718
+ // 因此补充监听该事件以覆盖文本内容编辑场景。
719
+ editor.on('component:update', scheduleSave)
720
+ editor.on('component:selected', updateAttributePanel)
721
+ }
722
+
723
+ function updateAttributePanel(component) {
724
+ const tag = component?.get('tagName')
725
+ const attributes = component?.getAttributes?.() || {}
726
+ const image = tag === 'img'
727
+ const link = tag === 'a'
728
+ attributesPanel.hidden = !image && !link
729
+ srcInput.parentElement.hidden = !image
730
+ altInput.parentElement.hidden = !image
731
+ hrefInput.parentElement.hidden = !link
732
+ srcInput.value = attributes.src || ''
733
+ altInput.value = attributes.alt || ''
734
+ hrefInput.value = attributes.href || ''
735
+ }
736
+
737
+ function scheduleSave() {
738
+ if (applyingDocument || workbenchMode !== 'edit' || !activeFile) return
739
+ dirty = true
740
+ setStatus('等待自动保存', 'dirty')
741
+ clearTimeout(saveTimer)
742
+ saveTimer = setTimeout(save, 500)
743
+ }
744
+
745
+ async function save() {
746
+ if (!editor || !activeFile || !revision || !dirty) return
747
+ clearTimeout(saveTimer)
748
+ setStatus('正在写入磁盘', 'saving')
749
+ try {
750
+ const response = await fetch(api('/api/document'), {
751
+ method: 'PUT',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({
754
+ baseRevision: revision,
755
+ bodyHtml: getBodyHtml(),
756
+ css: editor.getCss(),
757
+ bodyScripts,
758
+ }),
759
+ })
760
+ const result = await response.json()
761
+ if (response.status === 409) {
762
+ setStatus('磁盘有更新,已停止覆盖', 'conflict')
763
+ dirty = false
764
+ await applyDocument(result.document)
765
+ return
766
+ }
767
+ if (!response.ok) throw new Error(result.message || result.error)
768
+ revision = result.revision
769
+ bodyScripts = result.bodyScripts || bodyScripts
770
+ dirty = false
771
+ setStatus('已与磁盘同步', 'saved')
772
+ } catch (error) {
773
+ setStatus(error.message || '保存失败', 'error')
774
+ }
775
+ }
776
+
777
+ async function applyDocument(document) {
778
+ activeFile = document.filePath
779
+ assetBase = document.assetBase
780
+ revision = document.revision
781
+ bodyScripts = document.bodyScripts || []
782
+ sourceCss = document.sourceCss || ''
783
+ links = document.links || []
784
+ headScripts = document.headScripts || []
785
+ htmlAttributes = document.htmlAttributes || {}
786
+ bodyAttributes = document.bodyAttributes || {}
787
+ fileInput.value = activeFile
788
+ fileName.textContent = document.fileName
789
+ fileName.title = activeFile
790
+ applyingDocument = true
791
+ clearTimeout(saveTimer)
792
+ dirty = false
793
+ editor.setComponents(document.bodyHtml)
794
+ editor.setStyle(document.overrideCss || '')
795
+ await injectSourceAssets()
796
+ requestAnimationFrame(() => editor.refresh())
797
+ setTimeout(() => {
798
+ clearTimeout(saveTimer)
799
+ dirty = false
800
+ applyingDocument = false
801
+ }, 250)
802
+ }
803
+
804
+ async function loadFile(file = activeFile) {
805
+ if (!file) return setStatus('请输入 HTML 文件路径', 'error')
806
+ if (dirty) await save()
807
+ setStatus('正在读取磁盘文件', 'saving')
808
+ try {
809
+ activeFile = file
810
+ const response = await fetch(api('/api/document'))
811
+ const document = await response.json()
812
+ if (!response.ok) throw new Error(document.message || document.error)
813
+ await applyDocument(document)
814
+ dirty = false
815
+ connectEvents()
816
+ const url = new URL(location.href)
817
+ url.searchParams.set('file', activeFile)
818
+ history.replaceState({}, '', url)
819
+ setFilePopover(false)
820
+ setStatus('已与磁盘同步', 'saved')
821
+ } catch (error) {
822
+ setStatus(error.message || '加载失败', 'error')
823
+ }
824
+ }
825
+
826
+ function connectEvents() {
827
+ eventSource?.close()
828
+ eventSource = new EventSource(api('/api/events'))
829
+ eventSource.onmessage = async (event) => {
830
+ const update = JSON.parse(event.data)
831
+ if (!update.revision || update.revision === revision) return
832
+ if (dirty) return setStatus('磁盘已更新,请先处理当前编辑', 'conflict')
833
+ await loadFile(activeFile)
834
+ }
835
+ eventSource.onerror = () => setStatus('文件监听暂时断开', 'error')
836
+ }
837
+
838
+ function saveAttribute(name, input) {
839
+ const selected = editor?.getSelected()
840
+ if (!selected) return
841
+ selected.addAttributes({ [name]: input.value })
842
+ scheduleSave()
843
+ }
844
+
845
+ initEditor()
846
+ document.querySelector('#open-button').addEventListener('click', () => loadFile(fileInput.value.trim()))
847
+ fileInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') loadFile(fileInput.value.trim()) })
848
+ fileMenuButton.addEventListener('click', () => setFilePopover(filePopover.hidden))
849
+ panelButtons.forEach((button) => button.addEventListener('click', () => openPanel(button.dataset.panel)))
850
+ modeButtons.forEach((button) => button.addEventListener('click', () => setWorkbenchMode(button.dataset.mode)))
851
+ closePanelButton.addEventListener('click', closePanel)
852
+ pinPanelButton.addEventListener('click', () => setPanelPinned(!panelPinned))
853
+ document.querySelector('#undo-button').addEventListener('click', () => editor.UndoManager.undo())
854
+ document.querySelector('#redo-button').addEventListener('click', () => editor.UndoManager.redo())
855
+ saveButton.addEventListener('click', save)
856
+ document.querySelector('#replace-image-button').addEventListener('click', () => {
857
+ const selected = editor.getSelected()
858
+ if (!selected || selected.get('tagName') !== 'img') return setStatus('请先选中图片', 'error')
859
+ const next = window.prompt('输入新的图片 URL 或相对路径', selected.getAttributes().src || '')
860
+ if (next == null) return
861
+ selected.addAttributes({ src: next })
862
+ scheduleSave()
863
+ })
864
+ document.querySelectorAll('[data-width]').forEach((button) => button.addEventListener('click', () => {
865
+ canvasWidth = Number(button.dataset.width)
866
+ document.querySelectorAll('[data-width]').forEach((item) => item.classList.toggle('active', item === button))
867
+ updateCanvasScale()
868
+ }))
869
+ fitButton.addEventListener('click', () => {
870
+ fitCanvas = !fitCanvas
871
+ fitButton.classList.toggle('active', fitCanvas)
872
+ fitButton.setAttribute('aria-pressed', String(fitCanvas))
873
+ updateCanvasScale()
874
+ })
875
+ canvasArea.addEventListener('click', (event) => {
876
+ if (event.target === canvasArea || event.target === canvasStage) {
877
+ if (!panelPinned) closePanel()
878
+ setFilePopover(false)
879
+ }
880
+ })
881
+ document.addEventListener('pointerdown', (event) => {
882
+ if (!filePopover.hidden && !event.target.closest('.file-cluster')) setFilePopover(false)
883
+ })
884
+ document.addEventListener('keydown', (event) => {
885
+ if (event.key !== 'Escape') return
886
+ setFilePopover(false)
887
+ if (!panelPinned) closePanel()
888
+ })
889
+ new ResizeObserver(updateCanvasScale).observe(canvasArea)
890
+ srcInput.addEventListener('change', () => saveAttribute('src', srcInput))
891
+ altInput.addEventListener('change', () => saveAttribute('alt', altInput))
892
+ hrefInput.addEventListener('change', () => saveAttribute('href', hrefInput))
893
+
894
+ const initialFile = new URLSearchParams(location.search).get('file')
895
+ if (initialFile) loadFile(initialFile)
896
+ else setStatus('输入路径或使用 ?file= 打开 HTML', 'idle')
897
+ updateCanvasScale()
898
+
899
+ </script>
900
+ </body>
901
+ </html>