@lazlon-platform/html-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/ci.yml +34 -0
  3. package/README.md +24 -0
  4. package/demo/App.tsx +62 -0
  5. package/demo/EditorView/PageView/NodeContent.tsx +35 -0
  6. package/demo/EditorView/PageView/SnapLines.tsx +28 -0
  7. package/demo/EditorView/PageView/index.tsx +45 -0
  8. package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
  9. package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
  10. package/demo/EditorView/SelectionFrame/index.tsx +27 -0
  11. package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
  12. package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
  13. package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
  14. package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
  15. package/demo/EditorView/Toolbar/index.tsx +68 -0
  16. package/demo/EditorView/index.tsx +47 -0
  17. package/demo/Navbar/index.tsx +33 -0
  18. package/demo/Sidebar/index.tsx +71 -0
  19. package/demo/hotkeys.ts +93 -0
  20. package/demo/main.tsx +10 -0
  21. package/demo/style.css +1 -0
  22. package/eslint.config.js +43 -0
  23. package/index.html +14 -0
  24. package/lib/hooks/actions.ts +426 -0
  25. package/lib/hooks/batch.ts +102 -0
  26. package/lib/hooks/editor.ts +18 -0
  27. package/lib/hooks/index.ts +23 -0
  28. package/lib/hooks/node.ts +33 -0
  29. package/lib/hooks/page.ts +26 -0
  30. package/lib/hooks/pointer/moveable.ts +98 -0
  31. package/lib/hooks/pointer/pointer.ts +56 -0
  32. package/lib/hooks/pointer/resize.ts +281 -0
  33. package/lib/hooks/pointer/rotation.ts +111 -0
  34. package/lib/hooks/pointer/selectionFrame.ts +97 -0
  35. package/lib/hooks/pointer/selector.ts +64 -0
  36. package/lib/hooks/pointer/snap.ts +97 -0
  37. package/lib/hooks/textMarks.ts +276 -0
  38. package/lib/lib/googleFonts.ts +162 -0
  39. package/lib/model/editor.ts +169 -0
  40. package/lib/model/geometry.ts +155 -0
  41. package/lib/model/history.ts +135 -0
  42. package/lib/model/index.ts +12 -0
  43. package/lib/model/node/editable/index.ts +85 -0
  44. package/lib/model/node/editable/letterSpacing.ts +61 -0
  45. package/lib/model/node/editable/persistentMarks.ts +45 -0
  46. package/lib/model/node/editable/tiptapExtensions.ts +33 -0
  47. package/lib/model/node/formattable.ts +108 -0
  48. package/lib/model/node/group.ts +79 -0
  49. package/lib/model/node/image.ts +41 -0
  50. package/lib/model/node/shape/polygon.ts +173 -0
  51. package/lib/model/node/shape/shape.ts +48 -0
  52. package/lib/model/node/text.ts +55 -0
  53. package/lib/model/node.ts +101 -0
  54. package/lib/model/page.ts +51 -0
  55. package/lib/model/traversal.ts +21 -0
  56. package/lib/ui/colors.ts +23 -0
  57. package/lib/ui/extractor.ts +57 -0
  58. package/lib/ui/index.ts +8 -0
  59. package/lib/ui/node/EditableContent.tsx +101 -0
  60. package/lib/ui/node/GroupContent.tsx +46 -0
  61. package/lib/ui/node/ImageContent.tsx +36 -0
  62. package/lib/ui/node/NodeView.tsx +68 -0
  63. package/lib/ui/node/PolygonContent.tsx +81 -0
  64. package/lib/ui/node/TextContent.tsx +40 -0
  65. package/lib/ui/node/useDoubleClick.ts +37 -0
  66. package/lib/ui/selection.ts +38 -0
  67. package/package.json +70 -0
  68. package/tests/createTestEditor.ts +19 -0
  69. package/tests/hooks/actions.test.tsx +736 -0
  70. package/tests/hooks/batch.test.tsx +332 -0
  71. package/tests/hooks/editor.test.tsx +56 -0
  72. package/tests/hooks/page.test.tsx +135 -0
  73. package/tests/hooks/pointer/pointer.test.tsx +244 -0
  74. package/tests/hooks/textMarks.test.tsx +624 -0
  75. package/tests/model/editor.test.ts +384 -0
  76. package/tests/model/history.test.ts +293 -0
  77. package/tests/model/node/group.test.ts +294 -0
  78. package/tests/model/node/image.test.ts +150 -0
  79. package/tests/model/node/polygon.test.ts +408 -0
  80. package/tests/model/node/text.test.ts +158 -0
  81. package/tests/model/node.test.ts +276 -0
  82. package/tests/model/page.test.ts +150 -0
  83. package/tests/setup.ts +7 -0
  84. package/tsconfig.json +28 -0
  85. package/vite.config.ts +9 -0
  86. package/vitest.config.ts +13 -0
