@agent-native/core 0.80.0 → 0.80.2

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.
Files changed (228) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  4. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +14 -1
  5. package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  6. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +15 -1
  7. package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  8. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +15 -1
  9. package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  10. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +15 -1
  11. package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  12. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +15 -1
  13. package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  14. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +16 -1
  15. package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  16. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +14 -1
  17. package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  18. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +14 -1
  19. package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  20. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +14 -1
  21. package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  22. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +14 -1
  23. package/corpus/core/docs/content/skills-guide.mdx +5 -0
  24. package/corpus/core/docs/content/template-design.mdx +14 -1
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/application-state/handlers.ts +1 -1
  27. package/corpus/core/src/brand-kit/index.ts +1 -1
  28. package/corpus/core/src/cli/skills.ts +18 -0
  29. package/corpus/core/src/client/AgentPanel.tsx +1 -1
  30. package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
  31. package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
  32. package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
  33. package/corpus/core/src/extensions/path.ts +1 -4
  34. package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
  35. package/corpus/core/src/mcp/build-server.ts +2 -2
  36. package/corpus/core/src/mcp/embed-app.ts +35 -1
  37. package/corpus/core/src/org/handlers.ts +5 -5
  38. package/corpus/core/src/org/plugin.ts +2 -2
  39. package/corpus/core/src/scripts/call-agent.ts +1 -1
  40. package/corpus/core/src/scripts/db/safety.ts +1 -1
  41. package/corpus/core/src/server/auth.ts +3 -0
  42. package/corpus/core/src/server/builder-design-systems.ts +308 -0
  43. package/corpus/core/src/server/core-routes-plugin.ts +29 -3
  44. package/corpus/core/src/server/index.ts +8 -0
  45. package/corpus/core/src/styles/agent-native.css +14 -7
  46. package/corpus/core/src/usage/store.ts +2 -2
  47. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
  48. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
  49. package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
  50. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
  51. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
  52. package/corpus/templates/analytics/app/i18n/zh-TW.ts +37 -0
  53. package/corpus/templates/analytics/app/i18n-data.ts +404 -0
  54. package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
  55. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
  56. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
  57. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +49 -26
  58. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +137 -36
  59. package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
  60. package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
  61. package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
  62. package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
  63. package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
  66. package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
  67. package/corpus/templates/analytics/server/lib/dashboard-report.ts +49 -26
  68. package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
  69. package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
  70. package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
  71. package/corpus/templates/analytics/server/lib/session-replay.ts +29 -8
  72. package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
  73. package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
  74. package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
  75. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
  76. package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
  77. package/corpus/templates/content/shared/document-export.ts +1 -1
  78. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
  79. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +17 -0
  80. package/corpus/templates/design/actions/add-localhost-screens.ts +32 -5
  81. package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
  82. package/corpus/templates/design/actions/update-file.ts +9 -2
  83. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +33 -75
  84. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +72 -9
  85. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +41 -39
  86. package/corpus/templates/design/app/components/design/EditPanel.tsx +1029 -249
  87. package/corpus/templates/design/app/components/design/LayersPanel.tsx +450 -101
  88. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +978 -154
  89. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +7 -9
  90. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +133 -66
  91. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1234 -344
  92. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +144 -94
  93. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +301 -72
  94. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +876 -286
  95. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
  96. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
  97. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +30 -7
  98. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +212 -155
  99. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
  100. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +487 -107
  101. package/corpus/templates/design/app/components/design/inspector/index.ts +27 -0
  102. package/corpus/templates/design/app/global.css +29 -3
  103. package/corpus/templates/design/app/i18n/zh-TW.ts +13 -0
  104. package/corpus/templates/design/app/i18n-data.ts +239 -1
  105. package/corpus/templates/design/app/pages/DesignEditor.tsx +1023 -353
  106. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
  107. package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
  108. package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
  109. package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
  110. package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
  111. package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
  112. package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
  113. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
  114. package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
  115. package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
  116. package/corpus/templates/design/shared/code-layer.ts +209 -9
  117. package/corpus/templates/design/shared/html-content.ts +21 -0
  118. package/corpus/templates/design/shared/pen-path.ts +207 -0
  119. package/corpus/templates/mail/app/lib/utils.ts +2 -2
  120. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
  121. package/corpus/templates/slides/AGENTS.md +3 -2
  122. package/corpus/templates/slides/actions/import-file.ts +26 -17
  123. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
  124. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
  125. package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
  126. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
  127. package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
  128. package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
  129. package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
  130. package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
  131. package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
  132. package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
  133. package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
  134. package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
  135. package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
  136. package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
  137. package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
  138. package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
  139. package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
  140. package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
  141. package/dist/application-state/handlers.js +1 -1
  142. package/dist/application-state/handlers.js.map +1 -1
  143. package/dist/brand-kit/index.d.ts +1 -1
  144. package/dist/brand-kit/index.js +1 -1
  145. package/dist/brand-kit/index.js.map +1 -1
  146. package/dist/cli/skills.d.ts.map +1 -1
  147. package/dist/cli/skills.js +18 -0
  148. package/dist/cli/skills.js.map +1 -1
  149. package/dist/client/AgentPanel.js +1 -1
  150. package/dist/client/AgentPanel.js.map +1 -1
  151. package/dist/client/MultiTabAssistantChat.js +1 -1
  152. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  153. package/dist/client/extensions/portable-extension.d.ts +1 -1
  154. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  155. package/dist/client/extensions/portable-extension.js.map +1 -1
  156. package/dist/code-agents/transcript-normalizer.js +1 -1
  157. package/dist/code-agents/transcript-normalizer.js.map +1 -1
  158. package/dist/collab/awareness.d.ts +2 -2
  159. package/dist/collab/awareness.d.ts.map +1 -1
  160. package/dist/collab/routes.d.ts +1 -1
  161. package/dist/extensions/path.d.ts +1 -1
  162. package/dist/extensions/path.d.ts.map +1 -1
  163. package/dist/extensions/path.js.map +1 -1
  164. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  165. package/dist/integrations/a2a-continuation-processor.js +1 -1
  166. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  167. package/dist/mcp/build-server.d.ts +1 -1
  168. package/dist/mcp/build-server.d.ts.map +1 -1
  169. package/dist/mcp/build-server.js +1 -1
  170. package/dist/mcp/build-server.js.map +1 -1
  171. package/dist/mcp/embed-app.d.ts.map +1 -1
  172. package/dist/mcp/embed-app.js +35 -1
  173. package/dist/mcp/embed-app.js.map +1 -1
  174. package/dist/notifications/routes.d.ts +3 -3
  175. package/dist/observability/routes.d.ts +8 -8
  176. package/dist/org/handlers.js +5 -5
  177. package/dist/org/handlers.js.map +1 -1
  178. package/dist/org/plugin.js +2 -2
  179. package/dist/org/plugin.js.map +1 -1
  180. package/dist/resources/handlers.d.ts +1 -1
  181. package/dist/scripts/call-agent.js +1 -1
  182. package/dist/scripts/call-agent.js.map +1 -1
  183. package/dist/scripts/db/safety.js +1 -1
  184. package/dist/scripts/db/safety.js.map +1 -1
  185. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  186. package/dist/server/auth.d.ts.map +1 -1
  187. package/dist/server/auth.js +3 -0
  188. package/dist/server/auth.js.map +1 -1
  189. package/dist/server/builder-design-systems.d.ts +28 -0
  190. package/dist/server/builder-design-systems.d.ts.map +1 -0
  191. package/dist/server/builder-design-systems.js +217 -0
  192. package/dist/server/builder-design-systems.js.map +1 -0
  193. package/dist/server/core-routes-plugin.d.ts +1 -0
  194. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  195. package/dist/server/core-routes-plugin.js +20 -3
  196. package/dist/server/core-routes-plugin.js.map +1 -1
  197. package/dist/server/index.d.ts +1 -0
  198. package/dist/server/index.d.ts.map +1 -1
  199. package/dist/server/index.js +1 -0
  200. package/dist/server/index.js.map +1 -1
  201. package/dist/styles/agent-native.css +14 -7
  202. package/dist/usage/store.js +2 -2
  203. package/dist/usage/store.js.map +1 -1
  204. package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  205. package/docs/content/locales/ar-SA/template-design.mdx +14 -1
  206. package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  207. package/docs/content/locales/de-DE/template-design.mdx +15 -1
  208. package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  209. package/docs/content/locales/es-ES/template-design.mdx +15 -1
  210. package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  211. package/docs/content/locales/fr-FR/template-design.mdx +15 -1
  212. package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  213. package/docs/content/locales/hi-IN/template-design.mdx +15 -1
  214. package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  215. package/docs/content/locales/ja-JP/template-design.mdx +16 -1
  216. package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  217. package/docs/content/locales/ko-KR/template-design.mdx +14 -1
  218. package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  219. package/docs/content/locales/pt-BR/template-design.mdx +14 -1
  220. package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  221. package/docs/content/locales/zh-CN/template-design.mdx +14 -1
  222. package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  223. package/docs/content/locales/zh-TW/template-design.mdx +14 -1
  224. package/docs/content/skills-guide.mdx +5 -0
  225. package/docs/content/template-design.mdx +14 -1
  226. package/package.json +1 -1
  227. package/corpus/templates/design/actions/import-figma-file.ts +0 -166
  228. package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
@@ -13,12 +13,28 @@ import {
13
13
  screenToCanvasPoint,
14
14
  type ArrowNudgeKey,
15
15
  } from "@shared/canvas-math";
16
+ import {
17
+ appendPenNode,
18
+ clonePenPath,
19
+ closePenPath,
20
+ constrainPointTo45Degrees,
21
+ createCornerNode,
22
+ createSmoothNode,
23
+ getPenPathGeometry,
24
+ isPenCloseTarget,
25
+ scalePenPathToGeometry,
26
+ serializePenPath,
27
+ translatePenPath,
28
+ type PenNode,
29
+ type PenPath,
30
+ } from "@shared/pen-path";
16
31
  import { IconCopy, IconMaximize } from "@tabler/icons-react";
17
32
  import {
18
33
  useRef,
19
34
  useState,
20
35
  useCallback,
21
36
  useEffect,
37
+ type CSSProperties,
22
38
  type ReactNode,
23
39
  } from "react";
24
40
 
