@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,55 @@
1
+ import { computed, 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 TextNodeProps = EditableNodeProps &
8
+ Partial<Pick<TextNode, "halign" | "scale">>
9
+
10
+ export class TextNode extends EditableNode {
11
+ get name() {
12
+ return "text"
13
+ }
14
+
15
+ @state accessor halign: "left" | "center" | "right" | "justify"
16
+ @state accessor scale: number = 1
17
+ @state accessor contentHeight: number = 24
18
+
19
+ @computed get height(): number {
20
+ return this.contentHeight * this.scale
21
+ }
22
+
23
+ set height(n: number) {
24
+ this.scale = n / this.contentHeight
25
+ }
26
+
27
+ constructor(editor: Editor, page: Page, { halign, scale, ...props }: TextNodeProps) {
28
+ super(editor, page, props)
29
+ this.halign = halign ?? "center"
30
+ this.scale = scale ?? 1
31
+
32
+ this.tiptap.on("transaction", () => {
33
+ const height = this.tiptap.view.dom.clientHeight
34
+ if (height > 0) this.contentHeight = height
35
+ })
36
+ }
37
+
38
+ set contentRef(ref: HTMLElement | null) {
39
+ super.contentRef = ref
40
+ if (ref) this.contentHeight = ref.clientHeight
41
+ }
42
+
43
+ @computed get contentRef() {
44
+ return super.contentRef
45
+ }
46
+
47
+ props(): TextNodeProps {
48
+ const { halign, scale } = this
49
+ return { ...super.props(), halign, scale }
50
+ }
51
+
52
+ serialize(): SerializedNode<this["name"], TextNodeProps> {
53
+ return super.serialize()
54
+ }
55
+ }
@@ -0,0 +1,101 @@
1
+ import { computed, state } from "react-bolt"
2
+ import type { Editor } from "./editor"
3
+ import { rotatedAABB } from "./geometry"
4
+ import type { Page } from "./page"
5
+
6
+ export type Schema = Map<string, typeof Node>
7
+
8
+ export type SerializedNode<
9
+ Name extends string = string,
10
+ Props extends NodeProps = NodeProps,
11
+ > = {
12
+ name: Name
13
+ props: Props
14
+ }
15
+
16
+ export interface NodeProps
17
+ extends Pick<Node, "id" | "x" | "y" | "width" | "height">,
18
+ Partial<Pick<Node, "locked" | "rotation" | "css">> {
19
+ // serialized as a list in case we need to allow
20
+ // nodes to have multiple roles in the future
21
+ role?: string[]
22
+ }
23
+
24
+ export abstract class Node {
25
+ abstract get name(): string
26
+ readonly id: string
27
+ readonly page: Page
28
+
29
+ @state accessor ref: HTMLElement | null = null
30
+
31
+ @state private accessor _width: number
32
+ @state private accessor _height: number
33
+
34
+ @state accessor rotation: number
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
40
+
41
+ @computed get width() {
42
+ return this._width
43
+ }
44
+
45
+ set width(n: number) {
46
+ if (n < 1) n = 1
47
+ this._width = n
48
+ }
49
+
50
+ @computed get height() {
51
+ return this._height
52
+ }
53
+
54
+ set height(n: number) {
55
+ if (n < 1) n = 1
56
+ this._height = n
57
+ }
58
+
59
+ constructor(_: Editor, page: Page, props: NodeProps) {
60
+ this.page = page
61
+ this.id = props.id
62
+ this.x = props.x
63
+ this.y = props.y
64
+ this._width = props.width
65
+ this._height = props.height
66
+ this.role = props.role?.[0] || null
67
+ this.locked = props.locked ?? false
68
+ this.rotation = props.rotation ?? 0
69
+ this.css = props.css ?? ""
70
+ }
71
+
72
+ blockMove(_: React.PointerEvent): boolean {
73
+ return !!this.locked
74
+ }
75
+
76
+ props(): NodeProps {
77
+ const { id, role, locked, rotation, css, x, y, width, height } = this
78
+ return {
79
+ id,
80
+ x,
81
+ y,
82
+ width,
83
+ height,
84
+ ...(role && { role: [role] }),
85
+ ...(css && { css }),
86
+ ...(locked && { locked }),
87
+ ...(rotation && { rotation }),
88
+ }
89
+ }
90
+
91
+ serialize(): SerializedNode {
92
+ return {
93
+ name: this.name,
94
+ props: this.props(),
95
+ }
96
+ }
97
+
98
+ @computed get boundingBox() {
99
+ return rotatedAABB(this, this.rotation)
100
+ }
101
+ }
@@ -0,0 +1,51 @@
1
+ import { state } from "react-bolt"
2
+ import type { Editor } from "./editor"
3
+ import { Node, type SerializedNode } from "./node"
4
+ // import { createRef } from "react"
5
+
6
+ export interface PageProps
7
+ extends Partial<Pick<Page, "background" | "width" | "height">> {
8
+ id: string
9
+ nodes?: Node[]
10
+ }
11
+
12
+ export interface SerializedPage extends Omit<PageProps, "nodes"> {
13
+ nodes: SerializedNode[]
14
+ }
15
+
16
+ export class Page {
17
+ readonly id: string
18
+
19
+ ref: HTMLElement | null = null
20
+
21
+ @state accessor nodes = new Map<string, Node>()
22
+ @state accessor background: string
23
+ @state accessor width = 841
24
+ @state accessor height = 595
25
+
26
+ @state accessor snapLines: Array<
27
+ | { x: number; y?: number; h?: number; w?: never } // horizontal
28
+ | { y: number; x?: number; w?: number; h?: never } // vertical
29
+ > = []
30
+
31
+ constructor(_: Editor, props: PageProps) {
32
+ this.id = props.id
33
+ this.background = props.background ?? "#ffffff"
34
+ this.width = props.width ?? 841
35
+ this.height = props.height ?? 595
36
+ this.nodes = new Map((props.nodes ?? []).map((node) => [node.id, node]))
37
+ }
38
+
39
+ serialize(): SerializedPage {
40
+ return {
41
+ id: this.id,
42
+ background: this.background,
43
+ width: this.width,
44
+ height: this.height,
45
+ nodes: this.nodes
46
+ .values()
47
+ .map((node) => node.serialize())
48
+ .toArray(),
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,21 @@
1
+ import { Node } from "./node"
2
+ import { GroupNode } from "./node/group"
3
+ import { Page } from "./page"
4
+
5
+ export function flattenNodes(root: Node | Page): Array<Node> {
6
+ if (root instanceof Page) {
7
+ return [...root.nodes.values()].reduce(
8
+ (acc, node) => [...acc, ...flattenNodes(node)],
9
+ new Array<Node>(),
10
+ )
11
+ }
12
+
13
+ if (root instanceof GroupNode) {
14
+ return [...root.nodes].reduce(
15
+ (acc, node) => [...acc, ...flattenNodes(node)],
16
+ new Array<Node>(root),
17
+ )
18
+ }
19
+
20
+ return [root]
21
+ }
@@ -0,0 +1,23 @@
1
+ function toLinear(c: number) {
2
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
3
+ }
4
+
5
+ /**
6
+ * Returns "#000000" or "#ffffff" based on contrast against a hex background.
7
+ */
8
+ export function getReadableForeground(hex: string): "#000000" | "#ffffff" {
9
+ if (!/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) {
10
+ throw new Error("Expected #RRGGBB or #RRGGBBAA hex color")
11
+ }
12
+
13
+ const r = parseInt(hex.slice(1, 3), 16) / 255
14
+ const g = parseInt(hex.slice(3, 5), 16) / 255
15
+ const b = parseInt(hex.slice(5, 7), 16) / 255
16
+
17
+ const l = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
18
+
19
+ const black = (l + 0.05) / 0.05
20
+ const white = 1.05 / (l + 0.05)
21
+
22
+ return black > white ? "#000000" : "#ffffff"
23
+ }
@@ -0,0 +1,57 @@
1
+ import type { JSONContent } from "@tiptap/core"
2
+ import { uniq } from "es-toolkit"
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"
7
+ import { Page } from "../model/page"
8
+ import { flattenNodes } from "../model/traversal"
9
+
10
+ function getMarks({
11
+ content = [],
12
+ marks = [],
13
+ }: JSONContent): NonNullable<JSONContent["marks"]> {
14
+ return [...marks, ...content.flatMap(getMarks)]
15
+ }
16
+
17
+ function getMarkKey(nodes: Node[], key: string) {
18
+ return nodes
19
+ .filter((n) => n instanceof EditableNode)
20
+ .flatMap((n) => getMarks(n.content))
21
+ .filter((m) => m.type === "textStyle")
22
+ .map((m) => m.attrs)
23
+ .filter((m) => !!m)
24
+ .map((m) => m[key] as string)
25
+ .filter((f) => !!f)
26
+ }
27
+
28
+ export function getFontFamilies(root: Node | Page) {
29
+ const nodes = flattenNodes(root)
30
+
31
+ const editableFamilies = getMarkKey(nodes, "fontFamily")
32
+
33
+ const formattableFamilies = nodes
34
+ .filter((n) => n instanceof FormattableNode)
35
+ .map((n) => n.family)
36
+ .filter((f) => typeof f === "string")
37
+
38
+ return uniq([...editableFamilies, ...formattableFamilies])
39
+ }
40
+
41
+ export function getColors(root: Node | Page) {
42
+ const nodes = flattenNodes(root)
43
+
44
+ const editableColors = getMarkKey(nodes, "color")
45
+
46
+ const formattableColors = nodes
47
+ .filter((n) => n instanceof FormattableNode)
48
+ .map((n) => n.color)
49
+ .filter((c) => typeof c === "string")
50
+
51
+ const shapeColors = nodes
52
+ .filter((n) => n instanceof ShapeNode)
53
+ .map((n) => n.background)
54
+ .filter((c) => typeof c === "string")
55
+
56
+ return uniq([...editableColors, ...formattableColors, ...shapeColors])
57
+ }
@@ -0,0 +1,8 @@
1
+ export { getReadableForeground } from "./colors"
2
+ export { getColors, getFontFamilies } from "./extractor"
3
+ export { EditableContent } from "./node/EditableContent"
4
+ export { GroupContent } from "./node/GroupContent"
5
+ export { ImageContent } from "./node/ImageContent"
6
+ export { NodeView } from "./node/NodeView"
7
+ export { PolygonContent } from "./node/PolygonContent"
8
+ export { TextContent } from "./node/TextContent"
@@ -0,0 +1,101 @@
1
+ import { EditorContent, useEditorState } from "@tiptap/react"
2
+ import clsx from "clsx"
3
+ import { debounce, isEqual } from "es-toolkit"
4
+ import { useCallback, useEffect, useRef, useState } from "react"
5
+ import { useStore } from "react-bolt"
6
+ import { useEditor } from "../../hooks/editor"
7
+ import { blurNode } from "../../hooks/textMarks"
8
+ import type { EditableNode, EditableNodeProps } from "../../model/node/editable"
9
+ import { useDoubleClick } from "./useDoubleClick"
10
+
11
+ export function EditableContent(props: {
12
+ node: EditableNode
13
+ className?: string
14
+ style?: React.CSSProperties
15
+ isStatic?: boolean
16
+ placeholder?: string
17
+ }) {
18
+ const { node, className, placeholder, isStatic, style } = props
19
+ const editor = useEditor()
20
+ const selection = useStore(editor, "selection")
21
+ const { action } = useStore(editor, "action")
22
+ const prevContent = useRef(node.tiptap.getJSON())
23
+ const [clicked, setClicked] = useState(false)
24
+ const onDoubleClick = useDoubleClick(
25
+ useCallback(() => {
26
+ setClicked(true)
27
+ node.tiptap.commands.focus()
28
+ }, [node, setClicked]),
29
+ )
30
+
31
+ const html = useEditorState({
32
+ editor: node.tiptap,
33
+ selector: (s) => s.editor.getHTML(),
34
+ })
35
+
36
+ useEffect(() => {
37
+ if (!selection.has(node)) {
38
+ node.tiptap.commands.blur()
39
+ blurNode(node)
40
+ setClicked(false)
41
+ }
42
+ }, [selection, node])
43
+
44
+ useEffect(() => {
45
+ const onUpdate = debounce(() => {
46
+ const next = node.tiptap.getJSON()
47
+ const prev = prevContent.current
48
+ if (!isEqual(prev, next)) {
49
+ editor.history.push<EditableNodeProps>({
50
+ undo: ["set-node-props", [node.id, { content: prev }]],
51
+ redo: ["set-node-props", [node.id, { content: next }]],
52
+ })
53
+ prevContent.current = next
54
+ }
55
+ }, 500)
56
+
57
+ node.tiptap.on("update", onUpdate)
58
+ return () => void node.tiptap.off("update", onUpdate)
59
+ }, [editor, node])
60
+
61
+ const isFocused = useEditorState({
62
+ editor: node.tiptap,
63
+ selector: (s) => s.editor.isFocused,
64
+ })
65
+
66
+ const isEmpty = useEditorState({
67
+ editor: node.tiptap,
68
+ selector: (s) => s.editor.isEmpty,
69
+ })
70
+
71
+ const lineHeight = useStore(node, "lineHeight")
72
+ const showEditor = !isStatic && selection.has(node) && !action && clicked
73
+
74
+ return (
75
+ <div
76
+ ref={(ref) => void (node.contentRef = ref)}
77
+ className={clsx("relative min-w-[min(80%,6ch)]", className)}
78
+ onPointerDown={onDoubleClick}
79
+ style={{ ...style, ...(!isEmpty && { lineHeight }) }}
80
+ >
81
+ {showEditor && isEmpty && placeholder && (
82
+ <span className="absolute inset-0 text-nowrap opacity-50">{placeholder}</span>
83
+ )}
84
+ {showEditor ? (
85
+ <EditorContent
86
+ onDragStart={(e) => e.preventDefault()}
87
+ editor={node.tiptap}
88
+ className={clsx("*:outline-none", !isFocused && "*:pointer-events-none")}
89
+ />
90
+ ) : !isEmpty ? (
91
+ <div
92
+ className="tiptap ProseMirror select-none [&_p]:empty:before:content-['\00a0']"
93
+ // `select-none` does not work on `EditorContent` because its always contenteditable
94
+ dangerouslySetInnerHTML={{ __html: html }}
95
+ />
96
+ ) : (
97
+ placeholder && <span className="text-nowrap opacity-50">{placeholder}</span>
98
+ )}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,46 @@
1
+ import { useStore } from "react-bolt"
2
+ import type { Node } from "../../model/node"
3
+ import type { GroupNode } from "../../model/node/group"
4
+
5
+ function GroupedNodeContent(props: { node: Node; children: React.ReactNode }) {
6
+ const { node, children } = props
7
+
8
+ const [x, y, width, height, rotation] = useStore(
9
+ node,
10
+ "x",
11
+ "y",
12
+ "width",
13
+ "height",
14
+ "rotation",
15
+ )
16
+
17
+ return (
18
+ <div
19
+ className="absolute"
20
+ style={{
21
+ width: `${width}px`,
22
+ height: `${height}px`,
23
+ transform: `translate(${x}px,${y}px) rotate(${rotation}deg)`,
24
+ }}
25
+ >
26
+ {children}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export function GroupContent(props: {
32
+ node: GroupNode
33
+ children(node: Node): React.ReactNode
34
+ }) {
35
+ const [nodes] = useStore(props.node, "nodes", "x", "y")
36
+
37
+ return (
38
+ <div className="relative size-full">
39
+ {[...nodes].map((node) => (
40
+ <GroupedNodeContent key={node.id} node={node}>
41
+ {props.children(node)}
42
+ </GroupedNodeContent>
43
+ ))}
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,36 @@
1
+ import clsx from "clsx"
2
+ import { useStore } from "react-bolt"
3
+ import type { ImageNode } from "../../model/node/image"
4
+
5
+ export function ImageContent(props: { node: ImageNode; fallback?: React.ReactNode }) {
6
+ const { node, fallback } = props
7
+
8
+ const [url, fit, roundness, borderWidth, borderColor] = useStore(
9
+ node,
10
+ "url",
11
+ "fit",
12
+ "roundness",
13
+ "borderWidth",
14
+ "borderColor",
15
+ )
16
+
17
+ return url ? (
18
+ <img
19
+ src={url}
20
+ draggable={false}
21
+ className={clsx(
22
+ "size-full",
23
+ fit === "cover" && "object-cover",
24
+ fit === "contain" && "object-contain",
25
+ fit === "fill" && "object-fill",
26
+ )}
27
+ style={{
28
+ borderColor,
29
+ borderWidth,
30
+ borderRadius: `${roundness}px`,
31
+ }}
32
+ />
33
+ ) : (
34
+ <div className="flex size-full items-center justify-center">{fallback}</div>
35
+ )
36
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect } from "react"
2
+ import { useStore } from "react-bolt"
3
+ import { useEditor } from "../../hooks/editor"
4
+ import { Node } from "../../model/node"
5
+ import { getFontFamilies } from "../../ui/extractor"
6
+ import { isLoaded, loadFont } from "../../lib/googleFonts"
7
+ import { isPointerInSelectionRect } from "../selection"
8
+
9
+ export function NodeView(props: {
10
+ node: Node
11
+ children: React.ReactNode
12
+ className?: string
13
+ }) {
14
+ const { node, children, className } = props
15
+ const editor = useEditor()
16
+
17
+ const [id, x, y, width, height, rotation, css] = useStore(
18
+ node,
19
+ "id",
20
+ "x",
21
+ "y",
22
+ "width",
23
+ "height",
24
+ "rotation",
25
+ "css",
26
+ )
27
+
28
+ useEffect(() => {
29
+ for (const family of getFontFamilies(node)) {
30
+ if (!isLoaded(family)) {
31
+ editor.options.loadFonts(family).then((fonts) => {
32
+ for (const font of fonts) {
33
+ loadFont(font)
34
+ }
35
+ })
36
+ }
37
+ }
38
+ }, [node, editor])
39
+
40
+ return (
41
+ <div
42
+ id={id}
43
+ data-node={node.name}
44
+ ref={(ref) => void (node.ref = ref)}
45
+ className={className}
46
+ style={{
47
+ position: "absolute",
48
+ width: `${width}px`,
49
+ height: `${height}px`,
50
+ transform: `translate(${x}px,${y}px) rotate(${rotation ?? 0}deg)`,
51
+ }}
52
+ onDragStart={(e) => e.preventDefault()}
53
+ onPointerDown={(event) => {
54
+ if (event.button !== 0) return
55
+
56
+ const { selection } = editor
57
+ if (selection.size === 0 || event.shiftKey) {
58
+ editor.selection = new Set([...selection, node])
59
+ } else if (!isPointerInSelectionRect(selection, event)) {
60
+ editor.selection = new Set([node])
61
+ }
62
+ }}
63
+ >
64
+ {children}
65
+ {css && <style>{`#${id} { ${css} }`}</style>}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,81 @@
1
+ import clsx from "clsx"
2
+ import { useId } from "react"
3
+ import { useStore } from "react-bolt"
4
+ import { type PolygonNode } from "../../model/node/shape/polygon"
5
+ import { EditableContent } from "./EditableContent"
6
+
7
+ export function PolygonContent(props: { node: PolygonNode; isStatic?: boolean }) {
8
+ const maskId = useId()
9
+ const { node, isStatic } = props
10
+
11
+ const [valign, halign, background, borderWidth, borderColor, sides] = useStore(
12
+ node,
13
+ "valign",
14
+ "halign",
15
+ "background",
16
+ "borderWidth",
17
+ "borderColor",
18
+ "sides",
19
+ )
20
+
21
+ const [w, h, d] = useStore(node, "width", "height", "svgPathData")
22
+
23
+ const radii = useStore(
24
+ node,
25
+ "cornerTopLeft",
26
+ "cornerTopRight",
27
+ "cornerBottomRight",
28
+ "cornerBottomLeft",
29
+ )
30
+
31
+ return (
32
+ <div className="relative size-full">
33
+ {sides !== 4 ? (
34
+ <svg width={w} height={h} className="absolute inset-0">
35
+ <defs>
36
+ <mask id={maskId} maskUnits="userSpaceOnUse">
37
+ <path d={d} fill="white" />
38
+ </mask>
39
+ </defs>
40
+
41
+ <path d={d} fill={background} />
42
+ <path
43
+ d={d}
44
+ fill="none"
45
+ stroke={borderColor}
46
+ strokeWidth={borderWidth}
47
+ mask={`url(#${maskId})`}
48
+ />
49
+ </svg>
50
+ ) : (
51
+ <div
52
+ className="absolute inset-0 size-full"
53
+ onDragStart={(e) => e.preventDefault()}
54
+ style={{
55
+ borderRadius: radii.map((r) => `${r}px`).join(" "),
56
+ background: background,
57
+ border: `${borderWidth}px solid ${borderColor}`,
58
+ }}
59
+ />
60
+ )}
61
+ <div
62
+ className={clsx(
63
+ "flex size-full",
64
+ valign === "top" && "items-start",
65
+ valign === "center" && "items-center",
66
+ valign === "bottom" && "items-end",
67
+ halign === "left" && "justify-start text-left",
68
+ halign === "center" && "justify-center text-center",
69
+ halign === "right" && "justify-end text-right",
70
+ halign === "justify" && "w-full text-justify",
71
+ )}
72
+ >
73
+ <EditableContent
74
+ isStatic={isStatic}
75
+ node={node}
76
+ className={clsx(halign === "justify" && "w-full")}
77
+ />
78
+ </div>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,40 @@
1
+ import clsx from "clsx"
2
+ import { useEffect } from "react"
3
+ import { useStore } from "react-bolt"
4
+ import { useEditor } from "../../hooks/editor"
5
+ import type { TextNode } from "../../model/node/text"
6
+ import { EditableContent } from "./EditableContent"
7
+
8
+ export function TextContent(props: {
9
+ node: TextNode
10
+ placeholder?: string
11
+ isStatic?: boolean
12
+ }) {
13
+ const { node, placeholder, isStatic } = props
14
+ const editor = useEditor()
15
+ const selection = useStore(editor, "selection")
16
+ const [halign, scale, height] = useStore(node, "halign", "scale", "height")
17
+
18
+ useEffect(() => {
19
+ if (node.tiptap.isEmpty && !selection.has(node)) {
20
+ // TODO: remove node and make sure history is still valid
21
+ }
22
+ }, [node, selection])
23
+
24
+ return (
25
+ <div style={{ height }} className="overflow-clip m-auto">
26
+ <EditableContent
27
+ node={node}
28
+ isStatic={isStatic}
29
+ placeholder={placeholder}
30
+ style={{ transform: `scale(${scale})` }}
31
+ className={clsx(
32
+ halign === "left" && "text-left origin-top-left",
33
+ halign === "center" && "text-center origin-top",
34
+ halign === "right" && "text-right origin-top-right",
35
+ halign === "justify" && "text-justify *:w-full origin-top",
36
+ )}
37
+ />
38
+ </div>
39
+ )
40
+ }