canvu-react 0.4.31 → 0.4.33

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/react.cjs CHANGED
@@ -56,11 +56,161 @@ var init_custom_shape = __esm({
56
56
  }
57
57
  });
58
58
 
59
+ // src/scene/link-item.ts
60
+ function buildLinkCardSvg(width, _height, link) {
61
+ const cardWidth = Math.max(1, width);
62
+ const scale = cardWidth / DEFAULT_LINK_CARD_WIDTH;
63
+ const contentWidth = DEFAULT_LINK_CARD_WIDTH;
64
+ const contentHeight = DEFAULT_LINK_CARD_HEIGHT;
65
+ const padding = 14;
66
+ const badgeSize = 42;
67
+ const gap = 13;
68
+ const buttonSize = 34;
69
+ const textX = padding + badgeSize + gap;
70
+ const textWidth = Math.max(1, contentWidth - textX - buttonSize - gap - padding);
71
+ const hostname = getLinkHostname(link.href);
72
+ const title = link.title?.trim() || hostname || "Link";
73
+ const protocol = getLinkProtocol(link.href);
74
+ const subtitle = hostname || link.href;
75
+ const idSuffix = getStableLinkIdSuffix(`${hostname}:${link.href}`);
76
+ const gradientId = `canvu-link-favicon-gradient-${idSuffix}`;
77
+ const buttonX = contentWidth - padding - buttonSize;
78
+ const buttonY = (contentHeight - buttonSize) / 2;
79
+ const isSecure = protocol === "https:";
80
+ const subtitleX = isSecure ? textX + 13 : textX;
81
+ const subtitleWidth = isSecure ? textWidth - 13 : textWidth;
82
+ return `
83
+ <style>
84
+ .canvu-link-card-root .canvu-link-card { transition: transform .18s ease, filter .18s ease, stroke .18s ease; }
85
+ .canvu-link-card-root .canvu-link-open { opacity: 0; transform: translateX(-4px); transition: opacity .18s ease, transform .18s ease; }
86
+ .canvu-link-card-root:hover .canvu-link-card { transform: translateY(-2px); filter: drop-shadow(0 4px 14px oklch(0.4 0.05 265 / .08)) drop-shadow(0 1px 3px oklch(0.4 0.05 265 / .06)); stroke: ${LINK_CARD_BORDER_STRONG}; }
87
+ .canvu-link-card-root:hover .canvu-link-open { opacity: 1; transform: translateX(0); }
88
+ </style>
89
+ <g class="canvu-link-card-root" transform="scale(${formatNumber(scale)})">
90
+ <rect class="canvu-link-card" width="${formatNumber(contentWidth)}" height="${formatNumber(contentHeight)}" rx="16" fill="#ffffff" stroke="${LINK_CARD_BORDER}" stroke-width="1" filter="drop-shadow(0 1px 2px oklch(0.4 0.03 265 / .05)) drop-shadow(0 1px 1px oklch(0.4 0.03 265 / .04))" />
91
+ <defs>
92
+ <linearGradient id="${gradientId}" x1="8" y1="48" x2="48" y2="8" gradientUnits="userSpaceOnUse">
93
+ <stop stop-color="${LINK_CARD_ACCENT}" />
94
+ <stop offset="1" stop-color="${LINK_CARD_ACCENT_DEEP}" />
95
+ </linearGradient>
96
+ </defs>
97
+ <rect x="${formatNumber(padding)}" y="${formatNumber(padding)}" width="${formatNumber(badgeSize)}" height="${formatNumber(badgeSize)}" rx="11" fill="url(#${gradientId})" />
98
+ <text x="${formatNumber(padding + badgeSize / 2)}" y="${formatNumber(padding + badgeSize / 2 + 5)}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="17" font-weight="700" fill="#ffffff">${escapeHtmlText(getLinkInitial(hostname))}</text>
99
+ ${buildLinkTextBand({ x: textX, y: 16, width: textWidth, height: 19, text: title, fontSize: 14.5, color: LINK_CARD_TITLE_COLOR, fontWeight: 700 })}
100
+ ${isSecure ? `<g transform="translate(${formatNumber(textX)},40)" stroke="${LINK_CARD_TEXT_COLOR}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none"><rect x="1.5" y="4.5" width="7" height="6" rx="1" /><path d="M3 4.5 V3 a2 2 0 0 1 4 0 v1.5" /></g>` : ""}
101
+ ${buildLinkTextBand({ x: subtitleX, y: 36, width: subtitleWidth, height: 17, text: subtitle, fontSize: 12.5, color: LINK_CARD_TEXT_COLOR })}
102
+ <g class="canvu-link-open" transform="translate(${formatNumber(buttonX)},${formatNumber(buttonY)})">
103
+ <rect width="${formatNumber(buttonSize)}" height="${formatNumber(buttonSize)}" rx="10" fill="#ffffff" stroke="${LINK_CARD_BORDER}" stroke-width="1" />
104
+ <g transform="translate(10,10)" stroke="${LINK_CARD_TEXT_COLOR}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none">
105
+ <path d="M8 2 H14 V8" />
106
+ <path d="M14 2 L7 9" />
107
+ <path d="M12 10 V13 a1 1 0 0 1 -1 1 H3 a1 1 0 0 1 -1 -1 V5 a1 1 0 0 1 1 -1 H6" />
108
+ </g>
109
+ </g>
110
+ </g>
111
+ `;
112
+ }
113
+ function getLinkData(item) {
114
+ const entry = item.pluginData?.[LINK_PLUGIN_KEY];
115
+ return isCanvuLinkData(entry) ? entry : null;
116
+ }
117
+ function rebuildLinkItemSvg(item) {
118
+ const link = getLinkData(item);
119
+ if (!link) return item;
120
+ const scale = clamp(
121
+ item.bounds.width / DEFAULT_LINK_CARD_WIDTH,
122
+ LINK_CARD_MIN_SCALE,
123
+ LINK_CARD_MAX_SCALE
124
+ );
125
+ const width = DEFAULT_LINK_CARD_WIDTH * scale;
126
+ const height = DEFAULT_LINK_CARD_HEIGHT * scale;
127
+ const bounds = {
128
+ ...item.bounds,
129
+ width,
130
+ height
131
+ };
132
+ const customInnerSvg = buildLinkCardSvg(
133
+ DEFAULT_LINK_CARD_WIDTH,
134
+ DEFAULT_LINK_CARD_HEIGHT,
135
+ link
136
+ );
137
+ return {
138
+ ...item,
139
+ x: bounds.x,
140
+ y: bounds.y,
141
+ bounds,
142
+ customIntrinsicSize: {
143
+ width: DEFAULT_LINK_CARD_WIDTH,
144
+ height: DEFAULT_LINK_CARD_HEIGHT
145
+ },
146
+ customInnerSvg,
147
+ childrenSvg: buildLinkCardSvg(width, height, link)
148
+ };
149
+ }
150
+ var LINK_PLUGIN_KEY, DEFAULT_LINK_CARD_WIDTH, DEFAULT_LINK_CARD_HEIGHT, LINK_CARD_MIN_SCALE, LINK_CARD_MAX_SCALE, LINK_CARD_ASPECT, LINK_CARD_BORDER, LINK_CARD_BORDER_STRONG, LINK_CARD_ACCENT, LINK_CARD_ACCENT_DEEP, LINK_CARD_TITLE_COLOR, LINK_CARD_TEXT_COLOR, clamp, formatNumber, escapeHtmlText, getLinkHostname, buildLinkTextBand, getLinkProtocol, getLinkInitial, getStableLinkIdSuffix, isCanvuLinkData;
151
+ var init_link_item = __esm({
152
+ "src/scene/link-item.ts"() {
153
+ LINK_PLUGIN_KEY = "canvuLink";
154
+ DEFAULT_LINK_CARD_WIDTH = 320;
155
+ DEFAULT_LINK_CARD_HEIGHT = 70;
156
+ LINK_CARD_MIN_SCALE = 0.6;
157
+ LINK_CARD_MAX_SCALE = 6;
158
+ LINK_CARD_ASPECT = DEFAULT_LINK_CARD_WIDTH / DEFAULT_LINK_CARD_HEIGHT;
159
+ LINK_CARD_BORDER = "oklch(0.918 0.008 255)";
160
+ LINK_CARD_BORDER_STRONG = "oklch(0.86 0.012 255)";
161
+ LINK_CARD_ACCENT = "oklch(0.55 0.19 264)";
162
+ LINK_CARD_ACCENT_DEEP = "oklch(0.46 0.18 264)";
163
+ LINK_CARD_TITLE_COLOR = "oklch(0.26 0.022 265)";
164
+ LINK_CARD_TEXT_COLOR = "oklch(0.55 0.022 265)";
165
+ clamp = (value, min, max) => Math.min(max, Math.max(min, value));
166
+ formatNumber = (value) => {
167
+ const rounded = Math.round(value * 100) / 100;
168
+ return Object.is(rounded, -0) ? "0" : String(rounded);
169
+ };
170
+ escapeHtmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
171
+ getLinkHostname = (href) => {
172
+ try {
173
+ return new URL(href).hostname.replace(/^www\./, "");
174
+ } catch {
175
+ return href;
176
+ }
177
+ };
178
+ buildLinkTextBand = (band) => {
179
+ const lineHeight = band.height;
180
+ const weight = band.fontWeight != null ? `font-weight:${band.fontWeight};` : "";
181
+ return `<foreignObject x="${formatNumber(band.x)}" y="${formatNumber(band.y)}" width="${formatNumber(Math.max(1, band.width))}" height="${formatNumber(Math.max(1, band.height))}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;font-family:system-ui,sans-serif;font-size:${formatNumber(band.fontSize)}px;line-height:${formatNumber(lineHeight)}px;letter-spacing:-0.01em;color:${band.color};overflow:hidden;white-space:nowrap;text-overflow:ellipsis;${weight}">${escapeHtmlText(band.text)}</div></foreignObject>`;
182
+ };
183
+ getLinkProtocol = (href) => {
184
+ try {
185
+ return new URL(href).protocol;
186
+ } catch {
187
+ return "";
188
+ }
189
+ };
190
+ getLinkInitial = (hostname) => {
191
+ const first = hostname.trim().charAt(0).toUpperCase();
192
+ return first || "L";
193
+ };
194
+ getStableLinkIdSuffix = (value) => {
195
+ let hash = 0;
196
+ for (const char of value) {
197
+ hash = hash * 31 + char.charCodeAt(0) >>> 0;
198
+ }
199
+ return hash.toString(36);
200
+ };
201
+ isCanvuLinkData = (value) => {
202
+ if (!value || typeof value !== "object") return false;
203
+ const candidate = value;
204
+ return typeof candidate.href === "string" && candidate.href.length > 0;
205
+ };
206
+ }
207
+ });
208
+
59
209
  // src/scene/text-svg.ts
