@newtil/drawing 0.1.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/dist/index.cjs ADDED
@@ -0,0 +1,1235 @@
1
+ 'use strict';
2
+
3
+ // src/_styles.ts
4
+ var STYLES = '/* @newtil/drawing \u2014 \uADF8\uB9BC\uD310 \uD31D\uC5C5. \uC790\uAE30 shadow root \uC5D0 \uC8FC\uC785\uB418\uBBC0\uB85C \uD638\uC2A4\uD2B8 \uD398\uC774\uC9C0\uC640 \uC11C\uB85C \uC548 \uC11E\uC778\uB2E4 */\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.nd-backdrop {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;\n font-size: 13px;\n color: #111;\n}\n.nd-dialog {\n display: flex;\n flex-direction: column;\n width: min(96vw, 1280px);\n height: min(94vh, 900px);\n background: #fff;\n border-radius: 12px;\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);\n outline: none;\n overflow: hidden;\n}\n\n/* \u2500\u2500\u2500 \uD234\uBC14 \u2500\u2500\u2500 */\n.nd-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n border-bottom: 1px solid #e5e5e5;\n background: #fafafa;\n}\n.nd-group { display: flex; align-items: center; gap: 2px; }\n.nd-group + .nd-group { padding-left: 8px; border-left: 1px solid #e0e0e0; }\n.nd-group--end { margin-left: auto; border-left: 0 !important; }\n.nd-btn {\n min-width: 30px;\n height: 30px;\n padding: 0 6px;\n border: 1px solid transparent;\n border-radius: 6px;\n background: transparent;\n color: #333;\n font: 15px/1 inherit;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n}\n.nd-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }\n.nd-btn:hover { background: #ececec; }\n.nd-btn[aria-pressed="true"] { background: #e3edf9; border-color: #4a90e2; color: #1a5fb4; }\n.nd-btn:disabled { opacity: 0.35; cursor: default; }\n.nd-btn--text { padding: 0 12px; font-size: 13px; font-weight: 600; border: 1px solid #d0d0d0; background: #fff; }\n.nd-btn--primary { background: #4a90e2; border-color: #4a90e2; color: #fff; }\n.nd-btn--primary:hover { background: #3b7fd1; }\n.nd-swatch { width: 22px; min-width: 22px; height: 22px; padding: 0; border-radius: 50%; background: var(--swatch); border: 2px solid #fff; box-shadow: 0 0 0 1px #c8c8c8; }\n.nd-swatch:hover { background: var(--swatch); }\n.nd-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px #4a90e2; background: var(--swatch); }\n.nd-color-input { width: 22px; height: 22px; padding: 0; border: 0; background: none; cursor: pointer; }\n.nd-range { width: 80px; accent-color: #4a90e2; }\n.nd-label { font-size: 11px; color: #777; margin-left: 4px; white-space: nowrap; }\n.nd-stroke-preview { display: inline-block; width: 18px; border-radius: 2px; background: #333; }\n\n/* \u2500\u2500\u2500 \uD310 \u2500\u2500\u2500 */\n.nd-stage {\n position: relative;\n flex: 1;\n min-height: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 12px;\n background: #ececec;\n overflow: hidden;\n}\n.nd-canvas {\n position: relative;\n background: #fff;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);\n overflow: hidden;\n}\n.nd-bg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; user-select: none; }\n.nd-bg[hidden] { display: none; }\n.nd-svg { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; overflow: visible; }\n.nd-svg[data-tool="select"] { cursor: default; }\n/* \uADF8\uB9AC\uB294 \uB3C4\uAD6C\uC5D0\uC120 \uB3C4\uD615\uC774 \uC774\uBCA4\uD2B8\uB97C \uBA39\uC9C0 \uC54A\uB294\uB2E4. \uC120\uD0DD \uB3C4\uAD6C\uC5D0\uC120 \uC7A1\uAE30\uC6A9 \uD22C\uBA85 \uAD75\uC740 \uD68D(.nd-hit)\uC774 \uB9DE\uB294\uB2E4 */\n.nd-svg [data-shape] * { pointer-events: none; }\n.nd-svg[data-tool="select"] [data-shape] .nd-hit { pointer-events: stroke; cursor: move; }\n.nd-svg[data-tool="select"] [data-shape] rect.nd-hit,\n.nd-svg[data-tool="select"] [data-shape] ellipse.nd-hit,\n.nd-svg[data-tool="select"] [data-shape] circle.nd-hit { pointer-events: all; }\n.nd-selection * { pointer-events: none; }\n.nd-svg[data-tool="select"] .nd-handle { pointer-events: all; }\n.nd-handle[data-handle="nw"], .nd-handle[data-handle="se"] { cursor: nwse-resize; }\n.nd-handle[data-handle="ne"], .nd-handle[data-handle="sw"] { cursor: nesw-resize; }\n.nd-handle[data-handle="n"], .nd-handle[data-handle="s"] { cursor: ns-resize; }\n.nd-handle[data-handle="e"], .nd-handle[data-handle="w"] { cursor: ew-resize; }\n.nd-handle[data-handle="p1"], .nd-handle[data-handle="p2"] { cursor: grab; }\n.nd-rubber { fill: rgba(74, 144, 226, 0.12); stroke: #4a90e2; stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }\n.nd-hint {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n text-align: center;\n color: #888;\n font-size: 14px;\n pointer-events: none;\n}\n.nd-hint[hidden] { display: none; }\n.nd-notice {\n position: absolute;\n top: 16px;\n left: 50%;\n transform: translateX(-50%);\n max-width: 80%;\n padding: 8px 14px;\n border-radius: 8px;\n background: rgba(0, 0, 0, 0.8);\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n}\n.nd-notice[hidden] { display: none; }\n/* \uAE00\uC790 \uC778\uB77C\uC778 \uD3B8\uC9D1 \u2014 \uB3C4\uD615 \uC790\uB9AC\uC5D0 \uACB9\uCCD0 \uB193\uB294 textarea */\n.nd-text-edit {\n position: absolute;\n margin: 0;\n padding: 0;\n border: 1px dashed #4a90e2;\n border-radius: 2px;\n background: rgba(255, 255, 255, 0.85);\n color: inherit;\n font-family: inherit;\n font-weight: bold;\n line-height: 1.25;\n resize: none;\n overflow: hidden;\n outline: none;\n white-space: pre;\n min-width: 40px;\n}\n\n/* \u2500\u2500\u2500 \uBC1C \u2500\u2500\u2500 */\n.nd-footer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n border-top: 1px solid #e5e5e5;\n background: #fafafa;\n}\n.nd-footer .nd-status { color: #888; font-size: 12px; margin-right: auto; }\n.nd-footer .nd-btn--bg { font-weight: 500; }\n';
5
+
6
+ // src/i18n.ts
7
+ var ko = {
8
+ title: "\uADF8\uB9BC\uD310",
9
+ toolSelect: "\uC120\uD0DD\xB7\uC774\uB3D9 (V)",
10
+ toolPen: "\uD39C (P)",
11
+ toolHighlighter: "\uD615\uAD11\uD39C (H)",
12
+ toolLine: "\uC9C1\uC120 (L)",
13
+ toolArrow: "\uD654\uC0B4\uD45C (A)",
14
+ toolArrow2: "\uC591\uCABD \uD654\uC0B4\uD45C",
15
+ toolRect: "\uC0AC\uAC01\uD615 (R)",
16
+ toolEllipse: "\uD0C0\uC6D0 (E)",
17
+ toolText: "\uAE00\uC790 (T)",
18
+ toolBadge: "\uBC88\uD638 \uBC30\uC9C0 (B)",
19
+ color: "\uC0C9",
20
+ customColor: "\uB2E4\uB978 \uC0C9\u2026",
21
+ strokeWidth: "\uC120 \uAD75\uAE30",
22
+ fillNone: "\uCC44\uC6B0\uAE30 \uC5C6\uC74C",
23
+ fillTranslucent: "\uBC18\uD22C\uBA85 \uCC44\uC6B0\uAE30",
24
+ fillSolid: "\uCC44\uC6B0\uAE30",
25
+ dashed: "\uC810\uC120",
26
+ opacity: "\uD22C\uBA85\uB3C4",
27
+ undo: "\uB418\uB3CC\uB9AC\uAE30 (\u2318Z)",
28
+ redo: "\uB2E4\uC2DC \uC2E4\uD589 (\u21E7\u2318Z)",
29
+ delete: "\uC9C0\uC6B0\uAE30 (Delete)",
30
+ duplicate: "\uBCF5\uC81C (\u2318D)",
31
+ bringForward: "\uC55E\uC73C\uB85C",
32
+ sendBackward: "\uB4A4\uB85C",
33
+ background: "\uBC30\uACBD \uC774\uBBF8\uC9C0\u2026",
34
+ clearBackground: "\uBC30\uACBD \uC9C0\uC6B0\uAE30",
35
+ zoomFit: "\uD654\uBA74\uC5D0 \uB9DE\uCDA4",
36
+ done: "\uC644\uB8CC",
37
+ cancel: "\uCDE8\uC18C",
38
+ saving: "\uC800\uC7A5 \uC911\u2026",
39
+ textPrompt: "\uB123\uC744 \uAE00\uC790",
40
+ pasteHint: "\uCEA1\uCC98\uB97C \uBD99\uC5EC\uB123\uAC70\uB098(\u2318V / Ctrl+V) \uBC30\uACBD \uC774\uBBF8\uC9C0\uB97C \uACE0\uB974\uC138\uC694. \uBE48 \uD310\uC5D0 \uBC14\uB85C \uADF8\uB824\uB3C4 \uB429\uB2C8\uB2E4.",
41
+ discardConfirm: "\uADF8\uB9B0 \uAC83\uC744 \uBC84\uB9B4\uAE4C\uC694?",
42
+ exportFailed: "\uADF8\uB9BC\uC744 \uC774\uBBF8\uC9C0\uB85C \uB9CC\uB4E4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
43
+ saveFailed: (reason) => `\uADF8\uB9BC\uC744 \uC800\uC7A5\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uAC70\uB098 \uCDE8\uC18C\uD558\uC138\uC694.
44
+
45
+ ${reason}`
46
+ };
47
+ var en = {
48
+ title: "Drawing",
49
+ toolSelect: "Select / move (V)",
50
+ toolPen: "Pen (P)",
51
+ toolHighlighter: "Highlighter (H)",
52
+ toolLine: "Line (L)",
53
+ toolArrow: "Arrow (A)",
54
+ toolArrow2: "Double arrow",
55
+ toolRect: "Rectangle (R)",
56
+ toolEllipse: "Ellipse (E)",
57
+ toolText: "Text (T)",
58
+ toolBadge: "Number badge (B)",
59
+ color: "Color",
60
+ customColor: "Custom color\u2026",
61
+ strokeWidth: "Stroke width",
62
+ fillNone: "No fill",
63
+ fillTranslucent: "Translucent fill",
64
+ fillSolid: "Solid fill",
65
+ dashed: "Dashed",
66
+ opacity: "Opacity",
67
+ undo: "Undo (\u2318Z)",
68
+ redo: "Redo (\u21E7\u2318Z)",
69
+ delete: "Delete",
70
+ duplicate: "Duplicate (\u2318D)",
71
+ bringForward: "Bring forward",
72
+ sendBackward: "Send backward",
73
+ background: "Background image\u2026",
74
+ clearBackground: "Clear background",
75
+ zoomFit: "Fit to screen",
76
+ done: "Done",
77
+ cancel: "Cancel",
78
+ saving: "Saving\u2026",
79
+ textPrompt: "Text",
80
+ pasteHint: "Paste a screenshot (\u2318V / Ctrl+V) or pick a background image. You can also draw on the blank board.",
81
+ discardConfirm: "Discard the drawing?",
82
+ exportFailed: "Could not render the drawing to an image.",
83
+ saveFailed: (reason) => `Could not save the drawing. Try again or cancel.
84
+
85
+ ${reason}`
86
+ };
87
+ function messagesFor(lang) {
88
+ const l = (lang ?? (typeof document !== "undefined" ? document.documentElement.lang : "") ?? "").toLowerCase();
89
+ return l.startsWith("ko") || l === "" ? ko : en;
90
+ }
91
+
92
+ // src/model.ts
93
+ var DEFAULT_SIZE = { width: 800, height: 450 };
94
+ var MAX_EDGE = 1600;
95
+ var TRANSLUCENT = 0.25;
96
+ var HIGHLIGHTER_ALPHA = 0.35;
97
+ var seq = 0;
98
+ var nextId = () => `s${Date.now().toString(36)}${(seq++).toString(36)}`;
99
+ function emptyDrawing(width = DEFAULT_SIZE.width, height = DEFAULT_SIZE.height) {
100
+ return { version: 1, width, height, background: null, shapes: [] };
101
+ }
102
+ function textSize(stroke) {
103
+ return 14 + stroke * 4;
104
+ }
105
+ function fillOpacity(s) {
106
+ return s.fill === "solid" ? 1 : s.fill === "translucent" ? TRANSLUCENT : 0;
107
+ }
108
+ function clamp(v, lo, hi) {
109
+ return Math.min(hi, Math.max(lo, v));
110
+ }
111
+ function isBoxShape(s) {
112
+ return "x1" in s;
113
+ }
114
+ function isPath(s) {
115
+ return "points" in s;
116
+ }
117
+ function isLineLike(s) {
118
+ return s.type === "line" || s.type === "arrow" || s.type === "arrow2";
119
+ }
120
+ function bounds(s) {
121
+ if (isPath(s)) {
122
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
123
+ for (let i = 0; i + 1 < s.points.length; i += 2) {
124
+ const x = s.points[i], y = s.points[i + 1];
125
+ minX = Math.min(minX, x);
126
+ maxX = Math.max(maxX, x);
127
+ minY = Math.min(minY, y);
128
+ maxY = Math.max(maxY, y);
129
+ }
130
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, w: 0, h: 0 };
131
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
132
+ }
133
+ if (s.type === "text") {
134
+ const lines = s.text.split("\n");
135
+ const w = Math.max(1, ...lines.map((l) => l.length)) * s.size * 0.6;
136
+ return { x: s.x, y: s.y, w, h: lines.length * s.size * 1.25 };
137
+ }
138
+ if (s.type === "badge") {
139
+ const r = s.size;
140
+ return { x: s.x - r, y: s.y - r, w: r * 2, h: r * 2 };
141
+ }
142
+ return { x: Math.min(s.x1, s.x2), y: Math.min(s.y1, s.y2), w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) };
143
+ }
144
+ function moveShape(s, dx, dy) {
145
+ if (isPath(s)) {
146
+ for (let i = 0; i + 1 < s.points.length; i += 2) {
147
+ s.points[i] += dx;
148
+ s.points[i + 1] += dy;
149
+ }
150
+ } else if (s.type === "text" || s.type === "badge") {
151
+ s.x += dx;
152
+ s.y += dy;
153
+ } else {
154
+ s.x1 += dx;
155
+ s.x2 += dx;
156
+ s.y1 += dy;
157
+ s.y2 += dy;
158
+ }
159
+ }
160
+ function arrowHead(x1, y1, x2, y2, stroke) {
161
+ const len = 8 + stroke * 3, ang = Math.atan2(y2 - y1, x2 - x1), spread = Math.PI / 7;
162
+ return [
163
+ [x2, y2],
164
+ [x2 - len * Math.cos(ang - spread), y2 - len * Math.sin(ang - spread)],
165
+ [x2 - len * Math.cos(ang + spread), y2 - len * Math.sin(ang + spread)]
166
+ ];
167
+ }
168
+ function smoothPoints(points) {
169
+ if (points.length < 8) return points;
170
+ const out = [points[0], points[1]];
171
+ for (let i = 2; i + 3 < points.length; i += 2) {
172
+ out.push((points[i - 2] + points[i] + points[i + 2]) / 3, (points[i - 1] + points[i + 1] + points[i + 3]) / 3);
173
+ }
174
+ out.push(points[points.length - 2], points[points.length - 1]);
175
+ return out;
176
+ }
177
+ var FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif';
178
+
179
+ // src/render.ts
180
+ function loadImage(src) {
181
+ return new Promise((resolve, reject) => {
182
+ const img = new Image();
183
+ img.onload = () => resolve(img);
184
+ img.onerror = () => reject(new Error("image load failed"));
185
+ img.src = src;
186
+ });
187
+ }
188
+ function blobToDataURL(blob) {
189
+ return new Promise((resolve, reject) => {
190
+ const r = new FileReader();
191
+ r.onload = () => resolve(String(r.result));
192
+ r.onerror = () => reject(r.error ?? new Error("read failed"));
193
+ r.readAsDataURL(blob);
194
+ });
195
+ }
196
+ function paintShape(ctx, s) {
197
+ ctx.save();
198
+ ctx.globalAlpha = s.opacity ?? 1;
199
+ ctx.strokeStyle = s.color;
200
+ ctx.fillStyle = s.color;
201
+ ctx.lineWidth = s.stroke;
202
+ ctx.setLineDash(s.dashed ? [s.stroke * 3, s.stroke * 2] : []);
203
+ const b = bounds(s);
204
+ switch (s.type) {
205
+ case "rect": {
206
+ const r = Math.min(s.radius ?? 0, b.w / 2, b.h / 2);
207
+ ctx.beginPath();
208
+ if (r > 0) ctx.roundRect(b.x, b.y, b.w, b.h, r);
209
+ else ctx.rect(b.x, b.y, b.w, b.h);
210
+ if (fillOpacity(s) > 0) {
211
+ ctx.save();
212
+ ctx.globalAlpha *= fillOpacity(s);
213
+ ctx.fill();
214
+ ctx.restore();
215
+ }
216
+ ctx.stroke();
217
+ break;
218
+ }
219
+ case "ellipse":
220
+ ctx.beginPath();
221
+ ctx.ellipse(b.x + b.w / 2, b.y + b.h / 2, b.w / 2, b.h / 2, 0, 0, Math.PI * 2);
222
+ if (fillOpacity(s) > 0) {
223
+ ctx.save();
224
+ ctx.globalAlpha *= fillOpacity(s);
225
+ ctx.fill();
226
+ ctx.restore();
227
+ }
228
+ ctx.stroke();
229
+ break;
230
+ case "line":
231
+ case "arrow":
232
+ case "arrow2": {
233
+ ctx.lineCap = "round";
234
+ ctx.beginPath();
235
+ ctx.moveTo(s.x1, s.y1);
236
+ ctx.lineTo(s.x2, s.y2);
237
+ ctx.stroke();
238
+ ctx.setLineDash([]);
239
+ const heads = [];
240
+ if (s.type !== "line") heads.push(arrowHead(s.x1, s.y1, s.x2, s.y2, s.stroke));
241
+ if (s.type === "arrow2") heads.push(arrowHead(s.x2, s.y2, s.x1, s.y1, s.stroke));
242
+ for (const [a, b2, c] of heads) {
243
+ ctx.beginPath();
244
+ ctx.moveTo(a[0], a[1]);
245
+ ctx.lineTo(b2[0], b2[1]);
246
+ ctx.lineTo(c[0], c[1]);
247
+ ctx.closePath();
248
+ ctx.fill();
249
+ }
250
+ break;
251
+ }
252
+ case "pen":
253
+ case "highlighter": {
254
+ const pts = s.type === "pen" ? smoothPoints(s.points) : s.points;
255
+ if (pts.length < 4) break;
256
+ ctx.lineCap = "round";
257
+ ctx.lineJoin = "round";
258
+ if (s.type === "highlighter") {
259
+ ctx.globalAlpha *= HIGHLIGHTER_ALPHA;
260
+ ctx.lineWidth = s.stroke * 4;
261
+ ctx.lineCap = "butt";
262
+ }
263
+ ctx.beginPath();
264
+ ctx.moveTo(pts[0], pts[1]);
265
+ for (let i = 2; i + 1 < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);
266
+ ctx.stroke();
267
+ break;
268
+ }
269
+ case "text": {
270
+ ctx.font = `bold ${s.size}px ${FONT}`;
271
+ ctx.textBaseline = "top";
272
+ ctx.lineWidth = Math.max(3, s.size / 6);
273
+ ctx.strokeStyle = "#ffffff";
274
+ ctx.setLineDash([]);
275
+ s.text.split("\n").forEach((line, i) => {
276
+ ctx.strokeText(line, s.x, s.y + i * s.size * 1.25);
277
+ ctx.fillText(line, s.x, s.y + i * s.size * 1.25);
278
+ });
279
+ break;
280
+ }
281
+ case "badge": {
282
+ ctx.setLineDash([]);
283
+ ctx.beginPath();
284
+ ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
285
+ ctx.fill();
286
+ ctx.lineWidth = 2;
287
+ ctx.strokeStyle = "#ffffff";
288
+ ctx.stroke();
289
+ ctx.fillStyle = "#ffffff";
290
+ ctx.font = `bold ${Math.round(s.size * 1.2)}px ${FONT}`;
291
+ ctx.textAlign = "center";
292
+ ctx.textBaseline = "middle";
293
+ ctx.fillText(String(s.n), s.x, s.y + s.size * 0.05);
294
+ break;
295
+ }
296
+ }
297
+ ctx.restore();
298
+ }
299
+ async function renderToPng(data) {
300
+ const c = document.createElement("canvas");
301
+ c.width = data.width;
302
+ c.height = data.height;
303
+ const ctx = c.getContext("2d");
304
+ ctx.fillStyle = "#ffffff";
305
+ ctx.fillRect(0, 0, c.width, c.height);
306
+ if (data.background) ctx.drawImage(await loadImage(data.background), 0, 0, data.width, data.height);
307
+ for (const s of data.shapes) paintShape(ctx, s);
308
+ return new Promise((resolve, reject) => c.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png"));
309
+ }
310
+
311
+ // src/board.ts
312
+ var COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#a855f7", "#111111", "#ffffff"];
313
+ var SVG_NS = "http://www.w3.org/2000/svg";
314
+ var ICONS = {
315
+ select: '<path d="M5 3l14 8-6 2-3 6z"/>',
316
+ pen: '<path d="M4 20l4-1 11-11-3-3L5 16z"/>',
317
+ highlighter: '<path d="M4 20h6M9 15l6-6 3 3-6 6H9z"/><path d="M14 8l2-2 3 3-2 2"/>',
318
+ line: '<path d="M5 19L19 5"/>',
319
+ arrow: '<path d="M5 19L19 5M11 5h8v8"/>',
320
+ arrow2: '<path d="M5 19L19 5M11 5h8v8M13 19H5v-8"/>',
321
+ rect: '<rect x="4" y="6" width="16" height="12" rx="1"/>',
322
+ ellipse: '<ellipse cx="12" cy="12" rx="8" ry="6"/>',
323
+ text: '<path d="M5 6h14M12 6v13"/>',
324
+ badge: '<circle cx="12" cy="12" r="8"/><path d="M11 9l2-1v8"/>',
325
+ undo: '<path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/>',
326
+ redo: '<path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/>',
327
+ delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
328
+ duplicate: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 00-1-1H5a1 1 0 00-1 1v10a1 1 0 001 1h3"/>',
329
+ forward: '<path d="M12 19V5M6 11l6-6 6 6"/>',
330
+ backward: '<path d="M12 5v14M6 13l6 6 6-6"/>',
331
+ fillNone: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
332
+ fillTranslucent: '<rect x="5" y="5" width="14" height="14" rx="2"/><path d="M5 12h14M12 5v14" opacity=".5"/>',
333
+ fillSolid: '<rect x="5" y="5" width="14" height="14" rx="2" fill="currentColor"/>',
334
+ dashed: '<path d="M4 12h3M10 12h4M17 12h3"/>'
335
+ };
336
+ var icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] ?? ""}</svg>`;
337
+ async function toBackground(src) {
338
+ const url = typeof src === "string" ? src : await blobToDataURL(src);
339
+ const img = await loadImage(url);
340
+ const k = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
341
+ const width = Math.max(1, Math.round(img.naturalWidth * k)), height = Math.max(1, Math.round(img.naturalHeight * k));
342
+ const c = document.createElement("canvas");
343
+ c.width = width;
344
+ c.height = height;
345
+ c.getContext("2d").drawImage(img, 0, 0, width, height);
346
+ return { src: c.toDataURL("image/png"), width, height };
347
+ }
348
+ var DrawingBoard = class {
349
+ constructor(opts) {
350
+ this.opts = opts;
351
+ this.toolBtns = /* @__PURE__ */ new Map();
352
+ this.colorBtns = /* @__PURE__ */ new Map();
353
+ this.fillBtns = /* @__PURE__ */ new Map();
354
+ this.textEdit = null;
355
+ this.tool = "arrow";
356
+ this.color = COLORS[0];
357
+ this.stroke = 4;
358
+ this.fill = null;
359
+ this.dashed = false;
360
+ this.opacity = 1;
361
+ this.scale = 1;
362
+ this.selected = /* @__PURE__ */ new Set();
363
+ this.drag = null;
364
+ /** 두 번 클릭 판정 — pointerdown 을 preventDefault 하면 브라우저가 click/dblclick 을 안 만들어 직접 잰다 */
365
+ this.lastDown = { id: null, t: 0 };
366
+ this.past = [];
367
+ this.future = [];
368
+ this.dirty = false;
369
+ this.saving = false;
370
+ this.onResize = () => this.fit();
371
+ this.onPaste = (e) => {
372
+ if (this.textEdit) return;
373
+ const file = Array.from(e.clipboardData?.files ?? []).find((f) => f.type.startsWith("image/"));
374
+ if (!file) return;
375
+ e.preventDefault();
376
+ e.stopPropagation();
377
+ void this.setBackground(file);
378
+ };
379
+ this.onPointerDown = (e) => {
380
+ if (e.button !== 0 || this.saving) return;
381
+ e.preventDefault();
382
+ this.commitTextEdit();
383
+ const p = this.toPoint(e);
384
+ if (this.tool === "select") {
385
+ const hitId = this.shapeAt(e.target);
386
+ const now = performance.now();
387
+ const dbl = hitId !== null && hitId === this.lastDown.id && now - this.lastDown.t < 350;
388
+ this.lastDown = { id: hitId, t: now };
389
+ if (dbl) {
390
+ const s = this.data.shapes.find((x) => x.id === hitId);
391
+ if (s?.type === "text") {
392
+ this.select([s.id]);
393
+ this.startTextEdit(s, false);
394
+ return;
395
+ }
396
+ }
397
+ const handle = this.handleAt(e.target);
398
+ const only = this.selected.size === 1 ? this.selectedShapes()[0] : void 0;
399
+ if (handle && only) {
400
+ this.commit();
401
+ this.drag = { kind: "handle", id: only.id, handle, start: bounds(only), startShape: structuredClone(only), pointerId: e.pointerId };
402
+ this.dialog.focus({ preventScroll: true });
403
+ return;
404
+ }
405
+ const id = this.shapeAt(e.target);
406
+ if (id) {
407
+ if (e.shiftKey) {
408
+ const next = new Set(this.selected);
409
+ if (next.has(id)) next.delete(id);
410
+ else next.add(id);
411
+ this.select([...next]);
412
+ } else if (!this.selected.has(id)) this.select([id]);
413
+ if (this.selected.has(id)) {
414
+ this.commit();
415
+ this.drag = { kind: "move", ids: [...this.selected], last: p, moved: false, pointerId: e.pointerId };
416
+ }
417
+ this.dialog.focus({ preventScroll: true });
418
+ return;
419
+ }
420
+ if (!e.shiftKey) this.select([]);
421
+ this.drag = { kind: "rubber", from: p, to: p, additive: e.shiftKey, pointerId: e.pointerId };
422
+ this.dialog.focus({ preventScroll: true });
423
+ return;
424
+ }
425
+ if (this.tool === "text") {
426
+ this.startTextEdit({ id: nextId(), type: "text", color: this.color, stroke: this.stroke, x: p.x, y: p.y, text: "", size: textSize(this.stroke), opacity: this.opacity }, true);
427
+ return;
428
+ }
429
+ if (this.tool === "badge") {
430
+ const n = this.data.shapes.filter((s) => s.type === "badge").length + 1;
431
+ this.commit();
432
+ const shape2 = { id: nextId(), type: "badge", color: this.color, stroke: this.stroke, x: p.x, y: p.y, n, size: 8 + this.stroke * 2, opacity: this.opacity };
433
+ this.data.shapes.push(shape2);
434
+ this.render();
435
+ this.updateButtons();
436
+ this.dialog.focus({ preventScroll: true });
437
+ return;
438
+ }
439
+ this.dialog.focus({ preventScroll: true });
440
+ const base = { id: nextId(), color: this.color, stroke: this.stroke, dashed: this.dashed || void 0, opacity: this.opacity !== 1 ? this.opacity : void 0 };
441
+ const shape = this.tool === "pen" || this.tool === "highlighter" ? { ...base, type: this.tool, points: [p.x, p.y] } : this.tool === "rect" || this.tool === "ellipse" ? { ...base, type: this.tool, x1: p.x, y1: p.y, x2: p.x, y2: p.y, fill: this.fill } : { ...base, type: this.tool, x1: p.x, y1: p.y, x2: p.x, y2: p.y };
442
+ this.commit();
443
+ this.data.shapes.push(shape);
444
+ this.drag = { kind: "draw", shape, pointerId: e.pointerId };
445
+ this.render();
446
+ };
447
+ this.onPointerMove = (e) => {
448
+ if (!this.drag || e.pointerId !== this.drag.pointerId) return;
449
+ const p = this.toPoint(e);
450
+ const d = this.drag;
451
+ if (d.kind === "draw") {
452
+ const s = d.shape;
453
+ if (isPath(s)) s.points.push(p.x, p.y);
454
+ else if (isBoxShape(s)) {
455
+ if (e.shiftKey) {
456
+ const dx = p.x - s.x1, dy = p.y - s.y1;
457
+ const m = Math.max(Math.abs(dx), Math.abs(dy));
458
+ s.x2 = s.x1 + Math.sign(dx || 1) * m;
459
+ s.y2 = s.y1 + Math.sign(dy || 1) * m;
460
+ } else {
461
+ s.x2 = p.x;
462
+ s.y2 = p.y;
463
+ }
464
+ }
465
+ } else if (d.kind === "move") {
466
+ const dx = p.x - d.last.x, dy = p.y - d.last.y;
467
+ if (dx || dy) {
468
+ d.moved = true;
469
+ for (const s of this.data.shapes) if (d.ids.includes(s.id)) moveShape(s, dx, dy);
470
+ d.last = p;
471
+ }
472
+ } else if (d.kind === "handle") {
473
+ const s = this.data.shapes.find((x) => x.id === d.id);
474
+ if (!s || !isBoxShape(s)) return;
475
+ if (d.handle === "p1") {
476
+ s.x1 = p.x;
477
+ s.y1 = p.y;
478
+ } else if (d.handle === "p2") {
479
+ s.x2 = p.x;
480
+ s.y2 = p.y;
481
+ } else {
482
+ const b = d.start;
483
+ let x1 = b.x, y1 = b.y, x2 = b.x + b.w, y2 = b.y + b.h;
484
+ if (d.handle.includes("w")) x1 = p.x;
485
+ if (d.handle.includes("e")) x2 = p.x;
486
+ if (d.handle.includes("n")) y1 = p.y;
487
+ if (d.handle.includes("s")) y2 = p.y;
488
+ s.x1 = x1;
489
+ s.y1 = y1;
490
+ s.x2 = x2;
491
+ s.y2 = y2;
492
+ }
493
+ } else {
494
+ d.to = p;
495
+ }
496
+ this.render();
497
+ };
498
+ this.onPointerUp = (e) => {
499
+ if (!this.drag || e.pointerId !== this.drag.pointerId) return;
500
+ const d = this.drag;
501
+ this.drag = null;
502
+ if (d.kind === "draw") {
503
+ const s = d.shape;
504
+ const tiny = isPath(s) ? s.points.length < 4 : isBoxShape(s) ? Math.abs(s.x2 - s.x1) < 2 && Math.abs(s.y2 - s.y1) < 2 : false;
505
+ if (tiny) {
506
+ this.data.shapes = this.data.shapes.filter((x) => x.id !== s.id);
507
+ this.past.pop();
508
+ } else if (s.type === "pen") s.points = smoothPoints(s.points);
509
+ } else if (d.kind === "move" && !d.moved) {
510
+ this.past.pop();
511
+ } else if (d.kind === "rubber") {
512
+ const box = { x: Math.min(d.from.x, d.to.x), y: Math.min(d.from.y, d.to.y), w: Math.abs(d.to.x - d.from.x), h: Math.abs(d.to.y - d.from.y) };
513
+ if (box.w > 3 || box.h > 3) {
514
+ const hit = this.data.shapes.filter((s) => {
515
+ const b = bounds(s);
516
+ return b.x < box.x + box.w && b.x + b.w > box.x && b.y < box.y + box.h && b.y + b.h > box.y;
517
+ }).map((s) => s.id);
518
+ this.select(d.additive ? [.../* @__PURE__ */ new Set([...this.selected, ...hit])] : hit);
519
+ }
520
+ }
521
+ this.render();
522
+ this.updateButtons();
523
+ };
524
+ this.onKeyDown = (e) => {
525
+ e.stopPropagation();
526
+ if (this.textEdit) return;
527
+ const mod = e.metaKey || e.ctrlKey;
528
+ if (e.key === "Escape") {
529
+ e.preventDefault();
530
+ if (this.selected.size) this.select([]);
531
+ else this.cancel();
532
+ return;
533
+ }
534
+ if ((e.key === "Delete" || e.key === "Backspace") && this.selected.size) {
535
+ e.preventDefault();
536
+ this.deleteSelected();
537
+ return;
538
+ }
539
+ if (mod && e.key.toLowerCase() === "z") {
540
+ e.preventDefault();
541
+ if (e.shiftKey) this.redo();
542
+ else this.undo();
543
+ return;
544
+ }
545
+ if (mod && e.key.toLowerCase() === "d") {
546
+ e.preventDefault();
547
+ this.duplicate();
548
+ return;
549
+ }
550
+ if (mod && e.key.toLowerCase() === "a") {
551
+ e.preventDefault();
552
+ this.setTool("select");
553
+ this.select(this.data.shapes.map((s) => s.id));
554
+ return;
555
+ }
556
+ if (e.key.startsWith("Arrow") && this.selected.size) {
557
+ e.preventDefault();
558
+ const step = e.shiftKey ? 10 : 1;
559
+ this.nudge(e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0, e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0);
560
+ return;
561
+ }
562
+ if (e.key === "]" && this.selected.size) {
563
+ this.reorder(1);
564
+ return;
565
+ }
566
+ if (e.key === "[" && this.selected.size) {
567
+ this.reorder(-1);
568
+ return;
569
+ }
570
+ const tool = { v: "select", p: "pen", h: "highlighter", l: "line", a: "arrow", r: "rect", e: "ellipse", t: "text", b: "badge" }[e.key.toLowerCase()];
571
+ if (tool && !mod && !e.altKey) this.setTool(tool);
572
+ };
573
+ this.m = { ...messagesFor(opts.lang), ...opts.messages ?? {} };
574
+ this.data = structuredClone(opts.initial ?? emptyDrawing());
575
+ this.host = document.createElement("div");
576
+ this.host.className = "newtil-drawing";
577
+ const shadow = this.host.attachShadow({ mode: "open" });
578
+ const style = document.createElement("style");
579
+ style.textContent = STYLES;
580
+ shadow.appendChild(style);
581
+ this.backdrop = el("div", "nd-backdrop");
582
+ this.dialog = el("div", "nd-dialog");
583
+ this.dialog.setAttribute("role", "dialog");
584
+ this.dialog.setAttribute("aria-label", this.m.title);
585
+ this.dialog.tabIndex = -1;
586
+ this.backdrop.appendChild(this.dialog);
587
+ shadow.appendChild(this.backdrop);
588
+ this.dialog.appendChild(this.buildToolbar());
589
+ this.stage = el("div", "nd-stage");
590
+ this.canvasEl = el("div", "nd-canvas");
591
+ this.bgImg = document.createElement("img");
592
+ this.bgImg.className = "nd-bg";
593
+ this.bgImg.draggable = false;
594
+ this.bgImg.alt = "";
595
+ this.svg = document.createElementNS(SVG_NS, "svg");
596
+ this.svg.classList.add("nd-svg");
597
+ this.hintEl = el("div", "nd-hint");
598
+ this.hintEl.textContent = this.m.pasteHint;
599
+ this.canvasEl.append(this.bgImg, this.svg, this.hintEl);
600
+ this.noticeEl = el("div", "nd-notice");
601
+ this.noticeEl.hidden = true;
602
+ this.stage.append(this.canvasEl, this.noticeEl);
603
+ this.dialog.appendChild(this.stage);
604
+ const foot = el("div", "nd-footer");
605
+ this.statusEl = el("span", "nd-status");
606
+ const bgBtn = textBtn(this.m.background, () => {
607
+ void this.pickBackground();
608
+ });
609
+ bgBtn.classList.add("nd-btn--bg");
610
+ const cancel = textBtn(this.m.cancel, () => this.cancel());
611
+ this.doneBtn = textBtn(this.m.done, () => {
612
+ void this.finish();
613
+ });
614
+ this.doneBtn.classList.add("nd-btn--primary");
615
+ foot.append(bgBtn, this.statusEl, cancel, this.doneBtn);
616
+ this.dialog.appendChild(foot);
617
+ this.svg.addEventListener("pointerdown", this.onPointerDown);
618
+ window.addEventListener("pointermove", this.onPointerMove);
619
+ window.addEventListener("pointerup", this.onPointerUp);
620
+ window.addEventListener("pointercancel", this.onPointerUp);
621
+ this.dialog.addEventListener("keydown", this.onKeyDown);
622
+ this.dialog.addEventListener("paste", this.onPaste);
623
+ this.backdrop.addEventListener("pointerdown", (e) => {
624
+ if (e.target === this.backdrop) this.cancel();
625
+ });
626
+ window.addEventListener("resize", this.onResize);
627
+ }
628
+ async open() {
629
+ (this.opts.mount ?? document.body).appendChild(this.host);
630
+ if (!this.opts.initial && this.opts.background) {
631
+ try {
632
+ const bg = await toBackground(this.opts.background);
633
+ this.data.background = bg.src;
634
+ this.data.width = bg.width;
635
+ this.data.height = bg.height;
636
+ } catch {
637
+ }
638
+ }
639
+ this.applyBackground();
640
+ this.render();
641
+ this.setTool(this.tool);
642
+ this.reflectStyle();
643
+ this.updateButtons();
644
+ if (this.opts.notice) this.showNotice(this.opts.notice);
645
+ requestAnimationFrame(() => {
646
+ this.fit();
647
+ this.dialog.focus();
648
+ });
649
+ return new Promise((resolve) => {
650
+ this.done = resolve;
651
+ });
652
+ }
653
+ // ─── 툴바 ───
654
+ buildToolbar() {
655
+ const bar = el("div", "nd-toolbar");
656
+ const tools = [
657
+ ["select", this.m.toolSelect],
658
+ ["pen", this.m.toolPen],
659
+ ["highlighter", this.m.toolHighlighter],
660
+ ["line", this.m.toolLine],
661
+ ["arrow", this.m.toolArrow],
662
+ ["arrow2", this.m.toolArrow2],
663
+ ["rect", this.m.toolRect],
664
+ ["ellipse", this.m.toolEllipse],
665
+ ["text", this.m.toolText],
666
+ ["badge", this.m.toolBadge]
667
+ ];
668
+ const g1 = el("div", "nd-group");
669
+ for (const [tool, title] of tools) {
670
+ const b = iconBtn(icon(tool), title, () => this.setTool(tool));
671
+ b.dataset["tool"] = tool;
672
+ this.toolBtns.set(tool, b);
673
+ g1.appendChild(b);
674
+ }
675
+ bar.appendChild(g1);
676
+ const g2 = el("div", "nd-group");
677
+ for (const c of COLORS) {
678
+ const b = iconBtn("", this.m.color, () => this.setColor(c));
679
+ b.classList.add("nd-swatch");
680
+ b.style.setProperty("--swatch", c);
681
+ b.dataset["color"] = c;
682
+ this.colorBtns.set(c, b);
683
+ g2.appendChild(b);
684
+ }
685
+ this.colorInput = document.createElement("input");
686
+ this.colorInput.type = "color";
687
+ this.colorInput.className = "nd-color-input";
688
+ this.colorInput.title = this.m.customColor;
689
+ this.colorInput.addEventListener("input", () => this.setColor(this.colorInput.value));
690
+ g2.appendChild(this.colorInput);
691
+ bar.appendChild(g2);
692
+ const g3 = el("div", "nd-group");
693
+ this.strokePreview = el("span", "nd-stroke-preview");
694
+ this.strokeRange = range(1, 12, 1, this.m.strokeWidth, () => this.setStroke(Number(this.strokeRange.value)));
695
+ g3.append(this.strokePreview, this.strokeRange);
696
+ bar.appendChild(g3);
697
+ const g4 = el("div", "nd-group");
698
+ const fills = [[null, "fillNone", this.m.fillNone], ["translucent", "fillTranslucent", this.m.fillTranslucent], ["solid", "fillSolid", this.m.fillSolid]];
699
+ for (const [f, ic, title] of fills) {
700
+ const b = iconBtn(icon(ic), title, () => this.setFill(f));
701
+ b.dataset["fill"] = f ?? "none";
702
+ this.fillBtns.set(f ?? "none", b);
703
+ g4.appendChild(b);
704
+ }
705
+ this.dashedBtn = iconBtn(icon("dashed"), this.m.dashed, () => this.setDashed(!this.dashed));
706
+ this.dashedBtn.dataset["action"] = "dashed";
707
+ g4.appendChild(this.dashedBtn);
708
+ const opLabel = el("span", "nd-label");
709
+ opLabel.textContent = this.m.opacity;
710
+ this.opacityRange = range(10, 100, 5, this.m.opacity, () => this.setOpacity(Number(this.opacityRange.value) / 100));
711
+ g4.append(opLabel, this.opacityRange);
712
+ bar.appendChild(g4);
713
+ const g5 = el("div", "nd-group");
714
+ this.undoBtn = iconBtn(icon("undo"), this.m.undo, () => this.undo());
715
+ this.redoBtn = iconBtn(icon("redo"), this.m.redo, () => this.redo());
716
+ this.dupBtn = iconBtn(icon("duplicate"), this.m.duplicate, () => this.duplicate());
717
+ this.fwdBtn = iconBtn(icon("forward"), this.m.bringForward, () => this.reorder(1));
718
+ this.backBtn = iconBtn(icon("backward"), this.m.sendBackward, () => this.reorder(-1));
719
+ this.deleteBtn = iconBtn(icon("delete"), this.m.delete, () => this.deleteSelected());
720
+ this.deleteBtn.dataset["action"] = "delete";
721
+ g5.append(this.undoBtn, this.redoBtn, this.dupBtn, this.fwdBtn, this.backBtn, this.deleteBtn);
722
+ bar.appendChild(g5);
723
+ return bar;
724
+ }
725
+ /** 툴바가 지금 값(또는 고른 도형의 값)을 비추게 */
726
+ reflectStyle() {
727
+ for (const [k, b] of this.colorBtns) b.setAttribute("aria-pressed", String(k === this.color));
728
+ this.colorInput.value = /^#[0-9a-f]{6}$/i.test(this.color) ? this.color : "#000000";
729
+ this.strokeRange.value = String(this.stroke);
730
+ this.strokePreview.style.height = `${Math.min(12, this.stroke)}px`;
731
+ for (const [k, b] of this.fillBtns) b.setAttribute("aria-pressed", String(k === (this.fill ?? "none")));
732
+ this.dashedBtn.setAttribute("aria-pressed", String(this.dashed));
733
+ this.opacityRange.value = String(Math.round(this.opacity * 100));
734
+ }
735
+ fit() {
736
+ const r = this.stage.getBoundingClientRect();
737
+ const availW = Math.max(100, r.width - 24), availH = Math.max(100, r.height - 24);
738
+ this.scale = Math.min(1, availW / this.data.width, availH / this.data.height);
739
+ this.canvasEl.style.width = `${Math.round(this.data.width * this.scale)}px`;
740
+ this.canvasEl.style.height = `${Math.round(this.data.height * this.scale)}px`;
741
+ this.svg.setAttribute("viewBox", `0 0 ${this.data.width} ${this.data.height}`);
742
+ this.render();
743
+ }
744
+ applyBackground() {
745
+ if (this.data.background) {
746
+ this.bgImg.src = this.data.background;
747
+ this.bgImg.hidden = false;
748
+ } else {
749
+ this.bgImg.removeAttribute("src");
750
+ this.bgImg.hidden = true;
751
+ }
752
+ this.fit();
753
+ this.updateButtons();
754
+ }
755
+ showNotice(text) {
756
+ this.noticeEl.textContent = text;
757
+ this.noticeEl.hidden = false;
758
+ window.setTimeout(() => {
759
+ this.noticeEl.hidden = true;
760
+ }, 6e3);
761
+ }
762
+ // ─── 상태 ───
763
+ setTool(tool) {
764
+ this.commitTextEdit();
765
+ this.tool = tool;
766
+ for (const [t, b] of this.toolBtns) b.setAttribute("aria-pressed", String(t === tool));
767
+ this.svg.dataset["tool"] = tool;
768
+ if (tool !== "select") this.select([]);
769
+ }
770
+ selectedShapes() {
771
+ return this.data.shapes.filter((s) => this.selected.has(s.id));
772
+ }
773
+ /** 고른 도형들에 스타일을 적용 — 고른 게 없으면 다음 도형의 기본값만 바뀐다 */
774
+ applyStyle(mutate) {
775
+ const targets = this.selectedShapes();
776
+ if (!targets.length) return;
777
+ let changed = false;
778
+ const snapshot = structuredClone(this.data.shapes);
779
+ for (const s of targets) changed = mutate(s) || changed;
780
+ if (changed) {
781
+ this.past.push(snapshot);
782
+ this.future = [];
783
+ this.dirty = true;
784
+ this.render();
785
+ this.updateButtons();
786
+ }
787
+ }
788
+ setColor(c) {
789
+ this.color = c;
790
+ this.applyStyle((s) => s.color !== c ? (s.color = c, true) : false);
791
+ this.reflectStyle();
792
+ }
793
+ setStroke(w) {
794
+ this.stroke = w;
795
+ this.applyStyle((s) => {
796
+ if (s.stroke === w) return false;
797
+ s.stroke = w;
798
+ if (s.type === "text") s.size = textSize(w);
799
+ if (s.type === "badge") s.size = 8 + w * 2;
800
+ return true;
801
+ });
802
+ this.reflectStyle();
803
+ }
804
+ setFill(f) {
805
+ this.fill = f;
806
+ this.applyStyle((s) => (s.type === "rect" || s.type === "ellipse") && (s.fill ?? null) !== f ? (s.fill = f, true) : false);
807
+ this.reflectStyle();
808
+ }
809
+ setDashed(d) {
810
+ this.dashed = d;
811
+ this.applyStyle((s) => !!s.dashed !== d ? (s.dashed = d, true) : false);
812
+ this.reflectStyle();
813
+ }
814
+ setOpacity(o) {
815
+ this.opacity = o;
816
+ this.applyStyle((s) => (s.opacity ?? 1) !== o ? (s.opacity = o, true) : false);
817
+ this.reflectStyle();
818
+ }
819
+ select(ids) {
820
+ this.selected = new Set(ids);
821
+ const one = ids.length === 1 ? this.data.shapes.find((s) => s.id === ids[0]) : void 0;
822
+ if (one) {
823
+ this.color = one.color;
824
+ this.stroke = one.stroke;
825
+ this.fill = one.fill ?? null;
826
+ this.dashed = !!one.dashed;
827
+ this.opacity = one.opacity ?? 1;
828
+ this.reflectStyle();
829
+ }
830
+ this.render();
831
+ this.updateButtons();
832
+ }
833
+ commit() {
834
+ this.past.push(structuredClone(this.data.shapes));
835
+ if (this.past.length > 200) this.past.shift();
836
+ this.future = [];
837
+ this.dirty = true;
838
+ this.updateButtons();
839
+ }
840
+ undo() {
841
+ this.commitTextEdit();
842
+ const prev = this.past.pop();
843
+ if (!prev) return;
844
+ this.future.push(structuredClone(this.data.shapes));
845
+ this.data.shapes = prev;
846
+ this.select([...this.selected].filter((id) => prev.some((s) => s.id === id)));
847
+ }
848
+ redo() {
849
+ const next = this.future.pop();
850
+ if (!next) return;
851
+ this.past.push(structuredClone(this.data.shapes));
852
+ this.data.shapes = next;
853
+ this.select([...this.selected].filter((id) => next.some((s) => s.id === id)));
854
+ }
855
+ deleteSelected() {
856
+ if (!this.selected.size) return;
857
+ this.commit();
858
+ this.data.shapes = this.data.shapes.filter((s) => !this.selected.has(s.id));
859
+ this.select([]);
860
+ }
861
+ duplicate() {
862
+ const targets = this.selectedShapes();
863
+ if (!targets.length) return;
864
+ this.commit();
865
+ const copies = targets.map((s) => {
866
+ const c = structuredClone(s);
867
+ c.id = nextId();
868
+ moveShape(c, 16, 16);
869
+ return c;
870
+ });
871
+ this.data.shapes.push(...copies);
872
+ this.select(copies.map((c) => c.id));
873
+ }
874
+ /** 앞으로/뒤로 — 고른 도형들의 그리는 순서를 한 칸 옮긴다 */
875
+ reorder(dir) {
876
+ if (!this.selected.size) return;
877
+ const shapes = this.data.shapes;
878
+ const idx = shapes.map((s, i) => this.selected.has(s.id) ? i : -1).filter((i) => i >= 0);
879
+ if (dir === 1 && idx[idx.length - 1] === shapes.length - 1) return;
880
+ if (dir === -1 && idx[0] === 0) return;
881
+ this.commit();
882
+ const order = dir === 1 ? [...idx].reverse() : idx;
883
+ for (const i of order) {
884
+ const j = i + dir;
885
+ [shapes[i], shapes[j]] = [shapes[j], shapes[i]];
886
+ }
887
+ this.render();
888
+ this.updateButtons();
889
+ }
890
+ nudge(dx, dy) {
891
+ const targets = this.selectedShapes();
892
+ if (!targets.length) return;
893
+ this.commit();
894
+ for (const s of targets) moveShape(s, dx, dy);
895
+ this.render();
896
+ }
897
+ updateButtons() {
898
+ const n = this.selected.size;
899
+ this.undoBtn.disabled = this.past.length === 0;
900
+ this.redoBtn.disabled = this.future.length === 0;
901
+ this.deleteBtn.disabled = n === 0;
902
+ this.dupBtn.disabled = n === 0;
903
+ this.fwdBtn.disabled = n === 0;
904
+ this.backBtn.disabled = n === 0;
905
+ this.hintEl.hidden = !!this.data.background || this.data.shapes.length > 0;
906
+ this.statusEl.textContent = `${this.data.width}\xD7${this.data.height} \xB7 ${Math.round(this.scale * 100)}%` + (n ? ` \xB7 ${n}` : "");
907
+ }
908
+ // ─── 배경 ───
909
+ async pickBackground() {
910
+ const input = document.createElement("input");
911
+ input.type = "file";
912
+ input.accept = "image/*";
913
+ const file = await new Promise((resolve) => {
914
+ input.addEventListener("change", () => resolve(input.files?.[0] ?? null));
915
+ input.click();
916
+ });
917
+ if (file) await this.setBackground(file);
918
+ this.dialog.focus();
919
+ }
920
+ async setBackground(blob) {
921
+ try {
922
+ const bg = await toBackground(blob);
923
+ this.commit();
924
+ this.data.background = bg.src;
925
+ this.data.width = bg.width;
926
+ this.data.height = bg.height;
927
+ this.applyBackground();
928
+ } catch {
929
+ }
930
+ }
931
+ // ─── 포인터 ───
932
+ toPoint(e) {
933
+ const r = this.svg.getBoundingClientRect();
934
+ const k = r.width > 0 ? this.data.width / r.width : 1;
935
+ return { x: clamp((e.clientX - r.left) * k, 0, this.data.width), y: clamp((e.clientY - r.top) * k, 0, this.data.height) };
936
+ }
937
+ shapeAt(target) {
938
+ const g = target?.closest?.("[data-shape]");
939
+ return g?.dataset["shape"] ?? null;
940
+ }
941
+ handleAt(target) {
942
+ const h = target?.closest?.("[data-handle]");
943
+ return h?.dataset["handle"] ?? null;
944
+ }
945
+ // ─── 글자 인라인 편집 ───
946
+ startTextEdit(shape, isNew) {
947
+ this.commitTextEdit();
948
+ const ta = document.createElement("textarea");
949
+ ta.className = "nd-text-edit";
950
+ ta.value = shape.text;
951
+ ta.rows = 1;
952
+ const place = () => {
953
+ ta.style.left = `${shape.x * this.scale - 2}px`;
954
+ ta.style.top = `${shape.y * this.scale - 2}px`;
955
+ ta.style.fontSize = `${shape.size * this.scale}px`;
956
+ ta.style.color = shape.color;
957
+ const lines = ta.value.split("\n");
958
+ ta.style.width = `${Math.max(40, (Math.max(1, ...lines.map((l) => l.length)) * 0.65 + 1) * shape.size * this.scale)}px`;
959
+ ta.style.height = `${(lines.length * 1.25 + 0.2) * shape.size * this.scale}px`;
960
+ };
961
+ place();
962
+ ta.addEventListener("input", place);
963
+ ta.addEventListener("keydown", (e) => {
964
+ e.stopPropagation();
965
+ if (e.key === "Escape") {
966
+ e.preventDefault();
967
+ this.textEdit = null;
968
+ ta.remove();
969
+ if (!isNew) this.render();
970
+ this.dialog.focus();
971
+ } else if (e.key === "Enter" && !e.shiftKey) {
972
+ e.preventDefault();
973
+ this.commitTextEdit();
974
+ }
975
+ });
976
+ ta.addEventListener("blur", () => this.commitTextEdit());
977
+ this.canvasEl.appendChild(ta);
978
+ this.textEdit = ta;
979
+ ta.__shape = shape;
980
+ ta.__isNew = isNew;
981
+ if (!isNew) {
982
+ this.selected = /* @__PURE__ */ new Set();
983
+ this.render();
984
+ }
985
+ ta.focus();
986
+ ta.select();
987
+ }
988
+ commitTextEdit() {
989
+ const ta = this.textEdit;
990
+ if (!ta) return;
991
+ this.textEdit = null;
992
+ const shape = ta.__shape;
993
+ const text = ta.value.replace(/\s+$/, "");
994
+ ta.remove();
995
+ if (!shape) return;
996
+ if (ta.__isNew) {
997
+ if (text.trim()) {
998
+ this.commit();
999
+ shape.text = text;
1000
+ this.data.shapes.push(shape);
1001
+ this.setTool("select");
1002
+ this.select([shape.id]);
1003
+ }
1004
+ } else if (text.trim() && text !== shape.text) {
1005
+ this.commit();
1006
+ shape.text = text;
1007
+ this.select([shape.id]);
1008
+ } else if (!text.trim()) {
1009
+ this.commit();
1010
+ this.data.shapes = this.data.shapes.filter((s) => s.id !== shape.id);
1011
+ this.select([]);
1012
+ }
1013
+ this.render();
1014
+ this.updateButtons();
1015
+ this.dialog.focus({ preventScroll: true });
1016
+ }
1017
+ // ─── 그리기(SVG) ───
1018
+ render() {
1019
+ while (this.svg.firstChild) this.svg.removeChild(this.svg.firstChild);
1020
+ for (const s of this.data.shapes) this.svg.appendChild(this.renderShape(s));
1021
+ this.renderSelection();
1022
+ if (this.drag?.kind === "rubber") {
1023
+ const d = this.drag;
1024
+ this.svg.appendChild(svgEl("rect", { class: "nd-rubber", x: Math.min(d.from.x, d.to.x), y: Math.min(d.from.y, d.to.y), width: Math.abs(d.to.x - d.from.x), height: Math.abs(d.to.y - d.from.y) }));
1025
+ }
1026
+ }
1027
+ renderShape(s) {
1028
+ const g = document.createElementNS(SVG_NS, "g");
1029
+ g.dataset["shape"] = s.id;
1030
+ g.setAttribute("fill", "none");
1031
+ g.setAttribute("stroke", s.color);
1032
+ g.setAttribute("stroke-width", String(s.stroke));
1033
+ g.setAttribute("stroke-linecap", "round");
1034
+ g.setAttribute("stroke-linejoin", "round");
1035
+ if ((s.opacity ?? 1) !== 1) g.setAttribute("opacity", String(s.opacity));
1036
+ if (s.dashed) g.setAttribute("stroke-dasharray", `${s.stroke * 3} ${s.stroke * 2}`);
1037
+ const hitWidth = Math.max(14, s.stroke * 4);
1038
+ const b = bounds(s);
1039
+ const hit = (elm) => {
1040
+ elm.classList.add("nd-hit");
1041
+ elm.setAttribute("stroke", "transparent");
1042
+ elm.setAttribute("stroke-width", String(hitWidth));
1043
+ elm.setAttribute("stroke-dasharray", "none");
1044
+ return elm;
1045
+ };
1046
+ switch (s.type) {
1047
+ case "rect": {
1048
+ const r = Math.min(s.radius ?? 0, b.w / 2, b.h / 2);
1049
+ const shape = svgEl("rect", { x: b.x, y: b.y, width: b.w, height: b.h, rx: r });
1050
+ if (fillOpacity(s) > 0) {
1051
+ shape.setAttribute("fill", s.color);
1052
+ shape.setAttribute("fill-opacity", String(fillOpacity(s)));
1053
+ }
1054
+ const h = hit(svgEl("rect", { x: b.x, y: b.y, width: b.w, height: b.h, rx: r, fill: fillOpacity(s) > 0 ? s.color : "none", "fill-opacity": 0 }));
1055
+ g.append(h, shape);
1056
+ break;
1057
+ }
1058
+ case "ellipse": {
1059
+ const attrs = { cx: b.x + b.w / 2, cy: b.y + b.h / 2, rx: b.w / 2, ry: b.h / 2 };
1060
+ const shape = svgEl("ellipse", attrs);
1061
+ if (fillOpacity(s) > 0) {
1062
+ shape.setAttribute("fill", s.color);
1063
+ shape.setAttribute("fill-opacity", String(fillOpacity(s)));
1064
+ }
1065
+ g.append(hit(svgEl("ellipse", { ...attrs, fill: fillOpacity(s) > 0 ? s.color : "none", "fill-opacity": 0 })), shape);
1066
+ break;
1067
+ }
1068
+ case "line":
1069
+ case "arrow":
1070
+ case "arrow2": {
1071
+ g.appendChild(hit(svgEl("line", { x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 })));
1072
+ g.appendChild(svgEl("line", { x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 }));
1073
+ const heads = [];
1074
+ if (s.type !== "line") heads.push(arrowHead(s.x1, s.y1, s.x2, s.y2, s.stroke));
1075
+ if (s.type === "arrow2") heads.push(arrowHead(s.x2, s.y2, s.x1, s.y1, s.stroke));
1076
+ for (const h of heads) {
1077
+ const poly = svgEl("polygon", { points: h.map((p) => p.join(",")).join(" "), fill: s.color, stroke: "none" });
1078
+ g.appendChild(poly);
1079
+ }
1080
+ break;
1081
+ }
1082
+ case "pen":
1083
+ case "highlighter": {
1084
+ const pts = [];
1085
+ for (let i = 0; i + 1 < s.points.length; i += 2) pts.push(`${s.points[i]},${s.points[i + 1]}`);
1086
+ const line = svgEl("polyline", { points: pts.join(" ") });
1087
+ if (s.type === "highlighter") {
1088
+ line.setAttribute("stroke-width", String(s.stroke * 4));
1089
+ line.setAttribute("stroke-opacity", String(HIGHLIGHTER_ALPHA));
1090
+ line.setAttribute("stroke-linecap", "butt");
1091
+ }
1092
+ g.append(hit(svgEl("polyline", { points: pts.join(" ") })), line);
1093
+ break;
1094
+ }
1095
+ case "text": {
1096
+ g.appendChild(hit(svgEl("rect", { x: b.x - 4, y: b.y - 4, width: b.w + 8, height: b.h + 8, fill: "transparent" })));
1097
+ const t = svgEl("text", { x: s.x, y: s.y, "font-size": s.size, "font-weight": "bold", "paint-order": "stroke", "stroke-width": Math.max(3, s.size / 6), "dominant-baseline": "hanging", fill: s.color, stroke: "#ffffff", "font-family": FONT });
1098
+ s.text.split("\n").forEach((line, i) => {
1099
+ const span = svgEl("tspan", { x: s.x, dy: i === 0 ? 0 : s.size * 1.25 });
1100
+ span.textContent = line || " ";
1101
+ t.appendChild(span);
1102
+ });
1103
+ g.appendChild(t);
1104
+ break;
1105
+ }
1106
+ case "badge": {
1107
+ g.appendChild(hit(svgEl("circle", { cx: s.x, cy: s.y, r: s.size + 4, fill: "transparent" })));
1108
+ g.appendChild(svgEl("circle", { cx: s.x, cy: s.y, r: s.size, fill: s.color, stroke: "#ffffff", "stroke-width": 2 }));
1109
+ const t = svgEl("text", { x: s.x, y: s.y + s.size * 0.05, "font-size": Math.round(s.size * 1.2), "font-weight": "bold", "text-anchor": "middle", "dominant-baseline": "central", fill: "#ffffff", stroke: "none", "font-family": FONT });
1110
+ t.textContent = String(s.n);
1111
+ g.appendChild(t);
1112
+ break;
1113
+ }
1114
+ }
1115
+ return g;
1116
+ }
1117
+ /** 고른 도형의 점선 상자 + 손잡이(하나만 골랐을 때: 상자 8개 / 선 양 끝) */
1118
+ renderSelection() {
1119
+ const shapes = this.selectedShapes();
1120
+ if (!shapes.length) return;
1121
+ const sel = document.createElementNS(SVG_NS, "g");
1122
+ sel.classList.add("nd-selection");
1123
+ const pad = 6;
1124
+ for (const s of shapes) {
1125
+ const b = bounds(s);
1126
+ sel.appendChild(svgEl("rect", { x: b.x - pad, y: b.y - pad, width: b.w + pad * 2, height: b.h + pad * 2, fill: "none", stroke: "#4a90e2", "stroke-width": 1.5, "stroke-dasharray": "6 4", "vector-effect": "non-scaling-stroke" }));
1127
+ }
1128
+ if (shapes.length === 1) {
1129
+ const s = shapes[0];
1130
+ const b = bounds(s);
1131
+ const handles = isLineLike(s) && isBoxShape(s) ? [["p1", s.x1, s.y1], ["p2", s.x2, s.y2]] : s.type === "rect" || s.type === "ellipse" ? [["nw", b.x, b.y], ["n", b.x + b.w / 2, b.y], ["ne", b.x + b.w, b.y], ["e", b.x + b.w, b.y + b.h / 2], ["se", b.x + b.w, b.y + b.h], ["s", b.x + b.w / 2, b.y + b.h], ["sw", b.x, b.y + b.h], ["w", b.x, b.y + b.h / 2]] : [];
1132
+ const r = 6 / Math.max(this.scale, 0.2);
1133
+ for (const [h, x, y] of handles) {
1134
+ const c = svgEl("circle", { cx: x, cy: y, r, fill: "#ffffff", stroke: "#4a90e2", "stroke-width": 2, "vector-effect": "non-scaling-stroke", class: "nd-handle" });
1135
+ c.dataset["handle"] = h;
1136
+ sel.appendChild(c);
1137
+ }
1138
+ }
1139
+ this.svg.appendChild(sel);
1140
+ }
1141
+ // ─── 끝내기 ───
1142
+ cancel() {
1143
+ if (this.saving) return;
1144
+ this.commitTextEdit();
1145
+ if (this.dirty && !window.confirm(this.m.discardConfirm)) {
1146
+ this.dialog.focus();
1147
+ return;
1148
+ }
1149
+ this.close(null);
1150
+ }
1151
+ async finish() {
1152
+ if (this.saving) return;
1153
+ this.commitTextEdit();
1154
+ this.saving = true;
1155
+ this.doneBtn.disabled = true;
1156
+ this.doneBtn.textContent = this.m.saving;
1157
+ try {
1158
+ const png = await renderToPng(this.data);
1159
+ const saved = this.opts.save ? await this.opts.save(png, this.data) : void 0;
1160
+ this.close({ data: this.data, png, saved: saved ?? void 0 });
1161
+ } catch (e) {
1162
+ this.saving = false;
1163
+ this.doneBtn.disabled = false;
1164
+ this.doneBtn.textContent = this.m.done;
1165
+ window.alert(this.m.saveFailed(e instanceof Error ? e.message : String(e)));
1166
+ this.dialog.focus();
1167
+ }
1168
+ }
1169
+ close(result) {
1170
+ window.removeEventListener("resize", this.onResize);
1171
+ window.removeEventListener("pointermove", this.onPointerMove);
1172
+ window.removeEventListener("pointerup", this.onPointerUp);
1173
+ window.removeEventListener("pointercancel", this.onPointerUp);
1174
+ this.host.remove();
1175
+ this.done(result);
1176
+ }
1177
+ };
1178
+ function el(tag, className) {
1179
+ const e = document.createElement(tag);
1180
+ e.className = className;
1181
+ return e;
1182
+ }
1183
+ function svgEl(tag, attrs) {
1184
+ const e = document.createElementNS(SVG_NS, tag);
1185
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
1186
+ return e;
1187
+ }
1188
+ function iconBtn(html, title, onClick) {
1189
+ const b = document.createElement("button");
1190
+ b.type = "button";
1191
+ b.className = "nd-btn";
1192
+ b.innerHTML = html;
1193
+ b.title = title;
1194
+ b.setAttribute("aria-label", title);
1195
+ b.addEventListener("click", onClick);
1196
+ return b;
1197
+ }
1198
+ function textBtn(label, onClick) {
1199
+ const b = iconBtn("", label, onClick);
1200
+ b.textContent = label;
1201
+ b.classList.add("nd-btn--text");
1202
+ return b;
1203
+ }
1204
+ function range(min, max, step, title, onInput) {
1205
+ const r = document.createElement("input");
1206
+ r.type = "range";
1207
+ r.min = String(min);
1208
+ r.max = String(max);
1209
+ r.step = String(step);
1210
+ r.className = "nd-range";
1211
+ r.title = title;
1212
+ r.setAttribute("aria-label", title);
1213
+ r.addEventListener("input", onInput);
1214
+ r.addEventListener("pointerdown", (e) => e.stopPropagation());
1215
+ return r;
1216
+ }
1217
+ function openDrawing(opts = {}) {
1218
+ return new DrawingBoard(opts).open();
1219
+ }
1220
+
1221
+ // src/index.ts
1222
+ var VERSION = "0.1.0";
1223
+
1224
+ exports.DrawingBoard = DrawingBoard;
1225
+ exports.VERSION = VERSION;
1226
+ exports.blobToDataURL = blobToDataURL;
1227
+ exports.bounds = bounds;
1228
+ exports.emptyDrawing = emptyDrawing;
1229
+ exports.loadImage = loadImage;
1230
+ exports.messagesEn = en;
1231
+ exports.messagesKo = ko;
1232
+ exports.openDrawing = openDrawing;
1233
+ exports.renderToPng = renderToPng;
1234
+ //# sourceMappingURL=index.cjs.map
1235
+ //# sourceMappingURL=index.cjs.map