html-overlay-node 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.
@@ -0,0 +1,606 @@
1
+ import { hitTestNode, portRect } from "./hitTest.js";
2
+
3
+ export class CanvasRenderer {
4
+ static FONT_SIZE = 12;
5
+ static SELECTED_NODE_COLOR = "#6cf";
6
+ constructor(canvas, { theme = {}, registry, edgeStyle = "orthogonal" } = {}) {
7
+ this.canvas = canvas;
8
+ this.ctx = canvas.getContext("2d");
9
+ this.registry = registry; // to call per-node onDraw
10
+
11
+ // viewport transform
12
+ this.scale = 1;
13
+ this.minScale = 0.25;
14
+ this.maxScale = 3;
15
+ this.offsetX = 0;
16
+ this.offsetY = 0;
17
+
18
+ // 'bezier' | 'line' | 'orthogonal'
19
+ this.edgeStyle = edgeStyle;
20
+
21
+ this.theme = Object.assign(
22
+ {
23
+ bg: "#0d0d0f", // Darker background
24
+ grid: "#1a1a1d", // Subtle grid
25
+ node: "#16161a", // Darker nodes
26
+ nodeBorder: "#2a2a2f", // Subtle border
27
+ title: "#1f1f24", // Darker header
28
+ text: "#e4e4e7", // Softer white
29
+ textMuted: "#a1a1aa", // Muted text
30
+ port: "#6366f1", // Indigo for data ports
31
+ portExec: "#10b981", // Emerald for exec ports
32
+ edge: "#52525b", // Neutral edge color
33
+ edgeActive: "#8b5cf6", // Purple for active
34
+ accent: "#6366f1", // Indigo accent
35
+ accentBright: "#818cf8", // Brighter accent
36
+ },
37
+ theme
38
+ );
39
+ }
40
+ setEdgeStyle(style) {
41
+ this.edgeStyle =
42
+ style === "line" || style === "orthogonal" ? style : "bezier";
43
+ }
44
+ setRegistry(reg) {
45
+ this.registry = reg;
46
+ }
47
+ resize(w, h) {
48
+ this.canvas.width = w;
49
+ this.canvas.height = h;
50
+ }
51
+ setTransform({
52
+ scale = this.scale,
53
+ offsetX = this.offsetX,
54
+ offsetY = this.offsetY,
55
+ } = {}) {
56
+ this.scale = Math.min(this.maxScale, Math.max(this.minScale, scale));
57
+ this.offsetX = offsetX;
58
+ this.offsetY = offsetY;
59
+ }
60
+ panBy(dx, dy) {
61
+ this.offsetX += dx;
62
+ this.offsetY += dy;
63
+ }
64
+ zoomAt(factor, cx, cy) {
65
+ // factor > 1 zoom in, < 1 zoom out, centered at screen point (cx, cy)
66
+ const prev = this.scale;
67
+ const next = Math.min(
68
+ this.maxScale,
69
+ Math.max(this.minScale, prev * factor)
70
+ );
71
+ if (next === prev) return;
72
+ // keep the world point under cursor fixed: adjust offset
73
+ const wx = (cx - this.offsetX) / prev;
74
+ const wy = (cy - this.offsetY) / prev;
75
+ this.offsetX = cx - wx * next;
76
+ this.offsetY = cy - wy * next;
77
+ this.scale = next;
78
+ }
79
+
80
+ screenToWorld(x, y) {
81
+ return {
82
+ x: (x - this.offsetX) / this.scale,
83
+ y: (y - this.offsetY) / this.scale,
84
+ };
85
+ }
86
+ worldToScreen(x, y) {
87
+ return {
88
+ x: x * this.scale + this.offsetX,
89
+ y: y * this.scale + this.offsetY,
90
+ };
91
+ }
92
+ _applyTransform() {
93
+ const { ctx } = this;
94
+ ctx.setTransform(this.scale, 0, 0, this.scale, this.offsetX, this.offsetY);
95
+ }
96
+ _resetTransform() {
97
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
98
+ }
99
+
100
+ // ── Drawing ────────────────────────────────────────────────────────────────
101
+ _drawArrowhead(x1, y1, x2, y2, size = 10) {
102
+ const { ctx } = this;
103
+ const s = size / this.scale; // 줌에 따라 크기 보정
104
+ const ang = Math.atan2(y2 - y1, x2 - x1);
105
+
106
+ ctx.beginPath();
107
+ ctx.moveTo(x2, y2);
108
+ ctx.lineTo(
109
+ x2 - s * Math.cos(ang - Math.PI / 6),
110
+ y2 - s * Math.sin(ang - Math.PI / 6)
111
+ );
112
+ ctx.lineTo(
113
+ x2 - s * Math.cos(ang + Math.PI / 6),
114
+ y2 - s * Math.sin(ang + Math.PI / 6)
115
+ );
116
+ ctx.closePath();
117
+ ctx.fill(); // 선 색상과 동일한 fill이 자연스러움
118
+ }
119
+
120
+ _drawScreenText(
121
+ text,
122
+ lx,
123
+ ly,
124
+ {
125
+ fontPx = 12,
126
+ color = this.theme.text,
127
+ align = "left",
128
+ baseline = "alphabetic",
129
+ dpr = 1, // 추후 devicePixelRatio 도입
130
+ } = {}
131
+ ) {
132
+ const { ctx } = this;
133
+ const { x: sx, y: sy } = this.worldToScreen(lx, ly);
134
+
135
+ ctx.save();
136
+ // 화면 좌표계(스케일=1)로 리셋
137
+ this._resetTransform();
138
+
139
+ // 픽셀 스냅(번짐 방지)
140
+ const px = Math.round(sx) + 0.5;
141
+ const py = Math.round(sy) + 0.5;
142
+
143
+ ctx.font = `${fontPx * this.scale}px system-ui`;
144
+ ctx.fillStyle = color;
145
+ ctx.textAlign = align;
146
+ ctx.textBaseline = baseline;
147
+ ctx.fillText(text, px, py);
148
+ ctx.restore();
149
+ }
150
+
151
+ drawGrid() {
152
+ const { ctx, canvas, theme, scale, offsetX, offsetY } = this;
153
+ // clear screen in screen space
154
+
155
+ this._resetTransform();
156
+ ctx.fillStyle = theme.bg;
157
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
158
+
159
+ // draw grid in world space so it pans/zooms
160
+ this._applyTransform();
161
+ // Make grid subtle but visible
162
+ ctx.strokeStyle = this._rgba(theme.grid, 0.35); // Subtle but visible
163
+ ctx.lineWidth = 1 / scale; // keep 1px apparent
164
+
165
+ const base = 20; // world units
166
+ const step = base;
167
+
168
+ // visible world bounds
169
+ const x0 = -offsetX / scale;
170
+ const y0 = -offsetY / scale;
171
+ const x1 = (canvas.width - offsetX) / scale;
172
+ const y1 = (canvas.height - offsetY) / scale;
173
+
174
+ const startX = Math.floor(x0 / step) * step;
175
+ const startY = Math.floor(y0 / step) * step;
176
+
177
+ ctx.beginPath();
178
+ for (let x = startX; x <= x1; x += step) {
179
+ ctx.moveTo(x, y0);
180
+ ctx.lineTo(x, y1);
181
+ }
182
+ for (let y = startY; y <= y1; y += step) {
183
+ ctx.moveTo(x0, y);
184
+ ctx.lineTo(x1, y);
185
+ }
186
+ ctx.stroke();
187
+
188
+ this._resetTransform();
189
+ }
190
+
191
+ draw(
192
+ graph,
193
+ {
194
+ selection = new Set(),
195
+ tempEdge = null,
196
+ running = false,
197
+ time = performance.now(),
198
+ dt = 0,
199
+ groups = null,
200
+ activeEdges = new Set(),
201
+ } = {}
202
+ ) {
203
+ // Update transforms first
204
+ graph.updateWorldTransforms();
205
+
206
+ this.drawGrid();
207
+ const { ctx, theme } = this;
208
+ this._applyTransform();
209
+
210
+ ctx.save();
211
+
212
+ // 1. Draw Groups (Backgrounds)
213
+ for (const n of graph.nodes.values()) {
214
+ if (n.type === "core/Group") {
215
+ const sel = selection.has(n.id);
216
+ const def = this.registry?.types?.get(n.type);
217
+ if (def?.onDraw) def.onDraw(n, { ctx, theme });
218
+ else this._drawNode(n, sel);
219
+ }
220
+ }
221
+
222
+ // 2. Draw Edges (BEFORE nodes so ports appear on top)
223
+ ctx.lineWidth = 1.5 / this.scale; // Thinner, more refined edges
224
+
225
+ // Calculate animation values if running
226
+ let dashArray = null;
227
+ let dashOffset = 0;
228
+ if (running) {
229
+ const speed = 120;
230
+ const phase = (((time / 1000) * speed) / this.scale) % CanvasRenderer.FONT_SIZE;
231
+ dashArray = [6 / this.scale, 6 / this.scale];
232
+ dashOffset = -phase;
233
+ }
234
+
235
+ for (const e of graph.edges.values()) {
236
+ const shouldAnimate = activeEdges && activeEdges.size > 0 && activeEdges.has(e.id);
237
+
238
+ if (running && shouldAnimate && dashArray) {
239
+ ctx.setLineDash(dashArray);
240
+ ctx.lineDashOffset = dashOffset;
241
+ } else {
242
+ ctx.setLineDash([]);
243
+ ctx.lineDashOffset = 0;
244
+ }
245
+
246
+ const isActive = activeEdges && activeEdges.has(e.id);
247
+ if (isActive) {
248
+ ctx.strokeStyle = "#00ffff";
249
+ ctx.lineWidth = 3 * this.scale;
250
+ } else {
251
+ ctx.strokeStyle = theme.edge;
252
+ ctx.lineWidth = 1.5 / this.scale;
253
+ }
254
+ this._drawEdge(graph, e);
255
+ }
256
+
257
+ // temp edge preview
258
+ if (tempEdge) {
259
+ const a = this.screenToWorld(tempEdge.x1, tempEdge.y1);
260
+ const b = this.screenToWorld(tempEdge.x2, tempEdge.y2);
261
+
262
+ const prevDash = this.ctx.getLineDash();
263
+ this.ctx.setLineDash([6 / this.scale, 6 / this.scale]);
264
+
265
+ let ptsForArrow = null;
266
+ if (this.edgeStyle === "line") {
267
+ this._drawLine(a.x, a.y, b.x, b.y);
268
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
269
+ } else if (this.edgeStyle === "orthogonal") {
270
+ ptsForArrow = this._drawOrthogonal(a.x, a.y, b.x, b.y);
271
+ } else {
272
+ this._drawCurve(a.x, a.y, b.x, b.y);
273
+ ptsForArrow = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
274
+ }
275
+
276
+ this.ctx.setLineDash(prevDash);
277
+
278
+ if (ptsForArrow && ptsForArrow.length >= 2) {
279
+ const p1 = ptsForArrow[ptsForArrow.length - 2];
280
+ const p2 = ptsForArrow[ptsForArrow.length - 1];
281
+ this.ctx.fillStyle = this.theme.edge;
282
+ this.ctx.strokeStyle = this.theme.edge;
283
+ this._drawArrowhead(p1.x, p1.y, p2.x, p2.y, 12);
284
+ }
285
+ }
286
+
287
+ // 3. Draw Other Nodes (AFTER edges)
288
+ // For nodes with HTML overlays, skip ports initially
289
+ for (const n of graph.nodes.values()) {
290
+ if (n.type !== "core/Group") {
291
+ const sel = selection.has(n.id);
292
+ const def = this.registry?.types?.get(n.type);
293
+ const hasHtmlOverlay = !!(def?.html);
294
+
295
+ // Draw node, but skip ports if it has HTML overlay
296
+ this._drawNode(n, sel, hasHtmlOverlay);
297
+ if (def?.onDraw) def.onDraw(n, { ctx, theme });
298
+ }
299
+ }
300
+
301
+ // 4. Draw ports for HTML overlay nodes LAST (so they appear above HTML)
302
+ for (const n of graph.nodes.values()) {
303
+ if (n.type !== "core/Group") {
304
+ const def = this.registry?.types?.get(n.type);
305
+ const hasHtmlOverlay = !!(def?.html);
306
+
307
+ if (hasHtmlOverlay) {
308
+ this._drawPorts(n);
309
+ }
310
+ }
311
+ }
312
+
313
+ this._resetTransform();
314
+ }
315
+
316
+ _rgba(hex, a) {
317
+ const c = hex.replace("#", "");
318
+ const n = parseInt(
319
+ c.length === 3
320
+ ? c
321
+ .split("")
322
+ .map((x) => x + x)
323
+ .join("")
324
+ : c,
325
+ 16
326
+ );
327
+ const r = (n >> 16) & 255,
328
+ g = (n >> 8) & 255,
329
+ b = n & 255;
330
+ return `rgba(${r},${g},${b},${a})`;
331
+ }
332
+
333
+ _drawNode(node, selected, skipPorts = false) {
334
+ const { ctx, theme } = this;
335
+ const r = 8;
336
+ const { x, y, w, h } = node.computed;
337
+
338
+ // Draw subtle shadow
339
+ if (!selected) {
340
+ ctx.save();
341
+ ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
342
+ ctx.shadowBlur = 8 / this.scale;
343
+ ctx.shadowOffsetY = 2 / this.scale;
344
+ ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
345
+ roundRect(ctx, x, y, w, h, r);
346
+ ctx.fill();
347
+ ctx.restore();
348
+ }
349
+
350
+ // Draw main body
351
+ ctx.fillStyle = theme.node;
352
+ ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
353
+ ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
354
+ roundRect(ctx, x, y, w, h, r);
355
+ ctx.fill();
356
+ ctx.stroke();
357
+
358
+ // Draw header
359
+ ctx.fillStyle = theme.title;
360
+ roundRect(ctx, x, y, w, 24, { tl: r, tr: r, br: 0, bl: 0 });
361
+ ctx.fill();
362
+
363
+ // Header border (only top and sides)
364
+ ctx.strokeStyle = selected ? theme.accentBright : theme.nodeBorder;
365
+ ctx.lineWidth = (selected ? 1.5 : 1) / this.scale;
366
+ ctx.beginPath();
367
+ // Top-left corner to top-right corner
368
+ ctx.moveTo(x + r, y);
369
+ ctx.lineTo(x + w - r, y);
370
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
371
+ // Right side down to header bottom
372
+ ctx.lineTo(x + w, y + 24);
373
+ // Move to left side header bottom
374
+ ctx.moveTo(x, y + 24);
375
+ // Left side up to top-left corner
376
+ ctx.lineTo(x, y + r);
377
+ ctx.quadraticCurveTo(x, y, x + r, y);
378
+ ctx.stroke();
379
+
380
+ this._drawScreenText(node.title, x + 8, y + CanvasRenderer.FONT_SIZE, {
381
+ fontPx: CanvasRenderer.FONT_SIZE,
382
+ color: theme.text,
383
+ baseline: "middle",
384
+ align: "left",
385
+ });
386
+
387
+ // Skip port drawing if requested (for HTML overlay nodes)
388
+ if (skipPorts) return;
389
+
390
+ // Draw input ports
391
+ node.inputs.forEach((p, i) => {
392
+ const rct = portRect(node, p, i, "in");
393
+ const cx = rct.x + rct.w / 2;
394
+ const cy = rct.y + rct.h / 2;
395
+
396
+ if (p.portType === "exec") {
397
+ // Draw exec port - rounded square
398
+ const portSize = 8;
399
+ ctx.fillStyle = theme.portExec;
400
+ ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
401
+ ctx.lineWidth = 2 / this.scale;
402
+ ctx.beginPath();
403
+ ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
404
+ ctx.fill();
405
+ ctx.stroke();
406
+ } else {
407
+ // Draw data port - circle with outline
408
+ ctx.fillStyle = theme.port;
409
+ ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
410
+ ctx.lineWidth = 2 / this.scale;
411
+ ctx.beginPath();
412
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
413
+ ctx.fill();
414
+ ctx.stroke();
415
+ }
416
+ });
417
+
418
+ // Draw output ports
419
+ node.outputs.forEach((p, i) => {
420
+ const rct = portRect(node, p, i, "out");
421
+ const cx = rct.x + rct.w / 2;
422
+ const cy = rct.y + rct.h / 2;
423
+
424
+ if (p.portType === "exec") {
425
+ // Draw exec port - rounded square
426
+ const portSize = 8;
427
+ ctx.fillStyle = theme.portExec;
428
+ ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
429
+ ctx.lineWidth = 2 / this.scale;
430
+ ctx.beginPath();
431
+ ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
432
+ ctx.fill();
433
+ ctx.stroke();
434
+ } else {
435
+ // Draw data port - circle with outline
436
+ ctx.fillStyle = theme.port;
437
+ ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
438
+ ctx.lineWidth = 2 / this.scale;
439
+ ctx.beginPath();
440
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
441
+ ctx.fill();
442
+ ctx.stroke();
443
+ }
444
+ });
445
+ }
446
+
447
+ _drawPorts(node) {
448
+ const { ctx, theme } = this;
449
+
450
+ // Draw input ports
451
+ node.inputs.forEach((p, i) => {
452
+ const rct = portRect(node, p, i, "in");
453
+ const cx = rct.x + rct.w / 2;
454
+ const cy = rct.y + rct.h / 2;
455
+
456
+ if (p.portType === "exec") {
457
+ // Draw exec port - rounded square with subtle glow
458
+ const portSize = 8;
459
+ ctx.fillStyle = theme.portExec;
460
+ ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
461
+ ctx.lineWidth = 2 / this.scale;
462
+ ctx.beginPath();
463
+ ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
464
+ ctx.fill();
465
+ ctx.stroke();
466
+ } else {
467
+ // Draw data port - circle with subtle outline
468
+ ctx.fillStyle = theme.port;
469
+ ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
470
+ ctx.lineWidth = 2 / this.scale;
471
+ ctx.beginPath();
472
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
473
+ ctx.fill();
474
+ }
475
+ });
476
+
477
+ // Draw output ports
478
+ node.outputs.forEach((p, i) => {
479
+ const rct = portRect(node, p, i, "out");
480
+ const cx = rct.x + rct.w / 2;
481
+ const cy = rct.y + rct.h / 2;
482
+
483
+ if (p.portType === "exec") {
484
+ // Draw exec port - rounded square
485
+ const portSize = 8;
486
+ ctx.fillStyle = theme.portExec;
487
+ ctx.strokeStyle = "rgba(16, 185, 129, 0.3)";
488
+ ctx.lineWidth = 2 / this.scale;
489
+ ctx.beginPath();
490
+ ctx.roundRect(cx - portSize / 2, cy - portSize / 2, portSize, portSize, 2);
491
+ ctx.fill();
492
+ ctx.stroke();
493
+ } else {
494
+ // Draw data port - circle with outline
495
+ ctx.fillStyle = theme.port;
496
+ ctx.strokeStyle = "rgba(99, 102, 241, 0.3)";
497
+ ctx.lineWidth = 2 / this.scale;
498
+ ctx.beginPath();
499
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
500
+ ctx.fill();
501
+ ctx.stroke();
502
+ }
503
+ });
504
+ }
505
+
506
+ _drawEdge(graph, e) {
507
+ const from = graph.nodes.get(e.fromNode);
508
+ const to = graph.nodes.get(e.toNode);
509
+ if (!from || !to) return;
510
+ const iOut = from.outputs.findIndex((p) => p.id === e.fromPort);
511
+ const iIn = to.inputs.findIndex((p) => p.id === e.toPort);
512
+ const pr1 = portRect(from, null, iOut, "out");
513
+ const pr2 = portRect(to, null, iIn, "in");
514
+ const x1 = pr1.x,
515
+ y1 = pr1.y + 7,
516
+ x2 = pr2.x,
517
+ y2 = pr2.y + 7;
518
+ if (this.edgeStyle === "line") {
519
+ this._drawLine(x1, y1, x2, y2);
520
+ } else if (this.edgeStyle === "orthogonal") {
521
+ this._drawOrthogonal(x1, y1, x2, y2);
522
+ } else {
523
+ this._drawCurve(x1, y1, x2, y2); // bezier (기존)
524
+ }
525
+ }
526
+
527
+ _drawLine(x1, y1, x2, y2) {
528
+ const { ctx } = this;
529
+ ctx.beginPath();
530
+ ctx.moveTo(x1, y1);
531
+ ctx.lineTo(x2, y2);
532
+ ctx.stroke();
533
+ }
534
+
535
+ _drawPolyline(points) {
536
+ const { ctx } = this;
537
+ ctx.beginPath();
538
+ ctx.moveTo(points[0].x, points[0].y);
539
+ for (let i = 1; i < points.length; i++)
540
+ ctx.lineTo(points[i].x, points[i].y);
541
+ ctx.stroke();
542
+ }
543
+
544
+ _drawOrthogonal(x1, y1, x2, y2) {
545
+ const dx = Math.abs(x2 - x1);
546
+ const dy = Math.abs(y2 - y1);
547
+ // 중간 축을 결정 (더 짧은 축을 가운데에 두면 보기 좋음)
548
+ const useHVH = true; // 가로-세로-가로(HVH) vs 세로-가로-세로(VHV)
549
+ const midX = (x1 + x2) / 2;
550
+ const midY = (y1 + y2) / 2;
551
+
552
+ let pts;
553
+ if (useHVH) {
554
+ // x1,y1 → midX,y1 → midX,y2 → x2,y2
555
+ pts = [
556
+ { x: x1, y: y1 },
557
+ { x: midX, y: y1 },
558
+ { x: midX, y: y2 },
559
+ { x: x2, y: y2 },
560
+ ];
561
+ }
562
+ // else {
563
+ // // x1,y1 → x1,midY → x2,midY → x2,y2
564
+ // pts = [
565
+ // { x: x1, y: y1 },
566
+ // { x: x1, y: midY },
567
+ // { x: x2, y: midY },
568
+ // { x: x2, y: y2 },
569
+ // ];
570
+ // }
571
+
572
+ // 라운드 코너
573
+ const { ctx } = this;
574
+ const prevJoin = ctx.lineJoin,
575
+ prevCap = ctx.lineCap;
576
+ ctx.lineJoin = "round";
577
+ ctx.lineCap = "round";
578
+ this._drawPolyline(pts);
579
+ ctx.lineJoin = prevJoin;
580
+ ctx.lineCap = prevCap;
581
+
582
+ return pts; // 화살표 각도 계산에 사용
583
+ }
584
+ _drawCurve(x1, y1, x2, y2) {
585
+ const { ctx } = this;
586
+ const dx = Math.max(40, Math.abs(x2 - x1) * 0.4);
587
+ ctx.beginPath();
588
+ ctx.moveTo(x1, y1);
589
+ ctx.bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
590
+ ctx.stroke();
591
+ }
592
+ }
593
+ function roundRect(ctx, x, y, w, h, r = 6) {
594
+ if (typeof r === "number") r = { tl: r, tr: r, br: r, bl: r };
595
+ ctx.beginPath();
596
+ ctx.moveTo(x + r.tl, y);
597
+ ctx.lineTo(x + w - r.tr, y);
598
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
599
+ ctx.lineTo(x + w, y + h - r.br);
600
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
601
+ ctx.lineTo(x + r.bl, y + h);
602
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
603
+ ctx.lineTo(x, y + r.tl);
604
+ ctx.quadraticCurveTo(x, y, x + r.tl, y);
605
+ ctx.closePath();
606
+ }