60
210
  function escapeSvgTextContent(s) {
61
211
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
62
212
  }
63
- function escapeHtmlText(s) {
213
+ function escapeHtmlText2(s) {
64
214
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
65
215
  }
66
216
  function getSharedMeasureContext() {
@@ -161,9 +311,9 @@ function buildTextFixedBoundsSvg(content, width, height, fillColor = "#1d1d1d",
161
311
  const trimmed = content.trim();
162
312
  const padTop = EDIT_TOP_PAD_RATIO * fontSize;
163
313
  if (trimmed.length === 0) {
164
- return `<foreignObject width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;padding:${padTop}px 4px 0 4px;font-size:${fontSize}px;line-height:${lh}px;font-family:${TEXT_FONT_FAMILY};white-space:pre-wrap;word-wrap:break-word;overflow:hidden;color:#94a3b8;font-style:italic">${escapeHtmlText(PLACEHOLDER)}</div></foreignObject>`;
314
+ return `<foreignObject width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;padding:${padTop}px 4px 0 4px;font-size:${fontSize}px;line-height:${lh}px;font-family:${TEXT_FONT_FAMILY};white-space:pre-wrap;word-wrap:break-word;overflow:hidden;color:#94a3b8;font-style:italic">${escapeHtmlText2(PLACEHOLDER)}</div></foreignObject>`;
165
315
  }
166
- const body = escapeHtmlText(content);
316
+ const body = escapeHtmlText2(content);
167
317
  return `<foreignObject width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;padding:${padTop}px 4px 0 4px;font-size:${fontSize}px;line-height:${lh}px;font-family:${TEXT_FONT_FAMILY};white-space:pre-wrap;word-wrap:break-word;overflow:hidden;color:${fillColor}">${body}</div></foreignObject>`;
168
318
  }
169
319
  var DEFAULT_TEXT_FONT_SIZE, DEFAULT_TEXT_TOOL_FONT_SIZE, TEXT_FONT_FAMILY, LINE_HEIGHT_RATIO, FIRST_LINE_BASELINE_RATIO, EDIT_TOP_PAD_RATIO, BOTTOM_PAD_RATIO, PLACEHOLDER, MIN_TEXT_BOX_W, MIN_TEXT_BOX_H, TEXT_PAD_X, MAX_TEXT_MEASURE_CACHE_ENTRIES, sharedMeasureContext, textMeasureCache;
@@ -820,6 +970,9 @@ function rebuildItemSvg(item) {
820
970
  )
821
971
  };
822
972
  }
