@lazlon-platform/html-editor 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/lib/hooks/actions.ts +136 -87
  2. package/lib/hooks/batch.ts +24 -10
  3. package/lib/hooks/index.ts +7 -6
  4. package/lib/hooks/page.ts +2 -4
  5. package/lib/hooks/pointer/useMovePoint.ts +100 -0
  6. package/lib/hooks/pointer/{moveable.ts → useMoveable.ts} +47 -39
  7. package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +4 -5
  8. package/lib/hooks/pointer/useResize/index.ts +31 -0
  9. package/lib/hooks/pointer/useResize/multi.ts +161 -0
  10. package/lib/hooks/pointer/useResize/multiLineNode.ts +99 -0
  11. package/lib/hooks/pointer/useResize/multiRegularNode.ts +109 -0
  12. package/lib/hooks/pointer/useResize/multiTextNode.ts +108 -0
  13. package/lib/hooks/pointer/useResize/singleRegularNode.ts +91 -0
  14. package/lib/hooks/pointer/useResize/singleTextNode.ts +115 -0
  15. package/lib/hooks/pointer/useRotation.ts +102 -0
  16. package/lib/hooks/pointer/{selector.ts → useSelector.ts} +18 -3
  17. package/lib/hooks/pointer/{snap.ts → useSnap.ts} +5 -4
  18. package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
  19. package/lib/hooks/textMarks.ts +30 -21
  20. package/lib/lib/googleFonts.ts +1 -5
  21. package/lib/model/editor.ts +31 -13
  22. package/lib/model/geometry/math.ts +128 -1
  23. package/lib/model/history.ts +10 -13
  24. package/lib/model/index.ts +15 -10
  25. package/lib/model/node/{editable → editableNode}/index.ts +13 -29
  26. package/lib/model/node/{formattable.ts → formattableNode/index.ts} +5 -11
  27. package/lib/model/node/{group.ts → groupNode.ts} +9 -13
  28. package/lib/model/node/{image.ts → imageNode.ts} +6 -12
  29. package/lib/model/node/lineNode.ts +80 -0
  30. package/lib/model/node/{shape/shape.ts → shapeNode/index.ts} +30 -15
  31. package/lib/model/node/shapeNode/shape.ts +96 -0
  32. package/lib/model/node/{text.ts → textNode.ts} +9 -24
  33. package/lib/model/node.ts +27 -32
  34. package/lib/model/page.ts +4 -4
  35. package/lib/model/traversal.ts +1 -1
  36. package/lib/ui/extractor.ts +3 -3
  37. package/lib/ui/index.ts +2 -4
  38. package/lib/ui/node/{EditableContent.tsx → EditableContent/index.tsx} +10 -7
  39. package/lib/ui/node/GroupContent.tsx +1 -1
  40. package/lib/ui/node/ImageContent.tsx +1 -1
  41. package/lib/ui/node/LineContent.tsx +30 -0
  42. package/lib/ui/node/ShapeContent/ArrowContent.tsx +57 -0
  43. package/lib/ui/node/ShapeContent/EllipseContent.tsx +37 -0
  44. package/lib/ui/node/ShapeContent/PolygonContent.tsx +62 -0
  45. package/lib/ui/node/ShapeContent/RectangleContent.tsx +35 -0
  46. package/lib/ui/node/ShapeContent/StarContent.tsx +75 -0
  47. package/lib/ui/node/ShapeContent/index.tsx +43 -0
  48. package/lib/ui/node/TextContent.tsx +1 -1
  49. package/lib/ui/selection.ts +6 -5
  50. package/package.json +1 -1
  51. package/lib/hooks/pointer/resize.ts +0 -247
  52. package/lib/hooks/pointer/rotation.ts +0 -103
  53. package/lib/model/node/shape/arrow.ts +0 -50
  54. package/lib/model/node/shape/ellipse.ts +0 -26
  55. package/lib/model/node/shape/polygon.ts +0 -130
  56. package/lib/model/node/shape/star.ts +0 -91
  57. package/lib/ui/node/ArrowContent.tsx +0 -60
  58. package/lib/ui/node/EllipseContent.tsx +0 -49
  59. package/lib/ui/node/PolygonContent.tsx +0 -81
  60. package/lib/ui/node/StarContent.tsx +0 -60
  61. /package/lib/model/node/{editable → editableNode}/letterSpacing.ts +0 -0
  62. /package/lib/model/node/{editable → editableNode}/persistentMarks.ts +0 -0
  63. /package/lib/model/node/{editable → editableNode}/tiptapExtensions.ts +0 -0
  64. /package/lib/ui/node/{useDoubleClick.ts → EditableContent/useDoubleClick.ts} +0 -0