@@ -48,13 +64,17 @@ type MultiScreenCanvasTool =
48
64
  | "frame"
49
65
  | "rect"
50
66
  | "rectangle"
67
+ | "line"
68
+ | "arrow"
69
+ | "ellipse"
70
+ | "polygon"
71
+ | "star"
51
72
  | "text"
52
73
  | "pen"
53
74
  | "hand"
54
75
  | "comment"
55
76
  | "draw"
56
- | "scale"
57
- | "overview";
77
+ | "scale";
58
78
 
59
79
  interface CanvasToolProps {
60
80
  fill?: string;
@@ -67,10 +87,12 @@ export interface CanvasPrimitiveInsert {
67
87
  kind: DraftPrimitiveKind;
68
88
  geometry: FrameGeometry;
69
89
  points?: Point[];
90
+ pathData?: string;
70
91
  text?: string;
71
92
  fill?: string;
72
93
  stroke?: string;
73
94
  strokeWidth?: number;
95
+ autoSize?: boolean;
74
96
  }
75
97
 
76
98
  interface ScreenMetadata {
@@ -115,6 +137,7 @@ interface MultiScreenCanvasProps {
115
137
  screenId: string,
116
138
  primitive: CanvasPrimitiveInsert,
117
139
  ) => boolean;
140
+ onCreateScreenFrame?: (geometry: FrameGeometry) => void;
118
141
  onDeleteSelection?: (ids: string[]) => boolean | void;
119
142
  onZoomChange?: (zoom: number) => void;
120
143
  onZoomToEdit?: (id: string) => void;
@@ -147,13 +170,15 @@ const MAX_WHEEL_ZOOM_DELTA = 80;
147
170
  const MAX_WHEEL_PAN_DELTA = 140;
148
171
  const PIXEL_GRID_ZOOM = 800;
149
172
  const DRAFT_FRAME_WIDTH = 320;
150
- const DRAFT_FRAME_HEIGHT = 240;
173
+ const DRAFT_FRAME_HEIGHT = 640;
151
174
  const DRAFT_RECT_WIDTH = 160;
152
175
  const DRAFT_RECT_HEIGHT = 120;
153
176
  const DRAFT_TEXT_WIDTH = 180;
154
177
  const DRAFT_TEXT_HEIGHT = 48;
178
+ const DRAFT_LINE_WIDTH = 160;
155
179
  const DRAFT_PATH_MIN_SIZE = 12;
156
180
  const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
181
+ const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
157
182
 
158
183
  interface ResolvedScreenMetadata {
159
184
  source: ScreenSourceType;
@@ -194,18 +219,39 @@ export interface Point {
194
219
  y: number;
195
220
  }
196
221
 
197
- export type DraftPrimitiveKind = "frame" | "rectangle" | "text" | "path";
198
- type DraftCreationTool = "frame" | "rect" | "text" | "pen";
222
+ export type DraftPrimitiveKind =
223
+ | "frame"
224
+ | "rectangle"
225
+ | "ellipse"
226
+ | "polygon"
227
+ | "star"
228
+ | "line"
229
+ | "arrow"
230
+ | "text"
231
+ | "path";
232
+ type DraftCreationTool =
233
+ | "frame"
234
+ | "rect"
235
+ | "line"
236
+ | "arrow"
237
+ | "ellipse"
238
+ | "polygon"
239
+ | "star"
240
+ | "text"
241
+ | "pen";
199
242
 
200
243
  interface DraftPrimitive {
201
244
  id: string;
202
245
  kind: DraftPrimitiveKind;
203
246
  geometry: FrameGeometry;
204
247
  points?: Point[];
248
+ penPath?: PenPath;
249
+ pathData?: string;
205
250
  text?: string;
206
251
  fill?: string;
207
252
  stroke?: string;
208
253
  strokeWidth?: number;
254
+ autoSize?: boolean;
209
255
  }
210
256
 
211
257
  type DraftPrimitiveById = Record<string, DraftPrimitive>;
@@ -309,6 +355,14 @@ interface DraftCreateDragState {
309
355
  hasMoved: boolean;
310
356
  }
311
357
 
358
+ interface PenNodeDragState {
359
+ type: "pen-node";
360
+ originClient: Point;
361
+ anchor: Point;
362
+ pathBefore: PenPath | null;
363
+ hasMoved: boolean;
364
+ }
365
+
312
366
  interface DraftCreationPreview {
313
367
  tool: DraftCreationTool;
314
368
  geometry: FrameGeometry;
@@ -323,7 +377,8 @@ type DragState =
323
377
  | PanDragState
324
378
  | DraftMoveDragState
325
379
  | DraftResizeDragState
326
- | DraftCreateDragState;
380
+ | DraftCreateDragState
381
+ | PenNodeDragState;
327
382
 
328
383
  export function MultiScreenCanvas({
329
384
  screens,
@@ -341,6 +396,7 @@ export function MultiScreenCanvas({
341
396
  onGeometryChange,
342
397
  onGeometryCommit,
343
398
  onCreatePrimitive,
399
+ onCreateScreenFrame,
344
400
  onDeleteSelection,
345
401
  onZoomChange,
346
402
  onZoomToEdit,
@@ -365,6 +421,13 @@ export function MultiScreenCanvas({
365
421
  const selectedDraftIdsRef = useRef(selectedDraftIds);
366
422
  const [creationPreview, setCreationPreview] =
367
423
  useState<DraftCreationPreview | null>(null);
424
+ const [activePenPath, setActivePenPath] = useState<PenPath | null>(null);
425
+ const activePenPathRef = useRef<PenPath | null>(activePenPath);
426
+ const [penGesturePreview, setPenGesturePreview] = useState<PenPath | null>(
427
+ null,
428
+ );
429
+ const [penPointer, setPenPointer] = useState<Point | null>(null);
430
+ const [penCloseHover, setPenCloseHover] = useState(false);
368
431
  const [localActiveTool, setLocalActiveTool] =
369
432
  useState<MultiScreenCanvasTool>("move");
370
433
  const [selectedIds, setSelectedIds] = useState<string[]>(
@@ -397,6 +460,10 @@ export function MultiScreenCanvas({
397
460
  onGeometryCommitRef.current = onGeometryCommit;
398
461
  }, [onGeometryCommit]);
399
462
 
463
+ useEffect(() => {
464
+ activePenPathRef.current = activePenPath;
465
+ }, [activePenPath]);
466
+
400
467
  const updateFrameGeometry = useCallback(
401
468
  (updater: (current: FrameGeometryById) => FrameGeometryById) => {
402
469
  setFrameGeometry((current) => {
@@ -692,15 +759,19 @@ export function MultiScreenCanvas({
692
759
  handleMouseUp: (ev: MouseEvent) => void,
693
760
  ) => {
694
761
  dragCleanup.current?.();
762
+ let lastMouseEvent: MouseEvent | null = null;
695
763
  const move = (ev: MouseEvent) => {
764
+ lastMouseEvent = ev;
696
765
  ev.preventDefault();
697
766
  handleMouseMove(ev);
698
767
  };
699
768
  const up = (ev: MouseEvent) => {
769
+ lastMouseEvent = ev;
700
770
  ev.preventDefault();
701
771
  handleMouseUp(ev);
702
772
  };
703
- const cleanupOnBlur = () => handleMouseUp(new MouseEvent("mouseup"));
773
+ const cleanupOnBlur = () =>
774
+ handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
704
775
  dragCleanup.current = () => {
705
776
  window.removeEventListener("mousemove", move);
706
777
  window.removeEventListener("mouseup", up);
@@ -860,6 +931,253 @@ export function MultiScreenCanvas({
860
931
  ],
861
932
  );
862
933
 
934
+ const getTargetFrameForDraft = useCallback(
935
+ (draft: DraftPrimitive) =>
936
+ getCurrentFrameEntries()
937
+ .filter(({ geometry }) =>
938
+ rectContainsPoint(
939
+ {
940
+ left: geometry.x,
941
+ top: geometry.y,
942
+ right: geometry.x + geometry.width,
943
+ bottom: geometry.y + geometry.height,
944
+ },
945
+ getFrameCenter(draft.geometry),
946
+ ),
947
+ )
948
+ .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0],
949
+ [getCurrentFrameEntries],
950
+ );
951
+
952
+ const persistDraftPrimitive = useCallback(
953
+ (draft: DraftPrimitive) => {
954
+ const targetFrame = getTargetFrameForDraft(draft);
955
+ if (!targetFrame || !onCreatePrimitive) return null;
956
+ const targetScreen = screens.find(
957
+ (screen) => screen.id === targetFrame.id,
958
+ );
959
+ const targetMetadata = targetScreen
960
+ ? resolveScreenMetadata(
961
+ targetScreen,
962
+ metadataById?.[targetScreen.id],
963
+ getScreenMetadata?.(targetScreen),
964
+ )
965
+ : undefined;
966
+
967
+ const localPrimitive = draftPrimitiveToInsert(
968
+ draft,
969
+ targetFrame.geometry,
970
+ targetMetadata,
971
+ );
972
+ const persisted = onCreatePrimitive(targetFrame.id, localPrimitive);
973
+ return persisted ? targetFrame.id : null;
974
+ },
975
+ [
976
+ getScreenMetadata,
977
+ getTargetFrameForDraft,
978
+ metadataById,
979
+ onCreatePrimitive,
980
+ screens,
981
+ ],
982
+ );
983
+
984
+ const commitDraftPrimitive = useCallback(
985
+ (nextDraft: DraftPrimitive) => {
986
+ const persistedFrameId = persistDraftPrimitive(nextDraft);
987
+ if (persistedFrameId) {
988
+ updateDraftPrimitives((current) =>
989
+ current.filter((draft) => draft.id !== nextDraft.id),
990
+ );
991
+ updateSelectedDraftIds(() => []);
992
+ updateSelectedIds(() => [persistedFrameId]);
993
+ return;
994
+ }
995
+
996
+ updateDraftPrimitives((current) => [...current, nextDraft]);
997
+ updateSelectedIds(() => []);
998
+ updateSelectedDraftIds(() => [nextDraft.id]);
999
+ },
1000
+ [
1001
+ persistDraftPrimitive,
1002
+ updateDraftPrimitives,
1003
+ updateSelectedDraftIds,
1004
+ updateSelectedIds,
1005
+ ],
1006
+ );
1007
+
1008
+ const clearActivePenPath = useCallback(() => {
1009
+ setActivePenPath(null);
1010
+ setPenGesturePreview(null);
1011
+ setPenPointer(null);
1012
+ setPenCloseHover(false);
1013
+ }, []);
1014
+
1015
+ const finishPenPath = useCallback(
1016
+ (path = activePenPathRef.current) => {
1017
+ if (!path || path.nodes.length < 2) {
1018
+ clearActivePenPath();
1019
+ return;
1020
+ }
1021
+
1022
+ commitDraftPrimitive(
1023
+ createPenDraftPrimitive(path, {
1024
+ stroke: toolProps?.stroke,
1025
+ strokeWidth: toolProps?.strokeWidth,
1026
+ }),
1027
+ );
1028
+ clearActivePenPath();
1029
+ onActiveToolChange?.("pen");
1030
+ },
1031
+ [clearActivePenPath, commitDraftPrimitive, onActiveToolChange, toolProps],
1032
+ );
1033
+
1034
+ const getPenAnchorPoint = useCallback(
1035
+ (
1036
+ clientX: number,
1037
+ clientY: number,
1038
+ shiftKey: boolean,
1039
+ path: PenPath | null,
1040
+ ) => {
1041
+ const rawPoint = getCanvasPoint(clientX, clientY);
1042
+ const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
1043
+ return shiftKey && lastAnchor
1044
+ ? constrainPointTo45Degrees(lastAnchor, rawPoint)
1045
+ : rawPoint;
1046
+ },
1047
+ [getCanvasPoint],
1048
+ );
1049
+
1050
+ const updatePenPointer = useCallback(
1051
+ (clientX: number, clientY: number, shiftKey: boolean) => {
1052
+ const path = activePenPathRef.current;
1053
+ if (!path || path.closed) {
1054
+ setPenPointer(null);
1055
+ setPenCloseHover(false);
1056
+ return;
1057
+ }
1058
+
1059
+ const rawPoint = getCanvasPoint(clientX, clientY);
1060
+ const closeHover = isPenCloseTarget(
1061
+ path,
1062
+ rawPoint,
1063
+ PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
1064
+ );
1065
+ setPenCloseHover(closeHover);
1066
+ setPenPointer(
1067
+ closeHover
1068
+ ? path.nodes[0].point
1069
+ : getPenAnchorPoint(clientX, clientY, shiftKey, path),
1070
+ );
1071
+ },
1072
+ [getCanvasPoint, getPenAnchorPoint],
1073
+ );
1074
+
1075
+ const beginPenNodeCreation = useCallback(
1076
+ (e: React.MouseEvent) => {
1077
+ if (e.button !== 0) return;
1078
+ e.preventDefault();
1079
+ e.stopPropagation();
1080
+ suppressNextPick.current = true;
1081
+
1082
+ const pathBefore = activePenPathRef.current?.closed
1083
+ ? null
1084
+ : activePenPathRef.current;
1085
+ const rawPoint = getCanvasPoint(e.clientX, e.clientY);
1086
+ if (
1087
+ pathBefore &&
1088
+ isPenCloseTarget(
1089
+ pathBefore,
1090
+ rawPoint,
1091
+ PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
1092
+ )
1093
+ ) {
1094
+ finishPenPath(closePenPath(pathBefore));
1095
+ return;
1096
+ }
1097
+
1098
+ const anchor = getPenAnchorPoint(
1099
+ e.clientX,
1100
+ e.clientY,
1101
+ e.shiftKey,
1102
+ pathBefore,
1103
+ );
1104
+ const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
1105
+ dragState.current = {
1106
+ type: "pen-node",
1107
+ originClient: { x: e.clientX, y: e.clientY },
1108
+ anchor,
1109
+ pathBefore: pathSnapshot,
1110
+ hasMoved: false,
1111
+ };
1112
+ setPenGesturePreview(
1113
+ appendPenNode(pathSnapshot, createCornerNode(anchor)),
1114
+ );
1115
+ setPenPointer(null);
1116
+ setPenCloseHover(false);
1117
+ setIsDragging(true);
1118
+ setDragCursor("crosshair");
1119
+
1120
+ const handleMouseMove = (ev: MouseEvent) => {
1121
+ const state = dragState.current;
1122
+ if (!state || state.type !== "pen-node") return;
1123
+ if (
1124
+ !state.hasMoved &&
1125
+ Math.hypot(
1126
+ ev.clientX - state.originClient.x,
1127
+ ev.clientY - state.originClient.y,
1128
+ ) >= DRAG_THRESHOLD
1129
+ ) {
1130
+ state.hasMoved = true;
1131
+ }
1132
+
1133
+ const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
1134
+ const node = state.hasMoved
1135
+ ? createSmoothNode(
1136
+ state.anchor,
1137
+ ev.shiftKey
1138
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
1139
+ : handlePoint,
1140
+ )
1141
+ : createCornerNode(state.anchor);
1142
+ setPenGesturePreview(appendPenNode(state.pathBefore, node));
1143
+ };
1144
+
1145
+ const handleMouseUp = (ev: MouseEvent) => {
1146
+ const state = dragState.current;
1147
+ if (!state || state.type !== "pen-node") {
1148
+ finishDrag();
1149
+ return;
1150
+ }
1151
+
1152
+ const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
1153
+ const node: PenNode = state.hasMoved
1154
+ ? createSmoothNode(
1155
+ state.anchor,
1156
+ ev.shiftKey
1157
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
1158
+ : handlePoint,
1159
+ )
1160
+ : createCornerNode(state.anchor);
1161
+ setActivePenPath(appendPenNode(state.pathBefore, node));
1162
+ setPenGesturePreview(null);
1163
+ setPenPointer(null);
1164
+ setPenCloseHover(false);
1165
+ onActiveToolChange?.("pen");
1166
+ finishDrag();
1167
+ };
1168
+
1169
+ installDragListeners(handleMouseMove, handleMouseUp);
1170
+ },
1171
+ [
1172
+ finishDrag,
1173
+ finishPenPath,
1174
+ getCanvasPoint,
1175
+ getPenAnchorPoint,
1176
+ installDragListeners,
1177
+ onActiveToolChange,
1178
+ ],
1179
+ );
1180
+
863
1181
  const beginDraftCreation = useCallback(
864
1182
  (tool: DraftCreationTool, e: React.MouseEvent) => {
865
1183
  if (e.button !== 0) return;
@@ -872,7 +1190,15 @@ export function MultiScreenCanvas({
872
1190
  originCanvas,
873
1191
  originCanvas,
874
1192
  );
875
- const initialPoints = tool === "pen" ? [originCanvas] : undefined;
1193
+ const initialPoints =
1194
+ tool === "pen"
1195
+ ? [originCanvas]
1196
+ : tool === "line" || tool === "arrow"
1197
+ ? [
1198
+ originCanvas,
1199
+ { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
1200
+ ]
1201
+ : undefined;
876
1202
  dragState.current = {
877
1203
  type: "draft-create",
878
1204
  tool,
@@ -926,6 +1252,10 @@ export function MultiScreenCanvas({
926
1252
  state.originCanvas,
927
1253
  nextCanvas,
928
1254
  ),
1255
+ points:
1256
+ state.tool === "line" || state.tool === "arrow"
1257
+ ? [state.originCanvas, nextCanvas]
1258
+ : undefined,
929
1259
  });
930
1260
  };
931
1261
 
@@ -937,60 +1267,43 @@ export function MultiScreenCanvas({
937
1267
  }
938
1268
 
939
1269
  let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
1270
+ const releaseMoved =
1271
+ state.hasMoved ||
1272
+ Math.hypot(
1273
+ ev.clientX - state.originClient.x,
1274
+ ev.clientY - state.originClient.y,
1275
+ ) >= DRAG_THRESHOLD;
1276
+ state.hasMoved = releaseMoved;
940
1277
  let points = state.tool === "pen" ? state.points : undefined;
941
1278
  if (state.tool === "pen") {
942
1279
  points = appendPolylinePoint(state.points, endCanvas, 0);
943
- if (!state.hasMoved || points.length < 2) {
1280
+ if (!releaseMoved || points.length < 2) {
944
1281
  finishDrag();
945
1282
  return;
946
1283
  }
947
1284
  endCanvas = points[points.length - 1] ?? endCanvas;
948
1285
  }
1286
+ if (state.tool === "frame" && onCreateScreenFrame) {
1287
+ onCreateScreenFrame(
1288
+ getDraftGeometryForTool(state.tool, state.originCanvas, endCanvas),
1289
+ );
1290
+ if (activeTool === undefined) {
1291
+ setLocalActiveTool("move");
1292
+ }
1293
+ onActiveToolChange?.("move");
1294
+ finishDrag();
1295
+ return;
1296
+ }
949
1297
  const nextDraft = createDraftPrimitive({
950
1298
  tool: state.tool,
951
1299
  start: state.originCanvas,
952
1300
  end: endCanvas,
953
1301
  points,
954
- moved: state.hasMoved,
1302
+ moved: releaseMoved,
955
1303
  toolProps,
956
1304
  fallbackText: t("designEditor.tools.text"),
957
1305
  });
958
- const targetFrame = getCurrentFrameEntries()
959
- .filter(({ geometry }) =>
960
- rectContainsPoint(
961
- {
962
- left: geometry.x,
963
- top: geometry.y,
964
- right: geometry.x + geometry.width,
965
- bottom: geometry.y + geometry.height,
966
- },
967
- getFrameCenter(nextDraft.geometry),
968
- ),
969
- )
970
- .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
971
-
972
- if (targetFrame && onCreatePrimitive) {
973
- const localPrimitive = draftPrimitiveToInsert(
974
- nextDraft,
975
- targetFrame.geometry,
976
- );
977
- const persisted = onCreatePrimitive(targetFrame.id, localPrimitive);
978
- if (persisted) {
979
- updateDraftPrimitives((current) =>
980
- current.filter((draft) => draft.id !== nextDraft.id),
981
- );
982
- updateSelectedDraftIds(() => []);
983
- updateSelectedIds(() => [targetFrame.id]);
984
- } else {
985
- updateDraftPrimitives((current) => [...current, nextDraft]);
986
- updateSelectedIds(() => []);
987
- updateSelectedDraftIds(() => [nextDraft.id]);
988
- }
989
- } else {
990
- updateDraftPrimitives((current) => [...current, nextDraft]);
991
- updateSelectedIds(() => []);
992
- updateSelectedDraftIds(() => [nextDraft.id]);
993
- }
1306
+ commitDraftPrimitive(nextDraft);
994
1307
  if (activeTool === undefined) {
995
1308
  setLocalActiveTool("move");
996
1309
  }
@@ -1009,17 +1322,14 @@ export function MultiScreenCanvas({
1009
1322
  },
1010
1323
  [
1011
1324
  activeTool,
1325
+ commitDraftPrimitive,
1012
1326
  finishDrag,
1013
1327
  getCanvasPoint,
1014
- getCurrentFrameEntries,
1015
1328
  installDragListeners,
1016
1329
  onActiveToolChange,
1017
- onCreatePrimitive,
1330
+ onCreateScreenFrame,
1018
1331
  t,
1019
1332
  toolProps,
1020
- updateDraftPrimitives,
1021
- updateSelectedDraftIds,
1022
- updateSelectedIds,
1023
1333
  ],
1024
1334
  );
1025
1335
 
@@ -1110,6 +1420,29 @@ export function MultiScreenCanvas({
1110
1420
  };
1111
1421
 
1112
1422
  const handleMouseUp = () => {
1423
+ const state = dragState.current;
1424
+ if (state?.type === "draft-move" && state.hasMoved) {
1425
+ const persisted: Array<{ draftId: string; frameId: string }> = [];
1426
+ draftPrimitivesRef.current.forEach((draft) => {
1427
+ if (!state.targetIds.includes(draft.id)) return;
1428
+ const frameId = persistDraftPrimitive(draft);
1429
+ if (frameId) persisted.push({ draftId: draft.id, frameId });
1430
+ });
1431
+
1432
+ if (persisted.length > 0) {
1433
+ const persistedDraftIds = new Set(
1434
+ persisted.map((entry) => entry.draftId),
1435
+ );
1436
+ const lastFrameId = persisted[persisted.length - 1]?.frameId;
1437
+ updateDraftPrimitives((current) =>
1438
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
1439
+ );
1440
+ updateSelectedDraftIds((current) =>
1441
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
1442
+ );
1443
+ if (lastFrameId) updateSelectedIds(() => [lastFrameId]);
1444
+ }
1445
+ }
1113
1446
  finishDrag();
1114
1447
  };
1115
1448
 
@@ -1119,6 +1452,7 @@ export function MultiScreenCanvas({
1119
1452
  finishDrag,
1120
1453
  getCurrentCanvasEntries,
1121
1454
  installDragListeners,
1455
+ persistDraftPrimitive,
1122
1456
  showTransformFeedback,
1123
1457
  updateDraftPrimitives,
1124
1458
  updateSelectedDraftIds,
@@ -1752,16 +2086,36 @@ export function MultiScreenCanvas({
1752
2086
  beginPan(e);
1753
2087
  return;
1754
2088
  }
2089
+ if (e.button === 0 && tool === "pen") {
2090
+ beginPenNodeCreation(e);
2091
+ return;
2092
+ }
2093
+ const creationTool = getDraftCreationTool(tool);
2094
+ if (e.button === 0 && creationTool) {
2095
+ beginDraftCreation(creationTool, e);
2096
+ return;
2097
+ }
1755
2098
  if (e.button === 0 && !onFrame) {
1756
- const creationTool = getDraftCreationTool(tool);
1757
- if (creationTool) {
1758
- beginDraftCreation(creationTool, e);
1759
- return;
1760
- }
1761
2099
  beginMarquee(e);
1762
2100
  }
1763
2101
  },
1764
- [activeTool, beginDraftCreation, beginMarquee, beginPan, localActiveTool],
2102
+ [
2103
+ activeTool,
2104
+ beginDraftCreation,
2105
+ beginMarquee,
2106
+ beginPan,
2107
+ beginPenNodeCreation,
2108
+ localActiveTool,
2109
+ ],
2110
+ );
2111
+
2112
+ const handleMouseMove = useCallback(
2113
+ (e: React.MouseEvent) => {
2114
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
2115
+ if (tool !== "pen" || dragState.current?.type === "pen-node") return;
2116
+ updatePenPointer(e.clientX, e.clientY, e.shiftKey);
2117
+ },
2118
+ [activeTool, localActiveTool, updatePenPointer],
1765
2119
  );
1766
2120
 
1767
2121
  const handleWheelEvent = useCallback(
@@ -1846,6 +2200,7 @@ export function MultiScreenCanvas({
1846
2200
 
1847
2201
  useEffect(() => {
1848
2202
  const handleKeyDown = (event: KeyboardEvent) => {
2203
+ if (activePenPathRef.current) return;
1849
2204
  if (!isArrowNudgeKey(event.key) || isEditableHotkeyTarget(event.target)) {
1850
2205
  return;
1851
2206
  }
@@ -1946,6 +2301,7 @@ export function MultiScreenCanvas({
1946
2301
 
1947
2302
  useEffect(() => {
1948
2303
  const handleKeyDown = (event: KeyboardEvent) => {
2304
+ if (activePenPathRef.current) return;
1949
2305
  if (
1950
2306
  (event.key !== "Delete" && event.key !== "Backspace") ||
1951
2307
  event.metaKey ||
@@ -1966,9 +2322,65 @@ export function MultiScreenCanvas({
1966
2322
  };
1967
2323
  }, [deleteSelectedItems]);
1968
2324
 
2325
+ useEffect(() => {
2326
+ const handleKeyDown = (event: KeyboardEvent) => {
2327
+ const path = activePenPathRef.current;
2328
+ if (
2329
+ !path ||
2330
+ event.metaKey ||
2331
+ event.ctrlKey ||
2332
+ isEditableHotkeyTarget(event.target)
2333
+ ) {
2334
+ return;
2335
+ }
2336
+
2337
+ if (event.key === "Enter" || event.key === "Escape") {
2338
+ event.preventDefault();
2339
+ event.stopPropagation();
2340
+ event.stopImmediatePropagation();
2341
+ finishPenPath(path);
2342
+ return;
2343
+ }
2344
+
2345
+ if (event.key === "Delete" || event.key === "Backspace") {
2346
+ event.preventDefault();
2347
+ event.stopPropagation();
2348
+ event.stopImmediatePropagation();
2349
+ const remainingNodes = path.nodes.slice(0, -1);
2350
+ if (remainingNodes.length === 0) {
2351
+ clearActivePenPath();
2352
+ return;
2353
+ }
2354
+ setActivePenPath({ nodes: remainingNodes, closed: false });
2355
+ setPenPointer(null);
2356
+ setPenCloseHover(false);
2357
+ }
2358
+ };
2359
+
2360
+ window.addEventListener("keydown", handleKeyDown, true);
2361
+ return () => {
2362
+ window.removeEventListener("keydown", handleKeyDown, true);
2363
+ };
2364
+ }, [clearActivePenPath, finishPenPath]);
2365
+
2366
+ useEffect(() => {
2367
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
2368
+ if (tool !== "pen") clearActivePenPath();
2369
+ }, [activeTool, clearActivePenPath, localActiveTool]);
2370
+
1969
2371
  const scale = canvasZoom / 100;
2372
+ const chromeScale = scale > 0 ? 1 / scale : 1;
1970
2373
  const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
1971
2374
  const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
2375
+ const penActive = effectiveTool === "pen";
2376
+ const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
2377
+ const displayedPenPath = penGesturePreview
2378
+ ? penGesturePreview
2379
+ : activePenPath && penPointer && activePenPath.nodes.length > 0
2380
+ ? penCloseHover
2381
+ ? closePenPath(activePenPath)
2382
+ : appendPenNode(activePenPath, createCornerNode(penPointer))
2383
+ : activePenPath;
1972
2384
  const selectedIdSet = new Set(selectedIds);
1973
2385
  const selectedDraftIdSet = new Set(selectedDraftIds);
1974
2386
  const surfaceCursor = isPanning
@@ -1977,7 +2389,7 @@ export function MultiScreenCanvas({
1977
2389
  ? dragCursor
1978
2390
  : isDragging && marquee
1979
2391
  ? "crosshair"
1980
- : getDraftCreationTool(effectiveTool)
2392
+ : penActive || getDraftCreationTool(effectiveTool)
1981
2393
  ? "crosshair"
1982
2394
  : effectiveTool === "hand"
1983
2395
  ? "grab"
@@ -2006,6 +2418,7 @@ export function MultiScreenCanvas({
2006
2418
  ref={surfaceRef}
2007
2419
  className="relative h-full w-full select-none overflow-hidden bg-background"
2008
2420
  onMouseDown={handleMouseDown}
2421
+ onMouseMove={handleMouseMove}
2009
2422
  style={{ cursor: surfaceCursor, touchAction: "none" }}
2010
2423
  >
2011
2424
  {showPixelGrid ? (
@@ -2046,6 +2459,9 @@ export function MultiScreenCanvas({
2046
2459
  isSelected={selectedIdSet.has(screen.id)}
2047
2460
  groupSelected={hasGroupSelection}
2048
2461
  handlesEnabled={!hasGroupSelection}
2462
+ penActive={penActive}
2463
+ creationToolActive={creationToolActive}
2464
+ chromeScale={chromeScale}
2049
2465
  onPick={handleFrameClick}
2050
2466
  onEdit={handleFrameDoubleClick}
2051
2467
  onStartFrameDrag={beginFrameDrag}
@@ -2062,6 +2478,8 @@ export function MultiScreenCanvas({
2062
2478
  draft={draft}
2063
2479
  isSelected={selectedDraftIdSet.has(draft.id)}
2064
2480
  groupSelected={Boolean(selectedDraftGroupBounds)}
2481
+ penActive={penActive}
2482
+ chromeScale={chromeScale}
2065
2483
  onClick={handleDraftClick}
2066
2484
  onStartDrag={beginDraftDrag}
2067
2485
  onStartResize={beginDraftResize}
@@ -2074,15 +2492,22 @@ export function MultiScreenCanvas({
2074
2492
  isSelected
2075
2493
  preview
2076
2494
  groupSelected={false}
2495
+ penActive={penActive}
2496
+ chromeScale={chromeScale}
2077
2497
  onClick={() => {}}
2078
2498
  onStartDrag={() => {}}
2079
2499
  onStartResize={() => {}}
2080
2500
  />
2081
2501
  ) : null}
2082
2502
 
2503
+ {displayedPenPath ? (
2504
+ <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
2505
+ ) : null}
2506
+
2083
2507
  {selectedGroupBounds ? (
2084
2508
  <GroupSelectionBox
2085
2509
  bounds={selectedGroupBounds}
2510
+ chromeScale={chromeScale}
2086
2511
  onStartResize={beginGroupResize}
2087
2512
  />
2088
2513
  ) : null}
@@ -2090,6 +2515,7 @@ export function MultiScreenCanvas({
2090
2515
  {selectedDraftGroupBounds ? (
2091
2516
  <GroupSelectionBox
2092
2517
  bounds={selectedDraftGroupBounds}
2518
+ chromeScale={chromeScale}
2093
2519
  onStartResize={beginDraftGroupResize}
2094
2520
  />
2095
2521
  ) : null}
@@ -2178,6 +2604,8 @@ function DraftPrimitiveLayer({
2178
2604
  draft,
2179
2605
  isSelected,
2180
2606
  groupSelected,
2607
+ penActive,
2608
+ chromeScale,
2181
2609
  preview = false,
2182
2610
  onClick,
2183
2611
  onStartDrag,
@@ -2186,6 +2614,8 @@ function DraftPrimitiveLayer({
2186
2614
  draft: DraftPrimitive;
2187
2615
  isSelected: boolean;
2188
2616
  groupSelected: boolean;
2617
+ penActive: boolean;
2618
+ chromeScale: number;
2189
2619
  preview?: boolean;
2190
2620
  onClick: (id: string, e: React.MouseEvent) => void;
2191
2621
  onStartDrag: (id: string, e: React.MouseEvent) => void;
@@ -2203,7 +2633,7 @@ function DraftPrimitiveLayer({
2203
2633
  type="button"
2204
2634
  className={cn(
2205
2635
  "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2206
- preview ? "cursor-crosshair" : "cursor-move",
2636
+ preview || penActive ? "cursor-crosshair" : "cursor-move",
2207
2637
  )}
2208
2638
  style={{
2209
2639
  left: SURFACE_PADDING + geometry.x,
@@ -2216,25 +2646,32 @@ function DraftPrimitiveLayer({
2216
2646
  : undefined,
2217
2647
  }}
2218
2648
  onClick={(event) => {
2649
+ if (penActive) return;
2219
2650
  if (!preview) onClick(draft.id, event);
2220
2651
  }}
2221
2652
  onMouseDown={(event) => {
2653
+ if (penActive) return;
2222
2654
  if (!preview) onStartDrag(draft.id, event);
2223
2655
  }}
2224
2656
  >
2225
2657
  <DraftPrimitiveContent draft={draft} preview={preview} />
2226
2658
  <span
2227
2659
  className={cn(
2228
- "pointer-events-none absolute -inset-[5px] rounded-sm border transition-opacity",
2660
+ "pointer-events-none absolute rounded-sm border transition-opacity",
2229
2661
  selected
2230
2662
  ? "border-[var(--design-editor-accent-color)] opacity-100"
2231
2663
  : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
2232
2664
  )}
2665
+ style={{
2666
+ inset: -5 * chromeScale,
2667
+ borderWidth: 1.5 * chromeScale,
2668
+ }}
2233
2669
  />
2234
2670
  <ResizeHandles
2235
2671
  active={selected || preview}
2236
- enabled={(isSelected && !groupSelected) || preview}
2672
+ enabled={!penActive && ((isSelected && !groupSelected) || preview)}
2237
2673
  showRotate={false}
2674
+ chromeScale={chromeScale}
2238
2675
  onStartResize={(handle, event) =>
2239
2676
  onStartResize(draft.id, handle, event)
2240
2677
  }
@@ -2252,19 +2689,45 @@ function DraftPrimitiveContent({
2252
2689
  preview: boolean;
2253
2690
  }) {
2254
2691
  const muted = preview ? "opacity-70" : "";
2255
- if (draft.kind === "path") {
2692
+ if (
2693
+ draft.kind === "path" ||
2694
+ draft.kind === "line" ||
2695
+ draft.kind === "arrow"
2696
+ ) {
2697
+ const markerId = `arrow-${draft.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
2698
+ const pathData =
2699
+ draft.pathData ??
2700
+ (draft.penPath
2701
+ ? serializePenPath(draft.penPath)
2702
+ : pointsToPath(draft.points ?? []));
2256
2703
  return (
2257
2704
  <svg
2258
2705
  className={cn("block size-full overflow-visible", muted)}
2259
2706
  viewBox={`${draft.geometry.x} ${draft.geometry.y} ${draft.geometry.width} ${draft.geometry.height}`}
2260
2707
  >
2708
+ {draft.kind === "arrow" ? (
2709
+ <defs>
2710
+ <marker
2711
+ id={markerId}
2712
+ markerWidth="10"
2713
+ markerHeight="10"
2714
+ refX="8"
2715
+ refY="5"
2716
+ orient="auto"
2717
+ markerUnits="strokeWidth"
2718
+ >
2719
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
2720
+ </marker>
2721
+ </defs>
2722
+ ) : null}
2261
2723
  <path
2262
- d={pointsToPath(draft.points ?? [])}
2724
+ d={pathData}
2263
2725
  fill="none"
2264
2726
  stroke={draft.stroke ?? "hsl(var(--primary))"}
2265
2727
  strokeLinecap="round"
2266
2728
  strokeLinejoin="round"
2267
2729
  strokeWidth={draft.strokeWidth ?? 3}
2730
+ markerEnd={draft.kind === "arrow" ? `url(#${markerId})` : undefined}
2268
2731
  />
2269
2732
  </svg>
2270
2733
  );
@@ -2294,6 +2757,43 @@ function DraftPrimitiveContent({
2294
2757
  );
2295
2758
  }
2296
2759
 
2760
+ if (draft.kind === "ellipse") {
2761
+ return (
2762
+ <div
2763
+ className={cn("size-full rounded-full border", muted)}
2764
+ style={{
2765
+ background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2766
+ borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2767
+ borderWidth: draft.strokeWidth ?? 1,
2768
+ }}
2769
+ />
2770
+ );
2771
+ }
2772
+
2773
+ if (draft.kind === "polygon" || draft.kind === "star") {
2774
+ return (
2775
+ <svg
2776
+ className={cn("block size-full overflow-visible", muted)}
2777
+ viewBox={`0 0 ${Math.max(1, draft.geometry.width)} ${Math.max(
2778
+ 1,
2779
+ draft.geometry.height,
2780
+ )}`}
2781
+ >
2782
+ <polygon
2783
+ points={polygonPointsForBox(
2784
+ draft.kind,
2785
+ draft.geometry.width,
2786
+ draft.geometry.height,
2787
+ )}
2788
+ fill={draft.fill ?? "hsl(var(--primary) / 0.12)"}
2789
+ stroke={draft.stroke ?? "hsl(var(--primary))"}
2790
+ strokeLinejoin="round"
2791
+ strokeWidth={draft.strokeWidth ?? 1.5}
2792
+ />
2793
+ </svg>
2794
+ );
2795
+ }
2796
+
2297
2797
  return (
2298
2798
  <div
2299
2799
  className={cn("size-full rounded-sm border", muted)}
@@ -2306,6 +2806,112 @@ function DraftPrimitiveContent({
2306
2806
  );
2307
2807
  }
2308
2808
 
2809
+ function PenPathOverlay({
2810
+ path,
2811
+ closeHover,
2812
+ }: {
2813
+ path: PenPath;
2814
+ closeHover: boolean;
2815
+ }) {
2816
+ const geometry = getPenPathGeometry(path);
2817
+ const pathData = serializePenPath(path);
2818
+ return (
2819
+ <div
2820
+ data-pen-path-overlay
2821
+ className="pointer-events-none absolute z-[90]"
2822
+ style={{
2823
+ left: SURFACE_PADDING + geometry.x,
2824
+ top: SURFACE_PADDING + geometry.y,
2825
+ width: geometry.width,
2826
+ height: geometry.height,
2827
+ }}
2828
+ >
2829
+ <svg
2830
+ className="absolute inset-0 size-full overflow-visible"
2831
+ viewBox={`${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`}
2832
+ >
2833
+ {path.nodes.map((node, index) => (
2834
+ <g key={`handles-${index}`}>
2835
+ {node.handleIn ? (
2836
+ <line
2837
+ x1={node.point.x}
2838
+ y1={node.point.y}
2839
+ x2={node.handleIn.x}
2840
+ y2={node.handleIn.y}
2841
+ stroke="var(--design-editor-accent-color)"
2842
+ strokeDasharray="3 3"
2843
+ strokeWidth={1}
2844
+ />
2845
+ ) : null}
2846
+ {node.handleOut ? (
2847
+ <line
2848
+ x1={node.point.x}
2849
+ y1={node.point.y}
2850
+ x2={node.handleOut.x}
2851
+ y2={node.handleOut.y}
2852
+ stroke="var(--design-editor-accent-color)"
2853
+ strokeDasharray="3 3"
2854
+ strokeWidth={1}
2855
+ />
2856
+ ) : null}
2857
+ </g>
2858
+ ))}
2859
+ <path
2860
+ d={pathData}
2861
+ fill="none"
2862
+ stroke="rgba(255,255,255,0.95)"
2863
+ strokeLinecap="round"
2864
+ strokeLinejoin="round"
2865
+ strokeWidth={5}
2866
+ />
2867
+ <path
2868
+ d={pathData}
2869
+ fill="none"
2870
+ stroke="var(--design-editor-accent-color)"
2871
+ strokeLinecap="round"
2872
+ strokeLinejoin="round"
2873
+ strokeWidth={2}
2874
+ />
2875
+ </svg>
2876
+ {path.nodes.map((node, index) => (
2877
+ <span
2878
+ key={`anchor-${index}`}
2879
+ data-pen-anchor
2880
+ className={cn(
2881
+ "absolute size-2 rounded-[2px] border shadow-sm",
2882
+ index === 0 && closeHover
2883
+ ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
2884
+ : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
2885
+ )}
2886
+ style={{
2887
+ left: node.point.x - geometry.x - 4,
2888
+ top: node.point.y - geometry.y - 4,
2889
+ }}
2890
+ />
2891
+ ))}
2892
+ {path.nodes.flatMap((node, index) =>
2893
+ [node.handleIn, node.handleOut]
2894
+ .filter(isPoint)
2895
+ .map((handle, handleIndex) => (
2896
+ <span
2897
+ key={`handle-${index}-${handleIndex}`}
2898
+ data-pen-handle
2899
+ className="absolute size-1.5 rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
2900
+ style={{
2901
+ left: handle.x - geometry.x - 3,
2902
+ top: handle.y - geometry.y - 3,
2903
+ }}
2904
+ />
2905
+ )),
2906
+ )}
2907
+ </div>
2908
+ );
2909
+ }
2910
+
2911
+ function isPoint(point: Point | undefined): point is Point {
2912
+ return !!point;
2913
+ }
2914
+
2309
2915
  function Screen({
2310
2916
  screen,
2311
2917
  metadata,
@@ -2314,6 +2920,9 @@ function Screen({
2314
2920
  isSelected,
2315
2921
  groupSelected,
2316
2922
  handlesEnabled,
2923
+ penActive,
2924
+ creationToolActive,
2925
+ chromeScale,
2317
2926
  onPick,
2318
2927
  onEdit,
2319
2928
  onStartFrameDrag,
@@ -2329,6 +2938,9 @@ function Screen({
2329
2938
  isSelected: boolean;
2330
2939
  groupSelected: boolean;
2331
2940
  handlesEnabled: boolean;
2941
+ penActive: boolean;
2942
+ creationToolActive: boolean;
2943
+ chromeScale: number;
2332
2944
  screenContent?: ReactNode;
2333
2945
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
2334
2946
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
@@ -2351,7 +2963,8 @@ function Screen({
2351
2963
  const suppressNextClick = useRef(false);
2352
2964
  const emphasized = isActive || isSelected;
2353
2965
  const selectionOutlined = isSelected && !groupSelected;
2354
- const showHoverOutline = !isSelected || !groupSelected;
2966
+ const showHoverOutline =
2967
+ !creationToolActive && (!isSelected || !groupSelected);
2355
2968
 
2356
2969
  return (
2357
2970
  <div
@@ -2372,6 +2985,7 @@ function Screen({
2372
2985
  className="flex h-7 w-full cursor-default items-center justify-between gap-2 px-1"
2373
2986
  onMouseDown={(e) => {
2374
2987
  if (e.button !== 0) return;
2988
+ if (penActive || creationToolActive) return;
2375
2989
  if (e.shiftKey) {
2376
2990
  e.stopPropagation();
2377
2991
  return;
@@ -2399,13 +3013,32 @@ function Screen({
2399
3013
  {metadata.width} x {metadata.height}
2400
3014
  </span>
2401
3015
  </div>
2402
- <div className="h-5 shrink-0" />
3016
+ <button
3017
+ type="button"
3018
+ className={cn(
3019
+ "flex h-5 max-w-[46%] shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
3020
+ "hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
3021
+ "group-hover/frame:opacity-100 group-focus-within/frame:opacity-100",
3022
+ emphasized && "opacity-100",
3023
+ )}
3024
+ aria-label={t("multiScreenCanvas.fullView")}
3025
+ title={t("multiScreenCanvas.fullView")}
3026
+ onClick={(event) => onEdit(screen.id, event)}
3027
+ onMouseDown={(event) => {
3028
+ event.preventDefault();
3029
+ event.stopPropagation();
3030
+ }}
3031
+ >
3032
+ <IconMaximize className="size-3 shrink-0" />
3033
+ <span className="truncate">{t("multiScreenCanvas.fullView")}</span>
3034
+ </button>
2403
3035
  </div>
2404
3036
  <div
2405
3037
  data-screen-card
2406
3038
  role="button"
2407
3039
  tabIndex={0}
2408
3040
  onClick={(e) => {
3041
+ if (isInteractiveScreenContentTarget(e.target)) return;
2409
3042
  e.stopPropagation();
2410
3043
  if (suppressNextClick.current) {
2411
3044
  suppressNextClick.current = false;
@@ -2415,14 +3048,18 @@ function Screen({
2415
3048
  onPick(screen.id, e);
2416
3049
  }}
2417
3050
  onDoubleClick={(e) => {
3051
+ if (isInteractiveScreenContentTarget(e.target)) return;
2418
3052
  e.preventDefault();
2419
3053
  e.stopPropagation();
2420
3054
  }}
2421
3055
  onMouseDown={(e) => {
3056
+ if (creationToolActive) return;
3057
+ if (isInteractiveScreenContentTarget(e.target)) return;
2422
3058
  if (e.detail > 1) {
2423
3059
  e.stopPropagation();
2424
3060
  return;
2425
3061
  }
3062
+ if (penActive) return;
2426
3063
  if (e.altKey && e.button === 0) {
2427
3064
  suppressNextClick.current = true;
2428
3065
  onStartDuplicateGesture(screen, display, e);
@@ -2446,25 +3083,36 @@ function Screen({
2446
3083
  style={{
2447
3084
  width: geometry.width,
2448
3085
  height: geometry.height,
2449
- cursor: isSelected ? "move" : "pointer",
3086
+ cursor:
3087
+ penActive || creationToolActive
3088
+ ? "crosshair"
3089
+ : isSelected
3090
+ ? "move"
3091
+ : "pointer",
2450
3092
  touchAction: "none",
2451
3093
  }}
2452
3094
  >
2453
3095
  <span
2454
3096
  className={cn(
2455
- "pointer-events-none absolute -inset-[5px] rounded-[13px] border transition-opacity",
3097
+ "pointer-events-none absolute border transition-opacity",
2456
3098
  selectionOutlined
2457
3099
  ? "border-[var(--design-editor-accent-color)] opacity-100"
2458
3100
  : showHoverOutline
2459
3101
  ? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
2460
3102
  : "border-transparent opacity-0",
2461
3103
  )}
3104
+ style={{
3105
+ inset: -5 * chromeScale,
3106
+ borderRadius: 13 * chromeScale,
3107
+ borderWidth: 1.5 * chromeScale,
3108
+ }}
2462
3109
  />
2463
3110
  <span
2464
3111
  className={cn(
2465
3112
  "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
2466
3113
  "border-border group-hover/artboard:border-muted-foreground/60",
2467
3114
  )}
3115
+ style={{ pointerEvents: penActive ? "none" : undefined }}
2468
3116
  >
2469
3117
  {screenContent ?? (
2470
3118
  <iframe
@@ -2484,31 +3132,19 @@ function Screen({
2484
3132
  title={screen.filename}
2485
3133
  />
2486
3134
  )}
3135
+ {creationToolActive ? (
3136
+ <span
3137
+ className="absolute inset-0 z-20 cursor-crosshair"
3138
+ aria-hidden="true"
3139
+ />
3140
+ ) : null}
2487
3141
  <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
2488
- <button
2489
- type="button"
2490
- className={cn(
2491
- "absolute right-2 top-2 z-20 flex h-7 max-w-[calc(100%-1rem)] translate-y-1 items-center gap-1 rounded-md border border-border bg-background/95 px-2 text-[10px] font-medium text-foreground opacity-0 shadow-sm backdrop-blur transition-all",
2492
- "hover:bg-accent hover:text-accent-foreground focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
2493
- "group-hover/artboard:translate-y-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:translate-y-0 group-focus-visible/artboard:opacity-100",
2494
- emphasized && "translate-y-0 opacity-100",
2495
- )}
2496
- aria-label={t("multiScreenCanvas.fullView")}
2497
- title={t("multiScreenCanvas.fullView")}
2498
- onClick={(event) => onEdit(screen.id, event)}
2499
- onMouseDown={(event) => {
2500
- event.preventDefault();
2501
- event.stopPropagation();
2502
- }}
2503
- >
2504
- <IconMaximize className="size-3" />
2505
- <span>{t("multiScreenCanvas.fullView")}</span>
2506
- </button>
2507
3142
  </span>
2508
3143
  <ResizeHandles
2509
3144
  active={selectionOutlined}
2510
- enabled={handlesEnabled}
3145
+ enabled={!penActive && !creationToolActive && handlesEnabled}
2511
3146
  showRotate={false}
3147
+ chromeScale={chromeScale}
2512
3148
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
2513
3149
  onStartRotate={(e) => onStartRotate(screen.id, e)}
2514
3150
  />
@@ -2519,26 +3155,31 @@ function Screen({
2519
3155
 
2520
3156
  function GroupSelectionBox({
2521
3157
  bounds,
3158
+ chromeScale,
2522
3159
  onStartResize,
2523
3160
  }: {
2524
3161
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
3162
+ chromeScale: number;
2525
3163
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
2526
3164
  }) {
2527
3165
  return (
2528
3166
  <div
2529
3167
  data-frame-shell
2530
- className="pointer-events-none absolute z-30 rounded-[13px] border border-[var(--design-editor-accent-color)]"
3168
+ className="pointer-events-none absolute z-30 border border-[var(--design-editor-accent-color)]"
2531
3169
  style={{
2532
3170
  left: SURFACE_PADDING + bounds.left,
2533
3171
  top: SURFACE_PADDING + bounds.top,
2534
3172
  width: bounds.width,
2535
3173
  height: bounds.height,
3174
+ borderRadius: 13 * chromeScale,
3175
+ borderWidth: 1.5 * chromeScale,
2536
3176
  }}
2537
3177
  >
2538
3178
  <ResizeHandles
2539
3179
  active
2540
3180
  enabled
2541
3181
  showRotate={false}
3182
+ chromeScale={chromeScale}
2542
3183
  onStartResize={onStartResize}
2543
3184
  onStartRotate={() => {}}
2544
3185
  />
@@ -2550,19 +3191,21 @@ function ResizeHandles({
2550
3191
  active,
2551
3192
  enabled,
2552
3193
  showRotate = true,
3194
+ chromeScale = 1,
2553
3195
  onStartResize,
2554
3196
  onStartRotate,
2555
3197
  }: {
2556
3198
  active: boolean;
2557
3199
  enabled: boolean;
2558
3200
  showRotate?: boolean;
3201
+ chromeScale?: number;
2559
3202
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
2560
3203
  onStartRotate: (e: React.MouseEvent) => void;
2561
3204
  }) {
2562
3205
  if (!enabled) return null;
2563
3206
 
2564
3207
  const visibleHandleClass = cn(
2565
- "pointer-events-auto absolute z-20 size-2 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow-sm transition-opacity",
3208
+ "pointer-events-auto absolute z-20 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow transition-opacity",
2566
3209
  active
2567
3210
  ? "opacity-100"
2568
3211
  : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
@@ -2576,8 +3219,8 @@ function ResizeHandles({
2576
3219
  <span
2577
3220
  key={config.handle}
2578
3221
  data-resize-handle
2579
- className={cn(edgeHandleClass, config.className)}
2580
- style={{ cursor: config.cursor }}
3222
+ className={edgeHandleClass}
3223
+ style={edgeHandleStyle(config.handle, config.cursor, chromeScale)}
2581
3224
  onMouseDown={(e) => onStartResize(config.handle, e)}
2582
3225
  />
2583
3226
  ))}
@@ -2585,8 +3228,8 @@ function ResizeHandles({
2585
3228
  <span
2586
3229
  key={config.handle}
2587
3230
  data-resize-handle
2588
- className={cn(visibleHandleClass, config.className)}
2589
- style={{ cursor: config.cursor }}
3231
+ className={visibleHandleClass}
3232
+ style={cornerHandleStyle(config.handle, config.cursor, chromeScale)}
2590
3233
  onMouseDown={(e) => onStartResize(config.handle, e)}
2591
3234
  />
2592
3235
  ))}
@@ -2600,9 +3243,8 @@ function ResizeHandles({
2600
3243
  active
2601
3244
  ? "opacity-100"
2602
3245
  : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
2603
- config.className,
2604
3246
  )}
2605
- style={{ cursor: "grab" }}
3247
+ style={rotateHandleStyle(config.corner, chromeScale)}
2606
3248
  onMouseDown={onStartRotate}
2607
3249
  />
2608
3250
  ))
@@ -2613,40 +3255,22 @@ function ResizeHandles({
2613
3255
 
2614
3256
  const CORNER_RESIZE_HANDLE_CONFIGS: Array<{
2615
3257
  handle: ResizeHandle;
2616
- className: string;
2617
3258
  cursor: string;
2618
3259
  }> = [
2619
- { handle: "nw", className: "-left-1 -top-1", cursor: "nwse-resize" },
2620
- { handle: "ne", className: "-right-1 -top-1", cursor: "nesw-resize" },
2621
- { handle: "se", className: "-bottom-1 -right-1", cursor: "nwse-resize" },
2622
- { handle: "sw", className: "-bottom-1 -left-1", cursor: "nesw-resize" },
3260
+ { handle: "nw", cursor: "nwse-resize" },
3261
+ { handle: "ne", cursor: "nesw-resize" },
3262
+ { handle: "se", cursor: "nwse-resize" },
3263
+ { handle: "sw", cursor: "nesw-resize" },
2623
3264
  ];
2624
3265
 
2625
3266
  const EDGE_RESIZE_HANDLE_CONFIGS: Array<{
2626
3267
  handle: ResizeHandle;
2627
- className: string;
2628
3268
  cursor: string;
2629
3269
  }> = [
2630
- {
2631
- handle: "n",
2632
- className: "-top-1 left-0 right-0 h-2",
2633
- cursor: "ns-resize",
2634
- },
2635
- {
2636
- handle: "e",
2637
- className: "-right-1 bottom-0 top-0 w-2",
2638
- cursor: "ew-resize",
2639
- },
2640
- {
2641
- handle: "s",
2642
- className: "-bottom-1 left-0 right-0 h-2",
2643
- cursor: "ns-resize",
2644
- },
2645
- {
2646
- handle: "w",
2647
- className: "-left-1 bottom-0 top-0 w-2",
2648
- cursor: "ew-resize",
2649
- },
3270
+ { handle: "n", cursor: "ns-resize" },
3271
+ { handle: "e", cursor: "ew-resize" },
3272
+ { handle: "s", cursor: "ns-resize" },
3273
+ { handle: "w", cursor: "ew-resize" },
2650
3274
  ];
2651
3275
 
2652
3276
  const ALL_RESIZE_HANDLE_CONFIGS = [
@@ -2656,13 +3280,61 @@ const ALL_RESIZE_HANDLE_CONFIGS = [
2656
3280
 
2657
3281
  const ROTATE_HANDLE_CONFIGS: Array<{
2658
3282
  corner: string;
2659
- className: string;
2660
- }> = [
2661
- { corner: "nw", className: "-left-7 -top-7" },
2662
- { corner: "ne", className: "-right-7 -top-7" },
2663
- { corner: "se", className: "-bottom-7 -right-7" },
2664
- { corner: "sw", className: "-bottom-7 -left-7" },
2665
- ];
3283
+ }> = [{ corner: "nw" }, { corner: "ne" }, { corner: "se" }, { corner: "sw" }];
3284
+
3285
+ function edgeHandleStyle(
3286
+ handle: ResizeHandle,
3287
+ cursor: string,
3288
+ chromeScale: number,
3289
+ ): CSSProperties {
3290
+ const size = 14 * chromeScale;
3291
+ const offset = -size / 2;
3292
+ if (handle === "n" || handle === "s") {
3293
+ return {
3294
+ cursor,
3295
+ left: 0,
3296
+ right: 0,
3297
+ height: size,
3298
+ [handle === "n" ? "top" : "bottom"]: offset,
3299
+ };
3300
+ }
3301
+ return {
3302
+ cursor,
3303
+ top: 0,
3304
+ bottom: 0,
3305
+ width: size,
3306
+ [handle === "w" ? "left" : "right"]: offset,
3307
+ };
3308
+ }
3309
+
3310
+ function cornerHandleStyle(
3311
+ handle: ResizeHandle,
3312
+ cursor: string,
3313
+ chromeScale: number,
3314
+ ): CSSProperties {
3315
+ const size = 10 * chromeScale;
3316
+ const offset = -size / 2;
3317
+ return {
3318
+ cursor,
3319
+ width: size,
3320
+ height: size,
3321
+ borderWidth: Math.max(1, 1.25 * chromeScale),
3322
+ ...(handle.includes("n") ? { top: offset } : { bottom: offset }),
3323
+ ...(handle.includes("w") ? { left: offset } : { right: offset }),
3324
+ };
3325
+ }
3326
+
3327
+ function rotateHandleStyle(corner: string, chromeScale: number): CSSProperties {
3328
+ const size = 28 * chromeScale;
3329
+ const offset = -34 * chromeScale;
3330
+ return {
3331
+ cursor: "grab",
3332
+ width: size,
3333
+ height: size,
3334
+ ...(corner.includes("n") ? { top: offset } : { bottom: offset }),
3335
+ ...(corner.includes("w") ? { left: offset } : { right: offset }),
3336
+ };
3337
+ }
2666
3338
 
2667
3339
  interface FrameEntry {
2668
3340
  id: string;
@@ -2735,6 +3407,17 @@ function isEditableHotkeyTarget(target: EventTarget | null) {
2735
3407
  return tagName === "input" || tagName === "textarea" || tagName === "select";
2736
3408
  }
2737
3409
 
3410
+ function isInteractiveScreenContentTarget(target: EventTarget | null) {
3411
+ return (
3412
+ target instanceof Element &&
3413
+ Boolean(
3414
+ target.closest(
3415
+ ".design-canvas-iframe-wrapper,[data-design-preview-iframe]",
3416
+ ),
3417
+ )
3418
+ );
3419
+ }
3420
+
2738
3421
  function frameBoundsToGeometry(bounds: {
2739
3422
  left: number;
2740
3423
  top: number;
@@ -2828,7 +3511,9 @@ function getDraftGeometryForTool(
2828
3511
  start: Point,
2829
3512
  end: Point,
2830
3513
  ): FrameGeometry {
2831
- if (tool === "pen") return getPathGeometry([start, end]);
3514
+ if (tool === "pen" || tool === "line" || tool === "arrow") {
3515
+ return getPathGeometry([start, end]);
3516
+ }
2832
3517
  const options =
2833
3518
  tool === "frame"
2834
3519
  ? {
@@ -2885,9 +3570,7 @@ function createDraftPrimitive({
2885
3570
  toolProps,
2886
3571
  fallbackText,
2887
3572
  }: DraftPrimitiveInput): DraftPrimitive {
2888
- const id = `draft-${tool}-${Date.now()}-${Math.random()
2889
- .toString(36)
2890
- .slice(2, 8)}`;
3573
+ const id = createDraftId(tool);
2891
3574
  const geometry = moved
2892
3575
  ? getDraftGeometryForTool(tool, start, end)
2893
3576
  : getDraftGeometryForTool(tool, start, start);
@@ -2900,14 +3583,17 @@ function createDraftPrimitive({
2900
3583
  { x: start.x + 64, y: start.y + 24 },
2901
3584
  { x: start.x + 128, y: start.y },
2902
3585
  ];
2903
- return {
2904
- id,
2905
- kind: "path",
2906
- geometry: getPathGeometry(pathPoints),
2907
- points: pathPoints,
2908
- stroke: toolProps?.stroke,
2909
- strokeWidth: toolProps?.strokeWidth ?? 3,
2910
- };
3586
+ return createPenDraftPrimitive(
3587
+ {
3588
+ nodes: pathPoints.map(createCornerNode),
3589
+ closed: false,
3590
+ },
3591
+ {
3592
+ id,
3593
+ stroke: toolProps?.stroke,
3594
+ strokeWidth: toolProps?.strokeWidth,
3595
+ },
3596
+ );
2911
3597
  }
2912
3598
  if (tool === "text") {
2913
3599
  return {
@@ -2917,11 +3603,30 @@ function createDraftPrimitive({
2917
3603
  text: toolProps?.text ?? fallbackText,
2918
3604
  fill: toolProps?.fill,
2919
3605
  stroke: toolProps?.stroke,
3606
+ autoSize: !moved,
3607
+ };
3608
+ }
3609
+ if (tool === "line" || tool === "arrow") {
3610
+ const pathPoints = moved
3611
+ ? [start, end]
3612
+ : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
3613
+ return {
3614
+ id,
3615
+ kind: tool,
3616
+ geometry: getPathGeometry(pathPoints),
3617
+ points: pathPoints,
3618
+ stroke: toolProps?.stroke,
3619
+ strokeWidth: toolProps?.strokeWidth ?? 3,
2920
3620
  };
2921
3621
  }
2922
3622
  return {
2923
3623
  id,
2924
- kind: tool === "frame" ? "frame" : "rectangle",
3624
+ kind:
3625
+ tool === "frame"
3626
+ ? "frame"
3627
+ : tool === "ellipse" || tool === "polygon" || tool === "star"
3628
+ ? tool
3629
+ : "rectangle",
2925
3630
  geometry,
2926
3631
  fill: toolProps?.fill,
2927
3632
  stroke: toolProps?.stroke,
@@ -2929,33 +3634,83 @@ function createDraftPrimitive({
2929
3634
  };
2930
3635
  }
2931
3636
 
3637
+ function createPenDraftPrimitive(
3638
+ path: PenPath,
3639
+ {
3640
+ id = createDraftId("pen"),
3641
+ stroke,
3642
+ strokeWidth,
3643
+ }: { id?: string; stroke?: string; strokeWidth?: number } = {},
3644
+ ): DraftPrimitive {
3645
+ const penPath = clonePenPath(path);
3646
+ return {
3647
+ id,
3648
+ kind: "path",
3649
+ geometry: getPenPathGeometry(penPath),
3650
+ penPath,
3651
+ pathData: serializePenPath(penPath),
3652
+ stroke,
3653
+ strokeWidth: strokeWidth ?? 3,
3654
+ };
3655
+ }
3656
+
3657
+ function createDraftId(tool: DraftCreationTool) {
3658
+ return `draft-${tool}-${Date.now()}-${Math.random()
3659
+ .toString(36)
3660
+ .slice(2, 8)}`;
3661
+ }
3662
+
2932
3663
  function cloneDraftPrimitive(draft: DraftPrimitive): DraftPrimitive {
2933
3664
  return {
2934
3665
  ...draft,
2935
3666
  geometry: { ...draft.geometry },
2936
3667
  points: draft.points?.map((point) => ({ ...point })),
3668
+ penPath: draft.penPath ? clonePenPath(draft.penPath) : undefined,
2937
3669
  };
2938
3670
  }
2939
3671
 
2940
3672
  function draftPrimitiveToInsert(
2941
3673
  draft: DraftPrimitive,
2942
3674
  frameGeometry: FrameGeometry,
3675
+ metadata?: ResolvedScreenMetadata,
2943
3676
  ): CanvasPrimitiveInsert {
3677
+ const scaleX =
3678
+ metadata && frameGeometry.width > 0
3679
+ ? metadata.width / frameGeometry.width
3680
+ : 1;
3681
+ const scaleY =
3682
+ metadata && frameGeometry.height > 0
3683
+ ? metadata.height / frameGeometry.height
3684
+ : 1;
3685
+ const toLocalPoint = (point: Point) => ({
3686
+ x: Math.round((point.x - frameGeometry.x) * scaleX),
3687
+ y: Math.round((point.y - frameGeometry.y) * scaleY),
3688
+ });
3689
+ const localGeometry = {
3690
+ ...draft.geometry,
3691
+ x: Math.round((draft.geometry.x - frameGeometry.x) * scaleX),
3692
+ y: Math.round((draft.geometry.y - frameGeometry.y) * scaleY),
3693
+ width: Math.max(1, Math.round(draft.geometry.width * scaleX)),
3694
+ height: Math.max(1, Math.round(draft.geometry.height * scaleY)),
3695
+ };
3696
+ const scaledPenPath = draft.penPath
3697
+ ? scalePenPathToGeometry(draft.penPath, frameGeometry, {
3698
+ x: 0,
3699
+ y: 0,
3700
+ width: metadata?.width ?? frameGeometry.width,
3701
+ height: metadata?.height ?? frameGeometry.height,
3702
+ })
3703
+ : undefined;
2944
3704
  return {
2945
3705
  kind: draft.kind,
2946
- geometry: {
2947
- ...draft.geometry,
2948
- x: Math.round(draft.geometry.x - frameGeometry.x),
2949
- y: Math.round(draft.geometry.y - frameGeometry.y),
2950
- },
2951
- points: draft.points?.map((point) => ({
2952
- x: Math.round(point.x - frameGeometry.x),
2953
- y: Math.round(point.y - frameGeometry.y),
2954
- })),
3706
+ geometry: localGeometry,
3707
+ points: draft.points?.map(toLocalPoint),
3708
+ pathData: scaledPenPath ? serializePenPath(scaledPenPath) : undefined,
2955
3709
  text: draft.text,
2956
3710
  fill: draft.fill,
2957
3711
  stroke: draft.stroke,
2958
3712
  strokeWidth: draft.strokeWidth,
3713
+ autoSize: draft.autoSize,
2959
3714
  };
2960
3715
  }
2961
3716
 
@@ -2964,6 +3719,9 @@ function moveDraftPrimitive(
2964
3719
  dx: number,
2965
3720
  dy: number,
2966
3721
  ): DraftPrimitive {
3722
+ const movedPenPath = draft.penPath
3723
+ ? translatePenPath(draft.penPath, dx, dy)
3724
+ : undefined;
2967
3725
  return {
2968
3726
  ...draft,
2969
3727
  geometry: {
@@ -2975,6 +3733,8 @@ function moveDraftPrimitive(
2975
3733
  x: point.x + dx,
2976
3734
  y: point.y + dy,
2977
3735
  })),
3736
+ penPath: movedPenPath,
3737
+ pathData: movedPenPath ? serializePenPath(movedPenPath) : draft.pathData,
2978
3738
  };
2979
3739
  }
2980
3740
 
@@ -2982,17 +3742,21 @@ function applyDraftGeometry(
2982
3742
  draft: DraftPrimitive,
2983
3743
  geometry: FrameGeometry,
2984
3744
  ): DraftPrimitive {
2985
- if (!draft.points?.length) return { ...draft, geometry };
2986
3745
  const origin = draft.geometry;
2987
3746
  const scaleX = geometry.width / Math.max(1, origin.width);
2988
3747
  const scaleY = geometry.height / Math.max(1, origin.height);
3748
+ const scaledPenPath = draft.penPath
3749
+ ? scalePenPathToGeometry(draft.penPath, origin, geometry)
3750
+ : undefined;
2989
3751
  return {
2990
3752
  ...draft,
2991
3753
  geometry,
2992
- points: draft.points.map((point) => ({
3754
+ points: draft.points?.map((point) => ({
2993
3755
  x: geometry.x + (point.x - origin.x) * scaleX,
2994
3756
  y: geometry.y + (point.y - origin.y) * scaleY,
2995
3757
  })),
3758
+ penPath: scaledPenPath,
3759
+ pathData: scaledPenPath ? serializePenPath(scaledPenPath) : draft.pathData,
2996
3760
  };
2997
3761
  }
2998
3762
 
@@ -3008,6 +3772,11 @@ function getDraftCreationTool(
3008
3772
  if (
3009
3773
  tool === "frame" ||
3010
3774
  tool === "rect" ||
3775
+ tool === "line" ||
3776
+ tool === "arrow" ||
3777
+ tool === "ellipse" ||
3778
+ tool === "polygon" ||
3779
+ tool === "star" ||
3011
3780
  tool === "text" ||
3012
3781
  tool === "pen"
3013
3782
  ) {
@@ -3016,6 +3785,42 @@ function getDraftCreationTool(
3016
3785
  return null;
3017
3786
  }
3018
3787
 
3788
+ function polygonPointsForBox(
3789
+ kind: "polygon" | "star",
3790
+ width: number,
3791
+ height: number,
3792
+ ) {
3793
+ const safeWidth = Math.max(1, width);
3794
+ const safeHeight = Math.max(1, height);
3795
+ const cx = safeWidth / 2;
3796
+ const cy = safeHeight / 2;
3797
+ const radius = Math.max(1, Math.min(safeWidth, safeHeight) / 2);
3798
+ const points: Point[] = [];
3799
+
3800
+ if (kind === "polygon") {
3801
+ for (let index = 0; index < 3; index += 1) {
3802
+ const angle = -Math.PI / 2 + (index * Math.PI * 2) / 3;
3803
+ points.push({
3804
+ x: cx + Math.cos(angle) * radius,
3805
+ y: cy + Math.sin(angle) * radius,
3806
+ });
3807
+ }
3808
+ } else {
3809
+ for (let index = 0; index < 10; index += 1) {
3810
+ const angle = -Math.PI / 2 + (index * Math.PI) / 5;
3811
+ const r = index % 2 === 0 ? radius : radius * 0.45;
3812
+ points.push({
3813
+ x: cx + Math.cos(angle) * r,
3814
+ y: cy + Math.sin(angle) * r,
3815
+ });
3816
+ }
3817
+ }
3818
+
3819
+ return points
3820
+ .map((point) => `${roundCoord(point.x)},${roundCoord(point.y)}`)
3821
+ .join(" ");
3822
+ }
3823
+
3019
3824
  function pointsToPath(points: readonly Point[]) {
3020
3825
  if (points.length === 0) return "";
3021
3826
  const [first, ...rest] = points;
@@ -3039,9 +3844,28 @@ function previewDraftPrimitive(preview: DraftCreationPreview): DraftPrimitive {
3039
3844
  ? "frame"
3040
3845
  : preview.tool === "text"
3041
3846
  ? "text"
3042
- : "rectangle",
3847
+ : preview.tool === "line" ||
3848
+ preview.tool === "arrow" ||
3849
+ preview.tool === "ellipse" ||
3850
+ preview.tool === "polygon" ||
3851
+ preview.tool === "star"
3852
+ ? preview.tool
3853
+ : "rectangle",
3043
3854
  geometry: preview.geometry,
3044
- points: preview.points,
3855
+ points:
3856
+ preview.points ??
3857
+ (preview.tool === "line" || preview.tool === "arrow"
3858
+ ? [
3859
+ {
3860
+ x: preview.geometry.x,
3861
+ y: preview.geometry.y + preview.geometry.height / 2,
3862
+ },
3863
+ {
3864
+ x: preview.geometry.x + preview.geometry.width,
3865
+ y: preview.geometry.y + preview.geometry.height / 2,
3866
+ },
3867
+ ]
3868
+ : undefined),
3045
3869
  text: "Text", // i18n-ignore preview-only canvas placeholder
3046
3870
  };
3047
3871
  }