@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,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
|
+
}
|
package/lib/ui/colors.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/ui/index.ts
ADDED
|
@@ -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
|
+
}
|