@brianmsm/quarto-bento 0.0.0-stage → 0.1.1

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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/bin/qbento-npm.mjs +62 -0
  4. package/distribution/LICENSE +21 -0
  5. package/distribution/PACKAGING.md +59 -0
  6. package/distribution/bin/qbento +5 -0
  7. package/distribution/bin/qbento.cmd +4 -0
  8. package/distribution/manifest.json +720 -0
  9. package/distribution/runtime/LICENSE +21 -0
  10. package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
  11. package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
  12. package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
  13. package/distribution/runtime/_extensions/qbento/post.ts +2 -0
  14. package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
  15. package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
  16. package/distribution/runtime/src/bridge/browser.js +168 -0
  17. package/distribution/runtime/src/bridge/isolation.ts +55 -0
  18. package/distribution/runtime/src/browser/chromium.ts +218 -0
  19. package/distribution/runtime/src/cli.ts +232 -0
  20. package/distribution/runtime/src/core/agent.ts +546 -0
  21. package/distribution/runtime/src/core/annotations.ts +384 -0
  22. package/distribution/runtime/src/core/capture.ts +203 -0
  23. package/distribution/runtime/src/core/component-actions.ts +550 -0
  24. package/distribution/runtime/src/core/component-frames.ts +59 -0
  25. package/distribution/runtime/src/core/component-sync.ts +290 -0
  26. package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
  27. package/distribution/runtime/src/core/design-registry.ts +287 -0
  28. package/distribution/runtime/src/core/graph-relations.ts +354 -0
  29. package/distribution/runtime/src/core/graph-scope.ts +63 -0
  30. package/distribution/runtime/src/core/graph-sync.ts +228 -0
  31. package/distribution/runtime/src/core/io.ts +27 -0
  32. package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
  33. package/distribution/runtime/src/core/layouts.ts +245 -0
  34. package/distribution/runtime/src/core/project.ts +851 -0
  35. package/distribution/runtime/src/core/reconcile.ts +384 -0
  36. package/distribution/runtime/src/core/recovery.ts +1255 -0
  37. package/distribution/runtime/src/core/slide-actions.ts +158 -0
  38. package/distribution/runtime/src/core/source-scope.ts +50 -0
  39. package/distribution/runtime/src/core/sync.ts +2557 -0
  40. package/distribution/runtime/src/core/widgets.ts +300 -0
  41. package/distribution/runtime/src/integration/install.ts +98 -0
  42. package/distribution/runtime/src/integration/invocation.ts +100 -0
  43. package/distribution/runtime/src/integration/quarto.ts +457 -0
  44. package/distribution/runtime/src/render/assets.ts +66 -0
  45. package/distribution/runtime/src/render/capture.lua +8 -0
  46. package/distribution/runtime/src/render/chart.ts +165 -0
  47. package/distribution/runtime/src/render/components.ts +199 -0
  48. package/distribution/runtime/src/render/design.ts +820 -0
  49. package/distribution/runtime/src/render/diagnostics.ts +36 -0
  50. package/distribution/runtime/src/render/graphs.ts +747 -0
  51. package/distribution/runtime/src/render/html.ts +82 -0
  52. package/distribution/runtime/src/render/images.ts +268 -0
  53. package/distribution/runtime/src/render/inline.ts +259 -0
  54. package/distribution/runtime/src/render/kpi.ts +385 -0
  55. package/distribution/runtime/src/render/lists.ts +183 -0
  56. package/distribution/runtime/src/render/math.ts +413 -0
  57. package/distribution/runtime/src/render/measure.ts +185 -0
  58. package/distribution/runtime/src/render/native-layout.ts +174 -0
  59. package/distribution/runtime/src/render/producer-capture.ts +319 -0
  60. package/distribution/runtime/src/render/producer-environment.R +49 -0
  61. package/distribution/runtime/src/render/producer-environment.ts +96 -0
  62. package/distribution/runtime/src/render/producer-profile.R +91 -0
  63. package/distribution/runtime/src/render/producer-site-profile.R +18 -0
  64. package/distribution/runtime/src/render/project.ts +1806 -0
  65. package/distribution/runtime/src/render/quarto.ts +127 -0
  66. package/distribution/runtime/src/render/registry.ts +233 -0
  67. package/distribution/runtime/src/render/resources.ts +292 -0
  68. package/distribution/runtime/src/render/shell.ts +110 -0
  69. package/distribution/runtime/src/render/source-inline.ts +169 -0
  70. package/distribution/runtime/src/render/table.ts +220 -0
  71. package/distribution/runtime/src/render/text-overflow.ts +208 -0
  72. package/distribution/runtime/src/render/types.ts +71 -0
  73. package/distribution/runtime/src/render/validate.ts +572 -0
  74. package/distribution/runtime/src/rich/project.ts +117 -0
  75. package/distribution/runtime/src/rich/table.ts +374 -0
  76. package/distribution/runtime/src/source/annotations.ts +490 -0
  77. package/distribution/runtime/src/source/attributes.ts +415 -0
  78. package/distribution/runtime/src/source/chart.ts +165 -0
  79. package/distribution/runtime/src/source/code-language.ts +277 -0
  80. package/distribution/runtime/src/source/component-insertion.ts +160 -0
  81. package/distribution/runtime/src/source/deletion.ts +278 -0
  82. package/distribution/runtime/src/source/graphs.ts +517 -0
  83. package/distribution/runtime/src/source/identities.ts +256 -0
  84. package/distribution/runtime/src/source/identity-id.ts +6 -0
  85. package/distribution/runtime/src/source/image.ts +50 -0
  86. package/distribution/runtime/src/source/kpi.ts +196 -0
  87. package/distribution/runtime/src/source/mod.ts +876 -0
  88. package/distribution/runtime/src/source/notes.ts +308 -0
  89. package/distribution/runtime/src/source/structure.ts +647 -0
  90. package/distribution/runtime/src/source/table.ts +290 -0
  91. package/distribution/runtime/src/source/text-conversion.ts +146 -0
  92. package/distribution/runtime/src/state/components.ts +281 -0
  93. package/distribution/runtime/src/state/graphs.ts +200 -0
  94. package/distribution/runtime/src/state/json.ts +162 -0
  95. package/distribution/runtime/src/state/merge.ts +1034 -0
  96. package/distribution/runtime/src/state/mod.ts +5 -0
  97. package/distribution/runtime/src/state/store.ts +226 -0
  98. package/distribution/runtime/src/state/types.ts +151 -0
  99. package/distribution/runtime/src/studio/client.js +892 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +283 -0
  102. package/distribution/runtime/src/studio/layouts.js +170 -0
  103. package/distribution/runtime/src/studio/registry.js +283 -0
  104. package/distribution/runtime/src/studio/relations.js +169 -0
  105. package/distribution/runtime/src/studio/server.ts +461 -0
  106. package/distribution/runtime/src/widgets/deck.ts +39 -0
  107. package/distribution/runtime/src/widgets/plotly.ts +354 -0
  108. package/distribution/runtime/src/widgets/presenter.ts +367 -0
  109. package/distribution/runtime/src/widgets/snapshot.ts +116 -0
  110. package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
  111. package/distribution/runtime/vendor/bento/LICENSE +21 -0
  112. package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
  113. package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
  114. package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
  115. package/distribution/runtime/vendor/bento/pin.json +69 -0
  116. package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
  117. package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
  118. package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
  119. package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
  120. package/distribution/runtime/vendor/bento/shell.html +269 -0
  121. package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
  122. package/package.json +40 -4
