@ohos-ports/slides-grab 1.5.0-beta.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README-ko.md +292 -0
  3. package/README.md +301 -0
  4. package/bin/ohos-postinstall.cjs +118 -0
  5. package/bin/ppt-agent.js +620 -0
  6. package/convert.cjs +20 -0
  7. package/native/@img/sharp-libvips-openharmony-arm64/lib/glib-2.0/include/glibconfig.h +220 -0
  8. package/native/@img/sharp-libvips-openharmony-arm64/lib/index.js +1 -0
  9. package/native/@img/sharp-libvips-openharmony-arm64/lib/libexpat.so +0 -0
  10. package/native/@img/sharp-libvips-openharmony-arm64/lib/libffi.so +0 -0
  11. package/native/@img/sharp-libvips-openharmony-arm64/lib/libjpeg.so +0 -0
  12. package/native/@img/sharp-libvips-openharmony-arm64/lib/libpcre2-8.so.0 +0 -0
  13. package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips-cpp.so.42.20.3 +0 -0
  14. package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips.so.42.20.3 +0 -0
  15. package/native/@img/sharp-libvips-openharmony-arm64/package.json +13 -0
  16. package/native/@img/sharp-openharmony-arm64/lib/sharp-openharmony-arm64.node +0 -0
  17. package/native/@img/sharp-openharmony-arm64/package.json +16 -0
  18. package/package.json +95 -0
  19. package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
  20. package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
  21. package/scripts/build-viewer.js +444 -0
  22. package/scripts/design-gate.js +258 -0
  23. package/scripts/download-video.js +213 -0
  24. package/scripts/editor-server.js +1167 -0
  25. package/scripts/figma-export.js +169 -0
  26. package/scripts/generate-image.js +116 -0
  27. package/scripts/generate-images.js +164 -0
  28. package/scripts/html2pdf.js +822 -0
  29. package/scripts/html2png.js +246 -0
  30. package/scripts/html2pptx.js +162 -0
  31. package/scripts/import-template.js +86 -0
  32. package/scripts/install-runtime.js +216 -0
  33. package/scripts/render-tldraw.js +44 -0
  34. package/scripts/validate-slides.js +221 -0
  35. package/skills/slides-grab/SKILL.md +65 -0
  36. package/skills/slides-grab/references/presentation-workflow-reference.md +61 -0
  37. package/skills/slides-grab-card-news/SKILL.md +35 -0
  38. package/skills/slides-grab-design/SKILL.md +88 -0
  39. package/skills/slides-grab-design/references/beautiful-slide-defaults.md +88 -0
  40. package/skills/slides-grab-design/references/design-gate.md +349 -0
  41. package/skills/slides-grab-design/references/design-rules.md +76 -0
  42. package/skills/slides-grab-design/references/design-system-full.md +568 -0
  43. package/skills/slides-grab-design/references/detailed-design-rules.md +70 -0
  44. package/skills/slides-grab-export/SKILL.md +55 -0
  45. package/skills/slides-grab-export/references/export-rules.md +27 -0
  46. package/skills/slides-grab-export/references/html2pptx.md +627 -0
  47. package/skills/slides-grab-export/references/ooxml.md +427 -0
  48. package/skills/slides-grab-export/references/pptx-skill-reference.md +189 -0
  49. package/skills/slides-grab-html/SKILL.md +67 -0
  50. package/skills/slides-grab-image/SKILL.md +82 -0
  51. package/skills/slides-grab-plan/SKILL.md +68 -0
  52. package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
  53. package/skills/slides-grab-plan/references/outline-format.md +47 -0
  54. package/skills/slides-grab-plan/references/plan-workflow-reference.md +140 -0
  55. package/src/codex-imagen.js +182 -0
  56. package/src/design-diversity-data.js +6932 -0
  57. package/src/design-gate-report.js +244 -0
  58. package/src/design-gate-state.js +329 -0
  59. package/src/design-import.js +164 -0
  60. package/src/design-md-parser.js +415 -0
  61. package/src/design-styles-data.js +1928 -0
  62. package/src/design-styles.js +209 -0
  63. package/src/editor/codex-edit.js +584 -0
  64. package/src/editor/edit-subprocess.js +170 -0
  65. package/src/editor/editor-codex-prompt.md +50 -0
  66. package/src/editor/editor.html +1785 -0
  67. package/src/editor/js/editor-bbox.js +332 -0
  68. package/src/editor/js/editor-chat.js +56 -0
  69. package/src/editor/js/editor-direct-edit.js +238 -0
  70. package/src/editor/js/editor-dom.js +59 -0
  71. package/src/editor/js/editor-init.js +405 -0
  72. package/src/editor/js/editor-navigation.js +54 -0
  73. package/src/editor/js/editor-select.js +558 -0
  74. package/src/editor/js/editor-send.js +175 -0
  75. package/src/editor/js/editor-sse.js +163 -0
  76. package/src/editor/js/editor-state.js +41 -0
  77. package/src/editor/js/editor-type.js +71 -0
  78. package/src/editor/js/editor-utils.js +167 -0
  79. package/src/editor/js/model-registry.js +37 -0
  80. package/src/editor/screenshot.js +82 -0
  81. package/src/export-resolution.cjs +68 -0
  82. package/src/figma.js +71 -0
  83. package/src/html2pptx-scale.cjs +120 -0
  84. package/src/html2pptx.cjs +1262 -0
  85. package/src/image-contract.js +329 -0
  86. package/src/image-native.js +468 -0
  87. package/src/nano-banana.js +841 -0
  88. package/src/ohos-browser-bridge.cjs +272 -0
  89. package/src/pptx-raster-export.cjs +299 -0
  90. package/src/resolve.js +110 -0
  91. package/src/slide-mode.cjs +72 -0
  92. package/src/template-fidelity.js +267 -0
  93. package/src/template-import.js +505 -0
  94. package/src/template-layout.js +261 -0
  95. package/src/template-pack.js +255 -0
  96. package/src/tldraw/render.js +473 -0
  97. package/src/validation/cli.js +120 -0
  98. package/src/validation/core.js +1023 -0
  99. package/templates/chart.html +121 -0
  100. package/templates/closing.html +54 -0
  101. package/templates/content.html +50 -0
  102. package/templates/contents.html +60 -0
  103. package/templates/cover.html +64 -0
  104. package/templates/custom/.gitkeep +0 -0
  105. package/templates/custom/README.md +7 -0
  106. package/templates/design-styles/README.md +20 -0
  107. package/templates/design-styles/preview.html +4438 -0
  108. package/templates/diagram-tldraw.html +56 -0
  109. package/templates/diagram.html +98 -0
  110. package/templates/quote.html +31 -0
  111. package/templates/section-divider.html +43 -0
  112. package/templates/split-layout.html +43 -0
  113. package/templates/statistics.html +55 -0
  114. package/templates/team.html +49 -0
  115. package/templates/timeline.html +59 -0
