@imenam/mcp-design 1.0.10 → 1.0.11
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/README.md +44 -3
- package/dist/shared/feedback.d.ts +128 -0
- package/dist/shared/feedback.js +165 -0
- package/dist/shared/render.d.ts +7 -1
- package/dist/shared/render.js +32 -6
- package/dist/shared/schemas/feedback.schema.d.ts +185 -0
- package/dist/shared/schemas/feedback.schema.js +87 -0
- package/dist/shared/tree.d.ts +2 -0
- package/dist/shared/tree.js +4 -0
- package/dist/src/core/design-store.d.ts +1 -5
- package/dist/src/core/design-store.js +3 -56
- package/dist/src/core/feedback-store.d.ts +73 -0
- package/dist/src/core/feedback-store.js +308 -0
- package/dist/src/core/file-lock.d.ts +8 -0
- package/dist/src/core/file-lock.js +61 -0
- package/dist/src/core/screenshot.d.ts +3 -0
- package/dist/src/core/screenshot.js +28 -3
- package/dist/src/gui-worker.js +103 -1
- package/dist/src/index.js +175 -1
- package/dist/src/tools/delete-design.d.ts +3 -1
- package/dist/src/tools/delete-design.js +4 -1
- package/dist/src/tools/get-feedback.d.ts +3 -0
- package/dist/src/tools/get-feedback.js +62 -0
- package/dist/src/tools/mark-feedback-processed.d.ts +8 -0
- package/dist/src/tools/mark-feedback-processed.js +29 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/index-BXpZGU8f.css +1 -0
- package/gui/dist/assets/index-CgZSm3aX.js +15 -0
- package/gui/dist/assets/{search-CSwiX0lv.js → search-DnrsSC4m.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/gui/dist/assets/index-DBZNXgF_.js +0 -10
- package/gui/dist/assets/index-PQqxUQLx.css +0 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { defaultRoundTitle, elementsInArea, nodeTarget, orderRemarks, roundBox, } from "../../shared/feedback.js";
|
|
4
|
+
import { FeedbackFileSchema, migrateFeedbackFile } from "../../shared/schemas/feedback.schema.js";
|
|
5
|
+
import { withFileLock } from "./file-lock.js";
|
|
6
|
+
import { shortId } from "./ids.js";
|
|
7
|
+
import { DesignError } from "./node-factory.js";
|
|
8
|
+
import { screenshotDesign } from "./screenshot.js";
|
|
9
|
+
/** Marge autour d'un élément capturé, comme `screenshot_design`. */
|
|
10
|
+
const NODE_CAPTURE_PADDING = 16;
|
|
11
|
+
function emptyFile() {
|
|
12
|
+
return { version: 2, remarks: [], rounds: [], lastNumbers: {} };
|
|
13
|
+
}
|
|
14
|
+
function requireText(text) {
|
|
15
|
+
const value = typeof text === "string" ? text.trim() : "";
|
|
16
|
+
if (value === "")
|
|
17
|
+
throw new DesignError("A remark needs some text.");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function requireTitle(title) {
|
|
21
|
+
const value = typeof title === "string" ? title.trim() : "";
|
|
22
|
+
if (value === "")
|
|
23
|
+
throw new DesignError("A feedback round needs a name.");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Remarques de l'utilisateur et retours transmis à l'agent, dans
|
|
28
|
+
* `<dataDir>/feedback/remarks.json`, captures dans `<dataDir>/feedback/captures`.
|
|
29
|
+
*
|
|
30
|
+
* Un seul fichier pour tout le projet : un retour peut couvrir plusieurs pages.
|
|
31
|
+
*/
|
|
32
|
+
export class FeedbackStore {
|
|
33
|
+
designs;
|
|
34
|
+
dir;
|
|
35
|
+
capturesDir;
|
|
36
|
+
file;
|
|
37
|
+
constructor(designs) {
|
|
38
|
+
this.designs = designs;
|
|
39
|
+
this.dir = path.join(designs.getDataDir(), "feedback");
|
|
40
|
+
this.capturesDir = path.join(this.dir, "captures");
|
|
41
|
+
this.file = path.join(this.dir, "remarks.json");
|
|
42
|
+
fs.mkdirSync(this.capturesDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
getFilePath() {
|
|
45
|
+
return this.file;
|
|
46
|
+
}
|
|
47
|
+
read() {
|
|
48
|
+
if (!fs.existsSync(this.file))
|
|
49
|
+
return emptyFile();
|
|
50
|
+
let raw;
|
|
51
|
+
try {
|
|
52
|
+
raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new DesignError(`The remarks file ${this.file} is not readable: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
// Un fichier d'une version antérieure est lu au format courant ; il n'est
|
|
58
|
+
// réécrit qu'à la prochaine modification.
|
|
59
|
+
const parsed = FeedbackFileSchema.safeParse(migrateFeedbackFile(raw, defaultRoundTitle) ?? raw);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
throw new DesignError(`The remarks file ${this.file} does not match the expected schema: ${parsed.error.issues
|
|
62
|
+
.map((issue) => `${issue.path.join(".")} ${issue.message}`)
|
|
63
|
+
.join("; ")}`);
|
|
64
|
+
}
|
|
65
|
+
return parsed.data;
|
|
66
|
+
}
|
|
67
|
+
mutate(fn) {
|
|
68
|
+
return withFileLock(`${this.file}.lock`, "The remarks file", () => {
|
|
69
|
+
const draft = structuredClone(this.read());
|
|
70
|
+
const result = fn(draft);
|
|
71
|
+
const parsed = FeedbackFileSchema.safeParse(draft);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
throw new DesignError(`Resulting remarks file is invalid: ${parsed.error.issues
|
|
74
|
+
.map((issue) => `${issue.path.join(".")} ${issue.message}`)
|
|
75
|
+
.join("; ")}`);
|
|
76
|
+
}
|
|
77
|
+
const tmp = `${this.file}.tmp`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(parsed.data, null, 2), "utf-8");
|
|
79
|
+
fs.renameSync(tmp, this.file);
|
|
80
|
+
return result;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
remarks(designId) {
|
|
84
|
+
const remarks = this.read().remarks;
|
|
85
|
+
return designId === undefined ? remarks : remarks.filter((remark) => remark.designId === designId);
|
|
86
|
+
}
|
|
87
|
+
rounds() {
|
|
88
|
+
return this.read().rounds;
|
|
89
|
+
}
|
|
90
|
+
capturePath(name) {
|
|
91
|
+
if (name !== path.basename(name))
|
|
92
|
+
throw new DesignError(`Invalid capture name "${name}".`);
|
|
93
|
+
return path.join(this.capturesDir, name);
|
|
94
|
+
}
|
|
95
|
+
readCapture(name) {
|
|
96
|
+
const file = this.capturePath(name);
|
|
97
|
+
if (!fs.existsSync(file))
|
|
98
|
+
throw new DesignError(`Capture "${name}" not found in ${this.capturesDir}.`);
|
|
99
|
+
return fs.readFileSync(file);
|
|
100
|
+
}
|
|
101
|
+
/** Complète la cible d'après l'état courant du design. */
|
|
102
|
+
resolveTarget(designId, input) {
|
|
103
|
+
const design = this.designs.get(designId);
|
|
104
|
+
if (input.kind === "page")
|
|
105
|
+
return { kind: "page" };
|
|
106
|
+
if (input.kind === "node") {
|
|
107
|
+
if (!design.nodes.some((node) => node.id === input.nodeId)) {
|
|
108
|
+
throw new DesignError(`Node "${input.nodeId}" not found in design "${design.name}" (${design.id}).`);
|
|
109
|
+
}
|
|
110
|
+
return nodeTarget(design.nodes, input.nodeId);
|
|
111
|
+
}
|
|
112
|
+
const box = roundBox(input.box);
|
|
113
|
+
if (!(box.width >= 1 && box.height >= 1))
|
|
114
|
+
throw new DesignError("A remark area must be at least 1 px wide and high.");
|
|
115
|
+
if (box.x < 0 || box.y < 0 || box.x + box.width > design.width || box.y + box.height > design.height) {
|
|
116
|
+
throw new DesignError(`A remark area must lie within the ${design.width}x${design.height} artboard.`);
|
|
117
|
+
}
|
|
118
|
+
const hits = new Map(elementsInArea(design.nodes, box).map((hit) => [hit.id, hit]));
|
|
119
|
+
const elements = input.elementIds.map((id) => {
|
|
120
|
+
const hit = hits.get(id);
|
|
121
|
+
if (!hit)
|
|
122
|
+
throw new DesignError(`Node "${id}" is not inside the remark area.`);
|
|
123
|
+
return { id: hit.id, name: hit.name, type: hit.type };
|
|
124
|
+
});
|
|
125
|
+
return { kind: "area", box, elements };
|
|
126
|
+
}
|
|
127
|
+
renderCapture(designId, target) {
|
|
128
|
+
const design = this.designs.get(designId);
|
|
129
|
+
if (target.kind === "node") {
|
|
130
|
+
return screenshotDesign(design, { nodeId: target.element.id, padding: NODE_CAPTURE_PADDING }).png;
|
|
131
|
+
}
|
|
132
|
+
if (target.kind === "area")
|
|
133
|
+
return screenshotDesign(design, { region: target.box }).png;
|
|
134
|
+
throw new DesignError("A page remark has no capture.");
|
|
135
|
+
}
|
|
136
|
+
add(input) {
|
|
137
|
+
const text = requireText(input.text);
|
|
138
|
+
const target = this.resolveTarget(input.designId, input.target);
|
|
139
|
+
const id = shortId("rm");
|
|
140
|
+
const capture = input.capture && target.kind !== "page" ? `${id}.png` : null;
|
|
141
|
+
// Rendue hors verrou : la capture ne dépend que du design, et resvg peut
|
|
142
|
+
// prendre plus de temps qu'un autre écrivain n'en accepte d'attendre.
|
|
143
|
+
const png = capture ? this.renderCapture(input.designId, target) : null;
|
|
144
|
+
return this.mutate((draft) => {
|
|
145
|
+
if (target.kind === "page" &&
|
|
146
|
+
draft.remarks.some((remark) => remark.designId === input.designId && remark.status === "draft" && remark.target.kind === "page")) {
|
|
147
|
+
throw new DesignError("This page already has a remark on the whole page: edit it instead of adding another one.");
|
|
148
|
+
}
|
|
149
|
+
let number = null;
|
|
150
|
+
if (target.kind !== "page") {
|
|
151
|
+
number = (draft.lastNumbers[input.designId] ?? 0) + 1;
|
|
152
|
+
draft.lastNumbers[input.designId] = number;
|
|
153
|
+
}
|
|
154
|
+
if (capture && png)
|
|
155
|
+
fs.writeFileSync(this.capturePath(capture), png);
|
|
156
|
+
const now = new Date().toISOString();
|
|
157
|
+
const remark = {
|
|
158
|
+
id,
|
|
159
|
+
designId: input.designId,
|
|
160
|
+
number,
|
|
161
|
+
target,
|
|
162
|
+
text,
|
|
163
|
+
capture,
|
|
164
|
+
status: "draft",
|
|
165
|
+
round: null,
|
|
166
|
+
createdAt: now,
|
|
167
|
+
updatedAt: now,
|
|
168
|
+
};
|
|
169
|
+
draft.remarks.push(remark);
|
|
170
|
+
return remark;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
draftOf(file, id) {
|
|
174
|
+
const remark = file.remarks.find((candidate) => candidate.id === id);
|
|
175
|
+
if (!remark)
|
|
176
|
+
throw new DesignError(`Remark "${id}" not found.`);
|
|
177
|
+
if (remark.status !== "draft") {
|
|
178
|
+
throw new DesignError(`Remark "${id}" was sent with feedback round ${remark.round}: it can no longer be edited.`);
|
|
179
|
+
}
|
|
180
|
+
return remark;
|
|
181
|
+
}
|
|
182
|
+
updateText(id, text) {
|
|
183
|
+
const value = requireText(text);
|
|
184
|
+
return this.mutate((draft) => {
|
|
185
|
+
const remark = this.draftOf(draft, id);
|
|
186
|
+
remark.text = value;
|
|
187
|
+
remark.updatedAt = new Date().toISOString();
|
|
188
|
+
return remark;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Retire une remarque de la liste. La capture d'un brouillon part avec lui ;
|
|
193
|
+
* celle d'une remarque transmise reste, le retour qui la contient la cite.
|
|
194
|
+
*/
|
|
195
|
+
delete(id) {
|
|
196
|
+
const removed = this.mutate((draft) => {
|
|
197
|
+
const remark = draft.remarks.find((candidate) => candidate.id === id);
|
|
198
|
+
if (!remark)
|
|
199
|
+
throw new DesignError(`Remark "${id}" not found.`);
|
|
200
|
+
draft.remarks = draft.remarks.filter((candidate) => candidate.id !== id);
|
|
201
|
+
return remark;
|
|
202
|
+
});
|
|
203
|
+
if (removed.status === "draft" && removed.capture)
|
|
204
|
+
fs.rmSync(this.capturePath(removed.capture), { force: true });
|
|
205
|
+
}
|
|
206
|
+
/** Retire les remarques d'un design supprimé, selon la même règle que `delete`. */
|
|
207
|
+
deleteDesign(designId) {
|
|
208
|
+
const removed = this.mutate((draft) => {
|
|
209
|
+
const gone = draft.remarks.filter((remark) => remark.designId === designId);
|
|
210
|
+
draft.remarks = draft.remarks.filter((remark) => remark.designId !== designId);
|
|
211
|
+
delete draft.lastNumbers[designId];
|
|
212
|
+
return gone;
|
|
213
|
+
});
|
|
214
|
+
for (const remark of removed) {
|
|
215
|
+
if (remark.status === "draft" && remark.capture)
|
|
216
|
+
fs.rmSync(this.capturePath(remark.capture), { force: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Fige les brouillons choisis dans un nouveau retour nommé, et les marque transmis. */
|
|
220
|
+
submit(remarkIds, title) {
|
|
221
|
+
if (remarkIds.length === 0)
|
|
222
|
+
throw new DesignError("Select at least one remark to send.");
|
|
223
|
+
const name = requireTitle(title);
|
|
224
|
+
const names = new Map(this.designs.list().map((design) => [design.id, design.name]));
|
|
225
|
+
return this.mutate((draft) => {
|
|
226
|
+
const number = draft.rounds.length + 1;
|
|
227
|
+
const submittedAt = new Date().toISOString();
|
|
228
|
+
const selected = new Set(remarkIds);
|
|
229
|
+
const remarks = orderRemarks(draft.remarks.filter((remark) => selected.has(remark.id)));
|
|
230
|
+
for (const id of selected)
|
|
231
|
+
this.draftOf(draft, id);
|
|
232
|
+
const round = {
|
|
233
|
+
number,
|
|
234
|
+
title: name,
|
|
235
|
+
submittedAt,
|
|
236
|
+
remarks: remarks.map((remark) => {
|
|
237
|
+
const designName = names.get(remark.designId);
|
|
238
|
+
if (designName === undefined)
|
|
239
|
+
throw new DesignError(`Design "${remark.designId}" of remark "${remark.id}" no longer exists.`);
|
|
240
|
+
return {
|
|
241
|
+
id: remark.id,
|
|
242
|
+
designId: remark.designId,
|
|
243
|
+
designName,
|
|
244
|
+
number: remark.number,
|
|
245
|
+
target: remark.target,
|
|
246
|
+
text: remark.text,
|
|
247
|
+
capture: remark.capture,
|
|
248
|
+
processed: null,
|
|
249
|
+
};
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
for (const remark of remarks) {
|
|
253
|
+
remark.status = "sent";
|
|
254
|
+
remark.round = number;
|
|
255
|
+
remark.updatedAt = submittedAt;
|
|
256
|
+
}
|
|
257
|
+
draft.rounds.push(round);
|
|
258
|
+
return round;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
roundOf(file, number) {
|
|
262
|
+
const round = file.rounds.find((candidate) => candidate.number === number);
|
|
263
|
+
if (!round)
|
|
264
|
+
throw new DesignError(`Feedback round ${number} not found. Rounds 1 to ${file.rounds.length} exist.`);
|
|
265
|
+
return round;
|
|
266
|
+
}
|
|
267
|
+
rename(number, title) {
|
|
268
|
+
const name = requireTitle(title);
|
|
269
|
+
return this.mutate((draft) => {
|
|
270
|
+
const round = this.roundOf(draft, number);
|
|
271
|
+
round.title = name;
|
|
272
|
+
return round;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Déclare des remarques d'un retour traitées, ou les rouvre. Une remarque
|
|
277
|
+
* déjà traitée reste inchangée ; une nouvelle note remplace l'ancienne et
|
|
278
|
+
* son auteur, en gardant la date d'origine.
|
|
279
|
+
*/
|
|
280
|
+
setProcessed(input) {
|
|
281
|
+
const note = typeof input.note === "string" && input.note.trim() !== "" ? input.note.trim() : null;
|
|
282
|
+
return this.mutate((draft) => {
|
|
283
|
+
const round = this.roundOf(draft, input.round);
|
|
284
|
+
const ids = input.remarkIds ?? round.remarks.map((remark) => remark.id);
|
|
285
|
+
if (ids.length === 0)
|
|
286
|
+
throw new DesignError("Name at least one remark of the round.");
|
|
287
|
+
const targets = ids.map((id) => {
|
|
288
|
+
const remark = round.remarks.find((candidate) => candidate.id === id);
|
|
289
|
+
if (!remark)
|
|
290
|
+
throw new DesignError(`Remark "${id}" is not part of feedback round ${round.number}.`);
|
|
291
|
+
return remark;
|
|
292
|
+
});
|
|
293
|
+
const at = new Date().toISOString();
|
|
294
|
+
for (const remark of targets) {
|
|
295
|
+
if (!input.processed) {
|
|
296
|
+
remark.processed = null;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Une remarque déjà traitée le reste telle quelle, sauf nouvelle note :
|
|
300
|
+
// tout marquer d'un coup ne réattribue pas le travail de l'agent.
|
|
301
|
+
if (remark.processed && note === null)
|
|
302
|
+
continue;
|
|
303
|
+
remark.processed = { at: remark.processed?.at ?? at, by: input.by, note };
|
|
304
|
+
}
|
|
305
|
+
return round;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sérialise un cycle lire-modifier-écrire entre processus. `wx` échoue si le
|
|
3
|
+
* fichier de verrou existe déjà : c'est l'opération atomique qui départage deux
|
|
4
|
+
* écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
|
|
5
|
+
*
|
|
6
|
+
* `subject` nomme la ressource dans le message d'erreur, p.ex. `Design "landing"`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function withFileLock<T>(lockPath: string, subject: string, fn: () => T): T;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { DesignError } from "./node-factory.js";
|
|
3
|
+
/** Au-delà de ce délai, un verrou est réputé abandonné (processus tué en cours d'écriture). */
|
|
4
|
+
const LOCK_STALE_MS = 10_000;
|
|
5
|
+
const LOCK_RETRY_MS = 25;
|
|
6
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
7
|
+
/**
|
|
8
|
+
* Pause bloquante. Les stores sont entièrement synchrones — les rendre
|
|
9
|
+
* asynchrones pour l'attente d'un verrou contaminerait tous les appelants.
|
|
10
|
+
*/
|
|
11
|
+
function sleepSync(ms) {
|
|
12
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Sérialise un cycle lire-modifier-écrire entre processus. `wx` échoue si le
|
|
16
|
+
* fichier de verrou existe déjà : c'est l'opération atomique qui départage deux
|
|
17
|
+
* écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
|
|
18
|
+
*
|
|
19
|
+
* `subject` nomme la ressource dans le message d'erreur, p.ex. `Design "landing"`.
|
|
20
|
+
*/
|
|
21
|
+
export function withFileLock(lockPath, subject, fn) {
|
|
22
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
23
|
+
let fd;
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
fd = fs.openSync(lockPath, "wx");
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err.code !== "EEXIST")
|
|
31
|
+
throw err;
|
|
32
|
+
try {
|
|
33
|
+
// Un processus tué laisse son verrou derrière lui : passé le délai
|
|
34
|
+
// d'obsolescence on le casse, sinon la ressource serait bloquée à vie.
|
|
35
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
36
|
+
fs.unlinkSync(lockPath);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue; // Verrou disparu entre le open et le stat : on retente aussitôt.
|
|
42
|
+
}
|
|
43
|
+
if (Date.now() > deadline) {
|
|
44
|
+
throw new DesignError(`${subject} is being written by another editor and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
|
|
45
|
+
}
|
|
46
|
+
sleepSync(LOCK_RETRY_MS);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return fn();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(lockPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Verrou déjà cassé pour obsolescence par un autre processus.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Design } from "../../shared/schemas/design.schema.js";
|
|
2
|
+
import type { Box } from "../../shared/tree.js";
|
|
2
3
|
/**
|
|
3
4
|
* Capture d'écran d'un design.
|
|
4
5
|
*
|
|
@@ -12,6 +13,8 @@ export declare const DEFAULT_MAX_DIMENSION = 1568;
|
|
|
12
13
|
export interface ScreenshotOptions {
|
|
13
14
|
/** Cadre la capture sur ce noeud (typiquement un groupe) au lieu de tout l'artboard. */
|
|
14
15
|
nodeId?: string;
|
|
16
|
+
/** Cadre la capture sur cette zone, en coordonnées document. Exclusif de `nodeId`. */
|
|
17
|
+
region?: Box;
|
|
15
18
|
/** Facteur de zoom appliqué avant l'écrêtage par maxDimension (défaut 1). */
|
|
16
19
|
scale?: number;
|
|
17
20
|
/** Marge, en pixels du design, ajoutée autour du noeud capturé. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Resvg } from "@resvg/resvg-js";
|
|
2
|
-
import {
|
|
2
|
+
import { regionToSvg, subtreeToSvg } from "../../shared/render.js";
|
|
3
3
|
/**
|
|
4
4
|
* Capture d'écran d'un design.
|
|
5
5
|
*
|
|
@@ -40,10 +40,35 @@ export function rasterize(svg, scale) {
|
|
|
40
40
|
const rendered = resvg.render();
|
|
41
41
|
return { png: Buffer.from(rendered.asPng()), width: rendered.width, height: rendered.height };
|
|
42
42
|
}
|
|
43
|
+
function artboardOf(doc) {
|
|
44
|
+
return { x: 0, y: 0, width: doc.width, height: doc.height };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rendu d'une zone de la planche, découpé dans le rendu de la planche entière.
|
|
48
|
+
*
|
|
49
|
+
* resvg panique, en emportant le processus, sur un élément semi-transparent
|
|
50
|
+
* dont le dessin tombe entièrement hors du cadre — et la boîte d'un élément ne
|
|
51
|
+
* dit pas où tombe son dessin : un texte occupe rarement toute la largeur de sa
|
|
52
|
+
* boîte. Dans la planche entière, tout ce qui est visible est dans le cadre ; la
|
|
53
|
+
* zone en est ensuite extraite par une image, qui ne porte aucune opacité.
|
|
54
|
+
*/
|
|
55
|
+
function rasterizeRegion(doc, region, scale) {
|
|
56
|
+
const artboard = rasterize(regionToSvg(doc, artboardOf(doc)), scale);
|
|
57
|
+
const crop = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
|
|
58
|
+
`width="${region.width}" height="${region.height}" ` +
|
|
59
|
+
`viewBox="${region.x} ${region.y} ${region.width} ${region.height}">` +
|
|
60
|
+
`<image x="0" y="0" width="${doc.width}" height="${doc.height}" preserveAspectRatio="none" ` +
|
|
61
|
+
`xlink:href="data:image/png;base64,${artboard.png.toString("base64")}" />` +
|
|
62
|
+
`</svg>`;
|
|
63
|
+
return rasterize(crop, scale);
|
|
64
|
+
}
|
|
43
65
|
export function screenshotDesign(design, options = {}) {
|
|
44
66
|
const doc = toDocument(design);
|
|
45
67
|
const padding = Math.max(0, options.padding ?? 0);
|
|
46
|
-
const
|
|
68
|
+
const region = options.region;
|
|
69
|
+
// La planche entière est rendue comme une zone : un élément semi-transparent
|
|
70
|
+
// posé hors de l'artboard en est omis.
|
|
71
|
+
const svg = options.nodeId ? subtreeToSvg(doc, options.nodeId, padding) : regionToSvg(doc, region ?? artboardOf(doc));
|
|
47
72
|
const source = svgSize(svg);
|
|
48
73
|
if (!(source.width > 0) || !(source.height > 0)) {
|
|
49
74
|
throw new Error("Nothing to capture: the requested area has no size.");
|
|
@@ -52,7 +77,7 @@ export function screenshotDesign(design, options = {}) {
|
|
|
52
77
|
const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
|
|
53
78
|
// L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
|
|
54
79
|
const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
|
|
55
|
-
const rendered = rasterize(svg, scale);
|
|
80
|
+
const rendered = region ? rasterizeRegion(doc, region, scale) : rasterize(svg, scale);
|
|
56
81
|
return {
|
|
57
82
|
png: rendered.png,
|
|
58
83
|
width: rendered.width,
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -115,7 +115,7 @@ function ipcRequest(type, data, timeout = 5000) {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
|
-
const PUSHED_EVENTS = new Set(["INITIAL_STATE", "DESIGNS_UPDATED", "DESIGN_UPDATED"]);
|
|
118
|
+
const PUSHED_EVENTS = new Set(["INITIAL_STATE", "DESIGNS_UPDATED", "DESIGN_UPDATED", "REMARKS_UPDATED"]);
|
|
119
119
|
function setupIpcErrorHandlers() {
|
|
120
120
|
process.on("error", (err) => {
|
|
121
121
|
if (err.code === "ERR_IPC_CHANNEL_CLOSED")
|
|
@@ -270,6 +270,108 @@ app.delete("/api/designs/:id", async (c) => {
|
|
|
270
270
|
return c.json({ error: result.error }, 404);
|
|
271
271
|
return c.json(result.data);
|
|
272
272
|
});
|
|
273
|
+
app.get("/api/remarks", async (c) => {
|
|
274
|
+
const result = await ipcRequest("LIST_REMARKS");
|
|
275
|
+
if (result.error)
|
|
276
|
+
return c.json({ error: result.error }, 500);
|
|
277
|
+
return c.json(result.data);
|
|
278
|
+
});
|
|
279
|
+
app.post("/api/remarks", async (c) => {
|
|
280
|
+
let body;
|
|
281
|
+
try {
|
|
282
|
+
body = await c.req.json();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
286
|
+
}
|
|
287
|
+
// La capture est rendue côté serveur : un délai plus long qu'une simple écriture.
|
|
288
|
+
const result = await ipcRequest("ADD_REMARK", body, 20000);
|
|
289
|
+
if (result.error)
|
|
290
|
+
return c.json({ error: result.error }, 400);
|
|
291
|
+
return c.json(result.data);
|
|
292
|
+
});
|
|
293
|
+
app.put("/api/remarks/:id", async (c) => {
|
|
294
|
+
let body;
|
|
295
|
+
try {
|
|
296
|
+
body = await c.req.json();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
300
|
+
}
|
|
301
|
+
const result = await ipcRequest("UPDATE_REMARK", { id: c.req.param("id"), text: body?.text }, 10000);
|
|
302
|
+
if (result.error)
|
|
303
|
+
return c.json({ error: result.error }, 400);
|
|
304
|
+
return c.json(result.data);
|
|
305
|
+
});
|
|
306
|
+
app.delete("/api/remarks/:id", async (c) => {
|
|
307
|
+
const result = await ipcRequest("DELETE_REMARK", { id: c.req.param("id") }, 10000);
|
|
308
|
+
if (result.error)
|
|
309
|
+
return c.json({ error: result.error }, 404);
|
|
310
|
+
return c.json(result.data);
|
|
311
|
+
});
|
|
312
|
+
app.post("/api/feedback", async (c) => {
|
|
313
|
+
let body;
|
|
314
|
+
try {
|
|
315
|
+
body = await c.req.json();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
319
|
+
}
|
|
320
|
+
const result = await ipcRequest("SUBMIT_FEEDBACK", { remarkIds: body?.remarkIds, title: body?.title }, 10000);
|
|
321
|
+
if (result.error)
|
|
322
|
+
return c.json({ error: result.error }, 400);
|
|
323
|
+
return c.json(result.data);
|
|
324
|
+
});
|
|
325
|
+
app.put("/api/rounds/:round", async (c) => {
|
|
326
|
+
let body;
|
|
327
|
+
try {
|
|
328
|
+
body = await c.req.json();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
332
|
+
}
|
|
333
|
+
const result = await ipcRequest("RENAME_ROUND", { round: c.req.param("round"), title: body?.title }, 10000);
|
|
334
|
+
if (result.error)
|
|
335
|
+
return c.json({ error: result.error }, 400);
|
|
336
|
+
return c.json(result.data);
|
|
337
|
+
});
|
|
338
|
+
app.put("/api/rounds/:round/remarks/:id", async (c) => {
|
|
339
|
+
let body;
|
|
340
|
+
try {
|
|
341
|
+
body = await c.req.json();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
345
|
+
}
|
|
346
|
+
const result = await ipcRequest("SET_REMARK_PROCESSED", { round: c.req.param("round"), remarkId: c.req.param("id"), processed: body?.processed }, 10000);
|
|
347
|
+
if (result.error)
|
|
348
|
+
return c.json({ error: result.error }, 400);
|
|
349
|
+
return c.json(result.data);
|
|
350
|
+
});
|
|
351
|
+
app.post("/api/feedback/processed", async (c) => {
|
|
352
|
+
let body;
|
|
353
|
+
try {
|
|
354
|
+
body = await c.req.json();
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
358
|
+
}
|
|
359
|
+
// Sans numéro de retour, tous les retours sont visés.
|
|
360
|
+
const result = await ipcRequest("SET_ROUNDS_PROCESSED", { round: body?.round, processed: body?.processed }, 10000);
|
|
361
|
+
if (result.error)
|
|
362
|
+
return c.json({ error: result.error }, 400);
|
|
363
|
+
return c.json(result.data);
|
|
364
|
+
});
|
|
365
|
+
app.get("/api/captures/:name", async (c) => {
|
|
366
|
+
const result = await ipcRequest("GET_CAPTURE", { name: c.req.param("name") }, 10000);
|
|
367
|
+
if (result.error)
|
|
368
|
+
return c.json({ error: result.error }, 404);
|
|
369
|
+
return c.body(Buffer.from(result.data.png, "base64"), 200, {
|
|
370
|
+
"Content-Type": "image/png",
|
|
371
|
+
// Une capture n'est jamais réécrite : son nom suit l'id de la remarque.
|
|
372
|
+
"Cache-Control": "private, max-age=31536000, immutable",
|
|
373
|
+
});
|
|
374
|
+
});
|
|
273
375
|
// Fichiers statiques de la SPA
|
|
274
376
|
app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
|
|
275
377
|
function getHtmlBaseHref(appPath) {
|