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