@@ -0,0 +1,175 @@
1
+ // editor-send.js — API submission (applyChanges), updateSendState
2
+
3
+ import { state, runsById, activeRunBySlide, pendingRequestBySlide } from './editor-state.js';
4
+ import { slideIframe, slideStatusChip, btnSend, btnClearBboxes, promptInput, imageProviderSelect } from './editor-dom.js';
5
+ import { currentSlideFile, getSlideState, getLatestRunForSlide, normalizeBoxStatus, normalizeModelName, setStatus } from './editor-utils.js';
6
+ import { addChatMessage, renderRunsList } from './editor-chat.js';
7
+ import { renderBboxes, extractTargetsForBox } from './editor-bbox.js';
8
+ import { flushDirectSaveForSlide } from './editor-direct-edit.js';
9
+ import { buildApplyRequestBody, getEditorClient } from './editor-type.js';
10
+
11
+ export function updateSlideStatusChip() {
12
+ const slide = currentSlideFile();
13
+ if (!slide) {
14
+ slideStatusChip.textContent = 'idle';
15
+ slideStatusChip.className = 'slide-status-chip idle';
16
+ return;
17
+ }
18
+
19
+ let status = 'idle';
20
+ if (pendingRequestBySlide.has(slide)) {
21
+ status = 'running';
22
+ } else if (activeRunBySlide.has(slide)) {
23
+ status = 'running';
24
+ } else {
25
+ const latest = getLatestRunForSlide(slide);
26
+ if (latest?.status === 'success') status = 'success';
27
+ if (latest?.status === 'failed') status = 'failed';
28
+ }
29
+
30
+ slideStatusChip.textContent = status;
31
+ slideStatusChip.className = `slide-status-chip ${status}`;
32
+ }
33
+
34
+ export function updateSendState() {
35
+ const slide = currentSlideFile();
36
+ if (!slide) {
37
+ btnSend.disabled = true;
38
+ btnClearBboxes.disabled = true;
39
+ return;
40
+ }
41
+
42
+ const ss = getSlideState(slide);
43
+ const prompt = (promptInput.value || '').trim();
44
+ const pendingCount = ss.boxes.filter((box) => normalizeBoxStatus(box.status) === 'pending').length;
45
+ const blocked = pendingRequestBySlide.has(slide) || activeRunBySlide.has(slide);
46
+ const editorClient = getEditorClient();
47
+ const model = editorClient.usesModel ? normalizeModelName(ss.model) : '';
48
+
49
+ state.imageProvider = imageProviderSelect?.value || 'codex';
50
+ const requiresBbox = !editorClient.usesImageProvider;
51
+ btnSend.disabled = !prompt || (requiresBbox && pendingCount === 0) || blocked || (editorClient.usesModel && !model);
52
+ btnClearBboxes.disabled = ss.boxes.length === 0 || blocked;
53
+ updateSlideStatusChip();
54
+ }
55
+
56
+ export async function applyChanges() {
57
+ const slide = currentSlideFile();
58
+ if (!slide) return;
59
+
60
+ const editorClient = getEditorClient();
61
+ if (editorClient.supportsDirectSave) await flushDirectSaveForSlide(slide);
62
+
63
+ const ss = getSlideState(slide);
64
+ const prompt = (promptInput.value || '').trim();
65
+ const pendingBoxes = ss.boxes.filter((box) => normalizeBoxStatus(box.status) === 'pending');
66
+ const model = editorClient.usesModel
67
+ ? normalizeModelName(ss.model) || state.selectedModel || state.defaultModel
68
+ : '';
69
+ const imageProvider = imageProviderSelect?.value || 'codex';
70
+ state.imageProvider = imageProvider;
71
+ if (!prompt) return;
72
+ if (!editorClient.usesImageProvider && pendingBoxes.length === 0) {
73
+ setStatus('No pending (red) bbox to run. Draw a new box or click Rerun on a green box.');
74
+ return;
75
+ }
76
+
77
+ const submittedBoxIds = pendingBoxes.map((box) => box.id);
78
+ const submittedSet = new Set(submittedBoxIds);
79
+
80
+ const selections = pendingBoxes.map((box) => ({
81
+ x: box.x,
82
+ y: box.y,
83
+ width: box.width,
84
+ height: box.height,
85
+ targets: editorClient.includesSelectionTargets ? extractTargetsForBox(box) : [],
86
+ }));
87
+
88
+ addChatMessage('user', `[${slide}] [${editorClient.usesImageProvider ? `image:${imageProvider}` : model}] ${prompt}`, slide);
89
+
90
+ pendingRequestBySlide.add(slide);
91
+ ss.prompt = '';
92
+ promptInput.value = '';
93
+ updateSendState();
94
+ const engineLabel = editorClient.usesImageProvider ? `Image ${imageProvider}` : model.startsWith('claude-') ? 'Claude' : 'Codex';
95
+ setStatus(`Submitting ${slide} to ${engineLabel}...`);
96
+
97
+ try {
98
+ const res = await fetch('/api/apply', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify(buildApplyRequestBody({
102
+ slide,
103
+ prompt,
104
+ model,
105
+ selections,
106
+ provider: imageProvider,
107
+ })),
108
+ });
109
+
110
+ const data = await res.json().catch(() => ({}));
111
+
112
+ if (!res.ok) {
113
+ const message = data.error || `Server error ${res.status}`;
114
+ addChatMessage('error', `[${slide}] ${message}`, slide);
115
+ setStatus(`Error: ${message}`);
116
+ return;
117
+ }
118
+
119
+ if (data.runId) {
120
+ const existing = runsById.get(data.runId) || {};
121
+ runsById.set(data.runId, {
122
+ ...existing,
123
+ runId: data.runId,
124
+ slide,
125
+ status: data.success ? 'success' : 'failed',
126
+ code: data.code,
127
+ message: data.message,
128
+ model: data.model || model,
129
+ startedAt: existing.startedAt || new Date().toISOString(),
130
+ finishedAt: new Date().toISOString(),
131
+ logPreview: existing.logPreview || '',
132
+ });
133
+ renderRunsList();
134
+ }
135
+
136
+ addChatMessage(
137
+ data.success ? 'system' : 'error',
138
+ `[${slide}] ${data.message || (data.success ? 'Completed' : 'Failed')}`,
139
+ slide,
140
+ );
141
+
142
+ if (data.success) {
143
+ if (editorClient.usesImageProvider && data.image?.assetRef && slide === currentSlideFile()) {
144
+ const slideImage = slideIframe?.contentDocument?.querySelector('img.slide-image');
145
+ if (slideImage) {
146
+ const refreshedImageUrl = new URL(data.image.assetRef, slideIframe.contentWindow.location.href);
147
+ refreshedImageUrl.searchParams.set('refresh', Date.now().toString());
148
+ slideImage.src = refreshedImageUrl.href;
149
+ }
150
+ }
151
+
152
+ let marked = 0;
153
+ for (const box of ss.boxes) {
154
+ if (submittedSet.has(box.id) && normalizeBoxStatus(box.status) === 'pending') {
155
+ box.status = 'review';
156
+ marked += 1;
157
+ }
158
+ }
159
+ renderBboxes();
160
+ setStatus(
161
+ marked > 0
162
+ ? `${data.message || 'Codex run completed.'} Review ${marked} green bbox${marked === 1 ? '' : 'es'}: Check to accept or Rerun.`
163
+ : (data.message || 'Codex run completed.'),
164
+ );
165
+ } else {
166
+ setStatus(data.message || 'Codex run failed.');
167
+ }
168
+ } catch (error) {
169
+ addChatMessage('error', `[${slide}] ${error.message}`, slide);
170
+ setStatus(`Error: ${error.message}`);
171
+ } finally {
172
+ pendingRequestBySlide.delete(slide);
173
+ updateSendState();
174
+ }
175
+ }
@@ -0,0 +1,163 @@
1
+ // editor-sse.js — EventSource connection, run event handling
2
+
3
+ import { state, runsById, activeRunBySlide, localFileUpdateBySlide } from './editor-state.js';
4
+ import { slideIframe, statusDot, statusConn } from './editor-dom.js';
5
+ import { currentSlideFile, setStatus } from './editor-utils.js';
6
+ import { addChatMessage, renderRunsList } from './editor-chat.js';
7
+ import { renderBboxes } from './editor-bbox.js';
8
+ import { updateSendState } from './editor-send.js';
9
+
10
+ function upsertRun(run) {
11
+ if (!run?.runId) return;
12
+ const existing = runsById.get(run.runId) || {};
13
+ runsById.set(run.runId, {
14
+ ...existing,
15
+ ...run,
16
+ });
17
+ }
18
+
19
+ export function connectSSE() {
20
+ const evtSource = new EventSource('/api/events');
21
+
22
+ evtSource.onopen = () => {
23
+ statusDot.classList.add('connected');
24
+ statusDot.classList.remove('disconnected');
25
+ statusConn.textContent = 'Connected';
26
+ };
27
+
28
+ evtSource.addEventListener('runsSnapshot', (event) => {
29
+ try {
30
+ const payload = JSON.parse(event.data);
31
+
32
+ runsById.clear();
33
+ for (const run of payload.runs || []) {
34
+ upsertRun(run);
35
+ }
36
+
37
+ activeRunBySlide.clear();
38
+ for (const active of payload.activeRuns || []) {
39
+ if (active.slide && active.runId) {
40
+ activeRunBySlide.set(active.slide, active.runId);
41
+ }
42
+ }
43
+
44
+ renderRunsList();
45
+ updateSendState();
46
+ } catch (error) {
47
+ console.error('runsSnapshot parse error:', error);
48
+ }
49
+ });
50
+
51
+ evtSource.addEventListener('applyStarted', (event) => {
52
+ try {
53
+ const payload = JSON.parse(event.data);
54
+ activeRunBySlide.set(payload.slide, payload.runId);
55
+
56
+ upsertRun({
57
+ runId: payload.runId,
58
+ slide: payload.slide,
59
+ model: payload.model || '',
60
+ status: 'running',
61
+ message: `${payload.model ? `${payload.model} | ` : ''}Running (${payload.selectionsCount || 0} bbox)`,
62
+ startedAt: new Date().toISOString(),
63
+ logPreview: '',
64
+ });
65
+
66
+ addChatMessage('system', `[${payload.slide}] run started (${payload.runId})`, payload.slide);
67
+ renderRunsList();
68
+ updateSendState();
69
+ } catch (error) {
70
+ console.error('applyStarted parse error:', error);
71
+ }
72
+ });
73
+
74
+ evtSource.addEventListener('applyLog', (event) => {
75
+ try {
76
+ const payload = JSON.parse(event.data);
77
+ if (!payload.runId) return;
78
+ const run = runsById.get(payload.runId);
79
+ if (run) {
80
+ run.logPreview = (String(run.logPreview || '') + String(payload.chunk || '')).slice(-2000);
81
+ runsById.set(payload.runId, run);
82
+ }
83
+ } catch (error) {
84
+ console.error('applyLog parse error:', error);
85
+ }
86
+ });
87
+
88
+ evtSource.addEventListener('applyFinished', (event) => {
89
+ try {
90
+ const payload = JSON.parse(event.data);
91
+ activeRunBySlide.delete(payload.slide);
92
+
93
+ upsertRun({
94
+ runId: payload.runId,
95
+ slide: payload.slide,
96
+ model: payload.model || '',
97
+ status: payload.success ? 'success' : 'failed',
98
+ code: payload.code,
99
+ message: payload.message,
100
+ finishedAt: new Date().toISOString(),
101
+ });
102
+
103
+ addChatMessage(
104
+ payload.success ? 'system' : 'error',
105
+ `[${payload.slide}] ${payload.message || (payload.success ? 'completed' : 'failed')}`,
106
+ payload.slide,
107
+ );
108
+
109
+ renderRunsList();
110
+ updateSendState();
111
+ setStatus(payload.message || 'Run finished.');
112
+ } catch (error) {
113
+ console.error('applyFinished parse error:', error);
114
+ }
115
+ });
116
+
117
+ evtSource.addEventListener('fileChanged', (event) => {
118
+ try {
119
+ const { file } = JSON.parse(event.data);
120
+ if (file === currentSlideFile()) {
121
+ const updatedAt = localFileUpdateBySlide.get(file);
122
+ if (updatedAt && Date.now() - updatedAt < 2000) {
123
+ localFileUpdateBySlide.delete(file);
124
+ return;
125
+ }
126
+ slideIframe.src = slideIframe.src;
127
+ }
128
+ } catch (error) {
129
+ console.error('fileChanged parse error:', error);
130
+ }
131
+ });
132
+
133
+ evtSource.onerror = () => {
134
+ statusDot.classList.remove('connected');
135
+ statusDot.classList.add('disconnected');
136
+ statusConn.textContent = 'Disconnected';
137
+ };
138
+ }
139
+
140
+ export async function loadRunsInitial() {
141
+ try {
142
+ const res = await fetch('/api/runs');
143
+ if (!res.ok) return;
144
+ const payload = await res.json();
145
+
146
+ runsById.clear();
147
+ for (const run of payload.runs || []) {
148
+ upsertRun(run);
149
+ }
150
+
151
+ activeRunBySlide.clear();
152
+ for (const active of payload.activeRuns || []) {
153
+ if (active.slide && active.runId) {
154
+ activeRunBySlide.set(active.slide, active.runId);
155
+ }
156
+ }
157
+
158
+ renderRunsList();
159
+ updateSendState();
160
+ } catch {
161
+ // ignore
162
+ }
163
+ }
@@ -0,0 +1,41 @@
1
+ import { ALL_MODELS } from './model-registry.js';
2
+
3
+ export let SLIDE_W = 960;
4
+ export let SLIDE_H = 540;
5
+
6
+ export function setSlideFrame(width, height) {
7
+ const w = Number(width);
8
+ const h = Number(height);
9
+ if (Number.isFinite(w) && w > 0) SLIDE_W = Math.round(w);
10
+ if (Number.isFinite(h) && h > 0) SLIDE_H = Math.round(h);
11
+ }
12
+ export const TOOL_MODE_DRAW = 'draw';
13
+ export const TOOL_MODE_SELECT = 'select';
14
+ export const POPOVER_TEXT = 'text';
15
+ export const POPOVER_TEXT_COLOR = 'text-color';
16
+ export const POPOVER_BG_COLOR = 'bg-color';
17
+ export const POPOVER_SIZE = 'size';
18
+ export const DEFAULT_MODELS = ALL_MODELS.slice();
19
+ export const DIRECT_TEXT_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']);
20
+ export const NON_SELECTABLE_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript']);
21
+
22
+ export const slideStates = new Map();
23
+ export const activeRunBySlide = new Map();
24
+ export const pendingRequestBySlide = new Set();
25
+ export const runsById = new Map();
26
+ export const directSaveStateBySlide = new Map();
27
+ export const localFileUpdateBySlide = new Map();
28
+
29
+ export const state = {
30
+ slides: [],
31
+ currentIndex: 0,
32
+ drawStart: null,
33
+ drawing: false,
34
+ availableModels: DEFAULT_MODELS.slice(),
35
+ defaultModel: DEFAULT_MODELS[0],
36
+ selectedModel: DEFAULT_MODELS[0],
37
+ toolMode: TOOL_MODE_DRAW,
38
+ editorType: typeof document !== 'undefined' && document.body?.dataset.editorType === 'image' ? 'image' : 'html',
39
+ imageProvider: 'codex',
40
+ hoveredObjectXPath: '',
41
+ };
@@ -0,0 +1,71 @@
1
+ import { state, TOOL_MODE_SELECT } from './editor-state.js';
2
+ import { btnSend, btnSendLabel, editorBrand, editorHint } from './editor-dom.js';
3
+
4
+ const EDITOR_CLIENTS = Object.freeze({
5
+ html: Object.freeze({
6
+ type: 'html',
7
+ title: 'HTML Editor - slides-grab',
8
+ brand: 'HTML Editor',
9
+ drawHint: 'HTML Editor: drag on the slide to add red bboxes. Cmd/Ctrl+Enter to run.',
10
+ selectHint: 'HTML Editor: click an object to edit. Cmd/Ctrl+B, I, or U to style text.',
11
+ sendLabel: 'Run HTML Edit',
12
+ sendTitle: 'Run HTML edit',
13
+ usesModel: true,
14
+ usesImageProvider: false,
15
+ supportsDirectSave: true,
16
+ includesSelectionTargets: true,
17
+ requestMode: '',
18
+ }),
19
+ image: Object.freeze({
20
+ type: 'image',
21
+ title: 'Image Editor - slides-grab',
22
+ brand: 'Image Editor',
23
+ drawHint: 'Image Editor: drag on the slide to add red bboxes for regeneration.',
24
+ selectHint: 'Image Editor: draw a bbox for image regeneration.',
25
+ sendLabel: 'Regenerate Image',
26
+ sendTitle: 'Regenerate slide image',
27
+ usesModel: false,
28
+ usesImageProvider: true,
29
+ supportsDirectSave: false,
30
+ includesSelectionTargets: false,
31
+ requestMode: 'image',
32
+ }),
33
+ });
34
+
35
+ export function getEditorClient(editorType = state.editorType) {
36
+ return EDITOR_CLIENTS[editorType] || EDITOR_CLIENTS.html;
37
+ }
38
+
39
+ export function updateEditorClientLabels() {
40
+ const client = getEditorClient();
41
+ document.title = client.title;
42
+ editorBrand.textContent = client.brand;
43
+ editorHint.textContent = state.toolMode === TOOL_MODE_SELECT ? client.selectHint : client.drawHint;
44
+ btnSendLabel.textContent = client.sendLabel;
45
+ btnSend.title = client.sendTitle;
46
+ }
47
+
48
+ export function configureEditorClient(editorType) {
49
+ const client = getEditorClient(editorType);
50
+ state.editorType = client.type;
51
+ document.body.dataset.editorType = client.type;
52
+ updateEditorClientLabels();
53
+ return client;
54
+ }
55
+
56
+ export function buildApplyRequestBody({ slide, prompt, model, provider, selections }) {
57
+ const client = getEditorClient();
58
+ const body = {
59
+ slide,
60
+ prompt,
61
+ selections: selections.map((selection) => ({
62
+ ...selection,
63
+ targets: client.includesSelectionTargets ? selection.targets : [],
64
+ })),
65
+ };
66
+
67
+ if (client.usesModel) body.model = model;
68
+ if (client.requestMode) body.mode = client.requestMode;
69
+ if (client.usesImageProvider) body.provider = provider;
70
+ return body;
71
+ }
@@ -0,0 +1,167 @@
1
+ // editor-utils.js — Helper functions
2
+
3
+ import { state, DEFAULT_MODELS, slideStates, runsById, directSaveStateBySlide } from './editor-state.js';
4
+ import { statusMsg, modelSelect } from './editor-dom.js';
5
+
6
+ export function setStatus(message) {
7
+ statusMsg.textContent = message;
8
+ }
9
+
10
+ export function currentSlideFile() {
11
+ return state.slides[state.currentIndex] || null;
12
+ }
13
+
14
+ export function clamp(v, min, max) {
15
+ return Math.max(min, Math.min(max, v));
16
+ }
17
+
18
+ export function getDirectSaveState(slide) {
19
+ if (!directSaveStateBySlide.has(slide)) {
20
+ directSaveStateBySlide.set(slide, {
21
+ timer: null,
22
+ pendingHtml: '',
23
+ pendingMessage: '',
24
+ chain: Promise.resolve(),
25
+ });
26
+ }
27
+ return directSaveStateBySlide.get(slide);
28
+ }
29
+
30
+ export function escapeHtml(str) {
31
+ return String(str)
32
+ .replace(/&/g, '&amp;')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ .replace(/"/g, '&quot;')
36
+ .replace(/'/g, '&#39;');
37
+ }
38
+
39
+ export function formatTime(iso) {
40
+ if (!iso) return '-';
41
+ const date = new Date(iso);
42
+ if (Number.isNaN(date.getTime())) return iso;
43
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
44
+ }
45
+
46
+ export function randomId(prefix) {
47
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
48
+ }
49
+
50
+ export function normalizeModelName(value) {
51
+ return typeof value === 'string' ? value.trim() : '';
52
+ }
53
+
54
+ export function normalizeHexColor(value, fallback = '#111111') {
55
+ if (typeof value !== 'string') return fallback;
56
+ const hexMatch = value.trim().match(/^#([0-9a-f]{6})$/i);
57
+ if (hexMatch) {
58
+ return `#${hexMatch[1].toLowerCase()}`;
59
+ }
60
+
61
+ const rgbMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
62
+ if (!rgbMatch) return fallback;
63
+ const toHex = (part) => Number(part).toString(16).padStart(2, '0');
64
+ return `#${toHex(rgbMatch[1])}${toHex(rgbMatch[2])}${toHex(rgbMatch[3])}`;
65
+ }
66
+
67
+ export function parsePixelValue(value, fallback = 24) {
68
+ const parsed = Number.parseFloat(String(value || '').replace('px', ''));
69
+ if (!Number.isFinite(parsed)) return fallback;
70
+ return Math.round(parsed);
71
+ }
72
+
73
+ export function isBoldFontWeight(value) {
74
+ const numeric = Number.parseInt(value, 10);
75
+ if (Number.isFinite(numeric)) return numeric >= 600;
76
+ return /bold/i.test(String(value || ''));
77
+ }
78
+
79
+ export function loadSavedModel() {
80
+ try {
81
+ return normalizeModelName(window.localStorage.getItem('slides-grab-editor-model'));
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ export function saveSelectedModel(model) {
88
+ try {
89
+ window.localStorage.setItem('slides-grab-editor-model', model);
90
+ } catch {
91
+ // ignore
92
+ }
93
+ }
94
+
95
+ export function setModelOptions(models, preferredModel = '') {
96
+ const list = Array.isArray(models)
97
+ ? models
98
+ .map((name) => normalizeModelName(name))
99
+ .filter((name) => name !== '')
100
+ : [];
101
+ const uniqueModels = Array.from(new Set(list));
102
+
103
+ state.availableModels = uniqueModels.length > 0 ? uniqueModels : DEFAULT_MODELS.slice();
104
+
105
+ const preferred = normalizeModelName(preferredModel);
106
+ const saved = loadSavedModel();
107
+
108
+ if (state.availableModels.includes(preferred)) {
109
+ state.selectedModel = preferred;
110
+ } else if (state.availableModels.includes(saved)) {
111
+ state.selectedModel = saved;
112
+ } else {
113
+ state.selectedModel = state.availableModels[0];
114
+ }
115
+
116
+ state.defaultModel = state.selectedModel;
117
+
118
+ modelSelect.innerHTML = state.availableModels
119
+ .map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`)
120
+ .join('');
121
+ modelSelect.value = state.selectedModel;
122
+ saveSelectedModel(state.selectedModel);
123
+ }
124
+
125
+ export async function loadModelOptions() {
126
+ const saved = loadSavedModel();
127
+
128
+ try {
129
+ const res = await fetch('/api/models');
130
+ if (!res.ok) {
131
+ throw new Error(`HTTP ${res.status}`);
132
+ }
133
+
134
+ const payload = await res.json();
135
+ const serverModels = Array.isArray(payload.models) ? payload.models : [];
136
+ const serverDefault = normalizeModelName(payload.defaultModel);
137
+
138
+ setModelOptions(serverModels, saved || serverDefault);
139
+ } catch {
140
+ setModelOptions(DEFAULT_MODELS, saved);
141
+ }
142
+ }
143
+
144
+ export function normalizeBoxStatus(status) {
145
+ return status === 'review' ? 'review' : 'pending';
146
+ }
147
+
148
+ export function getSlideState(slide) {
149
+ if (!slideStates.has(slide)) {
150
+ slideStates.set(slide, {
151
+ prompt: '',
152
+ model: state.defaultModel,
153
+ messages: [],
154
+ boxes: [],
155
+ selectedBoxId: null,
156
+ selectedObjectXPath: '',
157
+ });
158
+ }
159
+ return slideStates.get(slide);
160
+ }
161
+
162
+ export function getLatestRunForSlide(slide) {
163
+ const runs = Array.from(runsById.values())
164
+ .filter((run) => run.slide === slide)
165
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
166
+ return runs[0] || null;
167
+ }
@@ -0,0 +1,37 @@
1
+ // Canonical editor model registry — single source of truth for the slide
2
+ // editor's model dropdown and dispatch routing. Imported by both Node code
3
+ // (scripts/editor-server.js, src/editor/codex-edit.js) and browser code
4
+ // (src/editor/js/editor-state.js), so this module MUST stay browser-safe:
5
+ // no Node imports, no Node-only globals.
6
+ //
7
+ // To add a new model:
8
+ // 1. Append to CODEX_MODELS or CLAUDE_MODELS below.
9
+ // 2. Add a matching <option> to src/editor/editor.html (also a fallback).
10
+ // 3. Existing tests/editor/editor-model-dispatch.test.js will automatically
11
+ // verify the new model dispatches correctly through the editor pipeline.
12
+ // No per-model test additions needed.
13
+
14
+ export const CODEX_MODELS = [
15
+ 'gpt-5.6-sol',
16
+ 'gpt-5.6-terra',
17
+ 'gpt-5.6-luna',
18
+ ];
19
+
20
+ export const CLAUDE_MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6'];
21
+
22
+ export const ALL_MODELS = [...CODEX_MODELS, ...CLAUDE_MODELS];
23
+
24
+ export const DEFAULT_CODEX_MODEL = CODEX_MODELS[0];
25
+ export const DEFAULT_MODEL = DEFAULT_CODEX_MODEL;
26
+
27
+ export function isClaudeModel(model) {
28
+ return typeof model === 'string' && CLAUDE_MODELS.includes(model.trim());
29
+ }
30
+
31
+ export function isCodexModel(model) {
32
+ return typeof model === 'string' && CODEX_MODELS.includes(model.trim());
33
+ }
34
+
35
+ export function isKnownEditorModel(model) {
36
+ return isCodexModel(model) || isClaudeModel(model);
37
+ }