@@ -0,0 +1,96 @@
1
+ // shapes are not reactive stores because the batch dispatcher and history model
2
+ // is designed for non-nested stores
3
+
4
+ import { clamp } from "es-toolkit"
5
+
6
+ export interface StarShape {
7
+ name: "star"
8
+ props: {
9
+ corners: number
10
+ roundness: number
11
+ depth: number
12
+ }
13
+ }
14
+
15
+ export function newStarShape(props?: Partial<StarShape["props"]>): StarShape {
16
+ return {
17
+ name: "star",
18
+ props: {
19
+ corners: Math.max(props?.corners ?? 5, 3),
20
+ roundness: Math.max(props?.roundness ?? 0, 0),
21
+ depth: clamp(props?.depth ?? 0.4, 0, 1),
22
+ },
23
+ }
24
+ }
25
+
26
+ export interface ArrowShape {
27
+ name: "arrow"
28
+ props: {
29
+ roundness: number
30
+ }
31
+ }
32
+
33
+ export function newArrowShape(props?: Partial<ArrowShape["props"]>): ArrowShape {
34
+ return {
35
+ name: "arrow",
36
+ props: {
37
+ roundness: Math.max(props?.roundness ?? 0, 0),
38
+ },
39
+ }
40
+ }
41
+
42
+ export interface EllipseShape {
43
+ name: "ellipse"
44
+ props: { _?: never }
45
+ }
46
+
47
+ export function newEllipseShape(): EllipseShape {
48
+ return {
49
+ name: "ellipse",
50
+ props: {},
51
+ }
52
+ }
53
+
54
+ export interface PolygonShape {
55
+ name: "polygon"
56
+ props: {
57
+ roundness: number
58
+ sides: number
59
+ }
60
+ }
61
+
62
+ export function newPolygonShape(props?: Partial<PolygonShape["props"]>): PolygonShape {
63
+ return {
64
+ name: "polygon",
65
+ props: {
66
+ roundness: Math.max(props?.roundness ?? 0, 0),
67
+ sides: clamp(props?.sides ?? 5, 3, 20),
68
+ },
69
+ }
70
+ }
71
+
72
+ export interface RectangleShape {
73
+ name: "rectangle"
74
+ props: {
75
+ cornerTopLeft: number
76
+ cornerTopRight: number
77
+ cornerBottomLeft: number
78
+ cornerBottomRight: number
79
+ }
80
+ }
81
+
82
+ export function newRectangleShape(
83
+ props?: Partial<RectangleShape["props"]>,
84
+ ): RectangleShape {
85
+ return {
86
+ name: "rectangle",
87
+ props: {
88
+ cornerTopLeft: Math.max(props?.cornerTopLeft ?? 0, 0),
89
+ cornerTopRight: Math.max(props?.cornerTopRight ?? 0, 0),
90
+ cornerBottomLeft: Math.max(props?.cornerBottomLeft ?? 0, 0),
91
+ cornerBottomRight: Math.max(props?.cornerBottomRight ?? 0, 0),
92
+ },
93
+ }
94
+ }
95
+
96
+ export type Shape = RectangleShape | ArrowShape | PolygonShape | StarShape | EllipseShape
@@ -1,40 +1,29 @@
1
1
  import { computed, state } from "react-bolt"
2
- import type { Editor } from "../editor"
3
2
  import type { Page } from "../page"
