@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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(node --version:*)",
5
+ "Bash(pnpm tsc:*)",
6
+ "Bash(pnpm lint:*)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,34 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ ci:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: pnpm/action-setup@v4
17
+ with:
18
+ version: 9
19
+
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 22
23
+ cache: pnpm
24
+
25
+ - run: pnpm install --frozen-lockfile
26
+
27
+ - name: Typecheck
28
+ run: pnpm typecheck
29
+
30
+ - name: Lint
31
+ run: pnpm lint
32
+
33
+ - name: Test
34
+ run: pnpm test
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # HTML Editor
2
+
3
+ Visual HTML editing library used in cakesanddays.com
4
+
5
+ ## Model
6
+
7
+ Editor -\*> Page -\*> Node
8
+
9
+ ## Demo App
10
+
11
+ The Demo App should demonstrate how to
12
+
13
+ - display the Model,
14
+ - use hooks such as `useMovable`, `useResize`, `useRotate`,
15
+ - how to display an overlay toolbar over the selection using `useSelectionFrame`,
16
+ - how to use action hooks such as `useAddNodeAction`, `useGroupAction` etc
17
+ - how to use model mutating hooks such as `useNodeFieldBatch`
18
+
19
+ ## Hacking
20
+
21
+ ```sh
22
+ pnpm install
23
+ pnpm dev
24
+ ```
package/demo/App.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { EditorContext } from "@lazlon/html-editor/hooks"
2
+ import {
3
+ Editor,
4
+ GroupNode,
5
+ ImageNode,
6
+ Page,
7
+ PolygonNode,
8
+ TextNode,
9
+ } from "@lazlon/html-editor/model"
10
+ import EditorView from "./EditorView"
11
+ import Hotkeys from "./hotkeys"
12
+ import Navbar from "./Navbar"
13
+ import SideBar from "./Sidebar"
14
+
15
+ function createEditor() {
16
+ const state = localStorage.getItem("state")
17
+
18
+ const editor = new Editor({
19
+ ...(state && { init: JSON.parse(state) }),
20
+ schema: [GroupNode, TextNode, PolygonNode, ImageNode],
21
+ onChange() {
22
+ localStorage.setItem("state", JSON.stringify(editor.serialize()))
23
+ },
24
+ })
25
+
26
+ if (state) return editor
27
+
28
+ const page = new Page(editor, {
29
+ id: editor.id(),
30
+ background: "#ffffff",
31
+ width: 841,
32
+ height: 595,
33
+ })
34
+
35
+ const polygon = new PolygonNode(editor, page, {
36
+ id: editor.id(),
37
+ x: 50,
38
+ y: 50,
39
+ width: 100,
40
+ height: 100,
41
+ })
42
+
43
+ page.nodes = new Map([[polygon.id, polygon]])
44
+ editor.pages = new Map([[page.id, page]])
45
+
46
+ return editor
47
+ }
48
+
49
+ export default function App() {
50
+ return (
51
+ <EditorContext value={createEditor()}>
52
+ <div className="h-full flex flex-col bg-amber-50">
53
+ <Navbar />
54
+ <div className="h-full flex overflow-auto">
55
+ <SideBar />
56
+ <EditorView />
57
+ </div>
58
+ </div>
59
+ <Hotkeys />
60
+ </EditorContext>
61
+ )
62
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ GroupNode,
3
+ ImageNode,
4
+ Node,
5
+ PolygonNode,
6
+ TextNode,
7
+ } from "@lazlon/html-editor/model"
8
+ import {
9
+ GroupContent,
10
+ ImageContent,
11
+ PolygonContent,
12
+ TextContent,
13
+ } from "@lazlon/html-editor/ui"
14
+
15
+ export default function NodeContent({ node }: { node: Node }) {
16
+ if (node instanceof PolygonNode) {
17
+ return <PolygonContent node={node} />
18
+ }
19
+
20
+ if (node instanceof TextNode) {
21
+ return <TextContent placeholder="Text..." node={node} />
22
+ }
23
+
24
+ if (node instanceof GroupNode) {
25
+ return (
26
+ <GroupContent node={node}>{(node) => <NodeContent node={node} />}</GroupContent>
27
+ )
28
+ }
29
+
30
+ if (node instanceof ImageNode) {
31
+ return <ImageContent node={node} />
32
+ }
33
+
34
+ throw Error(`unknown node: ${node.name}`)
35
+ }
@@ -0,0 +1,28 @@
1
+ import { usePage } from "@lazlon/html-editor/hooks"
2
+ import { useStore } from "react-bolt"
3
+
4
+ export default function SnapLines() {
5
+ const page = usePage()
6
+ const snapLines = useStore(page, "snapLines")
7
+ const [width, height] = useStore(page, "width", "height")
8
+
9
+ return (
10
+ <>
11
+ {snapLines.map(({ x, y, h, w }, i) => (
12
+ <div
13
+ key={i}
14
+ className="bg-black absolute"
15
+ style={{
16
+ top: `${y ?? 0}px`,
17
+ left: `${x ?? 0}px`,
18
+ width: `${w ?? (typeof y === "number" ? width : 1)}px`,
19
+ height: `${h ?? (typeof x === "number" ? height : 1)}px`,
20
+ background: "radial-gradient(circle, black 1px, transparent 1px",
21
+ backgroundSize: typeof y === "number" ? "5px 1px" : "1px 5px",
22
+ backgroundRepeat: typeof y === "number" ? "repeat-x" : "repeat-y",
23
+ }}
24
+ />
25
+ ))}
26
+ </>
27
+ )
28
+ }
@@ -0,0 +1,45 @@
1
+ import { useEditor, useMoveable, usePage } from "@lazlon/html-editor/hooks"
2
+ import { getReadableForeground, NodeView } from "@lazlon/html-editor/ui"
3
+ import { useStore } from "react-bolt"
4
+ import NodeContent from "./NodeContent"
5
+ import SnapLines from "./SnapLines"
6
+
7
+ export default function PageView() {
8
+ const page = usePage()
9
+ const zoom = useStore(useEditor(), "zoom")
10
+ const [width, height, background] = useStore(page, "width", "height", "background")
11
+ const nodes = useStore(page, "nodes").values().toArray()
12
+ const movable = useMoveable()
13
+
14
+ return (
15
+ <div
16
+ className="m-auto shrink-0"
17
+ style={{
18
+ width: Math.round(width * zoom),
19
+ height: Math.round(height * zoom),
20
+ }}
21
+ >
22
+ <div
23
+ ref={(ref) => void (page.ref = ref)}
24
+ className="origin-top-left relative shadow-xl"
25
+ style={{
26
+ width,
27
+ height,
28
+ background,
29
+ transform: `scale(${zoom})`,
30
+ color: getReadableForeground(background),
31
+ }}
32
+ onPointerDown={(event) => {
33
+ movable.onPointerDown(event)
34
+ }}
35
+ >
36
+ {nodes.map((node) => (
37
+ <NodeView key={node.id} node={node}>
38
+ <NodeContent node={node} />
39
+ </NodeView>
40
+ ))}
41
+ <SnapLines />
42
+ </div>
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,24 @@
1
+ import { useResize } from "@lazlon/html-editor/hooks"
2
+ import clsx from "clsx"
3
+
4
+ export default function Corner(props: {
5
+ className?: string
6
+ corner: "ne" | "nw" | "se" | "sw"
7
+ }) {
8
+ const { className, corner } = props
9
+ const { onPointerDown } = useResize(corner)
10
+
11
+ return (
12
+ <div
13
+ onPointerDown={onPointerDown}
14
+ className={clsx(
15
+ className,
16
+ "bg-black size-2 pointer-events-auto absolute select-none",
17
+ corner == "ne" && "hover:cursor-ne-resize",
18
+ corner == "nw" && "hover:cursor-nw-resize",
19
+ corner == "se" && "hover:cursor-se-resize",
20
+ corner == "sw" && "hover:cursor-nesw-resize",
21
+ )}
22
+ />
23
+ )
24
+ }
@@ -0,0 +1,21 @@
1
+ import { useResize } from "@lazlon/html-editor/hooks"
2
+ import clsx from "clsx"
3
+
4
+ export default function Edge(props: { className?: string; side: "n" | "s" | "w" | "e" }) {
5
+ const { className, side } = props
6
+ const { onPointerDown } = useResize(side)
7
+
8
+ return (
9
+ <div
10
+ onPointerDown={onPointerDown}
11
+ className={clsx(
12
+ className,
13
+ "bg-black pointer-events-auto absolute select-none",
14
+ side == "n" && "hover:cursor-n-resize",
15
+ side == "s" && "hover:cursor-s-resize",
16
+ side == "e" && "hover:cursor-e-resize",
17
+ side == "w" && "hover:cursor-w-resize",
18
+ )}
19
+ />
20
+ )
21
+ }
@@ -0,0 +1,27 @@
1
+ import { useEditor, useSelectionFrame } from "@lazlon/html-editor/hooks"
2
+ import { useStore } from "react-bolt"
3
+ import Corner from "./Corner"
4
+ import Edge from "./Edge"
5
+
6
+ export default function SelectionFrame() {
7
+ const editor = useEditor()
8
+ const selection = useStore(editor, "selection")
9
+ const selectionFrameRef = useSelectionFrame<HTMLDivElement>({
10
+ accountForSingleSelection: true,
11
+ })
12
+
13
+ return (
14
+ selection.size > 0 && (
15
+ <div ref={selectionFrameRef} className="pointer-events-none absolute inset-0">
16
+ <Edge className="-top-1 h-1 w-full" side="n" />
17
+ <Edge className="-right-1 h-full w-1" side="e" />
18
+ <Edge className="-bottom-1 h-1 w-full" side="s" />
19
+ <Edge className="-left-1 h-full w-1" side="w" />
20
+ <Corner className="-top-1.5 -right-1.5" corner="ne" />
21
+ <Corner className="-right-1.5 -bottom-1.5" corner="se" />
22
+ <Corner className="-bottom-1.5 -left-1.5" corner="sw" />
23
+ <Corner className="-top-1.5 -left-1.5" corner="nw" />
24
+ </div>
25
+ )
26
+ )
27
+ }
@@ -0,0 +1,32 @@
1
+ import { useEditor } from "@lazlon/html-editor/hooks"
2
+ import clsx from "clsx"
3
+ import { useStore } from "react-bolt"
4
+
5
+ export default function ActionHud(props: { className?: string }) {
6
+ const editor = useEditor()
7
+ const { action, payload } = useStore(editor, "action")
8
+
9
+ return (
10
+ payload && (
11
+ <div
12
+ className={clsx(
13
+ "select-none rounded-lg py-1 px-2 text-sm shadow-lg",
14
+ "ring-black/8 ring-1 ring-inset",
15
+ props.className,
16
+ )}
17
+ >
18
+ {action === "resize" && (
19
+ <span className="text-nowrap">
20
+ w:{payload.width}, h:{payload.height}
21
+ </span>
22
+ )}
23
+ {action === "move" && (
24
+ <span className="text-nowrap">
25
+ x:{payload.x}, y:{payload.y}
26
+ </span>
27
+ )}
28
+ {action === "rotate" && <span className="text-nowrap">r:{payload.deg}°</span>}
29
+ </div>
30
+ )
31
+ )
32
+ }
@@ -0,0 +1,39 @@
1
+ import { useEditor, useNodeField, useRotation } from "@lazlon/html-editor/hooks"
2
+ import clsx from "clsx"
3
+ import { useStore } from "react-bolt"
4
+
5
+ export default function Rotation(props: { className?: string }) {
6
+ const { onPointerDown } = useRotation()
7
+ const editor = useEditor()
8
+ const selection = useStore(editor, "selection")
9
+ const { action } = useStore(editor, "action")
10
+ const isLocked = useNodeField(selection, "locked", false)
11
+ const shouldShow = !action && !isLocked
12
+
13
+ return (
14
+ shouldShow && (
15
+ <div
16
+ className={clsx(
17
+ props.className,
18
+ "bg-white rounded-full shadow-lg",
19
+ "ring-black/8 ring-1 ring-inset",
20
+ )}
21
+ >
22
+ <div onPointerDown={onPointerDown} className="p-2">
23
+ <RotateIcon />
24
+ </div>
25
+ </div>
26
+ )
27
+ )
28
+ }
29
+
30
+ function RotateIcon() {
31
+ return (
32
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
33
+ <path
34
+ fill="currentColor"
35
+ d="M15.25 18.48V15a.75.75 0 1 0-1.5 0v4c0 .97.78 1.75 1.75 1.75h4a.75.75 0 1 0 0-1.5h-2.6a8.75 8.75 0 0 0-2.07-15.53.75.75 0 1 0-.49 1.42 7.25 7.25 0 0 1 .91 13.34zM8.75 5.52V9a.75.75 0 0 0 1.5 0V5c0-.97-.78-1.75-1.75-1.75h-4a.75.75 0 0 0 0 1.5h2.6a8.75 8.75 0 0 0 2.18 15.57.75.75 0 0 0 .47-1.43 7.25 7.25 0 0 1-1-13.37z"
36
+ />
37
+ </svg>
38
+ )
39
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ useAlignAction,
3
+ useBatchSet,
4
+ useDistributeAction,
5
+ useDuplicateAction,
6
+ useEditor,
7
+ useGroupAction,
8
+ useNodeField,
9
+ useStackOrderAction,
10
+ useToggleLockAction,
11
+ useTrashAction,
12
+ } from "@lazlon/html-editor/hooks"
13
+ import { GroupNode } from "@lazlon/html-editor/model"
14
+ import clsx from "clsx"
15
+ import {
16
+ Button,
17
+ Menu,
18
+ MenuItem,
19
+ MenuTrigger,
20
+ Popover,
21
+ SubmenuTrigger,
22
+ } from "react-aria-components"
23
+ import { useStore } from "react-bolt"
24
+
25
+ export default function Toolbar(props: { className?: string }) {
26
+ const editor = useEditor()
27
+ const selection = useStore(editor, "selection")
28
+ const [firstNode, ...restNode] = selection
29
+ const { action } = useStore(editor, "action")
30
+ const trash = useTrashAction()
31
+ const duplicate = useDuplicateAction()
32
+ const { group, ungroup } = useGroupAction()
33
+ const toggleLock = useToggleLockAction()
34
+ const align = useAlignAction()
35
+ const distribute = useDistributeAction()
36
+ const stack = useStackOrderAction()
37
+ const isLocked = useNodeField(selection, "locked", false)
38
+ const batchSet = useBatchSet()
39
+
40
+ return (
41
+ !action && (
42
+ <div
43
+ className={clsx(
44
+ props.className,
45
+ "flex gap-2 rounded-lg bg-white border-black/8 border shadow-lg px-2 py-1",
46
+ )}
47
+ >
48
+ <button onClick={() => trash()}>del</button>
49
+ <button onClick={() => duplicate()}>dup</button>
50
+ {restNode.length > 0 && <button onClick={() => group()}>group</button>}
51
+ {firstNode instanceof GroupNode && (
52
+ <button onClick={() => ungroup(firstNode)}>ungroup</button>
53
+ )}
54
+ <button onClick={() => toggleLock()}>{isLocked ? "unlock" : "lock"}</button>
55
+ <MenuTrigger>
56
+ <Button>menu</Button>
57
+ <Popover
58
+ offset={12}
59
+ placement="right top"
60
+ className="bg-white border-black/8 border shadow-lg px-2 py-1 rounded-lg w-52"
61
+ >
62
+ <Menu>
63
+ <SubmenuTrigger>
64
+ <MenuItem>align</MenuItem>
65
+ <Popover
66
+ offset={12}
67
+ className="bg-white border-black/8 border shadow-lg px-2 py-1 rounded-lg w-52"
68
+ >
69
+ <Menu>
70
+ <MenuItem onClick={align.right}>right</MenuItem>
71
+ <MenuItem onClick={align.center}>center</MenuItem>
72
+ <MenuItem onClick={align.left}>left</MenuItem>
73
+ <MenuItem onClick={align.top}>top</MenuItem>
74
+ <MenuItem onClick={align.middle}>middle</MenuItem>
75
+ <MenuItem onClick={align.bottom}>bottom</MenuItem>
76
+ </Menu>
77
+ </Popover>
78
+ </SubmenuTrigger>
79
+ <SubmenuTrigger>
80
+ <MenuItem>distribute</MenuItem>
81
+ <Popover
82
+ offset={12}
83
+ className="bg-white border-black/8 border shadow-lg px-2 py-1 rounded-lg w-52"
84
+ >
85
+ <Menu>
86
+ <MenuItem onClick={distribute.vertical}>vertically</MenuItem>
87
+ <MenuItem onClick={distribute.horizontal}>horizontally</MenuItem>
88
+ </Menu>
89
+ </Popover>
90
+ </SubmenuTrigger>
91
+ <SubmenuTrigger>
92
+ <MenuItem>stack</MenuItem>
93
+ <Popover
94
+ offset={12}
95
+ className="bg-white border-black/8 border shadow-lg px-2 py-1 rounded-lg w-52"
96
+ >
97
+ <Menu>
98
+ <MenuItem
99
+ isDisabled={!stack.canBringBackward}
100
+ onClick={stack.bringBackward}
101
+ >
102
+ bring backward
103
+ </MenuItem>
104
+ <MenuItem
105
+ isDisabled={!stack.canBringForward}
106
+ onClick={stack.bringForward}
107
+ >
108
+ bring forward
109
+ </MenuItem>
110
+ <MenuItem onClick={stack.bringToFront}>bring to front</MenuItem>
111
+ <MenuItem onClick={stack.bringToBack}>bring to back</MenuItem>
112
+ </Menu>
113
+ </Popover>
114
+ </SubmenuTrigger>
115
+ <MenuItem
116
+ onClick={() =>
117
+ batchSet(selection, (n) => ({ rotation: n.rotation + 90 }))
118
+ }
119
+ >
120
+ rotate 90°
121
+ </MenuItem>
122
+ </Menu>
123
+ </Popover>
124
+ </MenuTrigger>
125
+ </div>
126
+ )
127
+ )
128
+ }
@@ -0,0 +1,21 @@
1
+ import { useEditor, useSelectionFrame } from "@lazlon/html-editor/hooks"
2
+ import { useStore } from "react-bolt"
3
+ import ActionHud from "./ActionHud"
4
+ import Rotation from "./Rotation"
5
+ import Toolbar from "./Toolbar"
6
+
7
+ export default function SelectionOverlay() {
8
+ const editor = useEditor()
9
+ const selection = useStore(editor, "selection")
10
+ const frameRef = useSelectionFrame<HTMLDivElement>()
11
+
12
+ return (
13
+ selection.size > 0 && (
14
+ <div ref={frameRef} className="pointer-events-none absolute inset-0">
15
+ <ActionHud className="absolute -bottom-14 left-1/2 -translate-x-1/2" />
16
+ <Rotation className="pointer-events-auto absolute -bottom-18 left-1/2 -translate-x-1/2" />
17
+ <Toolbar className="pointer-events-auto absolute -top-18 left-1/2 -translate-x-1/2" />
18
+ </div>
19
+ )
20
+ )
21
+ }
@@ -0,0 +1,68 @@
1
+ import { useEditor, useTextMarks } from "@lazlon/html-editor/hooks"
2
+ import { EditableNode, FormattableNode } from "@lazlon/html-editor/model"
3
+ import { getReadableForeground } from "@lazlon/html-editor/ui"
4
+ import clsx from "clsx"
5
+ import { ColorField, Input, NumberField, Separator } from "react-aria-components"
6
+ import { useStore } from "react-bolt"
7
+
8
+ export default function Toolbar() {
9
+ const editor = useEditor()
10
+ const [page] = useStore(editor, "pages").values()
11
+ const selection = useStore(editor, "selection").values().toArray()
12
+
13
+ const { state, toggle, setColor, setSize } = useTextMarks({
14
+ editables: selection.filter((n) => n instanceof EditableNode),
15
+ formattables: selection.filter((n) => n instanceof FormattableNode),
16
+ })
17
+
18
+ return (
19
+ <div
20
+ className={clsx(
21
+ "absolute top-4 left-1/2 -translate-x-1/2",
22
+ "bg-white rounded-lg border-black/8 border shadow-lg px-2 py-1 ",
23
+ "flex gap-4 items-center",
24
+ )}
25
+ >
26
+ <div className="flex gap-2">
27
+ <button onClick={() => (editor.zoom += 0.05)}>zoom+</button>
28
+ <button onClick={() => (editor.zoom -= 0.05)}>zoom-</button>
29
+ </div>
30
+
31
+ {state && (
32
+ <>
33
+ <Separator className="h-7 bg-black w-px" />
34
+ <div className="flex gap-2">
35
+ {page && (
36
+ <ColorField
37
+ aria-label="color"
38
+ value={state.color ?? getReadableForeground(page.background)}
39
+ onChange={(c) =>
40
+ setColor(c?.toString("hex") ?? getReadableForeground(page.background), {
41
+ end: true,
42
+ })
43
+ }
44
+ >
45
+ <Input
46
+ className="w-18"
47
+ onKeyDown={(e) => {
48
+ if (e.key === "Enter") {
49
+ // HACK: ReactAria does not apply changes on enter by default
50
+ e.currentTarget.blur()
51
+ e.currentTarget.focus()
52
+ }
53
+ }}
54
+ />
55
+ </ColorField>
56
+ )}
57
+ <button onClick={() => toggle("Bold")}>bold</button>
58
+ <button onClick={() => toggle("Italic")}>italic</button>
59
+ <button onClick={() => toggle("Underline")}>underline</button>
60
+ <NumberField value={state.size} aria-label="font size" onChange={setSize}>
61
+ <Input className="w-8" />
62
+ </NumberField>
63
+ </div>
64
+ </>
65
+ )}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,47 @@
1
+ import { PageContext, useEditor, useSelector } from "@lazlon/html-editor/hooks"
2
+ import clsx from "clsx"
3
+ import { useRef } from "react"
4
+ import { useStore } from "react-bolt"
5
+ import PageView from "./PageView"
6
+ import SelectionFrame from "./SelectionFrame"
7
+ import SelectionOverlay from "./SelectionOverlay"
8
+ import Toolbar from "./Toolbar"
9
+
10
+ export default function EditorView(props: { className?: string }) {
11
+ const editor = useEditor()
12
+ const selection = useStore(editor, "selection")
13
+ const selectorRef = useRef<HTMLDivElement>(null)
14
+ const pages = useStore(editor, "pages").values().toArray()
15
+ const selector = useSelector({ ref: selectorRef })
16
+ const key = selection
17
+ .values()
18
+ .toArray()
19
+ .map((n) => n.id)
20
+ .join("")
21
+
22
+ return (
23
+ <div className={clsx("relative size-full overflow-auto", props.className)}>
24
+ <div
25
+ ref={(ref) => void (editor.ref = ref)}
26
+ className="relative flex size-full overflow-auto p-14 gap-4"
27
+ onPointerDown={(event) => {
28
+ selector.onPointerDown(event)
29
+ }}
30
+ >
31
+ {pages.map((page) => (
32
+ <PageContext key={page.id} value={page}>
33
+ <PageView />
34
+ </PageContext>
35
+ ))}
36
+ </div>
37
+ <div
38
+ ref={selectorRef}
39
+ className="absolute inset-0 border"
40
+ style={{ display: "none" }}
41
+ />
42
+ <SelectionOverlay />
43
+ <SelectionFrame />
44
+ <Toolbar key={key} />
45
+ </div>
46
+ )
47
+ }