@lazlon-platform/html-editor 0.6.0 → 0.7.1
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/lib/hooks/actions.ts +89 -67
- package/lib/hooks/batch.ts +9 -5
- package/lib/hooks/index.ts +7 -7
- package/lib/hooks/page.ts +2 -4
- package/lib/hooks/pointer/useMovePoint.ts +100 -0
- package/lib/hooks/pointer/{moveable.ts → useMoveable.ts} +30 -36
- package/lib/hooks/pointer/{pointer.ts → usePointer.ts} +2 -2
- package/lib/hooks/pointer/useResize/index.ts +31 -0
- package/lib/hooks/pointer/useResize/multi.ts +161 -0
- package/lib/hooks/pointer/useResize/multiLineNode.ts +99 -0
- package/lib/hooks/pointer/useResize/multiRegularNode.ts +109 -0
- package/lib/hooks/pointer/useResize/multiTextNode.ts +108 -0
- package/lib/hooks/pointer/useResize/singleRegularNode.ts +91 -0
- package/lib/hooks/pointer/useResize/singleTextNode.ts +115 -0
- package/lib/hooks/pointer/useRotation.ts +102 -0
- package/lib/hooks/pointer/{selector.ts → useSelector.ts} +2 -1
- package/lib/hooks/pointer/{snap.ts → useSnap.ts} +3 -2
- package/lib/hooks/{pointer/selectionFrame.ts → selectionFrame.ts} +9 -6
- package/lib/hooks/textMarks.ts +30 -19
- package/lib/model/editor.ts +18 -4
- package/lib/model/geometry/math.ts +8 -20
- package/lib/model/index.ts +9 -1
- package/lib/model/node/imageNode.ts +1 -1
- package/lib/model/node/lineNode.ts +41 -20
- package/lib/model/node/textNode.ts +5 -14
- package/lib/model/node.ts +18 -9
- package/lib/model/page.ts +3 -2
- package/lib/ui/node/EditableContent/index.tsx +6 -5
- package/lib/ui/node/ImageContent.tsx +0 -1
- package/lib/ui/node/LineContent.tsx +1 -3
- package/lib/ui/node/NodeView.tsx +2 -0
- package/lib/ui/node/ShapeContent/EllipseContent.tsx +0 -1
- package/lib/ui/node/ShapeContent/RectangleContent.tsx +0 -1
- package/lib/ui/selection.ts +6 -5
- package/package.json +1 -1
- package/lib/hooks/pointer/movePoint.ts +0 -75
- package/lib/hooks/pointer/resize.ts +0 -247
- package/lib/hooks/pointer/rotation.ts +0 -138
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import {
|
|
3
|
+
angle,
|
|
4
|
+
box,
|
|
5
|
+
boxRect,
|
|
6
|
+
deg,
|
|
7
|
+
floatNorm,
|
|
8
|
+
point,
|
|
9
|
+
rotatePoint,
|
|
10
|
+
type Box,
|
|
11
|
+
type Deg,
|
|
12
|
+
} from "../../model/geometry/math"
|
|
13
|
+
import { Node } from "../../model/node"
|
|
14
|
+
import { selectionBox } from "../../ui/selection"
|
|
15
|
+
import { useEditor } from "../editor"
|
|
16
|
+
import { cursorPosition, usePointer } from "./usePointer"
|
|
17
|
+
|
|
18
|
+
function snap(v: number): Deg {
|
|
19
|
+
return deg(Math.round(v / 45) * 45)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useRotation() {
|
|
23
|
+
const editor = useEditor()
|
|
24
|
+
const origin = point()
|
|
25
|
+
|
|
26
|
+
const state = useRef({
|
|
27
|
+
base: deg(0),
|
|
28
|
+
pivot: point(),
|
|
29
|
+
nodes: [] as Array<{ node: Node; baseBox: Box }>,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function historyProps(entry: Node | Box) {
|
|
33
|
+
const { x, y } = entry instanceof Node ? entry : boxRect(entry)
|
|
34
|
+
return { x, y, rotation: entry.rotation }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return usePointer({
|
|
38
|
+
onDown(event) {
|
|
39
|
+
const page = editor.selectionPage
|
|
40
|
+
const nodes = editor.selection
|
|
41
|
+
.values()
|
|
42
|
+
.toArray()
|
|
43
|
+
.filter((n) => !n.locked)
|
|
44
|
+
if (nodes.length === 0 || !page) return false
|
|
45
|
+
|
|
46
|
+
const { center: pivot } = selectionBox(editor.selection)
|
|
47
|
+
|
|
48
|
+
state.current = {
|
|
49
|
+
base: angle(pivot, origin, cursorPosition(event, page)),
|
|
50
|
+
pivot,
|
|
51
|
+
nodes: nodes.map((node) => ({ node, baseBox: box(node) })),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
editor.action = {
|
|
55
|
+
action: "rotate",
|
|
56
|
+
payload: { deg: nodes.length > 1 ? 0 : nodes[0].rotation },
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
onMove(event) {
|
|
61
|
+
const { base, pivot, nodes } = state.current
|
|
62
|
+
const cursor = cursorPosition(event, editor.selectionPage!)
|
|
63
|
+
|
|
64
|
+
const delta = deg(angle(pivot, origin, cursor) - base)
|
|
65
|
+
const singleBase = nodes.length === 1 ? nodes[0].baseBox.rotation : null
|
|
66
|
+
const resultDelta = deg(
|
|
67
|
+
event.shiftKey
|
|
68
|
+
? typeof singleBase === "number"
|
|
69
|
+
? snap(singleBase + delta) - singleBase
|
|
70
|
+
: snap(delta)
|
|
71
|
+
: delta,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for (const { node, baseBox } of nodes) {
|
|
75
|
+
const { x, y } = rotatePoint(baseBox.center, pivot, resultDelta)
|
|
76
|
+
node.x = floatNorm(x - baseBox.width / 2)
|
|
77
|
+
node.y = floatNorm(y - baseBox.height / 2)
|
|
78
|
+
node.rotation = deg(baseBox.rotation + resultDelta)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
editor.action = {
|
|
82
|
+
action: "rotate",
|
|
83
|
+
payload: { deg: resultDelta },
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
onCancel() {
|
|
88
|
+
editor.action = {}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
onEnd() {
|
|
92
|
+
editor.action = {}
|
|
93
|
+
|
|
94
|
+
editor.pushHistory(
|
|
95
|
+
state.current.nodes.map(({ node, baseBox }) => ({
|
|
96
|
+
redo: ["set-node-props", [node.id, historyProps(node)]],
|
|
97
|
+
undo: ["set-node-props", [node.id, historyProps(baseBox)]],
|
|
98
|
+
})),
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
accessibleLine,
|
|
12
12
|
} from "../../model/geometry/math"
|
|
13
13
|
import { useEditor } from "../editor"
|
|
14
|
-
import { cursorPosition, usePointer } from "./
|
|
14
|
+
import { cursorPosition, usePointer } from "./usePointer"
|
|
15
15
|
import { LineNode } from "../../model"
|
|
16
16
|
|
|
17
17
|
function clientPoint(event: { clientX: number; clientY: number }) {
|
|
@@ -63,6 +63,7 @@ export function useSelector(view: (props: null | Rect) => void) {
|
|
|
63
63
|
return page.nodes
|
|
64
64
|
.values()
|
|
65
65
|
.toArray()
|
|
66
|
+
.filter((node) => !node.locked)
|
|
66
67
|
.filter((node) => {
|
|
67
68
|
if (node instanceof LineNode) {
|
|
68
69
|
return lineIntersectsBox(node, selection)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useStore } from "react-bolt"
|
|
1
2
|
import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
|
|
2
3
|
import type { Node } from "../../model/node"
|
|
3
4
|
import type { Page } from "../../model/page"
|
|
@@ -8,8 +9,8 @@ const isN = (n?: number) => typeof n === "number"
|
|
|
8
9
|
|
|
9
10
|
export function useSnap(page: Page) {
|
|
10
11
|
const editor = useEditor()
|
|
11
|
-
|
|
12
|
-
const threshold = editor.options.snapThreshold
|
|
12
|
+
const zoom = useStore(editor, "zoom")
|
|
13
|
+
const threshold = editor.options.snapThreshold / zoom
|
|
13
14
|
|
|
14
15
|
function shouldSnap(point: number) {
|
|
15
16
|
return (line: number) => line + threshold > point && point > line - threshold
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react"
|
|
2
2
|
import { useComputed, useStore } from "react-bolt"
|
|
3
|
-
import { pointSubtract } from "
|
|
4
|
-
import { selectionDOMRect } from "
|
|
5
|
-
import { useEditor } from "
|
|
3
|
+
import { pointSubtract } from "../model/geometry/math"
|
|
4
|
+
import { selectionDOMRect } from "../ui/selection"
|
|
5
|
+
import { useEditor } from "./editor"
|
|
6
6
|
|
|
7
7
|
function arraysEqual<T>(a: T[], b: T[]): boolean {
|
|
8
8
|
if (a.length !== b.length) return false
|
|
@@ -33,6 +33,8 @@ function useObserver(onChange: () => void) {
|
|
|
33
33
|
.toArray()
|
|
34
34
|
|
|
35
35
|
useEffect(() => {
|
|
36
|
+
onChange()
|
|
37
|
+
|
|
36
38
|
const mObserver = new MutationObserver(onChange)
|
|
37
39
|
const rObserver = new ResizeObserver(onChange)
|
|
38
40
|
|
|
@@ -70,9 +72,10 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
|
|
|
70
72
|
|
|
71
73
|
useObserver(() => {
|
|
72
74
|
const frame = ref.current
|
|
73
|
-
const stage = editor.ref
|
|
75
|
+
const stage = editor.ref?.getBoundingClientRect()
|
|
76
|
+
if (!frame || !stage) return
|
|
74
77
|
|
|
75
|
-
if (props?.accountForSingleSelection && selection.size === 1
|
|
78
|
+
if (props?.accountForSingleSelection && selection.size === 1) {
|
|
76
79
|
const [firstNode] = selection
|
|
77
80
|
const { x, y, width, height, rotation } = firstNode
|
|
78
81
|
const relative = firstNode.page.ref!.getBoundingClientRect()
|
|
@@ -81,7 +84,7 @@ export function useSelectionFrame<E extends HTMLElement>(props?: {
|
|
|
81
84
|
frame.style.height = `${height * zoom}px`
|
|
82
85
|
frame.style.width = `${width * zoom}px`
|
|
83
86
|
frame.style.transform = `translate(${tx}px, ${ty}px) rotate(${rotation}deg)`
|
|
84
|
-
} else
|
|
87
|
+
} else {
|
|
85
88
|
const dom = selectionDOMRect(selection)
|
|
86
89
|
const t = pointSubtract(dom, stage)
|
|
87
90
|
frame.style.height = `${dom.height}px`
|
package/lib/hooks/textMarks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isEqual } from "es-toolkit"
|
|
2
2
|
import { useCallback, useRef, useSyncExternalStore } from "react"
|
|
3
3
|
import { effect, useComputed } from "react-bolt"
|
|
4
|
-
import {
|
|
4
|
+
import { EditableNode, FormattableNode, type Node, TextNode } from "../model"
|
|
5
5
|
import { useBatchSet, useNodeFieldBatch } from "./batch"
|
|
6
6
|
|
|
7
7
|
function mergeField<T>(values: T[]): T | null {
|
|
@@ -179,6 +179,10 @@ function useFocusedTiptap(editables: EditableNode[]) {
|
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
function nonLocked(node: Node) {
|
|
183
|
+
return !node.locked
|
|
184
|
+
}
|
|
185
|
+
|
|
182
186
|
// don't forget a unique key `selection.map((n) => n.id).join("")` on the parent component
|
|
183
187
|
export function useTextMarks(props: {
|
|
184
188
|
editables: Array<EditableNode>
|
|
@@ -197,21 +201,25 @@ export function useTextMarks(props: {
|
|
|
197
201
|
toggle(
|
|
198
202
|
mark: "Bold" | "Italic" | "Underline" | "Strike" | "Superscript" | "Subscript",
|
|
199
203
|
) {
|
|
200
|
-
if (focused) {
|
|
204
|
+
if (focused && !focused.locked) {
|
|
201
205
|
focused.tiptap.commands[`toggle${mark}`]()
|
|
202
206
|
} else {
|
|
203
207
|
const key = mark.toLowerCase() as Lowercase<typeof mark>
|
|
204
208
|
const isMark = state && state[`is${mark}`]
|
|
205
209
|
const action = isMark ? "unset" : "set"
|
|
206
|
-
|
|
210
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
211
|
+
editable.tiptap.chain().selectAll()[`${action}${mark}`]().run()
|
|
212
|
+
}
|
|
207
213
|
batchSet(formattables, { [key]: !isMark })
|
|
208
214
|
}
|
|
209
215
|
},
|
|
210
216
|
setColor(color: string, opts: { end: boolean }) {
|
|
211
|
-
if (focused) {
|
|
217
|
+
if (focused && !focused.locked) {
|
|
212
218
|
focused.tiptap.commands.setColor(color)
|
|
213
219
|
} else {
|
|
214
|
-
|
|
220
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
221
|
+
editable.tiptap.chain().selectAll().setColor(color).run()
|
|
222
|
+
}
|
|
215
223
|
if (opts.end) {
|
|
216
224
|
formattableColors.onChangeEnd(color)
|
|
217
225
|
} else {
|
|
@@ -220,24 +228,23 @@ export function useTextMarks(props: {
|
|
|
220
228
|
}
|
|
221
229
|
},
|
|
222
230
|
setSize(size: number) {
|
|
223
|
-
if (focused) {
|
|
231
|
+
if (focused && !focused.locked) {
|
|
224
232
|
if (focused instanceof TextNode) {
|
|
225
233
|
batchSet([focused], { size })
|
|
226
234
|
} else {
|
|
227
235
|
focused.tiptap.commands.setFontSize(`${size}px`)
|
|
228
236
|
}
|
|
229
237
|
} else {
|
|
230
|
-
editables
|
|
231
|
-
|
|
232
|
-
.
|
|
233
|
-
|
|
234
|
-
})
|
|
238
|
+
const nonTextNodes = editables.filter((e) => !(e instanceof TextNode))
|
|
239
|
+
for (const textNode of nonTextNodes.filter(nonLocked)) {
|
|
240
|
+
textNode.tiptap.chain().selectAll().setFontSize(`${size}px`).run()
|
|
241
|
+
}
|
|
235
242
|
const textNodes = editables.filter((e) => e instanceof TextNode)
|
|
236
243
|
batchSet([...formattables, ...textNodes], { size })
|
|
237
244
|
}
|
|
238
245
|
},
|
|
239
246
|
setFamily(family: string | null) {
|
|
240
|
-
if (focused) {
|
|
247
|
+
if (focused && !focused.locked) {
|
|
241
248
|
if (family) {
|
|
242
249
|
focused.tiptap.commands.setFontFamily(family)
|
|
243
250
|
} else {
|
|
@@ -245,20 +252,24 @@ export function useTextMarks(props: {
|
|
|
245
252
|
}
|
|
246
253
|
} else {
|
|
247
254
|
if (family) {
|
|
248
|
-
|
|
255
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
256
|
+
editable.tiptap.chain().selectAll().setFontFamily(family).run()
|
|
257
|
+
}
|
|
249
258
|
} else {
|
|
250
|
-
|
|
259
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
260
|
+
editable.tiptap.chain().selectAll().unsetFontFamily().run()
|
|
261
|
+
}
|
|
251
262
|
}
|
|
252
263
|
batchSet(formattables, { family })
|
|
253
264
|
}
|
|
254
265
|
},
|
|
255
266
|
setSpacing(spacing: number, opts: { end: boolean }) {
|
|
256
|
-
if (focused) {
|
|
267
|
+
if (focused && !focused.locked) {
|
|
257
268
|
focused.tiptap.commands.setLetterSpacing(`${spacing}px`)
|
|
258
269
|
} else {
|
|
259
|
-
editables.
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
for (const editable of editables.filter(nonLocked)) {
|
|
271
|
+
editable.tiptap.chain().selectAll().setLetterSpacing(`${spacing}px`).run()
|
|
272
|
+
}
|
|
262
273
|
if (opts.end) {
|
|
263
274
|
formattableSpacings.onChangeEnd(spacing)
|
|
264
275
|
} else {
|
|
@@ -267,7 +278,7 @@ export function useTextMarks(props: {
|
|
|
267
278
|
}
|
|
268
279
|
},
|
|
269
280
|
setLineHeight(lineHeight: number, opts: { end: boolean }) {
|
|
270
|
-
if (focused) {
|
|
281
|
+
if (focused && !focused.locked) {
|
|
271
282
|
focused.tiptap.commands.setLineHeight(`${lineHeight}`)
|
|
272
283
|
} else {
|
|
273
284
|
editables.map((e) =>
|
package/lib/model/editor.ts
CHANGED
|
@@ -59,6 +59,7 @@ export class Editor {
|
|
|
59
59
|
@state accessor zoom: number = 1
|
|
60
60
|
|
|
61
61
|
readonly options: EditorOptions
|
|
62
|
+
|
|
62
63
|
get history() {
|
|
63
64
|
return this.#history
|
|
64
65
|
}
|
|
@@ -74,8 +75,7 @@ export class Editor {
|
|
|
74
75
|
if (!equals) this._selection = set
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
@computed
|
|
78
|
-
get nodes(): Map<string, Node> {
|
|
78
|
+
@computed get nodes(): Map<string, Node> {
|
|
79
79
|
return new Map(
|
|
80
80
|
this.pages
|
|
81
81
|
.values()
|
|
@@ -84,8 +84,7 @@ export class Editor {
|
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
@computed
|
|
88
|
-
get selectionPage() {
|
|
87
|
+
@computed get selectionPage() {
|
|
89
88
|
if (this.selection.size === 0) return null
|
|
90
89
|
const [first, ...rest] = this.selection
|
|
91
90
|
return rest.reduce(
|
|
@@ -170,4 +169,19 @@ export class Editor {
|
|
|
170
169
|
},
|
|
171
170
|
}
|
|
172
171
|
}
|
|
172
|
+
|
|
173
|
+
pushHistory<N extends Node>(entries: HistoryEntry<N> | Array<HistoryEntry<N>>) {
|
|
174
|
+
if (Array.isArray(entries)) {
|
|
175
|
+
if (entries.length > 1) {
|
|
176
|
+
this.history.push({
|
|
177
|
+
undo: ["batch", entries.map((e) => e.undo)],
|
|
178
|
+
redo: ["batch", entries.map((e) => e.redo)],
|
|
179
|
+
})
|
|
180
|
+
} else if (entries.length === 1) {
|
|
181
|
+
this.history.push(entries[0])
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
this.history.push(entries)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
173
187
|
}
|
|
@@ -20,8 +20,8 @@ export interface Rect extends Point, Size {
|
|
|
20
20
|
readonly right: number
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export interface Line
|
|
24
|
-
readonly
|
|
23
|
+
export interface Line {
|
|
24
|
+
readonly vertices: Point[]
|
|
25
25
|
readonly strokeWidth: number
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -412,21 +412,13 @@ function segmentDistance(a: Point, b: Point, c: Point, d: Point): number {
|
|
|
412
412
|
)
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
-
function linePoints(line: Line): Point[] {
|
|
416
|
-
return line.points.map((p) => pointAdd(line, p))
|
|
417
|
-
}
|
|
418
|
-
|
|
419
415
|
/**
|
|
420
416
|
* @returns Whether point is contained in the stroked polyline.
|
|
421
417
|
*/
|
|
422
418
|
export function lineContainsPoint(line: Line, point: Point): boolean {
|
|
423
|
-
const points =
|
|
419
|
+
const points = line.vertices
|
|
424
420
|
const radius = line.strokeWidth / 2
|
|
425
421
|
|
|
426
|
-
if (points.length === 1) {
|
|
427
|
-
return pointDist(point, points[0]) <= radius
|
|
428
|
-
}
|
|
429
|
-
|
|
430
422
|
for (let i = 0; i < points.length - 1; i++) {
|
|
431
423
|
const a = points[i]
|
|
432
424
|
const b = points[i + 1]
|
|
@@ -441,9 +433,7 @@ export function lineContainsPoint(line: Line, point: Point): boolean {
|
|
|
441
433
|
* @returns Whether the stroked polyline intersects the box.
|
|
442
434
|
*/
|
|
443
435
|
export function lineIntersectsBox(line: Line, box: Box): boolean {
|
|
444
|
-
const points =
|
|
445
|
-
|
|
446
|
-
if (points.length === 0) return false
|
|
436
|
+
const points = line.vertices
|
|
447
437
|
|
|
448
438
|
for (const point of points) {
|
|
449
439
|
if (boxContainsPoint(box, point)) return true
|
|
@@ -479,12 +469,10 @@ export function lineIntersectsBox(line: Line, box: Box): boolean {
|
|
|
479
469
|
* @returns Line with increased strokeWidth for accessibility reasons.
|
|
480
470
|
*/
|
|
481
471
|
export function accessibleLine(line: Line): Line {
|
|
482
|
-
const {
|
|
472
|
+
const { strokeWidth, vertices } = line
|
|
483
473
|
return {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
points,
|
|
487
|
-
strokeWidth: Math.max(18, strokeWidth),
|
|
474
|
+
vertices,
|
|
475
|
+
strokeWidth: Math.max(24, strokeWidth),
|
|
488
476
|
}
|
|
489
477
|
}
|
|
490
478
|
|
|
@@ -586,7 +574,7 @@ export function resizeBox(box: Box, edge: Edge, by: number): Box {
|
|
|
586
574
|
* Scale box by one of its edges keeping its aspect ratio.
|
|
587
575
|
* The center and pivot is shifted accordingly so that visually only the edge moves.
|
|
588
576
|
*/
|
|
589
|
-
export function scaleBox(box: Box, direction: Edge | Corner, by: number) {
|
|
577
|
+
export function scaleBox(box: Box, direction: Edge | Corner, by: number): Box {
|
|
590
578
|
const vertical = direction.includes("n") || direction.includes("s")
|
|
591
579
|
const scale =
|
|
592
580
|
vertical && box.height !== 0
|
package/lib/model/index.ts
CHANGED
|
@@ -6,7 +6,15 @@ export { FormattableNode, type FormattableNodeProps } from "./node/formattableNo
|
|
|
6
6
|
export { GroupNode, type GroupNodeProps } from "./node/groupNode"
|
|
7
7
|
export { ImageNode, type ImageNodeProps } from "./node/imageNode"
|
|
8
8
|
export { LineNode, type LineNodeProps } from "./node/lineNode"
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
ShapeNode,
|
|
11
|
+
type ShapeNodeProps,
|
|
12
|
+
newRectangleShape,
|
|
13
|
+
newPolygonShape,
|
|
14
|
+
newEllipseShape,
|
|
15
|
+
newArrowShape,
|
|
16
|
+
newStarShape,
|
|
17
|
+
} from "./node/shapeNode"
|
|
10
18
|
export { TextNode, type TextNodeProps } from "./node/textNode"
|
|
11
19
|
export { Page, type PageProps, type SerializedPage } from "./page"
|
|
12
20
|
export { flattenNodes } from "./traversal"
|
|
@@ -22,7 +22,7 @@ export class ImageNode extends Node {
|
|
|
22
22
|
) {
|
|
23
23
|
super(page, props)
|
|
24
24
|
this.url = url ?? null
|
|
25
|
-
this.fit = "cover"
|
|
25
|
+
this.fit = fit ?? "cover"
|
|
26
26
|
this.roundness = roundness ?? 0
|
|
27
27
|
this.borderColor = borderColor ?? "#000000"
|
|
28
28
|
this.borderWidth = borderWidth ?? 0
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { computed, state } from "react-bolt"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
floatNorm,
|
|
4
|
+
pointAdd,
|
|
5
|
+
rect,
|
|
6
|
+
rectCenter,
|
|
7
|
+
rotatePoint,
|
|
8
|
+
type Line,
|
|
9
|
+
type Point,
|
|
10
|
+
} from "../geometry/math"
|
|
3
11
|
import { Node, type NodeProps } from "../node"
|
|
4
12
|
import type { Page } from "../page"
|
|
5
13
|
|
|
@@ -12,7 +20,7 @@ export class LineNode extends Node {
|
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
/** Relative to {@link x},{@link y} */
|
|
15
|
-
@state accessor points: Point[]
|
|
23
|
+
@state accessor points: Point[] = []
|
|
16
24
|
@state accessor strokeWidth: number
|
|
17
25
|
@state accessor strokeColor: string
|
|
18
26
|
|
|
@@ -20,40 +28,53 @@ export class LineNode extends Node {
|
|
|
20
28
|
return this.rect.height
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
set height(
|
|
24
|
-
|
|
31
|
+
set height(height: number) {
|
|
32
|
+
this.scalePoints("y", height)
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
@computed get width(): number {
|
|
28
36
|
return this.rect.width
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
set width(
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
@computed private get rect() {
|
|
36
|
-
const local = point({
|
|
37
|
-
x: super.x,
|
|
38
|
-
y: super.y,
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
return rect(...this.points.map((p) => pointAdd(p, local)))
|
|
39
|
+
set width(width: number) {
|
|
40
|
+
this.scalePoints("x", width)
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
constructor(page: Page, { points, strokeWidth, strokeColor, ...props }: LineNodeProps) {
|
|
45
44
|
super(page, props)
|
|
45
|
+
this.strokeWidth = strokeWidth ?? 1
|
|
46
|
+
this.strokeColor = strokeColor ?? "#000000"
|
|
46
47
|
this.points = points ?? [
|
|
47
|
-
{ x:
|
|
48
|
-
{ x: 100, y: 50 },
|
|
48
|
+
{ x: 0, y: 0 },
|
|
49
49
|
{ x: 100, y: 0 },
|
|
50
50
|
]
|
|
51
|
-
this.strokeWidth = strokeWidth ?? 1
|
|
52
|
-
this.strokeColor = strokeColor ?? "#000000"
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
get props(): LineNodeProps {
|
|
56
|
-
const {
|
|
54
|
+
const { strokeWidth, strokeColor, points } = this
|
|
57
55
|
return { ...super.props, points, strokeWidth, strokeColor }
|
|
58
56
|
}
|
|
57
|
+
|
|
58
|
+
@computed private get rect() {
|
|
59
|
+
return rect(...this.points)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@computed get vertices(): Line["vertices"] {
|
|
63
|
+
const center = rectCenter(this)
|
|
64
|
+
return this.points.map((p) => rotatePoint(pointAdd(p, this), center, this.rotation))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private scalePoints(axis: keyof Point, size: number) {
|
|
68
|
+
const { x, y, width, height } = this.rect
|
|
69
|
+
|
|
70
|
+
const origin = axis === "x" ? x : y
|
|
71
|
+
const scale = size / (axis === "x" ? width : height)
|
|
72
|
+
|
|
73
|
+
this.points = this.points.map((p) => {
|
|
74
|
+
return {
|
|
75
|
+
...p,
|
|
76
|
+
[axis]: floatNorm(origin + (p[axis] - origin) * scale),
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
59
80
|
}
|
|
@@ -11,28 +11,19 @@ export class TextNode extends EditableNode {
|
|
|
11
11
|
|
|
12
12
|
@state accessor halign: "left" | "center" | "right" | "justify"
|
|
13
13
|
@state accessor size: number = 16
|
|
14
|
-
@state accessor contentHeight: number = 24
|
|
15
14
|
|
|
16
15
|
private observer = new ResizeObserver(([entry]) => {
|
|
17
|
-
this.
|
|
16
|
+
this.height = entry.target.clientHeight
|
|
18
17
|
})
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
set height(_) {
|
|
25
|
-
// no-op: TextNode's height can be set using its font-size
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
constructor(page: Page, { halign, size, ...props }: TextNodeProps) {
|
|
29
|
-
super(page, props)
|
|
19
|
+
constructor(page: Page, { halign, size, height = 24, ...props }: TextNodeProps) {
|
|
20
|
+
super(page, { height, ...props })
|
|
30
21
|
this.halign = halign ?? "center"
|
|
31
22
|
this.size = size ?? 16
|
|
32
23
|
|
|
33
24
|
this.tiptap.on("transaction", () => {
|
|
34
25
|
const height = this.tiptap.view.dom.clientHeight
|
|
35
|
-
if (height > 0) this.
|
|
26
|
+
if (height > 0) this.height = height
|
|
36
27
|
})
|
|
37
28
|
}
|
|
38
29
|
|
|
@@ -41,7 +32,7 @@ export class TextNode extends EditableNode {
|
|
|
41
32
|
|
|
42
33
|
if (ref) {
|
|
43
34
|
this.observer.observe(ref)
|
|
44
|
-
this.
|
|
35
|
+
this.height = ref.clientHeight
|
|
45
36
|
} else {
|
|
46
37
|
this.observer.disconnect()
|
|
47
38
|
}
|
package/lib/model/node.ts
CHANGED
|
@@ -10,7 +10,8 @@ export type SerializedNode<N extends Node = Node> = {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface NodeProps
|
|
13
|
-
extends
|
|
13
|
+
extends
|
|
14
|
+
Pick<Node, "id" | "x" | "y" | "width" | "height">,
|
|
14
15
|
Partial<Pick<Node, "locked" | "rotation" | "css">> {
|
|
15
16
|
// serialized as a list in case we need to allow
|
|
16
17
|
// nodes to have multiple roles in the future
|
|
@@ -28,12 +29,12 @@ export abstract class Node {
|
|
|
28
29
|
@state private accessor _width: number
|
|
29
30
|
@state private accessor _height: number
|
|
30
31
|
|
|
31
|
-
@state accessor rotation: Deg
|
|
32
|
-
@state accessor role: string | null
|
|
33
|
-
@state accessor locked: boolean
|
|
32
|
+
@state accessor rotation: Deg = deg(0)
|
|
33
|
+
@state accessor role: string | null = null
|
|
34
|
+
@state accessor locked: boolean = false
|
|
34
35
|
@state accessor x: number = 0
|
|
35
36
|
@state accessor y: number = 0
|
|
36
|
-
@state accessor css: string
|
|
37
|
+
@state accessor css: string = ""
|
|
37
38
|
|
|
38
39
|
@computed get width() {
|
|
39
40
|
return this._width
|
|
@@ -60,10 +61,18 @@ export abstract class Node {
|
|
|
60
61
|
this.y = props.y
|
|
61
62
|
this._width = props.width
|
|
62
63
|
this._height = props.height
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
if (typeof props.role === "object") {
|
|
65
|
+
this.role = props.role?.[0]
|
|
66
|
+
}
|
|
67
|
+
if (typeof props.locked === "boolean") {
|
|
68
|
+
this.locked = props.locked
|
|
69
|
+
}
|
|
70
|
+
if (typeof props.rotation === "number") {
|
|
71
|
+
this.rotation = props.rotation
|
|
72
|
+
}
|
|
73
|
+
if (typeof props.css === "string") {
|
|
74
|
+
this.css = props.css
|
|
75
|
+
}
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
get props(): NodeProps {
|
package/lib/model/page.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { state } from "react-bolt"
|
|
|
2
2
|
import type { Editor } from "./editor"
|
|
3
3
|
import { Node, type SerializedNode } from "./node"
|
|
4
4
|
|
|
5
|
-
export interface PageProps
|
|
6
|
-
|
|
5
|
+
export interface PageProps extends Partial<
|
|
6
|
+
Pick<Page, "background" | "width" | "height">
|
|
7
|
+
> {
|
|
7
8
|
id: string
|
|
8
9
|
nodes?: Node[]
|
|
9
10
|
}
|