@vibe-x/dsh-html-workbench 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +442 -6
- package/package.json +1 -1
- package/scripts/workbench.py +865 -7
- package/src/client.js +422 -5
- package/src/host.js +327 -34
- package/src/index.js +13 -4
package/assets/workbench.html
CHANGED
|
@@ -133,6 +133,32 @@ button { -webkit-tap-highlight-color: transparent; }
|
|
|
133
133
|
.actions .save-button:hover { color: #fff; background: #353532; }
|
|
134
134
|
.actions button:disabled { opacity: .45; cursor: not-allowed; }
|
|
135
135
|
|
|
136
|
+
/* ── Visual selection: "add to chat" outcome toast ────────────────────────── */
|
|
137
|
+
/* The entry point lives in GrapesJS's own element toolbar (see
|
|
138
|
+
`addContextToolbarItem`), and the accumulated list lives in the chat
|
|
139
|
+
composer. All this file needs is transient feedback. */
|
|
140
|
+
.context-toast {
|
|
141
|
+
position: absolute;
|
|
142
|
+
z-index: 34;
|
|
143
|
+
right: 14px;
|
|
144
|
+
bottom: 14px;
|
|
145
|
+
max-width: min(340px, calc(100% - 28px));
|
|
146
|
+
padding: 9px 13px;
|
|
147
|
+
border: 1px solid var(--line-strong);
|
|
148
|
+
border-radius: 10px;
|
|
149
|
+
color: #fff;
|
|
150
|
+
background: var(--ink);
|
|
151
|
+
box-shadow: var(--shadow);
|
|
152
|
+
font-size: 11.5px;
|
|
153
|
+
line-height: 1.5;
|
|
154
|
+
}
|
|
155
|
+
.context-toast[data-state="error"] { color: #fff; background: var(--danger); border-color: transparent; }
|
|
156
|
+
|
|
157
|
+
/* The toolbar button itself is inside GrapesJS's chrome, so it is styled with
|
|
158
|
+
the editor's own class rather than one of ours. */
|
|
159
|
+
.gjs-toolbar-item svg { display: block; }
|
|
160
|
+
|
|
161
|
+
|
|
136
162
|
.tool-rail {
|
|
137
163
|
position: absolute;
|
|
138
164
|
z-index: 28;
|
|
@@ -274,6 +300,7 @@ button { -webkit-tap-highlight-color: transparent; }
|
|
|
274
300
|
.viewport-controls [data-width="375"], .viewport-controls [data-width="1280"] { display: none; }
|
|
275
301
|
.toolbar-separator { margin-inline: 1px; }
|
|
276
302
|
.actions #replace-image-button { display: none; }
|
|
303
|
+
.context-toast { right: 10px; left: 10px; max-width: none; }
|
|
277
304
|
}
|
|
278
305
|
|
|
279
306
|
</style>
|
|
@@ -372,6 +399,11 @@ button { -webkit-tap-highlight-color: transparent; }
|
|
|
372
399
|
</div>
|
|
373
400
|
</div>
|
|
374
401
|
</section>
|
|
402
|
+
|
|
403
|
+
<!-- Selection now flows through GrapesJS's own element toolbar (an "add to
|
|
404
|
+
chat" arrow next to move/copy/delete), so there is no separate tray to
|
|
405
|
+
manage. All that is left is a transient toast for the outcome. -->
|
|
406
|
+
<output id="context-toast" class="context-toast" hidden aria-live="polite"></output>
|
|
375
407
|
</main>
|
|
376
408
|
<script src="/vendor/grapesjs/grapes.min.js"></script>
|
|
377
409
|
<script type="module" data-bundle="workbench">
|
|
@@ -401,6 +433,7 @@ const hrefInput = document.querySelector('#href-input')
|
|
|
401
433
|
const workbench = document.querySelector('.workbench')
|
|
402
434
|
const modeButtons = [...document.querySelectorAll('[data-mode]')]
|
|
403
435
|
const previewFrame = document.querySelector('#preview-frame')
|
|
436
|
+
const contextToast = document.querySelector('#context-toast')
|
|
404
437
|
|
|
405
438
|
let editor
|
|
406
439
|
let activeFile = ''
|
|
@@ -408,6 +441,8 @@ let assetBase = ''
|
|
|
408
441
|
let revision = ''
|
|
409
442
|
let bodyScripts = []
|
|
410
443
|
let sourceCss = ''
|
|
444
|
+
let sourceBodyHtml = ''
|
|
445
|
+
let sourceIdentityCache = null
|
|
411
446
|
let links = []
|
|
412
447
|
let headScripts = []
|
|
413
448
|
let htmlAttributes = {}
|
|
@@ -423,6 +458,8 @@ let fitCanvas = true
|
|
|
423
458
|
let activePanel = ''
|
|
424
459
|
let panelPinned = false
|
|
425
460
|
let workbenchMode = 'edit'
|
|
461
|
+
let contextSending = false
|
|
462
|
+
let contextToastTimer
|
|
426
463
|
|
|
427
464
|
function setStatus(text, state = 'idle') {
|
|
428
465
|
status.querySelector('b').textContent = text
|
|
@@ -434,6 +471,23 @@ function api(route) {
|
|
|
434
471
|
return `${route}?file=${encodeURIComponent(activeFile)}`
|
|
435
472
|
}
|
|
436
473
|
|
|
474
|
+
// The HTML/JS assets are re-read from disk on every request, but a long-lived
|
|
475
|
+
// service keeps running the Python it was started with. So a new frontend can
|
|
476
|
+
// meet an old API, whose missing routes answer 501 with an HTML error page.
|
|
477
|
+
// Parsing that as JSON reports "Unexpected token '<'" and buries the real cause,
|
|
478
|
+
// so name it explicitly instead.
|
|
479
|
+
async function readJson(response) {
|
|
480
|
+
const text = await response.text()
|
|
481
|
+
try {
|
|
482
|
+
return JSON.parse(text)
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (response.status === 501 || response.status === 404) {
|
|
485
|
+
throw new Error('本地服务是旧版本,缺少该接口。请重启 Workbench 服务后重试。')
|
|
486
|
+
}
|
|
487
|
+
throw new Error(`服务返回了非 JSON 响应(HTTP ${response.status})。`)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
437
491
|
function setFilePopover(open) {
|
|
438
492
|
filePopover.hidden = !open
|
|
439
493
|
fileMenuButton.setAttribute('aria-expanded', String(open))
|
|
@@ -662,6 +716,11 @@ async function injectSourceAssets() {
|
|
|
662
716
|
interactionStyle.textContent = '[data-gjs-type="default"]:empty{pointer-events:none!important}'
|
|
663
717
|
document.head.append(interactionStyle)
|
|
664
718
|
|
|
719
|
+
const contextStyle = document.createElement('style')
|
|
720
|
+
contextStyle.dataset.sourceAsset = 'true'
|
|
721
|
+
contextStyle.textContent = CONTEXT_CANVAS_CSS
|
|
722
|
+
document.head.append(contextStyle)
|
|
723
|
+
|
|
665
724
|
if (!document.documentElement.dataset.workbenchInteractionBound) {
|
|
666
725
|
document.documentElement.dataset.workbenchInteractionBound = 'true'
|
|
667
726
|
document.addEventListener('click', (event) => {
|
|
@@ -680,6 +739,211 @@ async function injectSourceAssets() {
|
|
|
680
739
|
|
|
681
740
|
}
|
|
682
741
|
|
|
742
|
+
// ── Visual selection context ────────────────────────────────────────────────
|
|
743
|
+
//
|
|
744
|
+
// The agent cannot see the page, so a selection is only useful if it can be
|
|
745
|
+
// turned into coordinates in the file on disk. This half of the feature does
|
|
746
|
+
// exactly one job: describe WHICH node the user picked, in terms the server can
|
|
747
|
+
// re-resolve against the source. It deliberately does not try to locate source
|
|
748
|
+
// offsets itself — the canvas DOM is GrapesJS's runtime model, not the file.
|
|
749
|
+
//
|
|
750
|
+
// The descriptor's `path` is the load-bearing part, because most elements have
|
|
751
|
+
// no id. It MUST be numbered the same way the server numbers
|
|
752
|
+
// `structural_children`: element children only, with `<script>` excluded. The
|
|
753
|
+
// server strips body scripts before GrapesJS ever sees the document, so
|
|
754
|
+
// counting them here would shift every index after an inline script and the
|
|
755
|
+
// selection would silently land on its neighbour.
|
|
756
|
+
|
|
757
|
+
// The entry point is GrapesJS's own element toolbar: selecting an element in the
|
|
758
|
+
// canvas already shows move/copy/delete, so "add to chat" belongs right there
|
|
759
|
+
// rather than behind a separate mode the user has to remember to turn on.
|
|
760
|
+
|
|
761
|
+
const CONTEXT_CANVAS_CSS = `
|
|
762
|
+
[data-wb-context-sent] {
|
|
763
|
+
animation: wb-context-flash 900ms ease-out;
|
|
764
|
+
}
|
|
765
|
+
@keyframes wb-context-flash {
|
|
766
|
+
from { box-shadow: 0 0 0 3px rgba(47, 77, 196, .55); }
|
|
767
|
+
to { box-shadow: 0 0 0 3px rgba(47, 77, 196, 0); }
|
|
768
|
+
}
|
|
769
|
+
`
|
|
770
|
+
|
|
771
|
+
// Mirrors the server's `structural_children`: element children with `<script>`
|
|
772
|
+
// excluded, because `parse_source` strips body scripts before GrapesJS ever sees
|
|
773
|
+
// the document. Counting them would shift every index after an inline script.
|
|
774
|
+
function structuralComponents(parent) {
|
|
775
|
+
const children = parent.components?.()
|
|
776
|
+
if (!children) return null
|
|
777
|
+
return children.filter((child) => child.get('tagName') !== 'script' && child.get('type') !== 'script')
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The path is computed over GrapesJS's COMPONENT tree, not the canvas DOM.
|
|
781
|
+
// GrapesJS injects its own nodes as siblings of the user's content — a `<style>`
|
|
782
|
+
// element plus `div.gjs-css-rules` and `div.gjs-js-cont` helpers — so walking the
|
|
783
|
+
// live DOM yields `[1, 0, 1]` where the source body only knows `[0, 1]`, and the
|
|
784
|
+
// server then resolves a different element or none at all. The component tree
|
|
785
|
+
// contains exactly the document that was loaded from disk, so it lines up with
|
|
786
|
+
// the server's `structural_children` numbering.
|
|
787
|
+
function contextPathFor(element) {
|
|
788
|
+
const wrapper = editor?.getWrapper()
|
|
789
|
+
let component = componentFromCanvasElement(element)
|
|
790
|
+
if (!wrapper || !component) return null
|
|
791
|
+
const path = []
|
|
792
|
+
while (component !== wrapper) {
|
|
793
|
+
const parent = component.parent?.()
|
|
794
|
+
if (!parent) return null
|
|
795
|
+
const structural = structuralComponents(parent)
|
|
796
|
+
if (!structural) return null
|
|
797
|
+
const index = structural.indexOf(component)
|
|
798
|
+
if (index < 0) return null
|
|
799
|
+
path.push(index)
|
|
800
|
+
component = parent
|
|
801
|
+
}
|
|
802
|
+
return path.reverse()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Mirrors the server's IDENTITY_ATTRIBUTES ordering: our own `data-wb-id` is
|
|
806
|
+
// preferred because it is readable, whereas a page `id` may be a random token.
|
|
807
|
+
const GRAPESJS_ID_PATTERN = /^i[a-z0-9]{4,6}$/
|
|
808
|
+
|
|
809
|
+
function identityFor(element) {
|
|
810
|
+
for (const name of ['data-wb-id', 'id', 'data-mode']) {
|
|
811
|
+
const value = element.getAttribute(name)
|
|
812
|
+
if (!value) continue
|
|
813
|
+
if (name === 'id') {
|
|
814
|
+
// GrapesJS mints `id` attributes for its own style rules. Those ids do not
|
|
815
|
+
// exist in the source file, so trusting one would make the server look for
|
|
816
|
+
// a node that is not there. Only accept an id the source actually has —
|
|
817
|
+
// and skip the generated-looking ones even then, because an earlier bug may
|
|
818
|
+
// have persisted one and it would make a useless anchor label.
|
|
819
|
+
if (!sourceHasIdentity(name, value)) continue
|
|
820
|
+
if (GRAPESJS_ID_PATTERN.test(value)) continue
|
|
821
|
+
}
|
|
822
|
+
return { name, value }
|
|
823
|
+
}
|
|
824
|
+
return null
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// GrapesJS-generated ids are absent from the document we loaded from disk, so
|
|
828
|
+
// compare against the source body we were given rather than the live canvas.
|
|
829
|
+
function sourceHasIdentity(name, value) {
|
|
830
|
+
if (!sourceIdentityCache) {
|
|
831
|
+
const holder = document.implementation.createHTMLDocument('source')
|
|
832
|
+
holder.body.innerHTML = sourceBodyHtml
|
|
833
|
+
sourceIdentityCache = new Set()
|
|
834
|
+
holder.body.querySelectorAll('[id], [data-wb-id], [data-mode]').forEach((node) => {
|
|
835
|
+
for (const attribute of ['id', 'data-wb-id', 'data-mode']) {
|
|
836
|
+
const found = node.getAttribute(attribute)
|
|
837
|
+
if (found) sourceIdentityCache.add(`${attribute}=${found}`)
|
|
838
|
+
}
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
return sourceIdentityCache.has(`${name}=${value}`)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function describeSelection(element) {
|
|
845
|
+
const path = contextPathFor(element)
|
|
846
|
+
if (!path) return null
|
|
847
|
+
const label = element.getAttribute('data-wb-id')
|
|
848
|
+
|| element.getAttribute('id')
|
|
849
|
+
|| [...element.classList].slice(0, 2).map((item) => `.${item}`).join('')
|
|
850
|
+
|| element.tagName.toLowerCase()
|
|
851
|
+
return {
|
|
852
|
+
key: path.join('-'),
|
|
853
|
+
tag: element.tagName.toLowerCase(),
|
|
854
|
+
path,
|
|
855
|
+
identity: identityFor(element),
|
|
856
|
+
textHint: (element.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
857
|
+
label,
|
|
858
|
+
element,
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function showContextToast(message, state = 'info') {
|
|
863
|
+
contextToast.hidden = false
|
|
864
|
+
contextToast.dataset.state = state
|
|
865
|
+
contextToast.textContent = message
|
|
866
|
+
clearTimeout(contextToastTimer)
|
|
867
|
+
contextToastTimer = setTimeout(() => { contextToast.hidden = true }, state === 'error' ? 6000 : 2600)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function flashContextElement(element) {
|
|
871
|
+
if (!element?.isConnected) return
|
|
872
|
+
element.removeAttribute('data-wb-context-sent')
|
|
873
|
+
// Force a reflow so re-adding the attribute restarts the animation even when
|
|
874
|
+
// the same element is sent twice in a row.
|
|
875
|
+
void element.offsetWidth
|
|
876
|
+
element.setAttribute('data-wb-context-sent', '')
|
|
877
|
+
setTimeout(() => element.removeAttribute('data-wb-context-sent'), 1000)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Adds the currently selected element to the chat. One element per click keeps
|
|
881
|
+
// the mental model simple: the toolbar acts on exactly what the user just
|
|
882
|
+
// clicked, and the accumulating list lives in the composer where they can see
|
|
883
|
+
// and remove entries.
|
|
884
|
+
async function sendSelectionToChat(element) {
|
|
885
|
+
if (contextSending) return
|
|
886
|
+
const described = describeSelection(element)
|
|
887
|
+
if (!described) return showContextToast('该元素无法定位,请选择它的父级区域。', 'error')
|
|
888
|
+
|
|
889
|
+
contextSending = true
|
|
890
|
+
setStatus('正在生成上下文', 'saving')
|
|
891
|
+
try {
|
|
892
|
+
// Adding visual context must be read-only. A structural path plus the
|
|
893
|
+
// verbatim source snippet is already sufficient evidence for the agent;
|
|
894
|
+
// trying to mint `data-wb-id` here turns an otherwise harmless toolbar click
|
|
895
|
+
// into a disk write, which fails for a valid file opened outside DSH's
|
|
896
|
+
// workspace-write sandbox. Durable identities remain an optional future
|
|
897
|
+
// save-time enhancement — never a prerequisite for putting context in chat.
|
|
898
|
+
const selection = {
|
|
899
|
+
tag: described.tag,
|
|
900
|
+
path: described.path,
|
|
901
|
+
identity: described.identity,
|
|
902
|
+
textHint: described.textHint,
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const response = await fetch(api('/api/context'), {
|
|
906
|
+
method: 'POST',
|
|
907
|
+
headers: { 'Content-Type': 'application/json' },
|
|
908
|
+
body: JSON.stringify({ selections: [selection] }),
|
|
909
|
+
})
|
|
910
|
+
const packet = await readJson(response)
|
|
911
|
+
if (!response.ok) throw new Error(packet.message || packet.error)
|
|
912
|
+
publishContext(packet)
|
|
913
|
+
flashContextElement(element)
|
|
914
|
+
showContextToast(`已添加 ${packet.selections[0]?.selector || described.label} 到对话`)
|
|
915
|
+
setStatus('已添加到对话上下文', 'saved')
|
|
916
|
+
} catch (error) {
|
|
917
|
+
showContextToast(error.message || '生成上下文失败,请重新选择。', 'error')
|
|
918
|
+
setStatus('添加到对话失败', 'error')
|
|
919
|
+
} finally {
|
|
920
|
+
contextSending = false
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The workbench runs inside an iframe in the DSH panel. Hand the packet to the
|
|
925
|
+
// host when there is one; fall back to the clipboard for a standalone browser.
|
|
926
|
+
function publishContext(packet) {
|
|
927
|
+
const first = packet.selections[0] || {}
|
|
928
|
+
const message = {
|
|
929
|
+
type: 'html-workbench:context',
|
|
930
|
+
filePath: packet.filePath,
|
|
931
|
+
fileName: packet.fileName,
|
|
932
|
+
revision: packet.revision,
|
|
933
|
+
// A stable key lets the host de-duplicate: clicking the same element twice
|
|
934
|
+
// should not stack two identical chips in the composer.
|
|
935
|
+
key: first.identity ? `${first.identity.name}=${first.identity.value}` : `path:${(first.path || []).join('-')}`,
|
|
936
|
+
label: first.selector || first.tag || 'element',
|
|
937
|
+
textHint: first.text || '',
|
|
938
|
+
markdown: packet.markdown,
|
|
939
|
+
}
|
|
940
|
+
if (window.parent !== window) {
|
|
941
|
+
window.parent.postMessage(message, '*')
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
navigator.clipboard?.writeText(packet.markdown).catch(() => {})
|
|
945
|
+
}
|
|
946
|
+
|
|
683
947
|
function initEditor() {
|
|
684
948
|
editor = grapesjs.init({
|
|
685
949
|
container: '#gjs',
|
|
@@ -710,14 +974,47 @@ function initEditor() {
|
|
|
710
974
|
canvas: { frameStyle: 'html{background:#f4f4f5}body{min-height:100vh}' },
|
|
711
975
|
})
|
|
712
976
|
window.__GRAPESJS_WORKBENCH__ = { editor }
|
|
977
|
+
editor.Commands.add(CONTEXT_TOOLBAR_COMMAND, {
|
|
978
|
+
run(instance, sender, options = {}) {
|
|
979
|
+
const component = options.component || instance.getSelected()
|
|
980
|
+
const element = component?.getEl?.()
|
|
981
|
+
if (element) void sendSelectionToChat(element)
|
|
982
|
+
},
|
|
983
|
+
})
|
|
713
984
|
editor.on('load', () => void injectSourceAssets())
|
|
714
985
|
editor.on('update', scheduleSave)
|
|
715
986
|
// GrapesJS 的 RTE 文本编辑走 storeData → sync:content(noCount:true)路径,
|
|
716
987
|
// 不触发 editor 的 'update' 事件(changesCount 不增加)。但组件模型的
|
|
717
988
|
// Backbone change 事件不受 noCount 影响,会转发为 component:update,
|
|
718
989
|
// 因此补充监听该事件以覆盖文本内容编辑场景。
|
|
719
|
-
editor.on('component:update',
|
|
990
|
+
editor.on('component:update', scheduleSaveForContentChange)
|
|
720
991
|
editor.on('component:selected', updateAttributePanel)
|
|
992
|
+
editor.on('component:selected', addContextToolbarItem)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// GrapesJS builds a component's toolbar lazily on first selection, so extend it
|
|
996
|
+
// here rather than at init: the array already holds move/copy/delete and we only
|
|
997
|
+
// prepend one item. Guarded by a marker command name so re-selecting the same
|
|
998
|
+
// component never stacks duplicates.
|
|
999
|
+
const CONTEXT_TOOLBAR_COMMAND = 'wb-context-add'
|
|
1000
|
+
|
|
1001
|
+
function addContextToolbarItem(component) {
|
|
1002
|
+
if (!component || typeof component.get !== 'function') return
|
|
1003
|
+
const toolbar = component.get('toolbar')
|
|
1004
|
+
if (!Array.isArray(toolbar)) return
|
|
1005
|
+
if (toolbar.some((item) => item.command === CONTEXT_TOOLBAR_COMMAND)) return
|
|
1006
|
+
// `silent` matters: a normal `set` emits `component:update`, which is wired to
|
|
1007
|
+
// `scheduleSave`. Without it, merely SELECTING an element would mark the
|
|
1008
|
+
// document dirty and write GrapesJS's own minted attributes (e.g. `id="iftsf"`,
|
|
1009
|
+
// created for its style rules) into the user's source file.
|
|
1010
|
+
component.set('toolbar', [{
|
|
1011
|
+
id: CONTEXT_TOOLBAR_COMMAND,
|
|
1012
|
+
command: CONTEXT_TOOLBAR_COMMAND,
|
|
1013
|
+
attributes: { title: '添加到对话上下文', 'aria-label': '将选中元素添加到对话上下文' },
|
|
1014
|
+
// A speech bubble reads as “conversation” at toolbar size; the small plus
|
|
1015
|
+
// distinguishes “add this selection to chat” from a generic comment action.
|
|
1016
|
+
label: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M20 11.3a7.2 7.2 0 0 1-7.5 7.2 8.2 8.2 0 0 1-3.3-.7L4 20l1.8-4.3A6.8 6.8 0 0 1 5 12a7.2 7.2 0 0 1 7.5-7.2 7.2 7.2 0 0 1 7.5 6.5Z"/><path d="M15.8 7.4v4.2M13.7 9.5h4.2"/></svg>',
|
|
1017
|
+
}, ...toolbar], { silent: true })
|
|
721
1018
|
}
|
|
722
1019
|
|
|
723
1020
|
function updateAttributePanel(component) {
|
|
@@ -734,6 +1031,22 @@ function updateAttributePanel(component) {
|
|
|
734
1031
|
hrefInput.value = attributes.href || ''
|
|
735
1032
|
}
|
|
736
1033
|
|
|
1034
|
+
// Editor-state properties that GrapesJS stores on the component model but which
|
|
1035
|
+
// say nothing about the document: `open` is the layer tree's expand state,
|
|
1036
|
+
// `toolbar`/`status`/`selected` are selection chrome. They fire the same
|
|
1037
|
+
// `component:update` event as a real content edit, and letting them reach
|
|
1038
|
+
// autosave means merely CLICKING an element rewrites the user's file — which is
|
|
1039
|
+
// how GrapesJS's internally-minted `id="i4nxl"` style-rule attributes ended up
|
|
1040
|
+
// persisted in the source.
|
|
1041
|
+
const NON_CONTENT_PROPERTIES = new Set(['open', 'status', 'toolbar', 'selected', 'hovered', 'highlightable', 'draggable'])
|
|
1042
|
+
|
|
1043
|
+
function scheduleSaveForContentChange(component) {
|
|
1044
|
+
const changed = Object.keys(component?.changed || {})
|
|
1045
|
+
// An empty `changed` is a bulk/unknown update; be conservative and save.
|
|
1046
|
+
if (changed.length && changed.every((key) => NON_CONTENT_PROPERTIES.has(key))) return
|
|
1047
|
+
scheduleSave()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
737
1050
|
function scheduleSave() {
|
|
738
1051
|
if (applyingDocument || workbenchMode !== 'edit' || !activeFile) return
|
|
739
1052
|
dirty = true
|
|
@@ -757,7 +1070,7 @@ async function save() {
|
|
|
757
1070
|
bodyScripts,
|
|
758
1071
|
}),
|
|
759
1072
|
})
|
|
760
|
-
const result = await response
|
|
1073
|
+
const result = await readJson(response)
|
|
761
1074
|
if (response.status === 409) {
|
|
762
1075
|
setStatus('磁盘有更新,已停止覆盖', 'conflict')
|
|
763
1076
|
dirty = false
|
|
@@ -780,6 +1093,8 @@ async function applyDocument(document) {
|
|
|
780
1093
|
revision = document.revision
|
|
781
1094
|
bodyScripts = document.bodyScripts || []
|
|
782
1095
|
sourceCss = document.sourceCss || ''
|
|
1096
|
+
sourceBodyHtml = document.bodyHtml || ''
|
|
1097
|
+
sourceIdentityCache = null
|
|
783
1098
|
links = document.links || []
|
|
784
1099
|
headScripts = document.headScripts || []
|
|
785
1100
|
htmlAttributes = document.htmlAttributes || {}
|
|
@@ -808,7 +1123,7 @@ async function loadFile(file = activeFile) {
|
|
|
808
1123
|
try {
|
|
809
1124
|
activeFile = file
|
|
810
1125
|
const response = await fetch(api('/api/document'))
|
|
811
|
-
const document = await response
|
|
1126
|
+
const document = await readJson(response)
|
|
812
1127
|
if (!response.ok) throw new Error(document.message || document.error)
|
|
813
1128
|
await applyDocument(document)
|
|
814
1129
|
dirty = false
|
package/dsh.plugin.json
CHANGED