@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,162 @@
1
+ import { range } from "es-toolkit"
2
+ import { useEffect, useState } from "react"
3
+
4
+ const API_URL = "https://www.googleapis.com/webfonts/v1/webfonts"
5
+ const FONT_URL = "https://fonts.googleapis.com/css2"
6
+
7
+ const VARIANTS = {
8
+ regular: "0,400",
9
+ italic: "1,400",
10
+ "100": "0,100",
11
+ "200": "0,200",
12
+ "300": "0,300",
13
+ "500": "0,500",
14
+ "600": "0,600",
15
+ "700": "0,700",
16
+ "800": "0,800",
17
+ "900": "0,900",
18
+ "100italic": "1,100",
19
+ "200italic": "1,200",
20
+ "300italic": "1,300",
21
+ "500italic": "1,500",
22
+ "600italic": "1,600",
23
+ "700italic": "1,700",
24
+ "800italic": "1,800",
25
+ "900italic": "1,900",
26
+ } as const
27
+
28
+ type Variant = keyof typeof VARIANTS
29
+
30
+ type Subset =
31
+ | "latin"
32
+ | "latin-ext"
33
+ | "cyrillic"
34
+ | "cyrillic-ext"
35
+ | "greek"
36
+ | "math"
37
+ | "symbols"
38
+ | "greek-ext"
39
+ | "gothic"
40
+ | "runic"
41
+ | "emoji"
42
+ | (string & {})
43
+
44
+ export type FontCategory =
45
+ | "serif"
46
+ | "sans-serif"
47
+ | "handwriting"
48
+ | "display"
49
+ | "monospace"
50
+
51
+ export type GoogleWebfont = {
52
+ family: string
53
+ variants: Variant[]
54
+ placeholder?: boolean
55
+ category: FontCategory
56
+ }
57
+
58
+ const variants: Variant[] = ["regular", "italic", "700", "700italic"]
59
+
60
+ // server only
61
+ export async function getGoogleFonts(
62
+ key: string,
63
+ queryParams: {
64
+ sort?: null | "alpha" | "date" | "popularity" | "style" | "trending"
65
+ family?: null | string
66
+ subset?: null | Subset
67
+ category?: null | FontCategory
68
+ },
69
+ ): Promise<GoogleWebfont[]> {
70
+ if (typeof window === "object") return []
71
+
72
+ const url = new URL(API_URL)
73
+ url.searchParams.set("key", key)
74
+ const { sort, family, subset, category } = queryParams
75
+
76
+ if (sort) url.searchParams.set("sort", sort)
77
+ if (family) url.searchParams.set("family", family)
78
+ if (subset) url.searchParams.set("subet", subset)
79
+ if (category) url.searchParams.set("category", category)
80
+
81
+ const res = await fetch(url)
82
+ const { items = [] } = (await res.json()) as { items?: GoogleWebfont[] }
83
+
84
+ // the editor only uses these
85
+ const result = items.filter((font) => variants.some((v) => font.variants.includes(v)))
86
+ return sort ? result : result.sort()
87
+ }
88
+
89
+ export function getLink(font: GoogleWebfont) {
90
+ const family = font.family.replace(/ /g, "+")
91
+ const variantParams = font.variants
92
+ .filter((v) => v === "regular" || v === "italic" || v === "700" || v === "700italic")
93
+ .map((v) => VARIANTS[v])
94
+ .sort()
95
+ .join(";")
96
+
97
+ return `${FONT_URL}?display=swap&family=${family}:ital,wght@${variantParams}`
98
+ }
99
+
100
+ // client only
101
+ export function loadFont(font: GoogleWebfont) {
102
+ if (!isLoaded(font.family)) {
103
+ const link = document.createElement("link")
104
+ link.rel = "stylesheet"
105
+ link.href = getLink(font)
106
+ loaded.add(font.family)
107
+ document.head.appendChild(link)
108
+ }
109
+ }
110
+
111
+ const loaded = new Set<string>()
112
+
113
+ export function isLoaded(family: string) {
114
+ return loaded.has(family)
115
+ }
116
+
117
+ type LoadingState = "loaded" | "failed" | "loading"
118
+
119
+ export function WithGoogleFont(props: {
120
+ font: GoogleWebfont
121
+ italic?: boolean
122
+ bold?: boolean
123
+ size?: string
124
+ children: (props: { state: LoadingState; fontFamily: string | null }) => React.ReactNode
125
+ }) {
126
+ const { font, italic, bold, size, children } = props
127
+ const [state, setState] = useState<"loaded" | "failed" | "loading">("loading")
128
+
129
+ useEffect(() => {
130
+ if (font.placeholder) return
131
+ const fontSpec = [
132
+ italic && "italic",
133
+ bold && "bold",
134
+ size ?? "16px",
135
+ `"${font.family}"`,
136
+ ]
137
+ document.fonts
138
+ .load(fontSpec.filter((s) => typeof s === "string").join(" "))
139
+ .then(() => setState("loaded"))
140
+ .catch((error) => {
141
+ setState("failed")
142
+ console.error(error)
143
+ })
144
+ }, [font, italic, bold, size])
145
+
146
+ useEffect(() => {
147
+ if (!font.placeholder) {
148
+ loadFont(font)
149
+ }
150
+ }, [font])
151
+
152
+ return children({ state, fontFamily: font.placeholder ? null : font.family })
153
+ }
154
+
155
+ export function placeholder(length = 32): GoogleWebfont[] {
156
+ return range(length).map((i) => ({
157
+ family: `${i}`,
158
+ variants: [],
159
+ placeholder: true,
160
+ category: "sans-serif",
161
+ }))
162
+ }
@@ -0,0 +1,169 @@
1
+ import { computed, createStore, state } from "react-bolt"
2
+ import type { GoogleWebfont } from "../lib/googleFonts"
3
+ import { HistoryStore, type HistoryEntry } from "./history"
4
+ import type { Node, NodeProps, SerializedNode } from "./node"
5
+ import { Page, type SerializedPage } from "./page"
6
+
7
+ const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
8
+
9
+ export interface NodeConstructor<Props extends NodeProps = NodeProps> {
10
+ prototype: Node
11
+ new (ctx: Editor, page: Page, props: Props): Node
12
+ }
13
+
14
+ export interface SerializedEditor {
15
+ idCounter: number
16
+ pages: SerializedPage[]
17
+ history: {
18
+ undoHistory: HistoryEntry[]
19
+ redoHistory: HistoryEntry[]
20
+ }
21
+ }
22
+
23
+ export type Action =
24
+ | { action?: never; payload?: never }
25
+ | { action: "select"; payload?: never }
26
+ | { action: "move"; payload: { x: number; y: number } }
27
+ | { action: "resize"; payload: { width: number; height: number } }
28
+ | { action: "rotate"; payload: { deg: number } }
29
+
30
+ interface EditorOptions {
31
+ loadFonts(family: string): Promise<GoogleWebfont[]>
32
+ maxHistory: number
33
+ snapThreshold: number
34
+ }
35
+
36
+ export interface EditorProps {
37
+ schema: Array<NodeConstructor>
38
+ options?: Partial<EditorOptions>
39
+ init?: Partial<SerializedEditor>
40
+ onChange?: () => void
41
+ }
42
+
43
+ function unimplemented(): Promise<GoogleWebfont[]> {
44
+ throw Error("missing implementation")
45
+ }
46
+
47
+ export class Editor {
48
+ #idCounter = 0
49
+ #history: HistoryStore
50
+ #onChange: () => void
51
+ #schema: Map<string, NodeConstructor>
52
+
53
+ @state accessor ref: HTMLElement | null = null
54
+
55
+ @state accessor pages = new Map<string, Page>()
56
+ @state private accessor _selection = new Set<Node>()
57
+
58
+ @state accessor action: Action = {}
59
+ @state accessor zoom: number = 1
60
+
61
+ readonly options: EditorOptions
62
+ get history() {
63
+ return this.#history
64
+ }
65
+
66
+ @computed get selection() {
67
+ // TODO: also filter so that only nodes from a single page is included
68
+ const nodes = new Set(this.nodes.values())
69
+ return new Set(this._selection.values().filter((node) => nodes.has(node)))
70
+ }
71
+
72
+ set selection(set: Set<Node>) {
73
+ const equals = this._selection.size === set.size && this._selection.isSubsetOf(set)
74
+ if (!equals) this._selection = set
75
+ }
76
+
77
+ @computed
78
+ get nodes(): Map<string, Node> {
79
+ return new Map(
80
+ this.pages
81
+ .values()
82
+ .flatMap((page) => page.nodes.values())
83
+ .map((node) => [node.id, node] as const),
84
+ )
85
+ }
86
+
87
+ @computed
88
+ get selectionPage() {
89
+ if (this.selection.size === 0) return null
90
+ const [first, ...rest] = this.selection
91
+ return rest.reduce(
92
+ (acc: Page | null, curr) => (acc === curr.page ? curr.page : null),
93
+ first.page,
94
+ )
95
+ }
96
+
97
+ id(): string {
98
+ let c = ++this.#idCounter
99
+ let s = ""
100
+ while (c > 0) {
101
+ s = CHARS[c % CHARS.length] + s
102
+ c = Math.floor(c / CHARS.length)
103
+ }
104
+ return s
105
+ }
106
+
107
+ constructor(props: EditorProps) {
108
+ const { schema, options, init } = props
109
+ this.#onChange = () => props.onChange?.()
110
+ this.#schema = new Map(schema.map((ctor) => [ctor.prototype.name, ctor]))
111
+
112
+ this.options = createStore({
113
+ loadFonts: options?.loadFonts ?? unimplemented,
114
+ maxHistory: options?.maxHistory ?? 200,
115
+ snapThreshold: options?.snapThreshold ?? 8,
116
+ })
117
+
118
+ this.#history = new HistoryStore({
119
+ editor: this,
120
+ onChange: this.#onChange,
121
+ })
122
+
123
+ if (init) this.load(init)
124
+ }
125
+
126
+ load({ idCounter, pages = [], history }: Partial<SerializedEditor>) {
127
+ this.#idCounter = idCounter ?? 0
128
+ this.#history = new HistoryStore({
129
+ editor: this,
130
+ onChange: this.#onChange,
131
+ redoHistory: history?.redoHistory ?? [],
132
+ undoHistory: history?.undoHistory ?? [],
133
+ })
134
+
135
+ this.pages = new Map(
136
+ pages.map(({ nodes, ...props }) => {
137
+ const page = new Page(this, props)
138
+ page.nodes = new Map(
139
+ nodes.map((node) => [node.props.id, this.deserializeNode(page, node)]),
140
+ )
141
+
142
+ return [page.id, page] as const
143
+ }),
144
+ )
145
+ }
146
+
147
+ deserializeNode<T extends NodeProps>(
148
+ page: Page,
149
+ { name, props }: SerializedNode<string, T>,
150
+ ): Node {
151
+ const NodeClass = this.#schema.get(name)
152
+ if (!NodeClass) throw Error(`cannot deserialize unknown Node: ${name}`)
153
+ return new NodeClass(this, page, props)
154
+ }
155
+
156
+ serialize(): SerializedEditor {
157
+ return {
158
+ idCounter: this.#idCounter,
159
+ pages: this.pages
160
+ .values()
161
+ .map((page) => page.serialize())
162
+ .toArray(),
163
+ history: {
164
+ undoHistory: this.history.undoHistory,
165
+ redoHistory: this.history.redoHistory,
166
+ },
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,155 @@
1
+ export interface Point {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export interface Rect extends Point {
7
+ width: number
8
+ height: number
9
+ }
10
+
11
+ function rectCorners({ x, y, width, height }: Rect): Point[] {
12
+ return [
13
+ { x, y },
14
+ { x: x + width, y },
15
+ { x, y: y + height },
16
+ { x: x + width, y: y + height },
17
+ ]
18
+ }
19
+
20
+ /**
21
+ * @example
22
+ * ```ts
23
+ * const center = { x: 0, y: 0 }
24
+ * const P = { x: 2, y: 0 }
25
+ * rotate(P, center, 90) // P' = { x: 0, y: 2 }
26
+ * ```
27
+ *
28
+ * P
29
+ * ●
30
+ * │
31
+ * │ 90°
32
+ * ●─────● P'
33
+ */
34
+ export function rotatePoint(p: Point, center: Point, deg: number): Point {
35
+ const rad = (deg * Math.PI) / 180
36
+ const cos = Math.cos(rad)
37
+ const sin = Math.sin(rad)
38
+ const dx = p.x - center.x
39
+ const dy = p.y - center.y
40
+
41
+ return {
42
+ x: center.x + dx * cos - dy * sin,
43
+ y: center.y + dx * sin + dy * cos,
44
+ }
45
+ }
46
+
47
+ export function boundingBox(args: Point[] | Rect[]): Rect {
48
+ const points: Point[] = args.flatMap((arg) =>
49
+ "width" in arg && "height" in arg ? rectCorners(arg) : arg,
50
+ )
51
+
52
+ let minX = Infinity
53
+ let minY = Infinity
54
+ let maxX = -Infinity
55
+ let maxY = -Infinity
56
+
57
+ for (const p of points) {
58
+ if (p.x < minX) minX = p.x
59
+ if (p.y < minY) minY = p.y
60
+ if (p.x > maxX) maxX = p.x
61
+ if (p.y > maxY) maxY = p.y
62
+ }
63
+
64
+ return {
65
+ x: minX,
66
+ y: minY,
67
+ width: maxX - minX,
68
+ height: maxY - minY,
69
+ }
70
+ }
71
+
72
+ export function rectCenter({ x, y, width, height }: Rect): Point {
73
+ return {
74
+ x: x + width / 2,
75
+ y: y + height / 2,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * axis-aligned bounding box (AABB) of the rotated rectangle
81
+ */
82
+ export function rotatedAABB(rect: Rect, deg: number): Rect {
83
+ return boundingBox(rectCorners(rect).map((c) => rotatePoint(c, rectCenter(rect), deg)))
84
+ }
85
+
86
+ export function dist(a: Point, b: Point) {
87
+ const dx = a.x - b.x
88
+ const dy = a.y - b.y
89
+ return Math.hypot(dx, dy)
90
+ }
91
+
92
+ export function sub(a: Point, b: Point) {
93
+ return {
94
+ x: a.x - b.x,
95
+ y: a.y - b.y,
96
+ }
97
+ }
98
+
99
+ export function add(a: Point, b: Point) {
100
+ return {
101
+ x: a.x + b.x,
102
+ y: a.y + b.y,
103
+ }
104
+ }
105
+
106
+ export function mul(p: Point, n: number) {
107
+ return {
108
+ x: p.x * n,
109
+ y: p.y * n,
110
+ }
111
+ }
112
+
113
+ export function norm(p: Point) {
114
+ const d = Math.hypot(p.x, p.y)
115
+ return {
116
+ x: d === 0 ? 0 : p.x / d,
117
+ y: d === 0 ? 0 : p.y / d,
118
+ }
119
+ }
120
+
121
+ export function dot(a: Point, b: Point) {
122
+ return a.x * b.x + a.y * b.y
123
+ }
124
+
125
+ export function clamp(n: number, lo: number, hi: number) {
126
+ return Math.max(lo, Math.min(hi, n))
127
+ }
128
+
129
+ export function tidyFloat(v: number) {
130
+ return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
131
+ }
132
+
133
+ export function radToDeg(rad: number): number {
134
+ return (rad * 180) / Math.PI
135
+ }
136
+
137
+ /**
138
+ * @example
139
+ * ```ts
140
+ * const center = { x: 0, y: 0 }
141
+ * const a = { x: 0, y: 2 }
142
+ * const b = { x: 2, y: 0 }
143
+ * angle(center, a, b) // 90
144
+ * ```
145
+ * a
146
+ * ●
147
+ * │
148
+ * │ 90°
149
+ * ●─────● b
150
+ */
151
+ export function angle(center: Point, a: Point, b: Point): number {
152
+ const angA = radToDeg(Math.atan2(a.y - center.y, a.x - center.x))
153
+ const angB = radToDeg(Math.atan2(b.y - center.y, b.x - center.x))
154
+ return Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180)
155
+ }
@@ -0,0 +1,135 @@
1
+ import { computed, state } from "react-bolt"
2
+ import type { Editor } from "./editor"
3
+ import type { Node, NodeProps, SerializedNode } from "./node"
4
+ import type { PageProps } from "./page"
5
+
6
+ export type HistoryAction<Props extends NodeProps = NodeProps> =
7
+ | [action: "batch", payload: Array<HistoryAction>]
8
+ | [
9
+ action: "add-node",
10
+ payload: [pageId: string, nodes: Array<SerializedNode<string, Props>>],
11
+ ]
12
+ | [action: "delete-node", payload: [pageId: string, nodes: Array<string>]]
13
+ | [action: "set-node-props", payload: [nodeId: string, props: Partial<Props>]]
14
+ | [action: "set-page-props", payload: [pageId: string, props: Partial<PageProps>]]
15
+ | [action: "stack-order", payload: [pageId: string, order: Array<string>]]
16
+
17
+ function assert<T>(instance?: T): T {
18
+ if (!instance) throw Error("unreachable")
19
+ return instance!
20
+ }
21
+
22
+ export type HistoryEntry<Props extends NodeProps = NodeProps> = {
23
+ undo: HistoryAction<Props>
24
+ redo: HistoryAction<Props>
25
+ }
26
+
27
+ export class HistoryStore {
28
+ private emitChange: () => void
29
+ readonly editor: Editor
30
+
31
+ @state private accessor _undoHistory: HistoryEntry[]
32
+ @state private accessor _redoHistory: HistoryEntry[]
33
+
34
+ @computed get undoHistory() {
35
+ return this._undoHistory
36
+ }
37
+
38
+ @computed get redoHistory() {
39
+ return this._redoHistory
40
+ }
41
+
42
+ readonly push = <Props extends NodeProps = NodeProps>(entry: HistoryEntry<Props>) => {
43
+ if (this._redoHistory.length > 0) {
44
+ this._redoHistory = []
45
+ }
46
+
47
+ this._undoHistory = [
48
+ ...this._undoHistory.slice(0, this.editor.options.maxHistory - 1),
49
+ entry,
50
+ ]
51
+ this.emitChange()
52
+ }
53
+
54
+ readonly undo = () => {
55
+ const undoList = [...this._undoHistory]
56
+ const entry = undoList.pop()
57
+ if (entry) {
58
+ this.execute(entry.undo)
59
+ this._redoHistory = [...this._redoHistory, entry]
60
+ this._undoHistory = undoList
61
+ }
62
+ this.emitChange()
63
+ }
64
+
65
+ readonly redo = () => {
66
+ const redoList = [...this._redoHistory]
67
+ const entry = redoList.pop()
68
+ if (entry) {
69
+ this.execute(entry.redo)
70
+ this._undoHistory = [...this._undoHistory, entry]
71
+ this._redoHistory = redoList
72
+ }
73
+ this.emitChange()
74
+ }
75
+
76
+ private execute([action, payload]: HistoryAction) {
77
+ switch (action) {
78
+ case "batch": {
79
+ payload.map((action) => this.execute(action))
80
+ break
81
+ }
82
+ case "add-node": {
83
+ const [pageId, data] = payload
84
+ const page = assert(this.editor.pages.get(pageId))
85
+ page.nodes = new Map([
86
+ ...page.nodes,
87
+ ...data.map((props) => {
88
+ const node = this.editor.deserializeNode(page, props)
89
+ return [node.id, node] as const
90
+ }),
91
+ ])
92
+ break
93
+ }
94
+ case "delete-node": {
95
+ const [pageId, data] = payload
96
+ const page = assert(this.editor.pages.get(pageId))
97
+ page.nodes = new Map(page.nodes.entries().filter(([key]) => !data.includes(key)))
98
+ break
99
+ }
100
+ case "set-node-props": {
101
+ const [nodeId, props] = payload
102
+ const node = assert(this.editor.nodes.get(nodeId))
103
+ Object.assign(node, props)
104
+ break
105
+ }
106
+ case "set-page-props": {
107
+ const [pageId, props] = payload
108
+ const page = assert(this.editor.pages.get(pageId))
109
+ Object.assign(page, props)
110
+ break
111
+ }
112
+ case "stack-order": {
113
+ const [pageId, order] = payload
114
+ const page = assert(this.editor.pages.get(pageId))
115
+ page.nodes = order.reduce((acc, id) => {
116
+ const node = assert(page.nodes.get(id))
117
+ return acc.set(node.id, node)
118
+ }, new Map<string, Node>())
119
+ break
120
+ }
121
+ }
122
+ }
123
+
124
+ constructor(props: {
125
+ editor: Editor
126
+ undoHistory?: HistoryEntry[]
127
+ redoHistory?: HistoryEntry[]
128
+ onChange: () => void
129
+ }) {
130
+ this.editor = props.editor
131
+ this.emitChange = props.onChange
132
+ this._undoHistory = props.undoHistory ?? []
133
+ this._redoHistory = props.redoHistory ?? []
134
+ }
135
+ }
@@ -0,0 +1,12 @@
1
+ export { Editor, type NodeConstructor, type SerializedEditor } from "./editor"
2
+ export { type HistoryAction, type HistoryEntry } from "./history"
3
+ export { Node, type NodeProps, type SerializedNode } from "./node"
4
+ export { EditableNode, type EditableNodeProps } from "./node/editable"
5
+ export { FormattableNode, type FormattableNodeProps } from "./node/formattable"
6
+ export { GroupNode, type GroupNodeProps } from "./node/group"
7
+ export { ImageNode, type ImageNodeProps } from "./node/image"
8
+ export { PolygonNode, type PolygonNodeProps } from "./node/shape/polygon"
9
+ export { ShapeNode, type ShapeNodeProps } from "./node/shape/shape"
10
+ export { TextNode, type TextNodeProps } from "./node/text"
11
+ export { Page, type PageProps, type SerializedPage } from "./page"
12
+ export { flattenNodes } from "./traversal"