@@ -0,0 +1,892 @@
1
+ import { attachLayoutEditor } from "/layouts.js";
2
+ import { attachComponentEditor } from "/components.js";
3
+ import { attachGraphRelations } from "/relations.js";
4
+ import { attachRegistryEditor } from "/registry.js";
5
+ import { createBentoBridge } from "/bridge.js";
6
+ const $ = (id) => document.getElementById(id);
7
+ let state, bridge, syncPlan, reconcilePlan, busy = false, previewGeneration = 0;
8
+ let diagnosticGeneration = 0, projectDiagnostics = [], renderDiagnostics = [];
9
+ let renderDiagnosticSnapshot;
10
+ let connectedApi, connectedSnapshot;
11
+ let syncReviewSnapshot, syncPlanSelection;
12
+ async function api(path, body) {
13
+ const response = await fetch("/api/" + path, {
14
+ method: body === undefined ? "GET" : "POST",
15
+ headers: {
16
+ "x-qbento-token": globalThis.qbentoToken,
17
+ "Content-Type": "application/json",
18
+ },
19
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
20
+ });
21
+ const result = await response.json();
22
+ if (!response.ok) throw new Error(result.message || result.error);
23
+ if (
24
+ body !== undefined &&
25
+ [
26
+ "save",
27
+ "design-registry",
28
+ "sync/apply",
29
+ "reconcile/apply",
30
+ "slides/action",
31
+ "graphs/relations",
32
+ "components/action",
33
+ "layouts/apply",
34
+ ].includes(path)
35
+ ) {
36
+ void refreshDiagnostics();
37
+ }
38
+ return result;
39
+ }
40
+ function showDiagnostics(diagnostics) {
41
+ // Bounds are freshly checked against saved state so corrected boxes clear warnings.
42
+ renderDiagnostics = renderDiagnostics.filter((d) =>
43
+ ![
44
+ "OVERFLOW",
45
+ "TEXT_OVERFLOW",
46
+ "TEXT_MEASUREMENT_UNAVAILABLE",
47
+ "NATIVE_CONTENT_OVERFLOW",
48
+ "NATIVE_CONTENT_UNMEASURED",
49
+ ].includes(d.code)
50
+ );
51
+ projectDiagnostics = diagnostics;
52
+ $("diagnostic-list").replaceChildren();
53
+ const visible = [...renderDiagnostics, ...diagnostics];
54
+ for (const diagnostic of visible) {
55
+ const item = document.createElement("li");
56
+ item.textContent = `${diagnostic.code}${
57
+ diagnostic.slideId ? ` · ${diagnostic.slideId}` : ""
58
+ }${diagnostic.elementId ? ` · ${diagnostic.elementId}` : ""}${
59
+ diagnostic.reference ? ` · ${diagnostic.reference}` : ""
60
+ }: ${diagnostic.message}`;
61
+ $("diagnostic-list").append(item);
62
+ }
63
+ $("project-diagnostics").hidden = visible.length === 0;
64
+ }
65
+ function showRenderDiagnostics(receipt) {
66
+ // Design and content bounds can change after Save without source execution.
67
+ // Only projection-specific warnings remain until a new render/snapshot.
68
+ const isFresh = (diagnostic) =>
69
+ [
70
+ "MISSING_DESIGN",
71
+ "UNVERIFIABLE_DESIGN_ANNOTATION",
72
+ "OVERFLOW",
73
+ "TEXT_OVERFLOW",
74
+ "TEXT_MEASUREMENT_UNAVAILABLE",
75
+ "NATIVE_CONTENT_OVERFLOW",
76
+ "NATIVE_CONTENT_UNMEASURED",
77
+ ].includes(
78
+ diagnostic.code,
79
+ );
80
+ const diagnostics = receipt.diagnostics ?? [];
81
+ renderDiagnostics = diagnostics.filter((diagnostic) => !isFresh(diagnostic));
82
+ renderDiagnosticSnapshot = { docId: state.docId, revision: receipt.revision };
83
+ ++diagnosticGeneration;
84
+ showDiagnostics(diagnostics.filter(isFresh));
85
+ }
86
+ async function refreshDiagnostics() {
87
+ const generation = ++diagnosticGeneration;
88
+ try {
89
+ const diagnostics = await api("diagnostics");
90
+ if (generation === diagnosticGeneration) showDiagnostics(diagnostics);
91
+ } catch (error) {
92
+ if (generation === diagnosticGeneration) {
93
+ showDiagnostics([
94
+ ...projectDiagnostics.filter((d) =>
95
+ d.code !== "DIAGNOSTICS_UNAVAILABLE"
96
+ ),
97
+ {
98
+ code: "DIAGNOSTICS_UNAVAILABLE",
99
+ message:
100
+ `No se pudo comprobar el diseño del proyecto: ${error.message}`,
101
+ },
102
+ ]);
103
+ }
104
+ }
105
+ }
106
+ function status(text) {
107
+ $("status").textContent = text;
108
+ }
109
+ function showError(error) {
110
+ status(error.message);
111
+ }
112
+ function setBusy(value) {
113
+ busy = value;
114
+ $("busy").hidden = !value;
115
+ $("editor").inert = value;
116
+ for (
117
+ const id of [
118
+ "save",
119
+ "slides",
120
+ "relations",
121
+ "components",
122
+ "designs",
123
+ "layouts",
124
+ "review",
125
+ "reconcile",
126
+ "render",
127
+ "presenter",
128
+ ]
129
+ ) {
130
+ $(id).disabled = value;
131
+ }
132
+ if (value) {
133
+ $("editor").contentDocument?.activeElement?.blur();
134
+ $("render").focus();
135
+ }
136
+ }
137
+ function connect() {
138
+ try {
139
+ const editor = $("editor").contentWindow;
140
+ if (!editor.bento) {
141
+ setTimeout(connect, 150);
142
+ return;
143
+ }
144
+ // The iframe and its save revision must describe the same immutable snapshot.
145
+ // A later /api/state fetch can already belong to another editor's save.
146
+ const snapshot = editor.__qbentoSnapshot;
147
+ if (!snapshot || !Number.isSafeInteger(snapshot.revision)) {
148
+ throw new Error(
149
+ "El editor no tiene una revisión verificable. Recarga el proyecto.",
150
+ );
151
+ }
152
+ // The initial iframe load may finish before this module's imports load.
153
+ // Poll/load duplicates must not reset a revision already advanced by Save.
154
+ if (connectedApi === editor.bento && connectedSnapshot === snapshot) return;
155
+ if (
156
+ renderDiagnosticSnapshot &&
157
+ (renderDiagnosticSnapshot.docId !== snapshot.docId ||
158
+ renderDiagnosticSnapshot.revision !== snapshot.revision)
159
+ ) {
160
+ renderDiagnostics = [];
161
+ renderDiagnosticSnapshot = undefined;
162
+ showDiagnostics(projectDiagnostics);
163
+ }
164
+ state = {
165
+ docId: snapshot.docId,
166
+ revision: snapshot.revision,
167
+ pending: snapshot.pending,
168
+ };
169
+ const oldBridge = bridge;
170
+ bridge = createBentoBridge(editor.bento, {
171
+ docId: state.docId,
172
+ persist: async ({ doc }) => {
173
+ const receipt = await api("save", { doc, revision: state.revision });
174
+ state.revision = receipt.revision;
175
+ return receipt;
176
+ },
177
+ onEvent: (event) => {
178
+ if (event.type === "document" && !busy) status("Cambios sin guardar");
179
+ if (event.type === "saved" && !busy) {
180
+ status(
181
+ event.dirty
182
+ ? "Hay cambios nuevos sin guardar"
183
+ : "Proyecto guardado",
184
+ );
185
+ }
186
+ if (event.type === "error" || event.type === "save-error") {
187
+ showError(event.error);
188
+ }
189
+ },
190
+ });
191
+ oldBridge?.dispose();
192
+ connectedApi = editor.bento;
193
+ connectedSnapshot = snapshot;
194
+ void refreshDiagnostics();
195
+ setBusy(false);
196
+ status(
197
+ state.pending
198
+ ? `${state.pending} cambios pendientes de sincronización`
199
+ : "Proyecto abierto",
200
+ );
201
+ } catch (error) {
202
+ setBusy(false);
203
+ showError(error);
204
+ }
205
+ }
206
+ $("editor").addEventListener("load", connect);
207
+ $("save").onclick = async () => {
208
+ try {
209
+ if (!busy) await bridge.save();
210
+ } catch (error) {
211
+ showError(error);
212
+ }
213
+ };
214
+ function showPatches(plan) {
215
+ $("patches").replaceChildren();
216
+ function showPatch(patch, phase) {
217
+ const section = document.createElement("section"),
218
+ title = document.createElement("h3"),
219
+ before = document.createElement("pre"),
220
+ after = document.createElement("pre");
221
+ title.textContent = `${phase} · ${patch.blockId}`;
222
+ before.textContent = patch.expected;
223
+ before.className = "before";
224
+ after.textContent = patch.replacement;
225
+ after.className = "after";
226
+ section.append(title, before, after);
227
+ $("patches").append(section);
228
+ }
229
+ if (plan.identities?.patches.length) {
230
+ const explanation = document.createElement("p");
231
+ explanation.textContent =
232
+ "Primero se fijan las identidades de los bloques siguientes, incluidos los que no has editado, para conservar su vínculo con QMD al cambiar el archivo. Revisa cada fragmento antes y después; a continuación se aplican los cambios de contenido.";
233
+ $("patches").append(explanation);
234
+ for (const patch of plan.identities.patches) {
235
+ showPatch(patch, "1. Identidad de fuente");
236
+ }
237
+ }
238
+ for (const patch of plan.patch.patches) showPatch(patch, "2. Contenido");
239
+ for (const patch of plan.notes?.patches ?? []) {
240
+ showPatch(patch, "3. Notas de orador");
241
+ }
242
+ for (
243
+ const change of plan.changes.filter((change) =>
244
+ change.kind === "visual-attribute"
245
+ )
246
+ ) {
247
+ showPatch({
248
+ blockId: change.blockId,
249
+ expected: change.before,
250
+ replacement: change.after,
251
+ }, "4. Atributo visual");
252
+ }
253
+ for (const patch of plan.languages?.patches ?? []) {
254
+ showPatch(patch, "Lenguaje del código");
255
+ }
256
+ if (plan.structure) {
257
+ for (const step of plan.structure.steps) {
258
+ for (const edit of step.edits) {
259
+ const section = document.createElement("section"),
260
+ title = document.createElement("h3"),
261
+ before = document.createElement("pre"),
262
+ after = document.createElement("pre");
263
+ title.textContent = step.operation.type === "delete-slide"
264
+ ? `Eliminar ${step.operation.id} del QMD`
265
+ : step.operation.type === "delete-block"
266
+ ? `Eliminar elemento ${step.operation.blockId} del QMD`
267
+ : `5. Estructura · ${step.operation.type}`;
268
+ before.textContent = edit.expected;
269
+ before.className = "before";
270
+ after.textContent = edit.replacement || "Se elimina este fragmento";
271
+ after.className = "after";
272
+ section.append(title, before, after);
273
+ $("patches").append(section);
274
+ }
275
+ }
276
+ }
277
+ for (const [index, patch] of (plan.graphs?.patches ?? []).entries()) {
278
+ const request = plan.graphs.requests[index];
279
+ showPatch({
280
+ ...patch,
281
+ blockId:
282
+ `${request.componentId} · ${patch.blockId} · ${request.endpoint}`,
283
+ }, "6. Relación del diagrama");
284
+ }
285
+ for (const patch of plan.annotations?.patches ?? []) {
286
+ showPatch(patch, "7. Anotación visual opcional");
287
+ }
288
+ for (const diagnostic of plan.annotations?.diagnostics ?? []) {
289
+ const section = document.createElement("section"),
290
+ title = document.createElement("h3"),
291
+ message = document.createElement("p");
292
+ title.textContent = diagnostic.code;
293
+ message.textContent = diagnostic.message;
294
+ section.append(title, message);
295
+ $("patches").append(section);
296
+ }
297
+ if (
298
+ plan.structure || plan.identities || plan.graphs || plan.languages ||
299
+ plan.attributes || plan.notes || plan.annotations?.patches.length
300
+ ) {
301
+ const section = document.createElement("section"),
302
+ title = document.createElement("h3"),
303
+ result = document.createElement("pre");
304
+ title.textContent = "QMD completo propuesto";
305
+ result.textContent = plan.result;
306
+ section.append(title, result);
307
+ $("patches").append(section);
308
+ }
309
+ }
310
+ function syncChangeLabel(change) {
311
+ const kinds = {
312
+ "delete-element": "Eliminar elemento",
313
+ "delete-slide": "Eliminar diapositiva",
314
+ "create-element": "Crear elemento",
315
+ "create-slide": "Crear diapositiva",
316
+ "slide-order": "Orden de diapositivas",
317
+ };
318
+ const fields = {
319
+ html: "Texto",
320
+ notes: "Notas",
321
+ content: "Contenido",
322
+ grammarName: "Lenguaje del código",
323
+ };
324
+ const property = change.path.join(".");
325
+ const action = kinds[change.kind] || fields[property] || property || "Cambio";
326
+ return `${change.target.slideId || "Presentación"}${
327
+ change.target.elementId ? " · " + change.target.elementId : ""
328
+ } · ${action}`;
329
+ }
330
+ function currentSyncSelection() {
331
+ return [...$("sync-selection").querySelectorAll("input")]
332
+ .filter((box) => box.checked).map((box) => box.dataset.change);
333
+ }
334
+ function sameSyncSelection(left, right) {
335
+ return left.length === right.length && left.every((id) => right.includes(id));
336
+ }
337
+ function requiredSyncDeleteBoxes(selected) {
338
+ return [...$("delete-confirmations").querySelectorAll("input")].filter(
339
+ (box) => {
340
+ const required = !syncReviewSnapshot ||
341
+ selected.includes(box.dataset.change);
342
+ box.disabled = !required;
343
+ return required;
344
+ },
345
+ );
346
+ }
347
+ async function prepareSyncPreview() {
348
+ if (busy) return;
349
+ const generation = ++previewGeneration;
350
+ syncPlan = undefined;
351
+ $("apply").disabled = true;
352
+ $("patches").replaceChildren();
353
+ const selected = currentSyncSelection();
354
+ if (syncReviewSnapshot) {
355
+ const excluded = syncReviewSnapshot.pending.filter((change) =>
356
+ !selected.includes(change.id)
357
+ );
358
+ $("sync-selection-summary").textContent =
359
+ `${selected.length} seleccionados; ${excluded.length} excluidos y conservados pendientes.` +
360
+ (excluded.length
361
+ ? ` Excluidos: ${excluded.map(syncChangeLabel).join("; ")}.`
362
+ : "");
363
+ }
364
+ const boxes = requiredSyncDeleteBoxes(selected);
365
+ if (boxes.some((box) => !box.checked)) {
366
+ $("sync-note").textContent =
367
+ "Confirma cada eliminación seleccionada para preparar la propuesta o exclúyela. Puedes restaurar elementos desde Comparar con QMD y diapositivas desde Diapositivas.";
368
+ return;
369
+ }
370
+ $("sync-note").textContent = "Preparando los cambios propuestos…";
371
+ try {
372
+ const plan = await api("sync/preview", {
373
+ ...(syncReviewSnapshot
374
+ ? {
375
+ version: 1,
376
+ docId: syncReviewSnapshot.docId,
377
+ expectedRevision: syncReviewSnapshot.revision,
378
+ selectedChangeIds: selected,
379
+ }
380
+ : {}),
381
+ confirmedDeleteIds: boxes.filter((box) => box.checked)
382
+ .map((box) => box.dataset.change),
383
+ });
384
+ if (generation !== previewGeneration) return;
385
+ if (
386
+ plan.stateRevision !== state.revision ||
387
+ (syncReviewSnapshot && syncReviewSnapshot.docId !== state.docId)
388
+ ) {
389
+ throw new Error(
390
+ "El proyecto cambió mientras se preparaba la revisión. Recarga antes de sincronizar.",
391
+ );
392
+ }
393
+ if (
394
+ syncReviewSnapshot && !sameSyncSelection(plan.selectedChangeIds, selected)
395
+ ) {
396
+ throw new Error(
397
+ "La propuesta no coincide con la selección revisada. Prepara una revisión nueva.",
398
+ );
399
+ }
400
+ syncPlan = plan;
401
+ syncPlanSelection = selected;
402
+ showPatches(syncPlan);
403
+ const hasChanges = syncPlan.patch.patches.length ||
404
+ syncPlan.identities?.patches.length ||
405
+ syncPlan.attributes?.patches.length || syncPlan.notes?.patches.length ||
406
+ syncPlan.languages?.patches.length || syncPlan.graphs?.patches.length ||
407
+ syncPlan.annotations?.patches.length ||
408
+ syncPlan.structure;
409
+ $("sync-note").textContent = hasChanges
410
+ ? "Revisa los cambios propuestos. Aplicar modifica los fragmentos y la estructura indicados del QMD."
411
+ : syncPlan.annotations?.diagnostics.length
412
+ ? "Las anotaciones opcionales se han omitido. Revisa los diagnósticos mostrados; el QMD se conserva."
413
+ : "No hay cambios compatibles pendientes de sincronización.";
414
+ $("apply").disabled = !hasChanges;
415
+ } catch (error) {
416
+ if (generation === previewGeneration) {
417
+ $("sync-note").textContent = error.message;
418
+ }
419
+ }
420
+ }
421
+ $("review").onclick = async () => {
422
+ if (busy) return;
423
+ try {
424
+ await bridge.save();
425
+ const saved = await api("state");
426
+ if (saved.revision !== state.revision) {
427
+ throw new Error(
428
+ "Otra pestaña ha cambiado el proyecto. Conserva tus cambios y recarga antes de sincronizar.",
429
+ );
430
+ }
431
+ previewGeneration++;
432
+ syncPlan = undefined;
433
+ syncReviewSnapshot = { ...saved, docId: state.docId };
434
+ $("apply").disabled = true;
435
+ $("patches").replaceChildren();
436
+ $("sync-selection").replaceChildren();
437
+ for (const change of saved.pending) {
438
+ const label = document.createElement("label"),
439
+ checkbox = document.createElement("input");
440
+ checkbox.type = "checkbox";
441
+ checkbox.checked = true;
442
+ checkbox.dataset.change = change.id;
443
+ checkbox.onchange = prepareSyncPreview;
444
+ label.append(
445
+ checkbox,
446
+ document.createTextNode(` ${syncChangeLabel(change)}`),
447
+ );
448
+ $("sync-selection").append(label);
449
+ }
450
+ $("delete-confirmations").replaceChildren();
451
+ for (
452
+ const change of saved.pending.filter((item) =>
453
+ item.kind === "delete-slide" || item.kind === "delete-element"
454
+ )
455
+ ) {
456
+ const label = document.createElement("label"),
457
+ checkbox = document.createElement("input");
458
+ checkbox.type = "checkbox";
459
+ checkbox.dataset.change = change.id;
460
+ checkbox.onchange = prepareSyncPreview;
461
+ label.append(
462
+ checkbox,
463
+ document.createTextNode(
464
+ change.kind === "delete-element"
465
+ ? ` Confirmo eliminar el elemento ${change.target.elementId} de la diapositiva ${change.target.slideId} y su bloque del QMD`
466
+ : ` Confirmo eliminar la diapositiva ${change.target.slideId} y su contenido del QMD`,
467
+ ),
468
+ );
469
+ $("delete-confirmations").append(label);
470
+ }
471
+ showChanges($("changes"), saved.pending.map((change) => ({ change })));
472
+ $("review-dialog").showModal();
473
+ await prepareSyncPreview();
474
+ } catch (error) {
475
+ showError(error);
476
+ }
477
+ };
478
+ $("apply").onclick = async () => {
479
+ if (!syncPlan || busy) return;
480
+ const selected = currentSyncSelection();
481
+ if (requiredSyncDeleteBoxes(selected).some((box) => !box.checked)) return;
482
+ if (
483
+ syncReviewSnapshot && (
484
+ syncReviewSnapshot.docId !== state.docId ||
485
+ syncReviewSnapshot.revision !== state.revision ||
486
+ !sameSyncSelection(selected, syncPlanSelection) ||
487
+ !sameSyncSelection(selected, syncPlan.selectedChangeIds)
488
+ )
489
+ ) {
490
+ syncPlan = undefined;
491
+ $("apply").disabled = true;
492
+ $("sync-note").textContent =
493
+ "La selección cambió. Prepara una revisión nueva antes de aplicar.";
494
+ return;
495
+ }
496
+ setBusy(true);
497
+ $("busy").textContent = "Aplicando los cambios revisados al QMD…";
498
+ $("sync-note").textContent = $("busy").textContent;
499
+ $("apply").disabled = true;
500
+ $("close").disabled = true;
501
+ $("delete-confirmations").inert = true;
502
+ $("sync-selection").inert = true;
503
+ previewGeneration++;
504
+ try {
505
+ const receipt = await api("sync/apply", syncPlan);
506
+ state.revision = receipt.revision;
507
+ syncPlan = undefined;
508
+ $("sync-note").textContent = receipt.annotations?.diagnostics.length
509
+ ? "Cambios semánticos aplicados al QMD; las anotaciones opcionales se han omitido por los diagnósticos revisados. Los cambios aplicados seguirán pendientes hasta Recalcular y verificar el resultado."
510
+ : "Cambios aplicados al QMD. Seguirán pendientes hasta Recalcular y verificar el resultado. Los cambios excluidos se conservan.";
511
+ status("QMD sincronizado; listo para recalcular");
512
+ } catch (error) {
513
+ $("sync-note").textContent = error.message;
514
+ } finally {
515
+ setBusy(false);
516
+ $("close").disabled = false;
517
+ $("delete-confirmations").inert = false;
518
+ $("sync-selection").inert = false;
519
+ $("apply").disabled = !syncPlan;
520
+ }
521
+ };
522
+ $("close").onclick = () => {
523
+ if (!busy) $("review-dialog").close();
524
+ };
525
+ $("review-dialog").addEventListener("cancel", (event) => {
526
+ if (busy) event.preventDefault();
527
+ });
528
+ $("review-dialog").addEventListener("close", () => {
529
+ previewGeneration++;
530
+ syncPlan = undefined;
531
+ syncReviewSnapshot = undefined;
532
+ });
533
+ function reloadEditor() {
534
+ $("editor").contentWindow.__qbentoManagedReload = true;
535
+ $("editor").src = "/deck?revision=" + Date.now();
536
+ }
537
+ async function runSlideAction(action, slideId) {
538
+ if (busy) return;
539
+ setBusy(true);
540
+ $("busy").textContent = "Guardando cambios de diapositivas…";
541
+ try {
542
+ await bridge.save();
543
+ const receipt = await api("slides/action", {
544
+ action,
545
+ slideId,
546
+ revision: state.revision,
547
+ expectedDocId: state.docId,
548
+ });
549
+ state.revision = receipt.revision;
550
+ $("slides-dialog").close();
551
+ reloadEditor();
552
+ } catch (error) {
553
+ $("slides-note").textContent = error.message;
554
+ setBusy(false);
555
+ }
556
+ }
557
+ function slideLabel(slide) {
558
+ const title = slide.name || slide.title ||
559
+ slide.elements.find((element) => element.type === "text")?.html || "";
560
+ return `${slide.id}${
561
+ title ? " · " + String(title).replace(/<[^>]*>/g, " ") : ""
562
+ }`;
563
+ }
564
+ $("slides").onclick = async () => {
565
+ if (busy) return;
566
+ setBusy(true);
567
+ $("busy").textContent = "Guardando antes de revisar las diapositivas…";
568
+ try {
569
+ await bridge.save();
570
+ const saved = await api("state");
571
+ if (saved.revision !== state.revision) {
572
+ throw new Error(
573
+ "Otra pestaña cambió el proyecto. Recarga antes de cambiar diapositivas.",
574
+ );
575
+ }
576
+ $("slides-note").textContent = "";
577
+ $("slide-list").replaceChildren();
578
+ const row = (slide, description, actions) => {
579
+ const section = document.createElement("section"),
580
+ title = document.createElement("h3"),
581
+ detail = document.createElement("p");
582
+ title.textContent = slideLabel(slide);
583
+ detail.textContent = description;
584
+ section.append(title, detail);
585
+ for (const [action, text, disabled] of actions) {
586
+ const button = document.createElement("button");
587
+ button.textContent = text;
588
+ button.disabled = !!disabled;
589
+ button.onclick = () => runSlideAction(action, slide.id);
590
+ section.append(button);
591
+ }
592
+ $("slide-list").append(section);
593
+ };
594
+ for (const slide of saved.emitted.slides) {
595
+ const sourced = saved.generatedBaseline.slides.some((item) =>
596
+ item.id === slide.id
597
+ );
598
+ row(
599
+ slide,
600
+ (slide.hidden ? "Oculta" : "Visible") +
601
+ (!sourced ? " · Nueva: sincroniza antes de eliminarla de QMD." : ""),
602
+ [
603
+ [
604
+ slide.hidden ? "show" : "hide",
605
+ slide.hidden ? "Mostrar diapositiva" : "Ocultar diapositiva",
606
+ ],
607
+ [
608
+ "delete",
609
+ "Marcar para eliminar",
610
+ !sourced || saved.emitted.slides.length === 1,
611
+ ],
612
+ ],
613
+ );
614
+ }
615
+ for (
616
+ const change of saved.pending.filter((item) =>
617
+ item.kind === "delete-slide"
618
+ )
619
+ ) {
620
+ if (change.before.present && change.before.value?.elements) {
621
+ row(
622
+ change.before.value,
623
+ "Eliminación pendiente · QMD conservado hasta sincronizar.",
624
+ [
625
+ ["restore", "Restaurar diapositiva"],
626
+ ],
627
+ );
628
+ }
629
+ }
630
+ $("slides-dialog").showModal();
631
+ } catch (error) {
632
+ showError(error);
633
+ } finally {
634
+ setBusy(false);
635
+ }
636
+ };
637
+ $("slides-close").onclick = () => $("slides-dialog").close();
638
+ function describeValue(value) {
639
+ if (!value?.present) return "No existe";
640
+ return typeof value.value === "string"
641
+ ? value.value
642
+ : JSON.stringify(value.value, null, 2);
643
+ }
644
+ function showChanges(container, items, selectable = false) {
645
+ container.replaceChildren();
646
+ const origins = {
647
+ authored: "Contenido escrito",
648
+ computed: "Resultado calculado",
649
+ "bento-only": "Objeto añadido en Bento",
650
+ detached: "Desvinculado del cálculo",
651
+ };
652
+ const actions = {
653
+ "keep-source": "Usar QMD y descartar esta edición de Bento",
654
+ "keep-bento": "Conservar Bento para sincronizar después",
655
+ detach: "Desvincular este valor del cálculo",
656
+ };
657
+ for (const { change, actions: choices } of items) {
658
+ const section = document.createElement("section"),
659
+ title = document.createElement("h3"),
660
+ origin = document.createElement("p");
661
+ title.textContent = [
662
+ change.target.slideId || "Presentación",
663
+ change.target.elementId,
664
+ change.path.join(".") || change.kind,
665
+ ].filter(Boolean).join(" · ");
666
+ origin.textContent = `${origins[change.origin] || change.origin} · ${
667
+ change.status === "conflict" ? "Conflicto" : "Pendiente"
668
+ }`;
669
+ section.append(title, origin);
670
+ for (
671
+ const [label, value] of [["Base compartida", change.before], [
672
+ "QMD actual",
673
+ change.source ?? change.before,
674
+ ], ["Edición Bento", change.after]]
675
+ ) {
676
+ const heading = document.createElement("strong"),
677
+ content = document.createElement("pre");
678
+ heading.textContent = label;
679
+ content.textContent = describeValue(value);
680
+ section.append(heading, content);
681
+ }
682
+ if (change.reason) {
683
+ const note = document.createElement("p");
684
+ note.textContent = change.reason;
685
+ section.append(note);
686
+ }
687
+ if (selectable) {
688
+ const label = document.createElement("label"),
689
+ select = document.createElement("select");
690
+ label.textContent = "Decisión: ";
691
+ select.dataset.change = change.id;
692
+ for (const action of ["", ...choices]) {
693
+ const option = document.createElement("option");
694
+ option.value = action;
695
+ option.textContent =
696
+ change.kind === "delete-element" && action === "keep-source"
697
+ ? change.source?.present === false
698
+ ? "Descartar esta eliminación pendiente; recuperar su fuente desde historial"
699
+ : "Restaurar elemento con su texto y diseño pendientes"
700
+ : actions[action] || "Mantener pendiente";
701
+ select.append(option);
702
+ }
703
+ select.onchange = () => {
704
+ $("resolve").disabled = ![...container.querySelectorAll("select")].some(
705
+ (s) => s.value,
706
+ );
707
+ };
708
+ label.append(select);
709
+ section.append(label);
710
+ }
711
+ container.append(section);
712
+ }
713
+ }
714
+ $("reconcile").onclick = async () => {
715
+ if (busy) return;
716
+ setBusy(true);
717
+ $("busy").textContent = "Ejecutando QMD para comparar sus resultados…";
718
+ try {
719
+ await bridge.save();
720
+ reconcilePlan = await api("reconcile/preview", {});
721
+ if (reconcilePlan.stateRevision !== state.revision) {
722
+ reconcilePlan = undefined;
723
+ throw new Error(
724
+ "Otra pestaña cambió el proyecto. Recarga antes de elegir valores.",
725
+ );
726
+ }
727
+ showChanges($("reconcile-changes"), reconcilePlan.changes, true);
728
+ $("reconcile-note").textContent = reconcilePlan.changes.length
729
+ ? "No se ha aplicado ninguna decisión."
730
+ : "No hay divergencias pendientes.";
731
+ $("resolve").disabled = true;
732
+ $("reconcile-dialog").showModal();
733
+ } catch (error) {
734
+ showError(error);
735
+ } finally {
736
+ setBusy(false);
737
+ }
738
+ };
739
+ $("resolve-close").onclick = () => $("reconcile-dialog").close();
740
+ $("resolve").onclick = async () => {
741
+ if (!reconcilePlan || busy) return;
742
+ const choices = [...$("reconcile-changes").querySelectorAll("select")].filter(
743
+ (s) => s.value,
744
+ ).map((s) => ({ changeId: s.dataset.change, action: s.value }));
745
+ setBusy(true);
746
+ $("resolve").disabled = true;
747
+ $("resolve-close").disabled = true;
748
+ $("reconcile-note").textContent =
749
+ "Verificando de nuevo los resultados antes de guardar…";
750
+ try {
751
+ const result = await api("reconcile/apply", {
752
+ plan: reconcilePlan,
753
+ choices,
754
+ });
755
+ state.revision = result.revision;
756
+ reconcilePlan = undefined;
757
+ $("reconcile-dialog").close();
758
+ $("editor").contentWindow.__qbentoManagedReload = true;
759
+ $("editor").src = "/deck?revision=" + Date.now();
760
+ } catch (error) {
761
+ $("reconcile-note").textContent = error.message;
762
+ setBusy(false);
763
+ } finally {
764
+ $("resolve-close").disabled = false;
765
+ }
766
+ };
767
+ $("render").onclick = async () => {
768
+ if (busy) return;
769
+ setBusy(true);
770
+ $("busy").textContent = "Recalculando. Tus cambios están guardados.";
771
+ try {
772
+ await bridge.save();
773
+ status("Recalculando…");
774
+ const receipt = await api("render", {});
775
+ showRenderDiagnostics(receipt);
776
+ state.revision = receipt.revision;
777
+ if (bridge.dirty) {
778
+ throw new Error(
779
+ "Aparecieron cambios durante el recálculo. Se conservan en el editor; guárdalos antes de recargar.",
780
+ );
781
+ }
782
+ // Only this confirmed saved-and-recomputed navigation bypasses upstream's stale dirty flag.
783
+ $("editor").contentWindow.__qbentoManagedReload = true;
784
+ $("editor").src = "/deck?revision=" + Date.now();
785
+ // connect disposes the old bridge only after the replacement editor exists.
786
+ } catch (error) {
787
+ setBusy(false);
788
+ showError(error);
789
+ }
790
+ };
791
+ globalThis.addEventListener("beforeunload", (event) => {
792
+ if (bridge?.dirty) {
793
+ event.preventDefault();
794
+ event.returnValue = "";
795
+ }
796
+ });
797
+
798
+ $("presenter").onclick = async () => {
799
+ if (busy) return;
800
+ try {
801
+ setBusy(true);
802
+ $("busy").textContent =
803
+ "Preparando presentación con tus cambios guardados.";
804
+ await bridge.save();
805
+ if (bridge.dirty) {
806
+ throw new Error(
807
+ "Hay cambios nuevos sin guardar. Vuelve a preparar la presentación.",
808
+ );
809
+ }
810
+ const result = await api("presenter", { revision: state.revision });
811
+ $("presenter-frame").src = result.url;
812
+ $("presenter-dialog").showModal();
813
+ status("Presentación preparada con tus cambios guardados");
814
+ } catch (error) {
815
+ showError(error);
816
+ } finally {
817
+ setBusy(false);
818
+ }
819
+ };
820
+ $("presenter-close").onclick = () => $("presenter-dialog").close();
821
+ $("presenter-dialog").onclose = () => {
822
+ $("presenter-frame").src = "about:blank";
823
+ };
824
+
825
+ const layoutEditor = attachLayoutEditor({
826
+ api,
827
+ getState: () => state,
828
+ setBusy,
829
+ showError,
830
+ reloadEditor,
831
+ saveBefore: async () => {
832
+ if (!bridge) throw new Error("Espera a que abra el editor.");
833
+ await bridge.save();
834
+ if (bridge.dirty) {
835
+ throw new Error(
836
+ "Hay cambios nuevos sin guardar; vuelve a abrir Aplicar diseño.",
837
+ );
838
+ }
839
+ },
840
+ });
841
+ // WindowProxy alone survives navigation. Bind the request to the iframe's
842
+ // immutable loaded snapshot as well, then save live edits before any changes.
843
+ globalThis.addEventListener("message", (event) => {
844
+ const frame = $("editor").contentWindow, snapshot = frame?.__qbentoSnapshot;
845
+ if (
846
+ event.source !== frame || event.origin !== location.origin || busy ||
847
+ event.data?.type !== "qbento:apply-layout" ||
848
+ event.data.docId !== snapshot?.docId ||
849
+ event.data.revision !== snapshot?.revision ||
850
+ snapshot?.docId !== state?.docId
851
+ ) return;
852
+ void layoutEditor.open();
853
+ });
854
+ attachRegistryEditor({ api, getState: () => state, showError });
855
+ attachGraphRelations({
856
+ api,
857
+ getState: () => state,
858
+ setBusy,
859
+ showError,
860
+ saveBefore: async () => {
861
+ await bridge.save();
862
+ if (bridge.dirty) {
863
+ throw new Error(
864
+ "Hay cambios nuevos sin guardar; vuelve a abrir Relaciones.",
865
+ );
866
+ }
867
+ },
868
+ reloadEditor,
869
+ });
870
+
871
+ attachComponentEditor({
872
+ api,
873
+ getState: () => state,
874
+ setBusy,
875
+ showError,
876
+ saveBefore: async () => {
877
+ await bridge.save();
878
+ if (bridge.dirty) {
879
+ throw new Error(
880
+ "Hay cambios nuevos sin guardar; vuelve a abrir Añadir componente.",
881
+ );
882
+ }
883
+ },
884
+ reloadEditor: () => {
885
+ $("editor").contentWindow.__qbentoManagedReload = true;
886
+ $("editor").src = "/deck?revision=" + Date.now();
887
+ },
888
+ });
889
+
890
+ void refreshDiagnostics();
891
+
892
+ connect();