slides-grab 1.0.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/AGENTS.md +80 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +39 -0
- package/README.md +120 -0
- package/SETUP.md +51 -0
- package/bin/ppt-agent.js +204 -0
- package/convert.cjs +184 -0
- package/package.json +51 -0
- package/prd.json +135 -0
- package/prd.md +104 -0
- package/scripts/editor-server.js +779 -0
- package/scripts/html2pdf.js +217 -0
- package/scripts/validate-slides.js +416 -0
- package/skills/ppt-design-skill/SKILL.md +38 -0
- package/skills/ppt-plan-skill/SKILL.md +37 -0
- package/skills/ppt-pptx-skill/SKILL.md +37 -0
- package/skills/ppt-presentation-skill/SKILL.md +57 -0
- package/src/editor/codex-edit.js +213 -0
- package/src/editor/editor.html +1733 -0
- package/src/editor/js/editor-bbox.js +332 -0
- package/src/editor/js/editor-chat.js +56 -0
- package/src/editor/js/editor-direct-edit.js +110 -0
- package/src/editor/js/editor-dom.js +55 -0
- package/src/editor/js/editor-init.js +284 -0
- package/src/editor/js/editor-navigation.js +54 -0
- package/src/editor/js/editor-select.js +264 -0
- package/src/editor/js/editor-send.js +157 -0
- package/src/editor/js/editor-sse.js +163 -0
- package/src/editor/js/editor-state.js +32 -0
- package/src/editor/js/editor-utils.js +167 -0
- package/src/editor/screenshot.js +73 -0
- package/src/resolve.js +159 -0
- package/templates/chart.html +121 -0
- package/templates/closing.html +54 -0
- package/templates/content.html +50 -0
- package/templates/contents.html +60 -0
- package/templates/cover.html +64 -0
- package/templates/custom/.gitkeep +0 -0
- package/templates/custom/README.md +7 -0
- package/templates/diagram.html +98 -0
- package/templates/quote.html +31 -0
- package/templates/section-divider.html +43 -0
- package/templates/split-layout.html +41 -0
- package/templates/statistics.html +55 -0
- package/templates/team.html +49 -0
- package/templates/timeline.html +59 -0
- package/themes/corporate.css +8 -0
- package/themes/executive.css +10 -0
- package/themes/modern-dark.css +9 -0
- package/themes/sage.css +9 -0
- package/themes/warm.css +8 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// editor-init.js — Entry point: imports, event bindings, init()
|
|
2
|
+
|
|
3
|
+
import { state, TOOL_MODE_DRAW, TOOL_MODE_SELECT } from './editor-state.js';
|
|
4
|
+
import {
|
|
5
|
+
btnPrev, btnNext, slideIframe, drawLayer, promptInput, modelSelect,
|
|
6
|
+
btnSend, btnClearBboxes, slideCounter,
|
|
7
|
+
toggleBold, toggleItalic, toggleUnderline, toggleStrike,
|
|
8
|
+
alignLeft, alignCenter, alignRight,
|
|
9
|
+
popoverTextInput, popoverApplyText, popoverTextColorInput, popoverBgColorInput,
|
|
10
|
+
popoverSizeInput, popoverApplySize, toolModeDrawBtn, toolModeSelectBtn,
|
|
11
|
+
} from './editor-dom.js';
|
|
12
|
+
import {
|
|
13
|
+
currentSlideFile, getSlideState, normalizeModelName, setStatus,
|
|
14
|
+
saveSelectedModel, loadModelOptions, clamp,
|
|
15
|
+
} from './editor-utils.js';
|
|
16
|
+
import { renderChatMessages } from './editor-chat.js';
|
|
17
|
+
import {
|
|
18
|
+
onBboxChange, renderBboxes, scaleSlide, startDrawing, moveDrawing, endDrawing,
|
|
19
|
+
clearBboxesForCurrentSlide, initBboxLayerEvents, getXPath,
|
|
20
|
+
} from './editor-bbox.js';
|
|
21
|
+
import {
|
|
22
|
+
setToolMode, updateToolModeUI, renderObjectSelection, updateObjectEditorControls,
|
|
23
|
+
getSelectedObjectElement, setSelectedObjectXPath, updateHoveredObjectFromPointer,
|
|
24
|
+
clearHoveredObject, getSelectableTargetAt, readSelectedObjectStyleState,
|
|
25
|
+
} from './editor-select.js';
|
|
26
|
+
import {
|
|
27
|
+
mutateSelectedObject, applyTextDecorationToken,
|
|
28
|
+
} from './editor-direct-edit.js';
|
|
29
|
+
import { updateSendState, applyChanges } from './editor-send.js';
|
|
30
|
+
import { goToSlide } from './editor-navigation.js';
|
|
31
|
+
import { connectSSE, loadRunsInitial } from './editor-sse.js';
|
|
32
|
+
|
|
33
|
+
// Late-binding: connect bbox changes to updateSendState
|
|
34
|
+
onBboxChange(updateSendState);
|
|
35
|
+
|
|
36
|
+
// Bbox layer events
|
|
37
|
+
initBboxLayerEvents();
|
|
38
|
+
|
|
39
|
+
// Navigation
|
|
40
|
+
btnPrev.addEventListener('click', () => { void goToSlide(state.currentIndex - 1); });
|
|
41
|
+
btnNext.addEventListener('click', () => { void goToSlide(state.currentIndex + 1); });
|
|
42
|
+
|
|
43
|
+
// Tool modes
|
|
44
|
+
toolModeDrawBtn.addEventListener('click', () => setToolMode(TOOL_MODE_DRAW));
|
|
45
|
+
toolModeSelectBtn.addEventListener('click', () => setToolMode(TOOL_MODE_SELECT));
|
|
46
|
+
|
|
47
|
+
// Clear bboxes
|
|
48
|
+
btnClearBboxes.addEventListener('click', clearBboxesForCurrentSlide);
|
|
49
|
+
|
|
50
|
+
// Drawing
|
|
51
|
+
drawLayer.addEventListener('mousedown', startDrawing);
|
|
52
|
+
drawLayer.addEventListener('mousemove', (event) => {
|
|
53
|
+
if (state.toolMode !== TOOL_MODE_SELECT) return;
|
|
54
|
+
updateHoveredObjectFromPointer(event.clientX, event.clientY);
|
|
55
|
+
});
|
|
56
|
+
drawLayer.addEventListener('mouseleave', clearHoveredObject);
|
|
57
|
+
drawLayer.addEventListener('click', (event) => {
|
|
58
|
+
if (state.toolMode !== TOOL_MODE_SELECT) return;
|
|
59
|
+
const target = getSelectableTargetAt(event.clientX, event.clientY);
|
|
60
|
+
if (!target) {
|
|
61
|
+
setSelectedObjectXPath('', 'No selectable object at this point.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const xpath = getXPath(target);
|
|
66
|
+
setSelectedObjectXPath(xpath, `Object selected on ${currentSlideFile()}.`);
|
|
67
|
+
});
|
|
68
|
+
window.addEventListener('mousemove', moveDrawing);
|
|
69
|
+
window.addEventListener('mouseup', endDrawing);
|
|
70
|
+
|
|
71
|
+
// Send
|
|
72
|
+
btnSend.addEventListener('click', applyChanges);
|
|
73
|
+
|
|
74
|
+
// Model select
|
|
75
|
+
modelSelect.addEventListener('change', () => {
|
|
76
|
+
const nextModel = normalizeModelName(modelSelect.value);
|
|
77
|
+
if (!state.availableModels.includes(nextModel)) {
|
|
78
|
+
modelSelect.value = state.selectedModel;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const slide = currentSlideFile();
|
|
83
|
+
if (slide) {
|
|
84
|
+
const ss = getSlideState(slide);
|
|
85
|
+
ss.model = nextModel;
|
|
86
|
+
}
|
|
87
|
+
state.selectedModel = nextModel;
|
|
88
|
+
state.defaultModel = nextModel;
|
|
89
|
+
saveSelectedModel(state.selectedModel);
|
|
90
|
+
updateSendState();
|
|
91
|
+
setStatus(`Model selected: ${state.selectedModel}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Prompt input
|
|
95
|
+
promptInput.addEventListener('input', () => {
|
|
96
|
+
const slide = currentSlideFile();
|
|
97
|
+
if (slide) {
|
|
98
|
+
const ss = getSlideState(slide);
|
|
99
|
+
ss.prompt = promptInput.value;
|
|
100
|
+
}
|
|
101
|
+
updateSendState();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Text editing
|
|
105
|
+
popoverApplyText.addEventListener('click', () => {
|
|
106
|
+
if (popoverApplyText.disabled) return;
|
|
107
|
+
mutateSelectedObject((el) => {
|
|
108
|
+
const escaped = popoverTextInput.value
|
|
109
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
110
|
+
el.innerHTML = escaped.replace(/\n/g, '<br>');
|
|
111
|
+
}, 'Object text updated and saved.', { delay: 120 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
popoverApplySize.addEventListener('click', () => {
|
|
115
|
+
if (popoverApplySize.disabled) return;
|
|
116
|
+
const size = clamp(Number.parseInt(popoverSizeInput.value || '24', 10) || 24, 8, 180);
|
|
117
|
+
mutateSelectedObject((el) => {
|
|
118
|
+
el.style.fontSize = `${size}px`;
|
|
119
|
+
}, 'Object font size updated and saved.');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
popoverTextInput.addEventListener('keydown', (event) => {
|
|
123
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
popoverApplyText.click();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
popoverSizeInput.addEventListener('keydown', (event) => {
|
|
131
|
+
if (event.key === 'Enter') {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
popoverApplySize.click();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
popoverTextColorInput.addEventListener('input', () => {
|
|
139
|
+
if (popoverTextColorInput.disabled) return;
|
|
140
|
+
mutateSelectedObject((el) => {
|
|
141
|
+
el.style.color = popoverTextColorInput.value;
|
|
142
|
+
}, 'Text color updated.', { delay: 300 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
popoverBgColorInput.addEventListener('input', () => {
|
|
146
|
+
if (popoverBgColorInput.disabled) return;
|
|
147
|
+
mutateSelectedObject((el) => {
|
|
148
|
+
el.style.backgroundColor = popoverBgColorInput.value;
|
|
149
|
+
}, 'Background color updated.', { delay: 300 });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Style toggles
|
|
153
|
+
toggleBold.addEventListener('click', () => {
|
|
154
|
+
mutateSelectedObject((el) => {
|
|
155
|
+
const nextBold = !readSelectedObjectStyleState(el).bold;
|
|
156
|
+
el.style.fontWeight = nextBold ? '700' : '400';
|
|
157
|
+
}, 'Object font weight updated and saved.');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
toggleItalic.addEventListener('click', () => {
|
|
161
|
+
mutateSelectedObject((el) => {
|
|
162
|
+
const nextItalic = !readSelectedObjectStyleState(el).italic;
|
|
163
|
+
el.style.fontStyle = nextItalic ? 'italic' : 'normal';
|
|
164
|
+
}, 'Object font style updated and saved.');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
toggleUnderline.addEventListener('click', () => {
|
|
168
|
+
mutateSelectedObject((el) => {
|
|
169
|
+
const nextUnderline = !readSelectedObjectStyleState(el).underline;
|
|
170
|
+
applyTextDecorationToken(el, 'underline', nextUnderline);
|
|
171
|
+
}, 'Object underline updated and saved.');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
toggleStrike.addEventListener('click', () => {
|
|
175
|
+
mutateSelectedObject((el) => {
|
|
176
|
+
const nextStrike = !readSelectedObjectStyleState(el).strike;
|
|
177
|
+
applyTextDecorationToken(el, 'line-through', nextStrike);
|
|
178
|
+
}, 'Object strikethrough updated and saved.');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Alignment
|
|
182
|
+
alignLeft.addEventListener('click', () => {
|
|
183
|
+
mutateSelectedObject((el) => {
|
|
184
|
+
el.style.textAlign = 'left';
|
|
185
|
+
}, 'Object alignment updated and saved.');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
alignCenter.addEventListener('click', () => {
|
|
189
|
+
mutateSelectedObject((el) => {
|
|
190
|
+
el.style.textAlign = 'center';
|
|
191
|
+
}, 'Object alignment updated and saved.');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
alignRight.addEventListener('click', () => {
|
|
195
|
+
mutateSelectedObject((el) => {
|
|
196
|
+
el.style.textAlign = 'right';
|
|
197
|
+
}, 'Object alignment updated and saved.');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Global keyboard
|
|
201
|
+
document.addEventListener('keydown', (event) => {
|
|
202
|
+
const inPromptField = document.activeElement === promptInput;
|
|
203
|
+
|
|
204
|
+
if (state.toolMode === TOOL_MODE_SELECT && (event.ctrlKey || event.metaKey) && !inPromptField) {
|
|
205
|
+
const key = event.key.toLowerCase();
|
|
206
|
+
if (key === 'b') { event.preventDefault(); if (!toggleBold.disabled) toggleBold.click(); return; }
|
|
207
|
+
if (key === 'i') { event.preventDefault(); if (!toggleItalic.disabled) toggleItalic.click(); return; }
|
|
208
|
+
if (key === 'u') { event.preventDefault(); if (!toggleUnderline.disabled) toggleUnderline.click(); return; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
applyChanges();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (event.key === 'Escape') {
|
|
218
|
+
if (document.activeElement) document.activeElement.blur();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (inPromptField) return;
|
|
223
|
+
|
|
224
|
+
if (event.key === 'ArrowLeft') {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
void goToSlide(state.currentIndex - 1);
|
|
227
|
+
} else if (event.key === 'ArrowRight') {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
void goToSlide(state.currentIndex + 1);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Resize
|
|
234
|
+
window.addEventListener('resize', scaleSlide);
|
|
235
|
+
|
|
236
|
+
// Iframe load
|
|
237
|
+
slideIframe.addEventListener('load', () => {
|
|
238
|
+
const slide = currentSlideFile();
|
|
239
|
+
if (slide) {
|
|
240
|
+
const ss = getSlideState(slide);
|
|
241
|
+
if (ss.selectedObjectXPath && !getSelectedObjectElement(slide)) {
|
|
242
|
+
ss.selectedObjectXPath = '';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
state.hoveredObjectXPath = '';
|
|
246
|
+
renderBboxes();
|
|
247
|
+
renderObjectSelection();
|
|
248
|
+
updateObjectEditorControls();
|
|
249
|
+
updateSendState();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Init
|
|
253
|
+
async function init() {
|
|
254
|
+
setStatus('Loading slide list...');
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch('/api/slides');
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
throw new Error(`Failed to fetch slide list: ${res.status}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
state.slides = await res.json();
|
|
263
|
+
|
|
264
|
+
if (state.slides.length === 0) {
|
|
265
|
+
setStatus('No slides found.');
|
|
266
|
+
slideCounter.textContent = '0 / 0';
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await loadModelOptions();
|
|
271
|
+
updateToolModeUI();
|
|
272
|
+
await goToSlide(0);
|
|
273
|
+
scaleSlide();
|
|
274
|
+
await loadRunsInitial();
|
|
275
|
+
connectSSE();
|
|
276
|
+
|
|
277
|
+
setStatus(`Ready. Model: ${state.selectedModel}. Draw red pending bboxes, run Codex, then review green bboxes.`);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
setStatus(`Error loading slides: ${error.message}`);
|
|
280
|
+
console.error('Init error:', error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
init();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// editor-navigation.js — Slide navigation (goToSlide, persistDraft)
|
|
2
|
+
|
|
3
|
+
import { state } from './editor-state.js';
|
|
4
|
+
import {
|
|
5
|
+
slideIframe, slideCounter, btnPrev, btnNext, sessionFileChip,
|
|
6
|
+
promptInput, modelSelect,
|
|
7
|
+
} from './editor-dom.js';
|
|
8
|
+
import { currentSlideFile, getSlideState, normalizeModelName, setStatus } from './editor-utils.js';
|
|
9
|
+
import { renderChatMessages } from './editor-chat.js';
|
|
10
|
+
import { renderBboxes, scaleSlide } from './editor-bbox.js';
|
|
11
|
+
import { renderObjectSelection, updateObjectEditorControls } from './editor-select.js';
|
|
12
|
+
import { flushDirectSaveForSlide } from './editor-direct-edit.js';
|
|
13
|
+
import { updateSendState } from './editor-send.js';
|
|
14
|
+
|
|
15
|
+
export function persistCurrentSlideDraft() {
|
|
16
|
+
const slide = currentSlideFile();
|
|
17
|
+
if (!slide) return;
|
|
18
|
+
const ss = getSlideState(slide);
|
|
19
|
+
ss.prompt = promptInput.value;
|
|
20
|
+
ss.model = normalizeModelName(modelSelect.value) || ss.model || state.defaultModel;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function goToSlide(index) {
|
|
24
|
+
if (index < 0 || index >= state.slides.length) return;
|
|
25
|
+
|
|
26
|
+
const previousSlide = currentSlideFile();
|
|
27
|
+
persistCurrentSlideDraft();
|
|
28
|
+
if (previousSlide) {
|
|
29
|
+
await flushDirectSaveForSlide(previousSlide);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
state.currentIndex = index;
|
|
33
|
+
const slide = currentSlideFile();
|
|
34
|
+
slideIframe.src = `/slides/${slide}`;
|
|
35
|
+
if (sessionFileChip) sessionFileChip.textContent = slide;
|
|
36
|
+
slideCounter.textContent = `${state.currentIndex + 1} / ${state.slides.length}`;
|
|
37
|
+
btnPrev.disabled = state.currentIndex === 0;
|
|
38
|
+
btnNext.disabled = state.currentIndex === state.slides.length - 1;
|
|
39
|
+
|
|
40
|
+
const ss = getSlideState(slide);
|
|
41
|
+
if (!state.availableModels.includes(normalizeModelName(ss.model))) {
|
|
42
|
+
ss.model = state.defaultModel;
|
|
43
|
+
}
|
|
44
|
+
state.selectedModel = ss.model;
|
|
45
|
+
modelSelect.value = state.selectedModel;
|
|
46
|
+
promptInput.value = ss.prompt || '';
|
|
47
|
+
state.hoveredObjectXPath = '';
|
|
48
|
+
renderChatMessages();
|
|
49
|
+
renderBboxes();
|
|
50
|
+
renderObjectSelection();
|
|
51
|
+
updateObjectEditorControls();
|
|
52
|
+
updateSendState();
|
|
53
|
+
setStatus(`Loaded ${slide}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// editor-select.js — Object selection, hover, tool mode UI
|
|
2
|
+
|
|
3
|
+
import { state, TOOL_MODE_DRAW, TOOL_MODE_SELECT, SLIDE_W, SLIDE_H, NON_SELECTABLE_TAGS, DIRECT_TEXT_TAGS } from './editor-state.js';
|
|
4
|
+
import {
|
|
5
|
+
slideIframe, slidePanel, drawBox, toolModeDrawBtn, toolModeSelectBtn,
|
|
6
|
+
bboxToolbar, selectToolbar, editorHint, objectSelectedBox, objectHoverBox,
|
|
7
|
+
selectedObjectMini, miniTag, miniText, selectEmptyHint,
|
|
8
|
+
toggleBold, toggleItalic, toggleUnderline, toggleStrike,
|
|
9
|
+
alignLeft, alignCenter, alignRight,
|
|
10
|
+
popoverTextInput, popoverApplyText, popoverTextColorInput, popoverBgColorInput,
|
|
11
|
+
popoverSizeInput, popoverApplySize,
|
|
12
|
+
} from './editor-dom.js';
|
|
13
|
+
import {
|
|
14
|
+
currentSlideFile, getSlideState, setStatus, clamp,
|
|
15
|
+
normalizeHexColor, parsePixelValue, isBoldFontWeight,
|
|
16
|
+
} from './editor-utils.js';
|
|
17
|
+
import { renderBboxes, scaleSlide, clientToSlidePoint, getXPath } from './editor-bbox.js';
|
|
18
|
+
|
|
19
|
+
function isElementNode(node) {
|
|
20
|
+
return Boolean(node) && node.nodeType === Node.ELEMENT_NODE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveXPath(doc, xpath) {
|
|
24
|
+
if (!doc || typeof xpath !== 'string' || xpath.trim() === '') return null;
|
|
25
|
+
try {
|
|
26
|
+
return doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSelectedObjectElement(slide = currentSlideFile()) {
|
|
33
|
+
if (!slide) return null;
|
|
34
|
+
const ss = getSlideState(slide);
|
|
35
|
+
const xpath = ss.selectedObjectXPath;
|
|
36
|
+
if (!xpath) return null;
|
|
37
|
+
|
|
38
|
+
const doc = slideIframe.contentDocument;
|
|
39
|
+
const el = resolveXPath(doc, xpath);
|
|
40
|
+
return isElementNode(el) ? el : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isSelectableElement(el) {
|
|
44
|
+
if (!isElementNode(el)) return false;
|
|
45
|
+
const tag = el.tagName.toLowerCase();
|
|
46
|
+
if (NON_SELECTABLE_TAGS.has(tag)) return false;
|
|
47
|
+
const rect = el.getBoundingClientRect();
|
|
48
|
+
return rect.width > 1 && rect.height > 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isTextEditableElement(el) {
|
|
52
|
+
if (!isElementNode(el)) return false;
|
|
53
|
+
const tag = el.tagName.toLowerCase();
|
|
54
|
+
if (!DIRECT_TEXT_TAGS.has(tag)) return false;
|
|
55
|
+
const INLINE_TAGS = new Set(['BR','B','STRONG','I','EM','U','S','SPAN','A','SMALL','SUB','SUP','MARK','CODE']);
|
|
56
|
+
return Array.from(el.children).every(c => INLINE_TAGS.has(c.tagName));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getSelectableTargetAt(clientX, clientY) {
|
|
60
|
+
const doc = slideIframe.contentDocument;
|
|
61
|
+
if (!doc) return null;
|
|
62
|
+
|
|
63
|
+
const point = clientToSlidePoint(clientX, clientY);
|
|
64
|
+
let node = doc.elementFromPoint(point.x, point.y);
|
|
65
|
+
while (node && !isSelectableElement(node)) {
|
|
66
|
+
node = node.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return isElementNode(node) ? node : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function elementToSlideRect(el) {
|
|
72
|
+
if (!isElementNode(el)) return null;
|
|
73
|
+
const rect = el.getBoundingClientRect();
|
|
74
|
+
if (!rect.width || !rect.height) return null;
|
|
75
|
+
return {
|
|
76
|
+
x: clamp(Math.round(rect.left), 0, SLIDE_W),
|
|
77
|
+
y: clamp(Math.round(rect.top), 0, SLIDE_H),
|
|
78
|
+
width: Math.max(1, Math.round(rect.width)),
|
|
79
|
+
height: Math.max(1, Math.round(rect.height)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applyOverlayRect(node, rect) {
|
|
84
|
+
if (!node || !rect) {
|
|
85
|
+
if (node) node.style.display = 'none';
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
node.style.display = 'block';
|
|
90
|
+
node.style.left = `${rect.x}px`;
|
|
91
|
+
node.style.top = `${rect.y}px`;
|
|
92
|
+
node.style.width = `${rect.width}px`;
|
|
93
|
+
node.style.height = `${rect.height}px`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readSelectedObjectStyleState(el) {
|
|
97
|
+
const frameWindow = slideIframe.contentWindow;
|
|
98
|
+
const styles = frameWindow?.getComputedStyle ? frameWindow.getComputedStyle(el) : null;
|
|
99
|
+
const textDecorationLine = styles?.textDecorationLine || '';
|
|
100
|
+
return {
|
|
101
|
+
textEditable: isTextEditableElement(el),
|
|
102
|
+
textValue: (el.innerHTML || '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]*>/g, '').trim(),
|
|
103
|
+
textColor: normalizeHexColor(styles?.color, '#111111'),
|
|
104
|
+
backgroundColor: normalizeHexColor(styles?.backgroundColor, '#ffffff'),
|
|
105
|
+
fontSize: parsePixelValue(styles?.fontSize, 24),
|
|
106
|
+
bold: isBoldFontWeight(styles?.fontWeight),
|
|
107
|
+
italic: styles?.fontStyle === 'italic',
|
|
108
|
+
underline: /\bunderline\b/.test(textDecorationLine),
|
|
109
|
+
strike: /\bline-through\b/.test(textDecorationLine),
|
|
110
|
+
textAlign: styles?.textAlign || 'left',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setToggleActive(button, active) {
|
|
115
|
+
if (!button) return;
|
|
116
|
+
button.classList.toggle('active', Boolean(active));
|
|
117
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setControlEnabled(button, enabled) {
|
|
121
|
+
if (!button) return;
|
|
122
|
+
button.disabled = !enabled;
|
|
123
|
+
button.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getSelectedObjectCapabilities(el) {
|
|
127
|
+
const textEditable = isTextEditableElement(el);
|
|
128
|
+
return {
|
|
129
|
+
textEditable,
|
|
130
|
+
textColorEditable: textEditable,
|
|
131
|
+
backgroundEditable: isElementNode(el),
|
|
132
|
+
sizeEditable: textEditable,
|
|
133
|
+
emphasisEditable: textEditable,
|
|
134
|
+
alignEditable: textEditable,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function syncInlineInputs(snapshot) {
|
|
139
|
+
if (!document.activeElement || !document.activeElement.closest?.('#select-toolbar')) {
|
|
140
|
+
popoverTextInput.value = snapshot?.textValue || '';
|
|
141
|
+
popoverSizeInput.value = String(snapshot?.fontSize || 24);
|
|
142
|
+
}
|
|
143
|
+
popoverTextColorInput.value = snapshot?.textColor || '#111111';
|
|
144
|
+
popoverBgColorInput.value = snapshot?.backgroundColor || '#ffffff';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function updateObjectEditorControls() {
|
|
148
|
+
const selected = state.toolMode === TOOL_MODE_SELECT ? getSelectedObjectElement() : null;
|
|
149
|
+
const snapshot = selected ? readSelectedObjectStyleState(selected) : null;
|
|
150
|
+
const capabilities = getSelectedObjectCapabilities(selected);
|
|
151
|
+
|
|
152
|
+
if (bboxToolbar) {
|
|
153
|
+
bboxToolbar.hidden = state.toolMode !== TOOL_MODE_DRAW;
|
|
154
|
+
}
|
|
155
|
+
if (selectToolbar) {
|
|
156
|
+
selectToolbar.hidden = state.toolMode !== TOOL_MODE_SELECT;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (selectedObjectMini && selectEmptyHint) {
|
|
160
|
+
if (selected) {
|
|
161
|
+
const tag = selected.tagName.toLowerCase();
|
|
162
|
+
const fullText = (selected.textContent || '').trim();
|
|
163
|
+
const preview = fullText.slice(0, 24);
|
|
164
|
+
miniTag.textContent = `<${tag}>`;
|
|
165
|
+
miniText.textContent = preview ? ` ${preview}${fullText.length > 24 ? '\u2026' : ''}` : '';
|
|
166
|
+
selectedObjectMini.style.display = '';
|
|
167
|
+
selectEmptyHint.style.display = 'none';
|
|
168
|
+
} else {
|
|
169
|
+
selectedObjectMini.style.display = 'none';
|
|
170
|
+
selectEmptyHint.style.display = state.toolMode === TOOL_MODE_SELECT ? '' : 'none';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
popoverTextInput.disabled = !capabilities.textEditable;
|
|
175
|
+
popoverApplyText.disabled = !capabilities.textEditable;
|
|
176
|
+
popoverTextColorInput.disabled = !capabilities.textColorEditable;
|
|
177
|
+
popoverBgColorInput.disabled = !capabilities.backgroundEditable;
|
|
178
|
+
popoverSizeInput.disabled = !capabilities.sizeEditable;
|
|
179
|
+
popoverApplySize.disabled = !capabilities.sizeEditable;
|
|
180
|
+
|
|
181
|
+
setControlEnabled(toggleBold, capabilities.emphasisEditable);
|
|
182
|
+
setControlEnabled(toggleItalic, capabilities.emphasisEditable);
|
|
183
|
+
setControlEnabled(toggleUnderline, capabilities.emphasisEditable);
|
|
184
|
+
setControlEnabled(toggleStrike, capabilities.emphasisEditable);
|
|
185
|
+
setControlEnabled(alignLeft, capabilities.alignEditable);
|
|
186
|
+
setControlEnabled(alignCenter, capabilities.alignEditable);
|
|
187
|
+
setControlEnabled(alignRight, capabilities.alignEditable);
|
|
188
|
+
|
|
189
|
+
setToggleActive(toggleBold, capabilities.emphasisEditable && snapshot?.bold);
|
|
190
|
+
setToggleActive(toggleItalic, capabilities.emphasisEditable && snapshot?.italic);
|
|
191
|
+
setToggleActive(toggleUnderline, capabilities.emphasisEditable && snapshot?.underline);
|
|
192
|
+
setToggleActive(toggleStrike, capabilities.emphasisEditable && snapshot?.strike);
|
|
193
|
+
setToggleActive(alignLeft, capabilities.alignEditable && (snapshot?.textAlign === 'left' || snapshot?.textAlign === 'start'));
|
|
194
|
+
setToggleActive(alignCenter, capabilities.alignEditable && snapshot?.textAlign === 'center');
|
|
195
|
+
setToggleActive(alignRight, capabilities.alignEditable && (snapshot?.textAlign === 'right' || snapshot?.textAlign === 'end'));
|
|
196
|
+
|
|
197
|
+
syncInlineInputs(snapshot);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderObjectSelection() {
|
|
201
|
+
const selectedEl = state.toolMode === TOOL_MODE_SELECT ? getSelectedObjectElement() : null;
|
|
202
|
+
const hoveredEl = state.toolMode === TOOL_MODE_SELECT
|
|
203
|
+
? resolveXPath(slideIframe.contentDocument, state.hoveredObjectXPath)
|
|
204
|
+
: null;
|
|
205
|
+
|
|
206
|
+
const selectedRect = selectedEl ? elementToSlideRect(selectedEl) : null;
|
|
207
|
+
const hoveredRect = hoveredEl && hoveredEl !== selectedEl ? elementToSlideRect(hoveredEl) : null;
|
|
208
|
+
applyOverlayRect(objectSelectedBox, selectedRect);
|
|
209
|
+
applyOverlayRect(objectHoverBox, hoveredRect);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function updateToolModeUI() {
|
|
213
|
+
const isDraw = state.toolMode === TOOL_MODE_DRAW;
|
|
214
|
+
slidePanel.classList.toggle('mode-draw', isDraw);
|
|
215
|
+
slidePanel.classList.toggle('mode-select', !isDraw);
|
|
216
|
+
toolModeDrawBtn.classList.toggle('active', isDraw);
|
|
217
|
+
toolModeSelectBtn.classList.toggle('active', !isDraw);
|
|
218
|
+
toolModeDrawBtn.setAttribute('aria-pressed', isDraw ? 'true' : 'false');
|
|
219
|
+
toolModeSelectBtn.setAttribute('aria-pressed', !isDraw ? 'true' : 'false');
|
|
220
|
+
editorHint.textContent = isDraw
|
|
221
|
+
? 'Drag on the slide to add red bboxes. Cmd/Ctrl+Enter to run.'
|
|
222
|
+
: 'Click an object to edit. \u2318B bold \u00b7 \u2318I italic \u00b7 \u2318U underline';
|
|
223
|
+
renderBboxes();
|
|
224
|
+
renderObjectSelection();
|
|
225
|
+
updateObjectEditorControls();
|
|
226
|
+
scaleSlide();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function setToolMode(mode) {
|
|
230
|
+
state.toolMode = mode === TOOL_MODE_SELECT ? TOOL_MODE_SELECT : TOOL_MODE_DRAW;
|
|
231
|
+
state.drawing = false;
|
|
232
|
+
state.drawStart = null;
|
|
233
|
+
drawBox.style.display = 'none';
|
|
234
|
+
if (state.toolMode !== TOOL_MODE_SELECT) {
|
|
235
|
+
state.hoveredObjectXPath = '';
|
|
236
|
+
}
|
|
237
|
+
updateToolModeUI();
|
|
238
|
+
setStatus(state.toolMode === TOOL_MODE_SELECT ? 'Select mode enabled.' : 'BBox draw mode enabled.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function setSelectedObjectXPath(xpath, statusMessage = 'Object selected.') {
|
|
242
|
+
const slide = currentSlideFile();
|
|
243
|
+
if (!slide) return;
|
|
244
|
+
const ss = getSlideState(slide);
|
|
245
|
+
ss.selectedObjectXPath = xpath || '';
|
|
246
|
+
state.hoveredObjectXPath = xpath || '';
|
|
247
|
+
renderObjectSelection();
|
|
248
|
+
updateObjectEditorControls();
|
|
249
|
+
if (statusMessage) {
|
|
250
|
+
setStatus(statusMessage);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function updateHoveredObjectFromPointer(clientX, clientY) {
|
|
255
|
+
if (state.toolMode !== TOOL_MODE_SELECT) return;
|
|
256
|
+
const target = getSelectableTargetAt(clientX, clientY);
|
|
257
|
+
state.hoveredObjectXPath = target ? getXPath(target) : '';
|
|
258
|
+
renderObjectSelection();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function clearHoveredObject() {
|
|
262
|
+
state.hoveredObjectXPath = '';
|
|
263
|
+
renderObjectSelection();
|
|
264
|
+
}
|