@@ -0,0 +1,85 @@
1
+ import { Editor as Tiptap, type JSONContent } from "@tiptap/react"
2
+ import { computed, state } from "react-bolt"
3
+ import type { Editor } from "../../editor"
4
+ import { Node, type NodeProps, type SerializedNode } from "../../node"
5
+ import type { Page } from "../../page"
6
+ import { TiptapExtensions } from "./tiptapExtensions"
7
+
8
+ export { TiptapExtensions }
9
+
10
+ export type EditableNodeProps = NodeProps &
11
+ Partial<Pick<EditableNode, "content" | "lineHeight">>
12
+
13
+ export abstract class EditableNode extends Node {
14
+ @state accessor contentRef: HTMLElement | null = null
15
+ @state private accessor _isEmpty: boolean
16
+ @state private accessor _content: JSONContent
17
+
18
+ @state accessor lineHeight: number
19
+
20
+ set content(value: JSONContent) {
21
+ this.tiptap.commands.setContent(value, {
22
+ emitUpdate: false,
23
+ })
24
+ this._content = this.tiptap.getJSON()
25
+ }
26
+
27
+ @computed get content() {
28
+ return this._content
29
+ }
30
+
31
+ @computed get isEmpty() {
32
+ return this._isEmpty
33
+ }
34
+
35
+ set locked(isLocked: boolean) {
36
+ super.locked = isLocked
37
+ // this is invoked from super() which has no tiptap, hence the ?
38
+ this.tiptap?.setEditable(!isLocked)
39
+ }
40
+
41
+ get locked() {
42
+ return super.locked
43
+ }
44
+
45
+ readonly tiptap = new Tiptap({
46
+ extensions: TiptapExtensions,
47
+ onUpdate: () => {
48
+ this._content = this.tiptap.getJSON()
49
+ this._isEmpty = this.tiptap.isEmpty
50
+ },
51
+ })
52
+
53
+ constructor(
54
+ editor: Editor,
55
+ page: Page,
56
+ { content, lineHeight, ...props }: EditableNodeProps,
57
+ ) {
58
+ super(editor, page, props)
59
+ this._content = content ?? { type: "doc", content: [] }
60
+ this._isEmpty = this.tiptap.isEmpty
61
+ this.lineHeight = lineHeight ?? 1.2
62
+ this.content = this._content
63
+ }
64
+
65
+ blockMove(event: React.PointerEvent): boolean {
66
+ const rect = this.tiptap.view.dom.getBoundingClientRect()
67
+ return (
68
+ super.blockMove(event) ||
69
+ (this.tiptap.isFocused &&
70
+ event.clientX >= rect.left &&
71
+ event.clientX <= rect.right &&
72
+ event.clientY >= rect.top &&
73
+ event.clientY <= rect.bottom)
74
+ )
75
+ }
76
+
77
+ props(): EditableNodeProps {
78
+ const { content, lineHeight } = this
79
+ return { ...super.props(), content, lineHeight }
80
+ }
81
+
82
+ serialize(): SerializedNode<string, EditableNodeProps> {
83
+ return super.serialize()
84
+ }
85
+ }
@@ -0,0 +1,61 @@
1
+ import { Extension } from "@tiptap/core"
2
+ import { type Mark } from "@tiptap/pm/model"
3
+
4
+ type Options = Record<never, never>
5
+
6
+ type Storage = {
7
+ marks: Mark[] | null
8
+ }
9
+
10
+ declare module "@tiptap/core" {
11
+ interface Commands<ReturnType> {
12
+ letterSpacing: {
13
+ setLetterSpacing: (lineHeight: string) => ReturnType
14
+ }
15
+ }
16
+ }
17
+
18
+ export const LetterSpacing = Extension.create<Options, Storage>({
19
+ name: "letterSpacing",
20
+
21
+ addGlobalAttributes() {
22
+ return [
23
+ {
24
+ types: ["textStyle"],
25
+ attributes: {
26
+ letterSpacing: {
27
+ default: null,
28
+ parseHTML: (element) => element.style.letterSpacing,
29
+ renderHTML: (attributes) => {
30
+ if (!attributes.letterSpacing) {
31
+ return {}
32
+ }
33
+
34
+ return {
35
+ style: `letter-spacing: ${attributes.letterSpacing}`,
36
+ }
37
+ },
38
+ },
39
+ },
40
+ },
41
+ ]
42
+ },
43
+
44
+ addCommands() {
45
+ return {
46
+ setLetterSpacing:
47
+ (letterSpacing) =>
48
+ ({ chain }) => {
49
+ return chain().setMark("textStyle", { letterSpacing }).run()
50
+ },
51
+ unsetFontSize:
52
+ () =>
53
+ ({ chain }) => {
54
+ return chain()
55
+ .setMark("textStyle", { letterSpacing: null })
56
+ .removeEmptyTextStyle()
57
+ .run()
58
+ },
59
+ }
60
+ },
61
+ })
@@ -0,0 +1,45 @@
1
+ import { Extension } from "@tiptap/core"
2
+ import { type Mark } from "@tiptap/pm/model"
3
+ import { AllSelection } from "@tiptap/pm/state"
4
+
5
+ type Options = Record<never, never>
6
+
7
+ type Storage = {
8
+ marks: Mark[] | null
9
+ }
10
+
11
+ export const PersistentMarks = Extension.create<Options, Storage>({
12
+ name: "persistentMarks",
13
+
14
+ addStorage() {
15
+ return {
16
+ marks: null,
17
+ }
18
+ },
19
+
20
+ dispatchTransaction({ transaction, next }) {
21
+ // when ctrl+a selects everything capture marks on the first text
22
+ if (transaction.selection instanceof AllSelection) {
23
+ // doc > paragraph* > text*
24
+ const paragraph = transaction.doc.content.firstChild
25
+ const firstText = paragraph?.content.firstChild
26
+
27
+ this.storage.marks = firstText?.marks.values().toArray() ?? []
28
+ return next(transaction)
29
+ }
30
+
31
+ next(transaction)
32
+
33
+ // if the previous transaction was ctrl+a and this transaction
34
+ // emptied the doc, restore marks
35
+ if (this.storage.marks && this.editor.state.doc.textContent === "") {
36
+ const tr = this.storage.marks.reduce(
37
+ (tr, mark) => tr.addStoredMark(mark),
38
+ this.editor.state.tr,
39
+ )
40
+
41
+ this.editor.view.updateState(this.editor.state.apply(tr))
42
+ this.storage.marks = null
43
+ }
44
+ },
45
+ })
@@ -0,0 +1,33 @@
1
+ import Bold from "@tiptap/extension-bold"
2
+ import Document from "@tiptap/extension-document"
3
+ import HardBreak from "@tiptap/extension-hard-break"
4
+ import Italic from "@tiptap/extension-italic"
5
+ import Paragraph from "@tiptap/extension-paragraph"
6
+ import Strike from "@tiptap/extension-strike"
7
+ import Subscript from "@tiptap/extension-subscript"
8
+ import Superscript from "@tiptap/extension-superscript"
9
+ import Text from "@tiptap/extension-text"
10
+ import { TextStyleKit } from "@tiptap/extension-text-style"
11
+ import Underline from "@tiptap/extension-underline"
12
+ import { LetterSpacing } from "./letterSpacing"
13
+ import { PersistentMarks } from "./persistentMarks"
14
+
15
+ export const TiptapExtensions = [
16
+ Document,
17
+ Paragraph,
18
+ Text,
19
+ HardBreak,
20
+ Bold,
21
+ Italic,
22
+ Strike,
23
+ Underline,
24
+ Subscript,
25
+ Superscript,
26
+ LetterSpacing,
27
+ TextStyleKit.configure({
28
+ lineHeight: {
29
+ types: ["paragraph"],
30
+ },
31
+ }),
32
+ PersistentMarks,
33
+ ]
@@ -0,0 +1,108 @@
1
+ import { state } from "react-bolt"
2
+ import type { Editor } from "../editor"
3
+ import { Node, type NodeProps, type SerializedNode } from "../node"
4
+ import type { Page } from "../page"
5
+
6
+ export type FormattableNodeProps = NodeProps &
7
+ Partial<
8
+ Pick<
9
+ FormattableNode,
10
+ | "bold"
11
+ | "italic"
12
+ | "underline"
13
+ | "strike"
14
+ | "superscript"
15
+ | "subscript"
16
+ | "color"
17
+ | "size"
18
+ | "family"
19
+ | "spacing"
20
+ | "lineHeight"
21
+ | "casing"
22
+ >
23
+ >
24
+
25
+ export abstract class FormattableNode extends Node {
26
+ @state accessor bold: boolean
27
+ @state accessor italic: boolean
28
+ @state accessor underline: boolean
29
+ @state accessor strike: boolean
30
+ @state accessor superscript: boolean
31
+ @state accessor subscript: boolean
32
+ @state accessor color: string | null
33
+ @state accessor size: number
34
+ @state accessor family: string | null
35
+ @state accessor spacing: number
36
+ @state accessor lineHeight: number
37
+ @state accessor casing: "capitalize" | "uppercase" | "lowercase" | "normal"
38
+
39
+ constructor(
40
+ editor: Editor,
41
+ page: Page,
42
+ {
43
+ bold,
44
+ italic,
45
+ underline,
46
+ strike,
47
+ superscript,
48
+ subscript,
49
+ color,
50
+ size,
51
+ family,
52
+ spacing,
53
+ lineHeight,
54
+ casing,
55
+ ...props
56
+ }: FormattableNodeProps,
57
+ ) {
58
+ super(editor, page, props)
59
+ this.bold = bold ?? false
60
+ this.italic = italic ?? false
61
+ this.underline = underline ?? false
62
+ this.strike = strike ?? false
63
+ this.superscript = superscript ?? false
64
+ this.subscript = subscript ?? false
65
+ this.color = color ?? null
66
+ this.size = size ?? 16
67
+ this.family = family ?? null
68
+ this.spacing = spacing ?? 0
69
+ this.lineHeight = lineHeight ?? 1.2
70
+ this.casing = casing ?? "normal"
71
+ }
72
+
73
+ props(): FormattableNodeProps {
74
+ const {
75
+ bold,
76
+ italic,
77
+ underline,
78
+ strike,
79
+ superscript,
80
+ subscript,
81
+ color,
82
+ size,
83
+ family,
84
+ spacing,
85
+ lineHeight,
86
+ casing,
87
+ } = this
88
+ return {
89
+ ...super.props(),
90
+ size,
91
+ spacing,
92
+ lineHeight,
93
+ casing,
94
+ ...(bold && { bold }),
95
+ ...(italic && { italic }),
96
+ ...(underline && { underline }),
97
+ ...(strike && { strike }),
98
+ ...(superscript && { superscript }),
99
+ ...(subscript && { subscript }),
100
+ ...(color && { color }),
101
+ ...(family && { family }),
102
+ }
103
+ }
104
+
105
+ serialize(): SerializedNode<string, FormattableNodeProps> {
106
+ return super.serialize()
107
+ }
108
+ }
@@ -0,0 +1,79 @@
1
+ import { state } from "react-bolt"
2
+ import type { Editor } from "../editor"
3
+ import { Node, type NodeProps, type SerializedNode } from "../node"
4
+ import type { Page } from "../page"
5
+
6
+ export interface GroupNodeProps extends NodeProps {
7
+ nodes?: SerializedNode[]
8
+ }
9
+
10
+ export class GroupNode extends Node {
11
+ get name() {
12
+ return "group"
13
+ }
14
+
15
+ @state accessor nodes = new Set<Node>()
16
+
17
+ constructor(editor: Editor, page: Page, { nodes = [], ...props }: GroupNodeProps) {
18
+ super(editor, page, props)
19
+ this.nodes = new Set(nodes.map((node) => editor.deserializeNode(page, node)))
20
+ }
21
+
22
+ get width() {
23
+ return super.width
24
+ }
25
+
26
+ get height() {
27
+ return super.height
28
+ }
29
+
30
+ set width(n: number) {
31
+ const nodes = this.nodes
32
+ .values()
33
+ .map((node) => ({
34
+ node,
35
+ x: node.x / this.width,
36
+ w: node.width / this.width,
37
+ }))
38
+ .toArray()
39
+
40
+ super.width = n
41
+
42
+ for (const { node, x, w } of nodes) {
43
+ node.x = x * this.width
44
+ node.width = w * this.width
45
+ }
46
+ }
47
+
48
+ set height(n: number) {
49
+ const nodes = this.nodes
50
+ .values()
51
+ .map((node) => ({
52
+ node,
53
+ y: node.y / this.height,
54
+ h: node.height / this.height,
55
+ }))
56
+ .toArray()
57
+
58
+ super.height = n
59
+
60
+ for (const { node, y, h } of nodes) {
61
+ node.y = y * this.height
62
+ node.height = h * this.height
63
+ }
64
+ }
65
+
66
+ props(): GroupNodeProps {
67
+ return {
68
+ ...super.props(),
69
+ nodes: this.nodes
70
+ .values()
71
+ .map((node) => node.serialize())
72
+ .toArray(),
73
+ }
74
+ }
75
+
76
+ serialize(): SerializedNode<this["name"], GroupNodeProps> {
77
+ return super.serialize()
78
+ }
79
+ }
@@ -0,0 +1,41 @@
1
+ import { state } from "react-bolt"
2
+ import type { Editor } from "../editor"
3
+ import { Node, type NodeProps, type SerializedNode } from "../node"
4
+ import type { Page } from "../page"
5
+
6
+ export type ImageNodeProps = NodeProps &
7
+ Partial<Pick<ImageNode, "url" | "fit" | "roundness" | "borderWidth" | "borderColor">>
8
+
9
+ export class ImageNode extends Node {
10
+ get name() {
11
+ return "image"
12
+ }
13
+
14
+ @state accessor url: string | null
15
+ @state accessor fit: "cover" | "contain" | "fill"
16
+ @state accessor roundness: number
17
+ @state accessor borderWidth: number
18
+ @state accessor borderColor: string
19
+
20
+ constructor(
21
+ editor: Editor,
22
+ page: Page,
23
+ { url, fit, roundness, borderWidth, borderColor, ...props }: ImageNodeProps,
24
+ ) {
25
+ super(editor, page, props)
26
+ this.url = url ?? null
27
+ this.fit = "cover"
28
+ this.roundness = roundness ?? 0
29
+ this.borderColor = borderColor ?? "#000000"
30
+ this.borderWidth = borderWidth ?? 0
31
+ }
32
+
33
+ props(): ImageNodeProps {
34
+ const { url, fit, roundness, borderWidth, borderColor } = this
35
+ return { ...super.props(), url, fit, roundness, borderWidth, borderColor }
36
+ }
37
+
38
+ serialize(): SerializedNode<this["name"], ImageNodeProps> {
39
+ return super.serialize()
40
+ }
41
+ }
@@ -0,0 +1,173 @@
1
+ import { computed, state } from "react-bolt"
2
+ import type { Editor } from "../../editor"
3
+ import { add, clamp, dist, dot, mul, norm, sub, tidyFloat } from "../../geometry"
4
+ import type { SerializedNode } from "../../node"
5
+ import type { Page } from "../../page"
6
+ import { ShapeNode, type ShapeNodeProps } from "./shape"
7
+
8
+ export type PolygonNodeProps = ShapeNodeProps &
9
+ Partial<
10
+ Pick<
11
+ PolygonNode,
12
+ | "roundness"
13
+ | "sides"
14
+ | "cornerTopLeft"
15
+ | "cornerTopRight"
16
+ | "cornerBottomLeft"
17
+ | "cornerBottomRight"
18
+ >
19
+ >
20
+
21
+ export class PolygonNode extends ShapeNode {
22
+ get name() {
23
+ return "polygon"
24
+ }
25
+
26
+ @state private accessor _roundness: number
27
+ @state accessor sides: number
28
+
29
+ // rectangles are special cased where they support by corner rounding
30
+ @state accessor cornerTopLeft: number
31
+ @state accessor cornerTopRight: number
32
+ @state accessor cornerBottomLeft: number
33
+ @state accessor cornerBottomRight: number
34
+
35
+ set roundness(r: number) {
36
+ if (this.sides === 4) {
37
+ this.cornerTopLeft = r
38
+ this.cornerTopRight = r
39
+ this.cornerBottomLeft = r
40
+ this.cornerBottomRight = r
41
+ }
42
+
43
+ this._roundness = r
44
+ }
45
+
46
+ @computed get roundness() {
47
+ if (this.sides === 4) {
48
+ const [first, ...radii] = [
49
+ this.cornerTopLeft,
50
+ this.cornerTopRight,
51
+ this.cornerBottomLeft,
52
+ this.cornerBottomRight,
53
+ ]
54
+
55
+ return radii.reduce((acc, it) => (it === acc ? acc : 0), first)
56
+ }
57
+
58
+ return this._roundness
59
+ }
60
+
61
+ constructor(
62
+ editor: Editor,
63
+ page: Page,
64
+ {
65
+ sides,
66
+ cornerTopLeft,
67
+ cornerTopRight,
68
+ cornerBottomLeft,
69
+ cornerBottomRight,
70
+ roundness = 0,
71
+ ...props
72
+ }: PolygonNodeProps,
73
+ ) {
74
+ super(editor, page, props)
75
+ this._roundness = roundness
76
+ this.sides = sides ?? 4
77
+ this.cornerTopLeft = cornerTopLeft ?? roundness
78
+ this.cornerTopRight = cornerTopRight ?? roundness
79
+ this.cornerBottomLeft = cornerBottomLeft ?? roundness
80
+ this.cornerBottomRight = cornerBottomRight ?? roundness
81
+ }
82
+
83
+ props(): PolygonNodeProps {
84
+ return {
85
+ ...super.props(),
86
+ sides: this.sides,
87
+ cornerTopLeft: this.cornerTopLeft,
88
+ cornerTopRight: this.cornerTopRight,
89
+ cornerBottomLeft: this.cornerBottomLeft,
90
+ cornerBottomRight: this.cornerBottomRight,
91
+ }
92
+ }
93
+
94
+ serialize(): SerializedNode<this["name"], PolygonNodeProps> {
95
+ return super.serialize()
96
+ }
97
+
98
+ @computed get svgPathData() {
99
+ return pathData({
100
+ width: this.width,
101
+ height: this.height,
102
+ sides: this.sides,
103
+ roundness: this._roundness,
104
+ })
105
+ }
106
+ }
107
+
108
+ function pathData(props: {
109
+ width: number
110
+ height: number
111
+ sides: number
112
+ roundness: number
113
+ }): string {
114
+ const { width, height, sides, roundness } = props
115
+ const rotation = sides % 2 === 0 ? Math.PI / sides : 0
116
+ const cx = width / 2
117
+ const cy = height / 2
118
+ const rx = width / 2
119
+ const ry = height / 2
120
+
121
+ // vertices (raw)
122
+ const p = Array.from({ length: sides }, (_, i) => {
123
+ const angle = (i * Math.PI * 2) / sides - Math.PI / 2 + rotation
124
+ return {
125
+ x: cx + rx * Math.cos(angle),
126
+ y: cy + ry * Math.sin(angle),
127
+ }
128
+ })
129
+
130
+ const cmds = Array.from({ length: sides }, (_, i) => {
131
+ const prev = p[(i - 1 + sides) % sides]
132
+ const curr = p[i]
133
+ const next = p[(i + 1) % sides]
134
+ const cmd = i === 0 ? "M" : "L"
135
+
136
+ const v1 = norm(sub(prev, curr)) // direction from curr towards prev
137
+ const v2 = norm(sub(next, curr)) // direction from curr towards next
138
+
139
+ // interior angle between the two edges at curr
140
+ const cosTheta = clamp(dot(v1, v2), -1, 1)
141
+ const theta = Math.acos(cosTheta)
142
+
143
+ // degenerate / nearly straight: just treat as sharp
144
+ if (!isFinite(theta) || theta < 1e-6) {
145
+ const x = tidyFloat(curr.x)
146
+ const y = tidyFloat(curr.y)
147
+ return `${cmd} ${x} ${y}`
148
+ }
149
+
150
+ const dPrev = dist(curr, prev)
151
+ const dNext = dist(curr, next)
152
+
153
+ // how far to inset along each adjacent edge for radius r
154
+ const tIdeal = roundness / Math.tan(theta / 2)
155
+
156
+ // cannot exceed half of either edge
157
+ const t = Math.min(tIdeal, dPrev / 2, dNext / 2)
158
+
159
+ // if we had to clamp t, reduce radius so the arc still matches geometry
160
+ const rEff = t * Math.tan(theta / 2)
161
+
162
+ const p1 = add(curr, mul(v1, t)) // point on edge towards prev
163
+ const p2 = add(curr, mul(v2, t)) // point on edge towards next
164
+
165
+ // Arc to p2 with radius rEff.
166
+ // Use the small arc (0) and let SVG choose sweep; for convex polygons this is fine.
167
+ const arc = `A ${tidyFloat(rEff)} ${tidyFloat(rEff)} 0 0 1 ${tidyFloat(p2.x)} ${tidyFloat(p2.y)}`
168
+
169
+ return `${cmd} ${tidyFloat(p1.x)} ${tidyFloat(p1.y)} ${arc}`
170
+ })
171
+
172
+ return cmds.concat("Z").join(" ")
173
+ }
@@ -0,0 +1,48 @@
1
+ import { state } from "react-bolt"
2
+ import type { Editor } from "../../editor"
3
+ import type { Page } from "../../page"
4
+ import { EditableNode, type EditableNodeProps } from "../editable"
5
+ import type { SerializedNode } from "../../node"
6
+
7
+ export type ShapeNodeProps = EditableNodeProps &
8
+ Partial<
9
+ Pick<ShapeNode, "valign" | "halign" | "background" | "borderWidth" | "borderColor">
10
+ >
11
+
12
+ export abstract class ShapeNode extends EditableNode {
13
+ @state accessor background: string
14
+ @state accessor borderWidth: number
15
+ @state accessor borderColor: string
16
+
17
+ @state accessor halign: "left" | "center" | "right" | "justify"
18
+ @state accessor valign: "top" | "center" | "bottom"
19
+
20
+ constructor(
21
+ editor: Editor,
22
+ page: Page,
23
+ { valign, halign, background, borderColor, borderWidth, ...props }: ShapeNodeProps,
24
+ ) {
25
+ super(editor, page, props)
26
+ this.valign = valign ?? "center"
27
+ this.halign = halign ?? "center"
28
+ this.background = background ?? "#7f7f7f"
29
+ this.borderColor = borderColor ?? "#000000"
30
+ this.borderWidth = borderWidth ?? 0
31
+ }
32
+
33
+ props(): ShapeNodeProps {
34
+ const { valign, halign, background, borderColor, borderWidth } = this
35
+ return {
36
+ ...super.props(),
37
+ valign,
38
+ halign,
39
+ background,
40
+ ...(borderWidth && { borderColor }),
41
+ ...(borderWidth && { borderWidth }),
42
+ }
43
+ }
44
+
45
+ serialize(): SerializedNode<this["name"], EditableNodeProps> {
46
+ return super.serialize()
47
+ }
48
+ }