4
- import type { SerializedNode } from "../node"
5
- import { EditableNode, type EditableNodeProps } from "./editable"
3
+ import { EditableNode, type EditableNodeProps } from "./editableNode"
6
4
 
7
5
  export type TextNodeProps = EditableNodeProps & Partial<Pick<TextNode, "halign" | "size">>
8
6
 
9
7
  export class TextNode extends EditableNode {
10
- get name() {
8
+ get name(): "text" {
11
9
  return "text"
12
10
  }
13
11
 
14
12
  @state accessor halign: "left" | "center" | "right" | "justify"
15
13
  @state accessor size: number = 16
16
- @state accessor contentHeight: number = 24
17
14
 
18
15
  private observer = new ResizeObserver(([entry]) => {
19
- this.contentHeight = entry.target.clientHeight
16
+ this.height = entry.target.clientHeight
20
17
  })
21
18
 
22
- @computed get height(): number {
23
- return this.contentHeight
24
- }
25
-
26
- set height(_) {
27
- // no-op: TextNode's height can be set using its font-size
28
- }
29
-
30
- constructor(editor: Editor, page: Page, { halign, size, ...props }: TextNodeProps) {
31
- super(editor, page, props)
19
+ constructor(page: Page, { halign, size, height = 24, ...props }: TextNodeProps) {
20
+ super(page, { height, ...props })
32
21
  this.halign = halign ?? "center"
33
22
  this.size = size ?? 16
34
23
 
35
24
  this.tiptap.on("transaction", () => {
36
25
  const height = this.tiptap.view.dom.clientHeight
37
- if (height > 0) this.contentHeight = height
26
+ if (height > 0) this.height = height
38
27
  })
39
28
  }
40
29
 
@@ -43,7 +32,7 @@ export class TextNode extends EditableNode {
43
32
 
44
33
  if (ref) {
45
34
  this.observer.observe(ref)
46
- this.contentHeight = ref.clientHeight
35
+ this.height = ref.clientHeight
47
36
  } else {
48
37
  this.observer.disconnect()
49
38
  }
@@ -53,12 +42,8 @@ export class TextNode extends EditableNode {
53
42
  return super.contentRef
54
43
  }
55
44
 
56
- props(): TextNodeProps {
45
+ get props(): TextNodeProps {
57
46
  const { halign, size } = this
58
- return { ...super.props(), halign, size }
59
- }
60
-
61
- serialize(): SerializedNode<this["name"], TextNodeProps> {
62
- return super.serialize()
47
+ return { ...super.props, halign, size }
63
48
  }
64
49
  }
package/lib/model/node.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  import { computed, state } from "react-bolt"
2
- import type { Editor } from "./editor"
3
- import type { Page } from "./page"
4
2
  import { deg, type Deg } from "./geometry/math"
3
+ import type { Page } from "./page"
5
4
 
6
5
  export type Schema = Map<string, typeof Node>
7
6
 
8
- export type SerializedNode<
9
- Name extends string = string,
10
- Props extends NodeProps = NodeProps,
11
- > = {
12
- name: Name
13
- props: Props
7
+ export type SerializedNode<N extends Node = Node> = {
8
+ name: N["name"]
9
+ props: N["props"]
14
10
  }
15
11
 
16
12
  export interface NodeProps
17
- extends Pick<Node, "id" | "x" | "y" | "width" | "height">,
13
+ extends
14
+ Pick<Node, "id" | "x" | "y" | "width" | "height">,
18
15
  Partial<Pick<Node, "locked" | "rotation" | "css">> {
19
16
  // serialized as a list in case we need to allow
20
17
  // nodes to have multiple roles in the future
@@ -23,6 +20,7 @@ export interface NodeProps
23
20
 
24
21
  export abstract class Node {
25
22
  abstract get name(): string
23
+
26
24
  readonly id: string
27
25
  readonly page: Page
28
26
 
@@ -31,12 +29,12 @@ export abstract class Node {
31
29
  @state private accessor _width: number
32
30
  @state private accessor _height: number
33
31
 
34
- @state accessor rotation: Deg
35
- @state accessor role: string | null
36
- @state accessor locked: boolean
37
- @state accessor x: number
38
- @state accessor y: number
39
- @state accessor css: string
32
+ @state accessor rotation: Deg = deg(0)
33
+ @state accessor role: string | null = null
34
+ @state accessor locked: boolean = false
35
+ @state accessor x: number = 0
36
+ @state accessor y: number = 0
37
+ @state accessor css: string = ""
40
38
 
41
39
  @computed get width() {
42
40
  return this._width
@@ -56,24 +54,28 @@ export abstract class Node {
56
54
  this._height = n
57
55
  }
58
56
 
59
- constructor(_: Editor, page: Page, props: NodeProps) {
57
+ constructor(page: Page, props: NodeProps) {
60
58
  this.page = page
61
59
  this.id = props.id
62
60
  this.x = props.x
63
61
  this.y = props.y
64
62
  this._width = props.width
65
63
  this._height = props.height
66
- this.role = props.role?.[0] || null
67
- this.locked = props.locked ?? false
68
- this.rotation = props.rotation ?? deg(0)
69
- this.css = props.css ?? ""
70
- }
71
-
72
- blockMove(_: React.PointerEvent): boolean {
73
- return !!this.locked
64
+ if (typeof props.role === "object") {
65
+ this.role = props.role?.[0]
66
+ }
67
+ if (typeof props.locked === "boolean") {
68
+ this.locked = props.locked
69
+ }
70
+ if (typeof props.rotation === "number") {
71
+ this.rotation = props.rotation
72
+ }
73
+ if (typeof props.css === "string") {
74
+ this.css = props.css
75
+ }
74
76
  }
75
77
 
76
- props(): NodeProps {
78
+ get props(): NodeProps {
77
79
  const { id, role, locked, rotation, css, x, y, width, height } = this
78
80
  return {
79
81
  id,
@@ -87,11 +89,4 @@ export abstract class Node {
87
89
  ...(rotation && { rotation }),
88
90
  }
89
91
  }
90
-
91
- serialize(): SerializedNode {
92
- return {
93
- name: this.name,
94
- props: this.props(),
95
- }
96
- }
97
92
  }
package/lib/model/page.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { state } from "react-bolt"
2
2
  import type { Editor } from "./editor"
3
3
  import { Node, type SerializedNode } from "./node"
4
- // import { createRef } from "react"
5
4
 
6
- export interface PageProps
7
- extends Partial<Pick<Page, "background" | "width" | "height">> {
5
+ export interface PageProps extends Partial<
6
+ Pick<Page, "background" | "width" | "height">
7
+ > {
8
8
  id: string
9
9
  nodes?: Node[]
10
10
  }
@@ -46,7 +46,7 @@ export class Page {
46
46
  height: this.height,
47
47
  nodes: this.nodes
48
48
  .values()
49
- .map((node) => node.serialize())
49
+ .map((node) => this.editor.serializeNode(node))
50
50
  .toArray(),
51
51
  }
52
52
  }
@@ -1,5 +1,5 @@
1
1
  import { Node } from "./node"
2
- import { GroupNode } from "./node/group"
2
+ import { GroupNode } from "./node/groupNode"
3
3
  import { Page } from "./page"
4
4
 
5
5
  export function flattenNodes(root: Node | Page): Array<Node> {
@@ -1,9 +1,9 @@
1
1
  import type { JSONContent } from "@tiptap/core"
2
2
  import { uniq } from "es-toolkit"
3
3
  import { Node } from "../model/node"
4
- import { EditableNode } from "../model/node/editable"
5
- import { FormattableNode } from "../model/node/formattable"
6
- import { ShapeNode } from "../model/node/shape/shape"
4
+ import { EditableNode } from "../model/node/editableNode"
5
+ import { FormattableNode } from "../model/node/formattableNode"
6
+ import { ShapeNode } from "../model/node/shapeNode"
7
7
  import { Page } from "../model/page"
8
8
  import { flattenNodes } from "../model/traversal"
9
9
 
package/lib/ui/index.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  export { getReadableForeground } from "./colors"
2
2
  export { getColors, getFontFamilies } from "./extractor"
3
- export { ArrowContent } from "./node/ArrowContent"
4
3
  export { EditableContent } from "./node/EditableContent"
5
- export { EllipseContent } from "./node/EllipseContent"
6
4
  export { GroupContent } from "./node/GroupContent"
7
5
  export { ImageContent } from "./node/ImageContent"
6
+ export { LineContent } from "./node/LineContent"
8
7
  export { NodeView } from "./node/NodeView"
9
- export { PolygonContent } from "./node/PolygonContent"
10
- export { StarContent } from "./node/StarContent"
8
+ export { ShapeContent } from "./node/ShapeContent"
11
9
  export { TextContent } from "./node/TextContent"
@@ -3,8 +3,8 @@ import clsx from "clsx"
3
3
  import { debounce, isEqual } from "es-toolkit"
4
4
  import { useCallback, useEffect, useRef, useState } from "react"
5
5
  import { useStore } from "react-bolt"
6
- import { useEditor } from "../../hooks/editor"
7
- import type { EditableNode, EditableNodeProps } from "../../model/node/editable"
6
+ import { useEditor } from "../../../hooks/editor"
7
+ import type { EditableNode } from "../../../model"
8
8
  import { useDoubleClick } from "./useDoubleClick"
9
9
 
10
10
  export function EditableContent(props: {
@@ -18,6 +18,7 @@ export function EditableContent(props: {
18
18
  const editor = useEditor()
19
19
  const selection = useStore(editor, "selection")
20
20
  const { action } = useStore(editor, "action")
21
+ const locked = useStore(node, "locked")
21
22
  const prevContent = useRef(node.tiptap.getJSON())
22
23
 
23
24
  // start with selection.has(node) so that newly added editable nodes can be focused
@@ -25,9 +26,10 @@ export function EditableContent(props: {
25
26
 
26
27
  const onDoubleClick = useDoubleClick(
27
28
  useCallback(() => {
29
+ if (locked) return
28
30
  setClicked(true)
29
31
  node.tiptap.commands.focus()
30
- }, [node, setClicked]),
32
+ }, [node, locked, setClicked]),
31
33
  )
32
34
 
33
35
  const html = useEditorState({
@@ -36,18 +38,19 @@ export function EditableContent(props: {
36
38
  })
37
39
 
38
40
  useEffect(() => {
39
- if (!selection.has(node)) {
41
+ if (!selection.has(node) || locked) {
40
42
  node.blur()
43
+ // eslint-disable-next-line react-hooks/set-state-in-effect
41
44
  setClicked(false)
42
45
  }
43
- }, [selection, node])
46
+ }, [selection, node, locked])
44
47
 
45
48
  useEffect(() => {
46
49
  const onUpdate = debounce(() => {
47
50
  const next = node.tiptap.getJSON()
48
51
  const prev = prevContent.current
49
52
  if (!isEqual(prev, next)) {
50
- editor.history.push<EditableNodeProps>({
53
+ editor.history.push<EditableNode>({
51
54
  undo: ["set-node-props", [node.id, { content: prev }]],
52
55
  redo: ["set-node-props", [node.id, { content: next }]],
53
56
  })
@@ -70,7 +73,7 @@ export function EditableContent(props: {
70
73
  })
71
74
 
72
75
  const lineHeight = useStore(node, "lineHeight")
73
- const showEditor = !isStatic && selection.has(node) && !action && clicked
76
+ const showEditor = !isStatic && selection.has(node) && !action && clicked && !locked
74
77
 
75
78
  return (
76
79
  <div
@@ -1,6 +1,6 @@
1
1
  import { useStore } from "react-bolt"
2
2
  import type { Node } from "../../model/node"
3
- import type { GroupNode } from "../../model/node/group"
3
+ import type { GroupNode } from "../../model"
4
4
 
5
5
  function GroupedNodeContent(props: { node: Node; children: React.ReactNode }) {
6
6
  const { node, children } = props
@@ -1,6 +1,6 @@
1
1
  import clsx from "clsx"
2
2
  import { useStore } from "react-bolt"
3
- import type { ImageNode } from "../../model/node/image"
3
+ import type { ImageNode } from "../../model"
4
4
 
5
5
  export function ImageContent(props: { node: ImageNode; fallback?: React.ReactNode }) {
6
6
  const { node, fallback } = props
@@ -0,0 +1,30 @@
1
+ import { useStore } from "react-bolt"
2
+ import type { LineNode } from "../../model/node/lineNode"
3
+
4
+ export function LineContent(props: { node: LineNode }) {
5
+ const [width, height, points, strokeWidth, strokeColor] = useStore(
6
+ props.node,
7
+ "width",
8
+ "height",
9
+ "points",
10
+ "strokeWidth",
11
+ "strokeColor",
12
+ )
13
+
14
+ return (
15
+ <svg
16
+ className="absolute inset-0 overflow-visible"
17
+ width={width}
18
+ height={height}
19
+ fill="none"
20
+ >
21
+ <polyline
22
+ points={points.map(({ x, y }) => `${x},${y}`).join(" ")}
23
+ stroke={strokeColor}
24
+ strokeWidth={strokeWidth}
25
+ strokeLinecap="round"
26
+ strokeLinejoin="round"
27
+ />
28
+ </svg>
29
+ )
30
+ }
@@ -0,0 +1,57 @@
1
+ import { useId } from "react"
2
+ import { useStore } from "react-bolt"
3
+ import { roundedPathData } from "../../../model/geometry/svg"
4
+ import type { ArrowShape, ShapeNode } from "../../../model/node/shapeNode"
5
+
6
+ function arrowPath(props: { height: number; width: number; roundness: number }) {
7
+ const { height, width, roundness } = props
8
+ const bodyRight = width * 0.68
9
+ const inset = height * 0.28
10
+
11
+ const arrowPoints = [
12
+ { x: 0, y: inset },
13
+ { x: bodyRight, y: inset },
14
+ { x: bodyRight, y: 0 },
15
+ { x: width, y: height / 2 },
16
+ { x: bodyRight, y: height },
17
+ { x: bodyRight, y: height - inset },
18
+ { x: 0, y: height - inset },
19
+ ]
20
+
21
+ return roundedPathData(arrowPoints, roundness)
22
+ }
23
+
24
+ export function ArrowContent(props: { node: ShapeNode; shape: ArrowShape["props"] }) {
25
+ const maskId = useId()
26
+ const { node, shape } = props
27
+
28
+ const [background, borderWidth, borderColor] = useStore(
29
+ node,
30
+ "background",
31
+ "borderWidth",
32
+ "borderColor",
33
+ )
34
+
35
+ const [width, height] = useStore(node, "width", "height")
36
+ const { roundness } = shape
37
+ const d = arrowPath({ width, height, roundness })
38
+
39
+ return (
40
+ <svg width={width} height={height} className="absolute inset-0">
41
+ <defs>
42
+ <mask id={maskId} maskUnits="userSpaceOnUse">
43
+ <path d={d} fill="white" />
44
+ </mask>
45
+ </defs>
46
+
47
+ <path d={d} fill={background} />
48
+ <path
49
+ d={d}
50
+ fill="none"
51
+ stroke={borderColor}
52
+ strokeWidth={borderWidth}
53
+ mask={`url(#${maskId})`}
54
+ />
55
+ </svg>
56
+ )
57
+ }
@@ -0,0 +1,37 @@
1
+ import { useStore } from "react-bolt"
2
+ import type { ShapeNode } from "../../../model/node/shapeNode"
3
+
4
+ export function EllipseContent(props: { node: ShapeNode }) {
5
+ const { node } = props
6
+
7
+ const [background, borderWidth, borderColor] = useStore(
8
+ node,
9
+ "background",
10
+ "borderWidth",
11
+ "borderColor",
12
+ )
13
+
14
+ return (
15
+ <svg
16
+ aria-hidden="true"
17
+ className="absolute"
18
+ onDragStart={(e) => e.preventDefault()}
19
+ style={{
20
+ inset: borderWidth / 2,
21
+ width: `calc(100% - ${borderWidth}px)`,
22
+ height: `calc(100% - ${borderWidth}px)`,
23
+ overflow: "visible",
24
+ }}
25
+ >
26
+ <ellipse
27
+ cx="50%"
28
+ cy="50%"
29
+ rx="50%"
30
+ ry="50%"
31
+ fill={background}
32
+ stroke={borderColor}
33
+ strokeWidth={borderWidth}
34
+ />
35
+ </svg>
36
+ )
37
+ }
@@ -0,0 +1,62 @@
1
+ import { useId } from "react"
2
+ import { useStore } from "react-bolt"
3
+ import type { Point } from "../../../model/geometry/math"
4
+ import { roundedPathData } from "../../../model/geometry/svg"
5
+ import type { PolygonShape, ShapeNode } from "../../../model/node/shapeNode"
6
+
7
+ function regularPolygonPoints(props: {
8
+ width: number
9
+ height: number
10
+ sides: number
11
+ }): Point[] {
12
+ const { width, height, sides } = props
13
+ const rotation = sides % 2 === 0 ? Math.PI / sides : 0
14
+ const cx = width / 2
15
+ const cy = height / 2
16
+ const rx = width / 2
17
+ const ry = height / 2
18
+
19
+ return Array.from({ length: sides }, (_, i) => {
20
+ const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
21
+ return {
22
+ x: cx + rx * Math.cos(angle),
23
+ y: cy + ry * Math.sin(angle),
24
+ }
25
+ })
26
+ }
27
+
28
+ export function PolygonContent(props: { node: ShapeNode; shape: PolygonShape["props"] }) {
29
+ const maskId = useId()
30
+ const { node, shape } = props
31
+
32
+ const [background, borderWidth, borderColor] = useStore(
33
+ node,
34
+ "background",
35
+ "borderWidth",
36
+ "borderColor",
37
+ )
38
+
39
+ const [width, height] = useStore(node, "width", "height")
40
+ const { sides, roundness } = shape
41
+
42
+ const d = roundedPathData(regularPolygonPoints({ width, height, sides }), roundness)
43
+
44
+ return (
45
+ <svg width={width} height={height} className="absolute inset-0">
46
+ <defs>
47
+ <mask id={maskId} maskUnits="userSpaceOnUse">
48
+ <path d={d} fill="white" />
49
+ </mask>
50
+ </defs>
51
+
52
+ <path d={d} fill={background} />
53
+ <path
54
+ d={d}
55
+ fill="none"
56
+ stroke={borderColor}
57
+ strokeWidth={borderWidth}
58
+ mask={`url(#${maskId})`}
59
+ />
60
+ </svg>
61
+ )
62
+ }
@@ -0,0 +1,35 @@
1
+ import { useStore } from "react-bolt"
2
+ import type { RectangleShape, ShapeNode } from "../../../model/node/shapeNode"
3
+
4
+ export function RectangleShape(props: {
5
+ node: ShapeNode
6
+ shape: RectangleShape["props"]
7
+ }) {
8
+ const { node, shape } = props
9
+
10
+ const [background, borderWidth, borderColor] = useStore(
11
+ node,
12
+ "background",
13
+ "borderWidth",
14
+ "borderColor",
15
+ )
16
+
17
+ const radii = [
18
+ shape["cornerTopLeft"],
19
+ shape["cornerTopRight"],
20
+ shape["cornerBottomRight"],
21
+ shape["cornerBottomLeft"],
22
+ ]
23
+
24
+ return (
25
+ <div
26
+ className="absolute inset-0 size-full border-4"
27
+ onDragStart={(e) => e.preventDefault()}
28
+ style={{
29
+ borderRadius: radii.map((r) => `${r}px`).join(" "),
30
+ background: background,
31
+ border: `${borderWidth}px solid ${borderColor}`,
32
+ }}
33
+ />
34
+ )
35
+ }