@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,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"
|