@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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/ci.yml +34 -0
- package/README.md +24 -0
- package/demo/App.tsx +62 -0
- package/demo/EditorView/PageView/NodeContent.tsx +35 -0
- package/demo/EditorView/PageView/SnapLines.tsx +28 -0
- package/demo/EditorView/PageView/index.tsx +45 -0
- package/demo/EditorView/SelectionFrame/Corner.tsx +24 -0
- package/demo/EditorView/SelectionFrame/Edge.tsx +21 -0
- package/demo/EditorView/SelectionFrame/index.tsx +27 -0
- package/demo/EditorView/SelectionOverlay/ActionHud.tsx +32 -0
- package/demo/EditorView/SelectionOverlay/Rotation.tsx +39 -0
- package/demo/EditorView/SelectionOverlay/Toolbar.tsx +128 -0
- package/demo/EditorView/SelectionOverlay/index.tsx +21 -0
- package/demo/EditorView/Toolbar/index.tsx +68 -0
- package/demo/EditorView/index.tsx +47 -0
- package/demo/Navbar/index.tsx +33 -0
- package/demo/Sidebar/index.tsx +71 -0
- package/demo/hotkeys.ts +93 -0
- package/demo/main.tsx +10 -0
- package/demo/style.css +1 -0
- package/eslint.config.js +43 -0
- package/index.html +14 -0
- package/lib/hooks/actions.ts +426 -0
- package/lib/hooks/batch.ts +102 -0
- package/lib/hooks/editor.ts +18 -0
- package/lib/hooks/index.ts +23 -0
- package/lib/hooks/node.ts +33 -0
- package/lib/hooks/page.ts +26 -0
- package/lib/hooks/pointer/moveable.ts +98 -0
- package/lib/hooks/pointer/pointer.ts +56 -0
- package/lib/hooks/pointer/resize.ts +281 -0
- package/lib/hooks/pointer/rotation.ts +111 -0
- package/lib/hooks/pointer/selectionFrame.ts +97 -0
- package/lib/hooks/pointer/selector.ts +64 -0
- package/lib/hooks/pointer/snap.ts +97 -0
- package/lib/hooks/textMarks.ts +276 -0
- package/lib/lib/googleFonts.ts +162 -0
- package/lib/model/editor.ts +169 -0
- package/lib/model/geometry.ts +155 -0
- package/lib/model/history.ts +135 -0
- package/lib/model/index.ts +12 -0
- package/lib/model/node/editable/index.ts +85 -0
- package/lib/model/node/editable/letterSpacing.ts +61 -0
- package/lib/model/node/editable/persistentMarks.ts +45 -0
- package/lib/model/node/editable/tiptapExtensions.ts +33 -0
- package/lib/model/node/formattable.ts +108 -0
- package/lib/model/node/group.ts +79 -0
- package/lib/model/node/image.ts +41 -0
- package/lib/model/node/shape/polygon.ts +173 -0
- package/lib/model/node/shape/shape.ts +48 -0
- package/lib/model/node/text.ts +55 -0
- package/lib/model/node.ts +101 -0
- package/lib/model/page.ts +51 -0
- package/lib/model/traversal.ts +21 -0
- package/lib/ui/colors.ts +23 -0
- package/lib/ui/extractor.ts +57 -0
- package/lib/ui/index.ts +8 -0
- package/lib/ui/node/EditableContent.tsx +101 -0
- package/lib/ui/node/GroupContent.tsx +46 -0
- package/lib/ui/node/ImageContent.tsx +36 -0
- package/lib/ui/node/NodeView.tsx +68 -0
- package/lib/ui/node/PolygonContent.tsx +81 -0
- package/lib/ui/node/TextContent.tsx +40 -0
- package/lib/ui/node/useDoubleClick.ts +37 -0
- package/lib/ui/selection.ts +38 -0
- package/package.json +70 -0
- package/tests/createTestEditor.ts +19 -0
- package/tests/hooks/actions.test.tsx +736 -0
- package/tests/hooks/batch.test.tsx +332 -0
- package/tests/hooks/editor.test.tsx +56 -0
- package/tests/hooks/page.test.tsx +135 -0
- package/tests/hooks/pointer/pointer.test.tsx +244 -0
- package/tests/hooks/textMarks.test.tsx +624 -0
- package/tests/model/editor.test.ts +384 -0
- package/tests/model/history.test.ts +293 -0
- package/tests/model/node/group.test.ts +294 -0
- package/tests/model/node/image.test.ts +150 -0
- package/tests/model/node/polygon.test.ts +408 -0
- package/tests/model/node/text.test.ts +158 -0
- package/tests/model/node.test.ts +276 -0
- package/tests/model/page.test.ts +150 -0
- package/tests/setup.ts +7 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +9 -0
- 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
|
+
}
|