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,157 @@
|
|
|
1
|
+
// editor-send.js — API submission (applyChanges), updateSendState
|
|
2
|
+
|
|
3
|
+
import { state, runsById, activeRunBySlide, pendingRequestBySlide } from './editor-state.js';
|
|
4
|
+
import { slideStatusChip, btnSend, btnClearBboxes, promptInput, modelSelect } 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
|
+
|
|
10
|
+
export function updateSlideStatusChip() {
|
|
11
|
+
const slide = currentSlideFile();
|
|
12
|
+
if (!slide) {
|
|
13
|
+
slideStatusChip.textContent = 'idle';
|
|
14
|
+
slideStatusChip.className = 'slide-status-chip idle';
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let status = 'idle';
|
|
19
|
+
if (pendingRequestBySlide.has(slide)) {
|
|
20
|
+
status = 'running';
|
|
21
|
+
} else if (activeRunBySlide.has(slide)) {
|
|
22
|
+
status = 'running';
|
|
23
|
+
} else {
|
|
24
|
+
const latest = getLatestRunForSlide(slide);
|
|
25
|
+
if (latest?.status === 'success') status = 'success';
|
|
26
|
+
if (latest?.status === 'failed') status = 'failed';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
slideStatusChip.textContent = status;
|
|
30
|
+
slideStatusChip.className = `slide-status-chip ${status}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function updateSendState() {
|
|
34
|
+
const slide = currentSlideFile();
|
|
35
|
+
if (!slide) {
|
|
36
|
+
btnSend.disabled = true;
|
|
37
|
+
btnClearBboxes.disabled = true;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ss = getSlideState(slide);
|
|
42
|
+
const prompt = (promptInput.value || '').trim();
|
|
43
|
+
const pendingCount = ss.boxes.filter((box) => normalizeBoxStatus(box.status) === 'pending').length;
|
|
44
|
+
const blocked = pendingRequestBySlide.has(slide) || activeRunBySlide.has(slide);
|
|
45
|
+
const model = normalizeModelName(ss.model);
|
|
46
|
+
|
|
47
|
+
btnSend.disabled = !prompt || pendingCount === 0 || blocked || !model;
|
|
48
|
+
btnClearBboxes.disabled = ss.boxes.length === 0 || blocked;
|
|
49
|
+
updateSlideStatusChip();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function applyChanges() {
|
|
53
|
+
const slide = currentSlideFile();
|
|
54
|
+
if (!slide) return;
|
|
55
|
+
|
|
56
|
+
await flushDirectSaveForSlide(slide);
|
|
57
|
+
|
|
58
|
+
const ss = getSlideState(slide);
|
|
59
|
+
const prompt = (promptInput.value || '').trim();
|
|
60
|
+
const pendingBoxes = ss.boxes.filter((box) => normalizeBoxStatus(box.status) === 'pending');
|
|
61
|
+
const model = normalizeModelName(ss.model) || state.selectedModel || state.defaultModel;
|
|
62
|
+
|
|
63
|
+
if (!prompt) return;
|
|
64
|
+
if (pendingBoxes.length === 0) {
|
|
65
|
+
setStatus('No pending (red) bbox to run. Draw a new box or click Rerun on a green box.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const submittedBoxIds = pendingBoxes.map((box) => box.id);
|
|
70
|
+
const submittedSet = new Set(submittedBoxIds);
|
|
71
|
+
|
|
72
|
+
const selections = pendingBoxes.map((box) => ({
|
|
73
|
+
x: box.x,
|
|
74
|
+
y: box.y,
|
|
75
|
+
width: box.width,
|
|
76
|
+
height: box.height,
|
|
77
|
+
targets: extractTargetsForBox(box),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
addChatMessage('user', `[${slide}] [${model}] ${prompt}`, slide);
|
|
81
|
+
|
|
82
|
+
pendingRequestBySlide.add(slide);
|
|
83
|
+
ss.prompt = '';
|
|
84
|
+
promptInput.value = '';
|
|
85
|
+
updateSendState();
|
|
86
|
+
const engineLabel = model.startsWith('claude-') ? 'Claude' : 'Codex';
|
|
87
|
+
setStatus(`Submitting ${slide} to ${engineLabel}...`);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch('/api/apply', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
slide,
|
|
95
|
+
prompt,
|
|
96
|
+
model,
|
|
97
|
+
selections,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const data = await res.json().catch(() => ({}));
|
|
102
|
+
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const message = data.error || `Server error ${res.status}`;
|
|
105
|
+
addChatMessage('error', `[${slide}] ${message}`, slide);
|
|
106
|
+
setStatus(`Error: ${message}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (data.runId) {
|
|
111
|
+
const existing = runsById.get(data.runId) || {};
|
|
112
|
+
runsById.set(data.runId, {
|
|
113
|
+
...existing,
|
|
114
|
+
runId: data.runId,
|
|
115
|
+
slide,
|
|
116
|
+
status: data.success ? 'success' : 'failed',
|
|
117
|
+
code: data.code,
|
|
118
|
+
message: data.message,
|
|
119
|
+
model: data.model || model,
|
|
120
|
+
startedAt: existing.startedAt || new Date().toISOString(),
|
|
121
|
+
finishedAt: new Date().toISOString(),
|
|
122
|
+
logPreview: existing.logPreview || '',
|
|
123
|
+
});
|
|
124
|
+
renderRunsList();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
addChatMessage(
|
|
128
|
+
data.success ? 'system' : 'error',
|
|
129
|
+
`[${slide}] ${data.message || (data.success ? 'Completed' : 'Failed')}`,
|
|
130
|
+
slide,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (data.success) {
|
|
134
|
+
let marked = 0;
|
|
135
|
+
for (const box of ss.boxes) {
|
|
136
|
+
if (submittedSet.has(box.id) && normalizeBoxStatus(box.status) === 'pending') {
|
|
137
|
+
box.status = 'review';
|
|
138
|
+
marked += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
renderBboxes();
|
|
142
|
+
setStatus(
|
|
143
|
+
marked > 0
|
|
144
|
+
? `${data.message || 'Codex run completed.'} Review ${marked} green bbox${marked === 1 ? '' : 'es'}: Check to accept or Rerun.`
|
|
145
|
+
: (data.message || 'Codex run completed.'),
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
setStatus(data.message || 'Codex run failed.');
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
addChatMessage('error', `[${slide}] ${error.message}`, slide);
|
|
152
|
+
setStatus(`Error: ${error.message}`);
|
|
153
|
+
} finally {
|
|
154
|
+
pendingRequestBySlide.delete(slide);
|
|
155
|
+
updateSendState();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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,32 @@
|
|
|
1
|
+
// editor-state.js — State variables, constants, Maps/Sets
|
|
2
|
+
|
|
3
|
+
export const SLIDE_W = 960;
|
|
4
|
+
export const SLIDE_H = 540;
|
|
5
|
+
export const TOOL_MODE_DRAW = 'draw';
|
|
6
|
+
export const TOOL_MODE_SELECT = 'select';
|
|
7
|
+
export const POPOVER_TEXT = 'text';
|
|
8
|
+
export const POPOVER_TEXT_COLOR = 'text-color';
|
|
9
|
+
export const POPOVER_BG_COLOR = 'bg-color';
|
|
10
|
+
export const POPOVER_SIZE = 'size';
|
|
11
|
+
export const DEFAULT_MODELS = ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'claude-opus-4-6', 'claude-sonnet-4-6'];
|
|
12
|
+
export const DIRECT_TEXT_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']);
|
|
13
|
+
export const NON_SELECTABLE_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript']);
|
|
14
|
+
|
|
15
|
+
export const slideStates = new Map();
|
|
16
|
+
export const activeRunBySlide = new Map();
|
|
17
|
+
export const pendingRequestBySlide = new Set();
|
|
18
|
+
export const runsById = new Map();
|
|
19
|
+
export const directSaveStateBySlide = new Map();
|
|
20
|
+
export const localFileUpdateBySlide = new Map();
|
|
21
|
+
|
|
22
|
+
export const state = {
|
|
23
|
+
slides: [],
|
|
24
|
+
currentIndex: 0,
|
|
25
|
+
drawStart: null,
|
|
26
|
+
drawing: false,
|
|
27
|
+
availableModels: DEFAULT_MODELS.slice(),
|
|
28
|
+
defaultModel: DEFAULT_MODELS[0],
|
|
29
|
+
selectedModel: DEFAULT_MODELS[0],
|
|
30
|
+
toolMode: TOOL_MODE_DRAW,
|
|
31
|
+
hoveredObjectXPath: '',
|
|
32
|
+
};
|
|
@@ -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, '&')
|
|
33
|
+
.replace(/</g, '<')
|
|
34
|
+
.replace(/>/g, '>')
|
|
35
|
+
.replace(/"/g, '"')
|
|
36
|
+
.replace(/'/g, ''');
|
|
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,73 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
|
|
5
|
+
export const SCREENSHOT_SIZE = { width: 1600, height: 900 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Launch a reusable headless Chromium browser.
|
|
9
|
+
* Caller is responsible for closing the browser when done.
|
|
10
|
+
*/
|
|
11
|
+
export async function createScreenshotBrowser() {
|
|
12
|
+
const browser = await chromium.launch({ headless: true });
|
|
13
|
+
return { browser };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a fresh screenshot page/context from an existing browser.
|
|
18
|
+
* Caller must close the returned context.
|
|
19
|
+
*/
|
|
20
|
+
export async function createScreenshotPage(browser) {
|
|
21
|
+
const context = await browser.newContext({ viewport: SCREENSHOT_SIZE });
|
|
22
|
+
const page = await context.newPage();
|
|
23
|
+
return { context, page };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Capture a screenshot of a single slide HTML file.
|
|
28
|
+
*
|
|
29
|
+
* @param {import('playwright').Page} page – reusable Playwright page
|
|
30
|
+
* @param {string} slideFile – filename, e.g. "slide-04.html"
|
|
31
|
+
* @param {string} screenshotPath – output PNG path
|
|
32
|
+
* @param {string} slidesDir – directory containing the slide files
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {boolean} [options.useHttp] – if true, slidesDir is treated as a base URL
|
|
35
|
+
*/
|
|
36
|
+
export async function captureSlideScreenshot(page, slideFile, screenshotPath, slidesDir, options = {}) {
|
|
37
|
+
const slideUrl = options.useHttp
|
|
38
|
+
? `${slidesDir}/${slideFile}`
|
|
39
|
+
: pathToFileURL(join(slidesDir, slideFile)).href;
|
|
40
|
+
|
|
41
|
+
await page.goto(slideUrl, { waitUntil: 'load' });
|
|
42
|
+
await page.evaluate(async () => {
|
|
43
|
+
if (document.fonts?.ready) {
|
|
44
|
+
await document.fonts.ready;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await page.evaluate(({ width, height }) => {
|
|
49
|
+
const htmlStyle = document.documentElement.style;
|
|
50
|
+
const bodyStyle = document.body.style;
|
|
51
|
+
|
|
52
|
+
htmlStyle.margin = '0';
|
|
53
|
+
htmlStyle.padding = '0';
|
|
54
|
+
htmlStyle.overflow = 'hidden';
|
|
55
|
+
htmlStyle.background = '#ffffff';
|
|
56
|
+
|
|
57
|
+
bodyStyle.margin = '0';
|
|
58
|
+
bodyStyle.padding = '0';
|
|
59
|
+
bodyStyle.transformOrigin = 'top left';
|
|
60
|
+
|
|
61
|
+
const rect = document.body.getBoundingClientRect();
|
|
62
|
+
const sourceWidth = rect.width > 0 ? rect.width : width;
|
|
63
|
+
const sourceHeight = rect.height > 0 ? rect.height : height;
|
|
64
|
+
const scale = Math.min(width / sourceWidth, height / sourceHeight);
|
|
65
|
+
|
|
66
|
+
bodyStyle.transform = `scale(${scale})`;
|
|
67
|
+
}, SCREENSHOT_SIZE);
|
|
68
|
+
|
|
69
|
+
await page.screenshot({
|
|
70
|
+
path: screenshotPath,
|
|
71
|
+
fullPage: false,
|
|
72
|
+
});
|
|
73
|
+
}
|