973
+ if (getLinkData(item)) {
974
+ return rebuildLinkItemSvg(item);
975
+ }
823
976
  if (k === "custom" && item.customIntrinsicSize && item.customInnerSvg) {
824
977
  const b = normalizeRect(item.bounds);
825
978
  return {
@@ -1045,6 +1198,7 @@ var init_shape_builders = __esm({
1045
1198
  "src/scene/shape-builders.ts"() {
1046
1199
  init_rect();
1047
1200
  init_custom_shape();
1201
+ init_link_item();
1048
1202
  init_text_svg();
1049
1203
  DEFAULT_STROKE_STYLE = {
1050
1204
  stroke: "#1d1d1d",
@@ -6362,10 +6516,80 @@ function collectEraserTargetsAtWorldPoint(items, worldX, worldY, options) {
6362
6516
 
6363
6517
  // src/interaction/mutations.ts
6364
6518
  init_rect();
6519
+ init_link_item();
6365
6520
  init_shape_builders();
6366
6521
  init_text_svg();
6522
+ var LINK_CORNER_HANDLES = /* @__PURE__ */ new Set(["nw", "ne", "se", "sw"]);
6523
+ var clamp2 = (value, min, max) => Math.min(max, Math.max(min, value));
6524
+ var clampLinkResizeBounds = (startBounds, nextBounds, handle, intrinsicWidth) => {
6525
+ const next = normalizeRect(nextBounds);
6526
+ const scale = clamp2(
6527
+ next.width / Math.max(1e-9, intrinsicWidth),
6528
+ LINK_CARD_MIN_SCALE,
6529
+ LINK_CARD_MAX_SCALE
6530
+ );
6531
+ const width = intrinsicWidth * scale;
6532
+ const height = width / LINK_CARD_ASPECT;
6533
+ const start = normalizeRect(startBounds);
6534
+ const x0 = start.x;
6535
+ const y0 = start.y;
6536
+ const x1 = start.x + start.width;
6537
+ const y1 = start.y + start.height;
6538
+ switch (handle) {
6539
+ case "nw":
6540
+ return { x: x1 - width, y: y1 - height, width, height };
6541
+ case "ne":
6542
+ return { x: x0, y: y1 - height, width, height };
6543
+ case "sw":
6544
+ return { x: x1 - width, y: y0, width, height };
6545
+ default:
6546
+ return { x: x0, y: y0, width, height };
6547
+ }
6548
+ };
6367
6549
  function computeNewBoundsForResize(item, sb, handle, currentWorld) {
6368
6550
  const rot = getItemRotationRad(item);
6551
+ const link = getLinkData(item);
6552
+ if (link && item.customIntrinsicSize) {
6553
+ if (!LINK_CORNER_HANDLES.has(handle)) return sb;
6554
+ const intrinsicWidth = Math.max(1e-9, item.customIntrinsicSize.width);
6555
+ if (Math.abs(rot) < 1e-12) {
6556
+ const next = computeResizeBoundsFixedAspect(
6557
+ sb,
6558
+ handle,
6559
+ currentWorld,
6560
+ LINK_CARD_ASPECT
6561
+ );
6562
+ return clampLinkResizeBounds(sb, next, handle, intrinsicWidth);
6563
+ }
6564
+ const local2 = worldToItemLocal(
6565
+ currentWorld.x,
6566
+ currentWorld.y,
6567
+ sb.x,
6568
+ sb.y,
6569
+ sb.width,
6570
+ sb.height,
6571
+ rot
6572
+ );
6573
+ const localBounds2 = { x: 0, y: 0, width: sb.width, height: sb.height };
6574
+ const nextLocal = computeResizeBoundsFixedAspect(
6575
+ localBounds2,
6576
+ handle,
6577
+ local2,
6578
+ LINK_CARD_ASPECT
6579
+ );
6580
+ const clamped = clampLinkResizeBounds(
6581
+ localBounds2,
6582
+ nextLocal,
6583
+ handle,
6584
+ intrinsicWidth
6585
+ );
6586
+ return {
6587
+ x: sb.x + clamped.x,
6588
+ y: sb.y + clamped.y,
6589
+ width: clamped.width,
6590
+ height: clamped.height
6591
+ };
6592
+ }
6369
6593
  if (Math.abs(rot) < 1e-12) {
6370
6594
  if (item.toolKind === "image") {
6371
6595
  let aspect;
@@ -6838,17 +7062,8 @@ var SvgVectorRenderer = class {
6838
7062
  }
6839
7063
  };
6840
7064
 
6841
- // src/scene/link-item.ts
6842
- var LINK_PLUGIN_KEY = "canvuLink";
6843
- var isCanvuLinkData = (value) => {
6844
- if (!value || typeof value !== "object") return false;
6845
- const candidate = value;
6846
- return typeof candidate.href === "string" && candidate.href.length > 0;
6847
- };
6848
- function getLinkData(item) {
6849
- const entry = item.pluginData?.[LINK_PLUGIN_KEY];
6850
- return isCanvuLinkData(entry) ? entry : null;
6851
- }
7065
+ // src/react/VectorViewport.tsx
7066
+ init_link_item();
6852
7067
 
6853
7068
  // src/scene/scene.ts
6854
7069
  var VectorScene = class {
@@ -6942,8 +7157,10 @@ function smoothFreehandPointsToPathD(points) {
6942
7157
  }
6943
7158
 
6944
7159
  // src/react/InteractionOverlay.tsx
7160
+ init_link_item();
6945
7161
  init_shape_builders();
6946
7162
  var HANDLE_ORDER = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
7163
+ var LINK_HANDLE_ORDER = ["nw", "ne", "se", "sw"];
6947
7164
  var ERASER_TINT = "#cbd5e1";
6948
7165
  var ERASER_TINT_OPACITY = 0.95;
6949
7166
  var ERASER_PREVIEW_OPACITY = 0.3;
@@ -6996,7 +7213,7 @@ function InteractionOverlay({
6996
7213
  ) }, it.id);
6997
7214
  }),
6998
7215
  showResizeHandles && bSingle && single && rotHandlePos && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
6999
- HANDLE_ORDER.map((hid) => {
7216
+ (getLinkData(single) ? LINK_HANDLE_ORDER : HANDLE_ORDER).map((hid) => {
7000
7217
  const p = getHandleWorldPositionRotated(bSingle, hid, rotSingle);
7001
7218
  return /* @__PURE__ */ jsxRuntime.jsx(
7002
7219
  "circle",
@@ -7810,6 +8027,7 @@ function defaultPlacementWorld(tool, center) {
7810
8027
  lineWorld: [a, b]
7811
8028
  };
7812
8029
  }
8030
+ var LINK_CORNER_HANDLES2 = /* @__PURE__ */ new Set(["nw", "ne", "se", "sw"]);
7813
8031
  function pointInSelectedItemBounds(item, worldX, worldY) {
7814
8032
  const bounds = normalizeRect(item.bounds);
7815
8033
  const local = worldToItemLocal(
@@ -8287,7 +8505,11 @@ var VectorViewport = react.forwardRef(
8287
8505
  setEffectiveSelectedIdsRef.current = setEffectiveSelectedIds;
8288
8506
  toolIdRef.current = toolId;
8289
8507
  interactiveRef.current = interactive;
8290
- itemsRef.current = items;
8508
+ const normalizedItems = react.useMemo(
8509
+ () => items.map((item) => getLinkData(item) ? rebuildLinkItemSvg(item) : item),
8510
+ [items]
8511
+ );
8512
+ itemsRef.current = normalizedItems;
8291
8513
  onWorldPointerDownRef.current = onWorldPointerDown;
8292
8514
  const originalOnItemsChangeRef = react.useRef(onItemsChange);
8293
8515
  originalOnItemsChangeRef.current = onItemsChange;
@@ -8308,7 +8530,10 @@ var VectorViewport = react.forwardRef(
8308
8530
  autoResetToolToRef.current = autoResetToolTo;
8309
8531
  const onToolChangeRequestRef = react.useRef(onToolChangeRequest);
8310
8532
  onToolChangeRequestRef.current = onToolChangeRequest;
8311
- const resolvedItems = react.useMemo(() => resolveArrowBindingsInScene(items), [items]);
8533
+ const resolvedItems = react.useMemo(
8534
+ () => resolveArrowBindingsInScene(normalizedItems),
8535
+ [normalizedItems]
8536
+ );
8312
8537
  const resolvedItemsRef = react.useRef(resolvedItems);
8313
8538
  resolvedItemsRef.current = resolvedItems;
8314
8539
  const liveId = react.useId();
@@ -9729,7 +9954,8 @@ var VectorViewport = react.forwardRef(
9729
9954
  handleRadiusWorld,
9730
9955
  rot
9731
9956
  );
9732
- if (hb) {
9957
+ const isLinkResizeHandle = hb && getLinkData(selected) ? LINK_CORNER_HANDLES2.has(hb) : Boolean(hb);
9958
+ if (hb && isLinkResizeHandle) {
9733
9959
  const snapRs = bakedSnapshot(selected.id);
9734
9960
  if (!snapRs) return;
9735
9961
  dragStateRef.current = {