{
100 | // $isContextMenuOpen.set(false)
101 | // }}
102 | // />
103 | //
257 | // >
258 | // )
259 |
260 | // // return createPortal(
261 | // // <>
262 | // //
{
270 | // // $isContextMenuOpen.set(false)
271 | // // }}
272 | // // />
273 | // //
285 | // // {canSelectChildren && (
286 | // // $selectedNodes.set(selectedNodes[0].children)}
290 | // // />
291 | // // )}
292 |
293 | // // {canSelectParent && (
294 | // // {
298 | // // if (selectedNodes[0]?.parent) {
299 | // // $selectedNodes.set([selectedNodes[0].parent])
300 | // // }
301 | // // }}
302 | // // />
303 | // // )}
304 |
305 | // // {canWrap && (
306 | // // <>
307 | // // {
311 | // // const flexNode = commandWrapNodes(selectedNodes, 'Container')
312 |
313 | // // if (flexNode) {
314 | // // $selectedNodes.set([flexNode])
315 | // // }
316 | // // }}
317 | // // />
318 | // // {
322 | // // const flexNode = commandWrapNodes(selectedNodes, 'Flex')
323 |
324 | // // if (flexNode) {
325 | // // $selectedNodes.set([flexNode])
326 | // // }
327 | // // }}
328 | // // />
329 | // // >
330 | // // )}
331 |
332 | // // {canUnwrap && (
333 | // // {
337 | // // if (selectedNodes[0] && isUnwrappableNode(selectedNodes[0])) {
338 | // // const children = selectedNodes[0].children
339 | // // commandUnwrapNode(selectedNodes[0])
340 | // // $selectedNodes.set(children)
341 | // // }
342 | // // }}
343 | // // />
344 | // // )}
345 |
346 | // // {canSelectPreviousSibling && (
347 | // // {
351 | // // if (selectedNodes[0]?.previousSibling) {
352 | // // $selectedNodes.set([selectedNodes[0].previousSibling])
353 | // // }
354 | // // }}
355 | // // />
356 | // // )}
357 |
358 | // // {canSelectNextSibling && (
359 | // // {
363 | // // if (selectedNodes[0]?.nextSibling) {
364 | // // $selectedNodes.set([selectedNodes[0].nextSibling])
365 | // // }
366 | // // }}
367 | // // />
368 | // // )}
369 |
370 | // // {canMoveForward && (
371 | // // {
375 | // // const parent = selectedNodes[0]?.parent
376 | // // const previousSibling = selectedNodes[0]?.previousSibling
377 |
378 | // // if (previousSibling && parent) {
379 | // // parent.insertBefore([selectedNodes[0]], previousSibling)
380 | // // }
381 | // // }}
382 | // // />
383 | // // )}
384 |
385 | // // {canMoveBackward && (
386 | // // {
390 | // // const parent = selectedNodes[0]?.parent
391 | // // const nextSibling = selectedNodes[0]?.nextSibling
392 |
393 | // // if (nextSibling && parent) {
394 | // // parent.insertBefore([selectedNodes[0]], nextSibling.nextSibling)
395 | // // }
396 | // // }}
397 | // // />
398 | // // )}
399 |
400 | // // {selectedNodes.length === 1 && selectedNodes[0] instanceof PageNode && (
401 | // // commandFocusPage(selectedNodes[0] as PageNode, true)}
405 | // // />
406 | // // )}
407 |
408 | // // {canSelectChildren && (
409 | // // commandDeleteNodes(selectedNodes[0].children)}
413 | // // />
414 | // // )}
415 |
416 | // // {canRemove && (
417 | // // commandDeleteNodes(selectedNodes)}
421 | // // />
422 | // // )}
423 | // //
424 | // // >,
425 | // // document.body,
426 | // // )
427 | // }
428 |
--------------------------------------------------------------------------------
/src/control-center/control-center-node.tsx:
--------------------------------------------------------------------------------
1 | import { $selectedNodes, triggerRerenderGuides } from '@/atoms'
2 | import { commandRemoveNodes } from '@/command'
3 | import { keepNodeSelectionAttribute } from '@/data-attributes'
4 | import { studioApp } from '@/studio-app'
5 | import { useStore } from '@nanostores/react'
6 | import { DotsHorizontalIcon, TrashIcon } from '@radix-ui/react-icons'
7 | import {
8 | Box,
9 | Button,
10 | DropdownMenu,
11 | Flex,
12 | Heading,
13 | IconButton,
14 | Select,
15 | Separator,
16 | Text,
17 | } from '@radix-ui/themes'
18 | import { map } from 'nanostores'
19 | import { useEffect, useState } from 'react'
20 | import { SelectControls, SwitchControls, TextFieldControls } from './controls'
21 | import { TSX } from './tsx'
22 |
23 | export function ControlCenterNode() {
24 | const [_, update] = useState({})
25 |
26 | const selectedNodes = useStore($selectedNodes)
27 | const areAllSelectedNodesTheSame = selectedNodes.every(
28 | (node) => node.nodeName === selectedNodes[0]?.nodeName,
29 | )
30 | const firstSelectedNode = selectedNodes[0]
31 | useStore(firstSelectedNode?.$style ?? map({}))
32 |
33 | const nodeDefinition = firstSelectedNode?.definition
34 |
35 | useEffect(() => {
36 | const unsubscribes = selectedNodes.map((node) => {
37 | return node.$style.listen(() => {
38 | update({})
39 | })
40 | })
41 |
42 | return () => {
43 | unsubscribes.forEach((unsubscribe) => unsubscribe())
44 | }
45 | }, [selectedNodes])
46 |
47 | return (
48 |
49 |
50 | {!firstSelectedNode && (
51 |
69 | )}
70 |
71 | {firstSelectedNode && areAllSelectedNodesTheSame && (
72 | <>
73 |
74 |
75 | {firstSelectedNode.nodeName}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {
88 | e.preventDefault()
89 | e.stopPropagation()
90 | }}
91 | onClick={() => {
92 | commandRemoveNodes(selectedNodes)
93 | }}
94 | >
95 | Remove
96 |
97 |
98 |
99 |
100 |
101 |
102 | {nodeDefinition.props && (
103 |
104 | {nodeDefinition.props.map((prop) => {
105 | if (prop.format.type === 'options') {
106 | return (
107 | node.$props,
112 | )}
113 | propertyKey={prop.key}
114 | defaultValue={prop.default}
115 | options={[
116 | ...(prop.required
117 | ? []
118 | : [
119 | {
120 | label: 'Unset',
121 | value: 'undefined',
122 | },
123 | ]),
124 | ...prop.format.options.map((option) => {
125 | if (typeof option === 'string') {
126 | return {
127 | label: option,
128 | value: option,
129 | }
130 | }
131 |
132 | return {
133 | label: option.label,
134 | value: option.value,
135 | }
136 | }),
137 | ]}
138 | />
139 | )
140 | } else if (prop.format.type === 'boolean') {
141 | return (
142 | node.$props,
147 | )}
148 | propertyKey={prop.key}
149 | defaultValue={prop.default}
150 | />
151 | )
152 | } else if (prop.format.type === 'string') {
153 | return (
154 | node.$props,
159 | )}
160 | propertyKey={prop.key}
161 | defaultValue={prop.default}
162 | />
163 | )
164 | }
165 | })}
166 |
167 |
168 |
169 | Custom Styles
170 |
171 | {Array.from(
172 | new Set(
173 | selectedNodes.flatMap((node) =>
174 | Object.keys(node.$style.get()),
175 | ),
176 | ),
177 | )
178 | .sort()
179 | .map((styleKey) => (
180 | node.$style)}
184 | propertyKey={styleKey}
185 | defaultValue={undefined}
186 | extraButton={
187 | {
191 | selectedNodes.forEach((node) => {
192 | node.$style.setKey(styleKey, undefined)
193 | })
194 |
195 | triggerRerenderGuides(true)
196 | }}
197 | >
198 |
199 |
200 | }
201 | />
202 | ))}
203 |
204 |
205 | {
208 | selectedNodes.forEach((node) => {
209 | node.$style.setKey(value, '')
210 | })
211 | }}
212 | >
213 |
217 |
222 | {['flex', 'margin', 'padding', 'maxWidth'].map(
223 | (styleKey) => (
224 |
225 | {styleKey}
226 |
227 | ),
228 | )}
229 |
230 |
231 |
232 |
233 | )}
234 |
235 | {selectedNodes.length === 1 && (
236 | <>
237 |
238 |
239 | >
240 | )}
241 |
242 | >
243 | )}
244 |
245 |
246 | )
247 | }
248 |
--------------------------------------------------------------------------------
/src/control-center/control-center.tsx:
--------------------------------------------------------------------------------
1 | import { Box, ScrollArea, Tabs, Text } from '@radix-ui/themes'
2 | import { ControlCenterNode } from './control-center-node'
3 |
4 | export function ControlCenter() {
5 | return (
6 |
18 |
19 | Node
20 | Store
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Store
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/control-center/controls.tsx:
--------------------------------------------------------------------------------
1 | import { PropChangeAction } from '@/action'
2 | import { $selectedNodes, triggerRerenderGuides } from '@/atoms'
3 | import { keepNodeSelectionAttribute } from '@/data-attributes'
4 | import { History, HistoryStackItem } from '@/history'
5 | import { ExtractMapStoreGeneric } from '@/types/extract-generic'
6 | import { StoreKeys, useStore } from '@nanostores/react'
7 | import { Flex, Select, Switch, Text, TextField } from '@radix-ui/themes'
8 | import { MapStore } from 'nanostores'
9 | import { ReactNode, useEffect, useRef, useState } from 'react'
10 |
11 | function useCommonValue
>(
12 | propMapStores: M[],
13 | key: K,
14 | defaultValue: ExtractMapStoreGeneric[K],
15 | ) {
16 | const [_, update] = useState({})
17 | const firstProps = useStore(propMapStores[0], { keys: [key] }) // Only rerender when the key changes
18 |
19 | const firstValue = firstProps[key]
20 | const allSame = propMapStores.every(
21 | (store) => (store.get()[key] ?? defaultValue) === firstValue,
22 | )
23 |
24 | useEffect(() => {
25 | const unsubscribes = propMapStores.map((store) => {
26 | return store.listen(() => {
27 | update({})
28 | })
29 | })
30 |
31 | return () => {
32 | unsubscribes.forEach((unsubscribe) => unsubscribe())
33 | }
34 | }, [propMapStores])
35 |
36 | return allSame ? firstValue : undefined
37 | }
38 |
39 | type ControlsCommonFormProps> = {
40 | propMapStores: M[]
41 | controlsLabel: string
42 | propertyKey: K
43 | defaultValue: ExtractMapStoreGeneric[K]
44 | extraButton?: ReactNode
45 | }
46 |
47 | export type Option = {
48 | label: string
49 | value: V
50 | }
51 |
52 | export function SelectControls>({
53 | controlsLabel,
54 | propMapStores,
55 | propertyKey: key,
56 | options,
57 | defaultValue,
58 | }: ControlsCommonFormProps & {
59 | options: Option[K]>[]
60 | }) {
61 | const commonValue = useCommonValue(propMapStores, key, defaultValue)
62 |
63 | return (
64 |
65 |
66 | {controlsLabel}
67 | {/*
68 |
69 | */}
70 |
71 | {
75 | const historyStackItem: HistoryStackItem = {
76 | actions: [],
77 | previousSelectedNodes: $selectedNodes.get(),
78 | nextSelectedNodes: $selectedNodes.get(),
79 | }
80 |
81 | propMapStores.forEach((store) => {
82 | historyStackItem.actions.push(
83 | new PropChangeAction({
84 | propMapStore: store,
85 | oldProp: { key, value: store.get()[key] },
86 | newProp: {
87 | key,
88 | value: value === 'undefined' ? undefined : value,
89 | },
90 | }),
91 | )
92 |
93 | store.setKey(key, value === 'undefined' ? undefined : value)
94 | })
95 |
96 | History.push(historyStackItem)
97 |
98 | triggerRerenderGuides(true)
99 | }}
100 | >
101 |
102 |
103 | {options.map(({ value, label }) => (
104 |
105 | {label}
106 |
107 | ))}
108 |
109 |
110 |
111 | )
112 | }
113 |
114 | export function SwitchControls>({
115 | controlsLabel,
116 | propMapStores,
117 | propertyKey: key,
118 | defaultValue,
119 | }: ControlsCommonFormProps) {
120 | const commonValue = useCommonValue(propMapStores, key, defaultValue)
121 |
122 | return (
123 |
124 | {controlsLabel}
125 | {
129 | const historyStackItem: HistoryStackItem = {
130 | actions: [],
131 | previousSelectedNodes: $selectedNodes.get(),
132 | nextSelectedNodes: $selectedNodes.get(),
133 | }
134 |
135 | propMapStores.forEach((store) => {
136 | historyStackItem.actions.push(
137 | new PropChangeAction({
138 | propMapStore: store,
139 | oldProp: { key, value: store.get()[key] },
140 | newProp: { key, value: checked },
141 | }),
142 | )
143 |
144 | store.setKey(key, checked)
145 | })
146 |
147 | History.push(historyStackItem)
148 |
149 | triggerRerenderGuides(true)
150 | }}
151 | />
152 |
153 | )
154 | }
155 |
156 | export function TextFieldControls>({
157 | controlsLabel,
158 | propMapStores,
159 | propertyKey: key,
160 | defaultValue,
161 | extraButton,
162 | }: ControlsCommonFormProps) {
163 | const previousValues = useRef([])
164 | const timeout = useRef(0)
165 | const commonValue = useCommonValue(propMapStores, key, defaultValue)
166 |
167 | useEffect(() => {
168 | previousValues.current = propMapStores.map((store) => store.get()[key])
169 | }, [propMapStores, key])
170 |
171 | return (
172 |
173 | {controlsLabel}
174 |
175 | {extraButton}
176 | 1 && commonValue === undefined
180 | ? 'Multiple values'
181 | : undefined
182 | }
183 | onChange={(e) => {
184 | const value = e.target.value
185 |
186 | propMapStores.forEach((store) => {
187 | store.setKey(key, value)
188 | })
189 |
190 | triggerRerenderGuides(true)
191 |
192 | window.clearTimeout(timeout.current)
193 |
194 | timeout.current = window.setTimeout(() => {
195 | const historyStackItem: HistoryStackItem = {
196 | actions: [],
197 | nextSelectedNodes: $selectedNodes.get(),
198 | previousSelectedNodes: $selectedNodes.get(),
199 | }
200 |
201 | propMapStores.forEach((store, i) => {
202 | historyStackItem.actions.push(
203 | new PropChangeAction({
204 | propMapStore: store,
205 | oldProp: { key, value: previousValues.current[i] },
206 | newProp: { key, value },
207 | }),
208 | )
209 | })
210 |
211 | History.push(historyStackItem)
212 |
213 | previousValues.current = propMapStores.map(
214 | (store) => store.get()[key],
215 | )
216 | }, 250)
217 | }}
218 | />
219 |
220 |
221 | )
222 | }
223 |
--------------------------------------------------------------------------------
/src/control-center/tsx.tsx:
--------------------------------------------------------------------------------
1 | import { keepNodeSelectionAttribute } from '@/data-attributes'
2 | import { format } from '@/format'
3 | import { Node } from '@/node-class/node'
4 | import { PageNode } from '@/node-class/page'
5 | import { useStore } from '@nanostores/react'
6 | import { CheckIcon, ClipboardIcon, SizeIcon } from '@radix-ui/react-icons'
7 | import {
8 | Box,
9 | Button,
10 | Card,
11 | Dialog,
12 | Flex,
13 | IconButton,
14 | Inset,
15 | ScrollArea,
16 | Text,
17 | } from '@radix-ui/themes'
18 | import { pascalCase } from 'change-case'
19 | import hljs from 'highlight.js'
20 | import { useEffect, useRef, useState } from 'react'
21 |
22 | export async function generateSourceCode(node: Node) {
23 | // TODO: allow only available javascript function name
24 | const componentName =
25 | node instanceof PageNode
26 | ? pascalCase(node.$props.get().title.trim() || 'UntitledPage')
27 | : pascalCase(node.nodeName)
28 |
29 | const sourceCode = `
30 | function ${componentName}() {
31 | return ${await node.generateCode()}
32 | }
33 | `
34 |
35 | const formatted = await format(sourceCode)
36 |
37 | return formatted
38 | }
39 |
40 | /**
41 | * TODO: Add large view button
42 | */
43 | export function TSX({ node }: { node: Node }) {
44 | const props = useStore(node.$props)
45 | const pageTitle = node instanceof PageNode ? props.title : undefined
46 | const copyTimeout = useRef(0)
47 | const [copied, setCopied] = useState(false)
48 | const sourceCode = useRef('')
49 | const [syntaxHighlighted, setSyntaxHighlighted] = useState('')
50 |
51 | useEffect(() => {
52 | generateSourceCode(node).then((code): void => {
53 | sourceCode.current = code
54 | const highlighted = hljs.highlight(code, {
55 | language: 'tsx',
56 | }).value
57 | setSyntaxHighlighted(highlighted)
58 | })
59 | }, [node, props, pageTitle])
60 |
61 | function copyToClipboard() {
62 | navigator.clipboard.writeText(sourceCode.current)
63 | setCopied(true)
64 |
65 | window.clearTimeout(copyTimeout.current)
66 |
67 | copyTimeout.current = window.setTimeout(() => {
68 | setCopied(false)
69 | }, 2000)
70 | }
71 |
72 | return (
73 |
77 |
78 | TSX
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | TSX
90 |
91 |
92 |
93 |
94 |
95 |
103 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
123 | {copied ? : }
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
137 | {copied ? : }
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
155 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 | )
171 | }
172 |
--------------------------------------------------------------------------------
/src/data-attributes.ts:
--------------------------------------------------------------------------------
1 | import { Node } from './node-class/node'
2 |
3 | export const dataAttributes = {
4 | node: 'data-studio-node',
5 | nodeId: 'data-studio-node-id',
6 | ownerPageId: 'data-studio-owner-page-id',
7 | keepNodeSelection: 'data-studio-keep-node-selection',
8 | dropZone: 'data-studio-drop-zone',
9 | dropZoneId: 'data-studio-drop-zone-id',
10 | dropZoneOwnerPageId: 'data-studio-drop-zone-owner-page-id',
11 | dropZoneTargetNodeId: 'data-studio-drop-zone-target-node-id',
12 | dropZoneBefore: 'data-studio-drop-zone-before',
13 | }
14 |
15 | export const keepNodeSelectionAttribute = {
16 | [dataAttributes.keepNodeSelection]: 'true',
17 | }
18 |
19 | export function makeNodeBaseAttrs(node: Node) {
20 | return {
21 | [dataAttributes.node]: 'true',
22 | [dataAttributes.nodeId]: node.id,
23 | [dataAttributes.ownerPageId]: node.ownerPage?.id,
24 | }
25 | }
26 |
27 | export function makeNodeDropZoneAttrs(node: Node) {
28 | return {
29 | [dataAttributes.dropZone]: 'true',
30 | [dataAttributes.dropZoneId]: node.id,
31 | [dataAttributes.dropZoneTargetNodeId]: node.id,
32 | [dataAttributes.dropZoneBefore]: '',
33 | [dataAttributes.dropZoneOwnerPageId]: node.ownerPage?.id,
34 | }
35 | }
36 |
37 | export function makeDropZoneAttributes(dropZoneData: {
38 | dropZoneId: string
39 | dropZoneTargetNodeId: string
40 | dropZoneBefore: string | undefined
41 | }) {
42 | const { dropZoneId, dropZoneTargetNodeId, dropZoneBefore } = dropZoneData
43 |
44 | return {
45 | [dataAttributes.dropZone]: 'true',
46 | [dataAttributes.dropZoneId]: dropZoneId,
47 | [dataAttributes.dropZoneTargetNodeId]: dropZoneTargetNodeId,
48 | [dataAttributes.dropZoneBefore]: dropZoneBefore,
49 | }
50 | }
51 |
52 | /**
53 | * TODO: instead of checking closest data attribute, stop propagation of the event
54 | */
55 | export function shouldKeepNodeSelection(target: Element) {
56 | return target.closest(`[${dataAttributes.keepNodeSelection}]`)
57 | }
58 |
59 | export function makeNodeAttrs(node: Node) {
60 | return {
61 | id: `node-${node.id}`,
62 | ...makeNodeBaseAttrs(node),
63 | ...(node.isDroppable ? makeNodeDropZoneAttrs(node) : undefined),
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/drawer/drawer-item-wrapper.module.scss:
--------------------------------------------------------------------------------
1 | .drawerItemWrapper {
2 | display: flex;
3 | }
4 |
5 | .drawerItemComponentWrapper {
6 | // pointer-events: none;
7 | display: flex;
8 | flex-direction: column;
9 |
10 | user-select: none;
11 |
12 | > * {
13 | pointer-events: none;
14 | }
15 | }
16 |
17 | .drawerItemGhost {
18 | pointer-events: none;
19 | opacity: 0.8;
20 |
21 | user-select: none;
22 |
23 | position: fixed;
24 | }
25 |
--------------------------------------------------------------------------------
/src/drawer/drawer-item-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { $selectedNodes } from '@/atoms'
2 | import { commandInsertNodes } from '@/command'
3 | import { keepNodeSelectionAttribute } from '@/data-attributes'
4 | import { onMouseDownForDragAndDropNode } from '@/events'
5 | import { Node } from '@/node-class/node'
6 | import { ReactNode, useRef } from 'react'
7 | import styles from './drawer-item-wrapper.module.scss'
8 |
9 | export function DrawerItemWrapper({
10 | children,
11 | createNode,
12 | }: {
13 | children: ReactNode
14 | createNode: () => Node
15 | }) {
16 | const ref = useRef(null!)
17 |
18 | return (
19 |
20 |
{
24 | const cloneTargetElm = e.currentTarget
25 | const rect = cloneTargetElm.getBoundingClientRect()
26 |
27 | onMouseDownForDragAndDropNode(e, {
28 | draggingNodes: [createNode()],
29 | cloneTargetElm: ref.current.firstElementChild!,
30 | elmX: e.clientX - rect.left,
31 | elmY: e.clientY - rect.top,
32 | elementScale: 1,
33 | draggingElm: ref.current.firstElementChild!,
34 | })
35 | }}
36 | onClick={() => {
37 | const selectedNodes = $selectedNodes.get()
38 | if (selectedNodes.length !== 1) return
39 | if (
40 | !selectedNodes[0].parent ||
41 | (selectedNodes[0].isDroppable &&
42 | selectedNodes[0].children.length === 0)
43 | ) {
44 | commandInsertNodes(selectedNodes[0], [createNode()], null)
45 | } else if (selectedNodes[0].parent) {
46 | commandInsertNodes(
47 | selectedNodes[0].parent,
48 | [createNode()],
49 | selectedNodes[0].nextSibling,
50 | )
51 | }
52 | }}
53 | >
54 | {children}
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/drawer/drawer.module.scss:
--------------------------------------------------------------------------------
1 | .drawer {
2 | position: absolute;
3 | top: 0;
4 | right: 280px;
5 | bottom: 0;
6 | background-color: #fff;
7 | z-index: 100;
8 | border-left: 1px solid var(--accent-a6);
9 |
10 | background-color: var(--color-background);
11 |
12 | transform: translateX(100%);
13 | transition: transform 320ms cubic-bezier(0.37, 0.24, 0, 1);
14 | will-change: transform;
15 |
16 | &.open {
17 | transform: translateX(0);
18 | }
19 | }
20 |
21 | .resizer {
22 | z-index: 1;
23 |
24 | &:hover {
25 | cursor: ns-resize;
26 |
27 | .separator {
28 | background-color: var(--indigo-a9);
29 | height: 4px;
30 |
31 | transition:
32 | background-color 200ms ease,
33 | height 200ms ease;
34 | transition-delay: 200ms;
35 | }
36 | }
37 |
38 | @at-root :global(.resizing-drawer) & {
39 | .separator {
40 | background-color: var(--indigo-a9);
41 | height: 4px;
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/drawer/drawer.tsx:
--------------------------------------------------------------------------------
1 | import { keepNodeSelectionAttribute } from '@/data-attributes'
2 | import { EditorState } from '@/editor-state'
3 | import { Library, stringifyLibraryKey } from '@/library'
4 | import { Node } from '@/node-class/node'
5 | import { CubeIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
6 | import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes'
7 | import { useEffect, useRef, useState } from 'react'
8 | import { DrawerItemWrapper } from './drawer-item-wrapper'
9 | import styles from './drawer.module.scss'
10 |
11 | const studioLibrary: Library = {
12 | name: 'studio',
13 | version: '1.0.0',
14 | }
15 |
16 | const radixThemesLibrary: Library = {
17 | name: 'radix-themes',
18 | version: '3.0.1',
19 | }
20 |
21 | function createTextNode(value: string) {
22 | return new Node({
23 | library: studioLibrary,
24 | nodeName: 'Text',
25 | props: { value },
26 | })
27 | }
28 |
29 | export function Drawer({ library }: { library: Library }) {
30 | const ref = useRef(null)
31 | const [drawerItems, setDrawerItems] = useState<
32 | {
33 | createNode: () => Node
34 | render: () => JSX.Element
35 | }[]
36 | >([])
37 |
38 | useEffect(() => {
39 | const unsubscribe = EditorState.$drawerOpen.listen((drawerOpen) => {
40 | if (drawerOpen) {
41 | ref.current?.classList.add(styles.open)
42 | } else {
43 | ref.current?.classList.remove(styles.open)
44 | }
45 | })
46 |
47 | return () => {
48 | unsubscribe()
49 | }
50 | }, [])
51 |
52 | useEffect(() => {
53 | import(`@/libraries/${stringifyLibraryKey(library)}/drawer-items`).then(
54 | (mod) => {
55 | setDrawerItems(mod.drawerItems)
56 | return
57 | },
58 | )
59 | }, [library])
60 |
61 | return (
62 |
68 |
80 |
81 |
82 |
83 | Drawer
84 |
85 |
86 | {
89 | EditorState.$drawerOpen.set(false)
90 | }}
91 | >
92 |
93 |
94 |
95 |
96 |
97 |
98 | {drawerItems.map(({ createNode, render }, i) => (
99 |
100 | {render()}
101 |
102 | ))}
103 |
104 |
105 |
106 | )
107 | }
108 |
--------------------------------------------------------------------------------
/src/easel/easel-container.module.scss:
--------------------------------------------------------------------------------
1 | .easelContainer {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 |
6 | transform-origin: top left; // Important for ground zoom in/out
7 | }
8 |
--------------------------------------------------------------------------------
/src/easel/easel-container.tsx:
--------------------------------------------------------------------------------
1 | import { EaselWrapper } from '@/easel/easel-wrapper'
2 | import { Ground } from '@/ground'
3 | import { studioApp } from '@/studio-app'
4 | import { useStore } from '@nanostores/react'
5 | import { useEffect, useRef } from 'react'
6 | import styles from './easel-container.module.scss'
7 |
8 | export const EASEL_CONTAINER_ID = 'studio-easel-container'
9 |
10 | function scaleStyle(scale: number) {
11 | return scale.toString()
12 | }
13 |
14 | function translateStyle(translate: { x: number; y: number }) {
15 | return `${translate.x}px ${translate.y}px`
16 | }
17 |
18 | /**
19 | * A container of easels (pages)
20 | */
21 | export function EaselContainer() {
22 | const ref = useRef(null!)
23 | const pages = useStore(studioApp.$pages)
24 |
25 | useEffect(() => {
26 | const unsubscribeScale = Ground.$scale.subscribe((scale) => {
27 | ref.current.style.scale = scaleStyle(scale)
28 | })
29 |
30 | const unsubscribeTranslate = Ground.$translate.subscribe((translate) => {
31 | ref.current.style.translate = translateStyle(translate)
32 | })
33 |
34 | return () => {
35 | unsubscribeScale()
36 | unsubscribeTranslate()
37 | }
38 | }, [])
39 |
40 | return (
41 |
50 | {pages.map((page) => (
51 |
52 | ))}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/easel/easel-wrapper.module.scss:
--------------------------------------------------------------------------------
1 | .easelWrapper {
2 | position: absolute;
3 | display: inline-flex;
4 |
5 | &:hover {
6 | .resizer {
7 | opacity: 1;
8 | }
9 | }
10 | }
11 |
12 | .resizer {
13 | position: absolute;
14 | z-index: 10;
15 |
16 | transform-origin: top left;
17 |
18 | top: 100%;
19 | left: 100%;
20 |
21 | display: flex;
22 | align-items: flex-start;
23 | justify-content: flex-start;
24 |
25 | opacity: 0;
26 |
27 | transition: opacity 200ms ease;
28 |
29 | will-change: opacity transform;
30 |
31 | &:hover {
32 | opacity: 1;
33 | }
34 |
35 | svg {
36 | transform: scale(0.5);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/easel/easel-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $designMode,
3 | $hoveredNode,
4 | $interactiveMode,
5 | $isDraggingNode,
6 | $isResizingIframe,
7 | $massMode,
8 | } from '@/atoms'
9 | import { keepNodeSelectionAttribute, makeNodeAttrs } from '@/data-attributes'
10 | import { onMouseDownIframe } from '@/events'
11 | import { Ground } from '@/ground'
12 | import { PageNode } from '@/node-class/page'
13 | import { getClosestSelectableNodeFromElm } from '@/node-lib'
14 | import { useStore } from '@nanostores/react'
15 | import clsx from 'clsx'
16 | import { useEffect, useRef } from 'react'
17 | import styles from './easel-wrapper.module.scss'
18 | import { PageTitle } from './page-title'
19 | import { Resizer } from './resizer'
20 |
21 | export const EASEL_WRAPPER_CLASS_NAME = 'studio-easel-wrapper'
22 |
23 | export function getEaselIframeId(pageId: string) {
24 | return `easel-iframe-${pageId}`
25 | }
26 |
27 | /**
28 | * Previously hovered element used for simulating mouseover with mousemove.
29 | */
30 | let previousMouseOverElement: Element | null = null
31 |
32 | // Clear previously remembered hovered node.
33 | // When node reverts back by history undo at where the cursor is by, it doesn't trigger simulated mouseover event.
34 | // Because previousMouseOverElement is not cleared.
35 | $hoveredNode.listen(() => {
36 | previousMouseOverElement = null
37 | })
38 |
39 | export function EaselWrapper({ page }: { page: PageNode }) {
40 | const interactiveMode = useStore($interactiveMode)
41 | const iframeRef = useRef(null!)
42 | const coordinates = useStore(page.$coordinates)
43 |
44 | useEffect(() => {
45 | if (!iframeRef.current) return
46 |
47 | PageNode.attachIframeElement(page, iframeRef.current)
48 |
49 | const iframeWindow = iframeRef.current?.contentWindow!
50 |
51 | // Inject global references to iframe's window object.
52 | iframeWindow.parentFrame = iframeRef.current
53 | iframeWindow.ownerApp = page.ownerApp
54 | iframeWindow.pageNode = page
55 |
56 | // Inject shared data
57 | iframeWindow.shared = { $designMode, $massMode, $scale: Ground.$scale }
58 |
59 | iframeWindow.addEventListener('DOMContentLoaded', () => {
60 | const attributes = makeNodeAttrs(page)
61 |
62 | Object.entries(attributes).forEach(([key, value]) => {
63 | if (value) {
64 | iframeWindow.document.body.setAttribute(key, value)
65 | }
66 | })
67 | })
68 |
69 | return () => {
70 | PageNode.detachIframeElement(page)
71 | }
72 | }, [page])
73 |
74 | useEffect(() => {
75 | const unsubscribe = page.$dimensions.subscribe((dimensions) => {
76 | if (iframeRef.current) {
77 | iframeRef.current.style.width = `${dimensions.width}px`
78 | iframeRef.current.style.height = `${dimensions.height}px`
79 | }
80 | })
81 |
82 | return () => {
83 | unsubscribe()
84 | }
85 | }, [page.$dimensions])
86 |
87 | if (!page) {
88 | return null
89 | }
90 |
91 | return (
92 | {
99 | previousMouseOverElement = null
100 | $hoveredNode.set(null)
101 | }}
102 | onMouseMove={(e) => {
103 | // Hover node while moving mouse on the iframe
104 |
105 | if ($isDraggingNode.get() || $isResizingIframe.get()) {
106 | return
107 | }
108 |
109 | const rect = iframeRef.current.getBoundingClientRect()
110 | const pointScale = 1 / Ground.scale
111 | const elementAtCursor =
112 | iframeRef.current?.contentDocument?.elementFromPoint(
113 | (e.clientX - rect.left) * pointScale,
114 | (e.clientY - rect.top) * pointScale,
115 | )
116 |
117 | // Simulate mouseover with mousemove
118 | if (
119 | elementAtCursor &&
120 | !elementAtCursor.isSameNode(previousMouseOverElement)
121 | ) {
122 | previousMouseOverElement = elementAtCursor
123 |
124 | const hoveredNode = getClosestSelectableNodeFromElm(elementAtCursor)
125 |
126 | if (hoveredNode) {
127 | $hoveredNode.set(hoveredNode)
128 | }
129 | }
130 | }}
131 | onMouseDown={(e) => onMouseDownIframe(e, page, false)}
132 | >
133 |
146 |
147 |
148 |
149 |
150 | )
151 | }
152 |
--------------------------------------------------------------------------------
/src/easel/page-title.module.scss:
--------------------------------------------------------------------------------
1 | .pageLabel {
2 | position: absolute;
3 | bottom: 100%;
4 | left: 0;
5 |
6 | font-size: 13px;
7 | font-weight: 500;
8 |
9 | transform-origin: bottom left;
10 |
11 | will-change: transform;
12 |
13 | // background-color: rgba(255, 255, 255, 0.4);
14 |
15 | // padding: 4px 6px;
16 | // border-radius: 3px;
17 | }
18 |
--------------------------------------------------------------------------------
/src/easel/page-title.tsx:
--------------------------------------------------------------------------------
1 | import { $hoveredNode } from '@/atoms'
2 | import { onMouseDownIframe } from '@/events'
3 | import { Ground } from '@/ground'
4 | import { PageNode } from '@/node-class/page'
5 | import { useStore } from '@nanostores/react'
6 | import { useEffect, useRef } from 'react'
7 | import styles from './page-title.module.scss'
8 |
9 | function scaleStyle(scale: number) {
10 | return `${1 / scale}`
11 | }
12 |
13 | function translateStyle(scale: number) {
14 | return `0px ${-6 / scale}px`
15 | }
16 |
17 | export function PageTitle({ page }: { page: PageNode }) {
18 | const ref = useRef(null!)
19 | const { title } = useStore(page.$props, { keys: ['title'] })
20 | const trimmedPageLabel = title.trim()
21 |
22 | useEffect(() => {
23 | const unsubscribe = Ground.$scale.subscribe((scale) => {
24 | ref.current.style.scale = scaleStyle(scale)
25 | ref.current.style.translate = translateStyle(scale)
26 | })
27 |
28 | return () => {
29 | unsubscribe()
30 | }
31 | }, [])
32 |
33 | return (
34 | {
42 | $hoveredNode.set(page)
43 | }}
44 | onMouseDown={(e) => onMouseDownIframe(e, page, true)}
45 | >
46 | {trimmedPageLabel || 'Untitled'}
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/easel/resizer.tsx:
--------------------------------------------------------------------------------
1 | import { PageResizeAction } from '@/action'
2 | import {
3 | $isResizingIframe,
4 | $selectedNodes,
5 | triggerRerenderGuides,
6 | } from '@/atoms'
7 | import { Ground } from '@/ground'
8 | import { History } from '@/history'
9 | import { PageNode } from '@/node-class/page'
10 | import { useEffect, useRef } from 'react'
11 | import styles from './easel-wrapper.module.scss'
12 |
13 | const MIN_EASEL_WIDTH = 100
14 | const MIN_EASEL_HEIGHT = 100
15 |
16 | const MAX_EASEL_WIDTH = 2560
17 |
18 | function scaleStyle(scale: number) {
19 | return `${1 / scale}`
20 | }
21 |
22 | function translateStyle(scale: number) {
23 | return `${-10 / scale}px ${-10 / scale}px`
24 | }
25 |
26 | export function Resizer({ page }: { page: PageNode }) {
27 | const ref = useRef(null!)
28 | const oldSize = useRef(page.$dimensions.get())
29 |
30 | useEffect(() => {
31 | const unsubscribe = Ground.$scale.subscribe((scale) => {
32 | ref.current.style.scale = scaleStyle(scale)
33 | ref.current.style.translate = translateStyle(scale)
34 | })
35 |
36 | return () => {
37 | unsubscribe()
38 | }
39 | }, [])
40 |
41 | return (
42 | {
50 | e.preventDefault()
51 | e.stopPropagation()
52 |
53 | $isResizingIframe.set(true)
54 |
55 | const startX = e.clientX
56 | const startY = e.clientY
57 |
58 | const initialEaselSize = page.$dimensions.get()
59 |
60 | function onMouseMove(e: MouseEvent) {
61 | const deltaX = e.clientX - startX
62 | const deltaY = e.clientY - startY
63 |
64 | const scale = Ground.scale
65 |
66 | page.$dimensions.set({
67 | width: Math.min(
68 | Math.max(
69 | initialEaselSize.width + deltaX / scale,
70 | MIN_EASEL_WIDTH,
71 | ),
72 | MAX_EASEL_WIDTH,
73 | ),
74 | height: Math.max(
75 | initialEaselSize.height + deltaY / scale,
76 | MIN_EASEL_HEIGHT,
77 | ),
78 | })
79 |
80 | triggerRerenderGuides()
81 | }
82 |
83 | function onMouseUp() {
84 | $isResizingIframe.set(false)
85 |
86 | document.removeEventListener('mousemove', onMouseMove)
87 | document.removeEventListener('mouseup', onMouseUp)
88 |
89 | History.push({
90 | actions: [
91 | new PageResizeAction({
92 | page,
93 | oldSize: oldSize.current,
94 | newSize: page.$dimensions.get(),
95 | }),
96 | ],
97 | previousSelectedNodes: $selectedNodes.get(),
98 | nextSelectedNodes: $selectedNodes.get(),
99 | })
100 |
101 | oldSize.current = page.$dimensions.get()
102 | }
103 |
104 | document.addEventListener('mousemove', onMouseMove)
105 | document.addEventListener('mouseup', onMouseUp)
106 | }}
107 | >
108 |
120 |
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/src/editor-state.ts:
--------------------------------------------------------------------------------
1 | import { atom, map } from 'nanostores'
2 |
3 | export class EditorState {
4 | static $drawerOpen = atom(false)
5 | static $treeFoldedNodes = map>({})
6 | static $hiddenNodes = map>({})
7 | static $lockedNodes = map>({})
8 | }
9 |
--------------------------------------------------------------------------------
/src/empty-placeholder.tsx:
--------------------------------------------------------------------------------
1 | export function EmptyPlaceholder({ name, ...rest }: { name?: string }) {
2 | return (
3 |
18 | Empty{name ? ` ${name}` : ''}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, ErrorInfo, ReactNode } from 'react'
2 |
3 | interface Props {
4 | children?: ReactNode
5 | }
6 |
7 | interface State {
8 | hasError: boolean
9 | error: Error | null
10 | }
11 |
12 | export class ErrorBoundary extends Component {
13 | public state: State = {
14 | hasError: false,
15 | error: null,
16 | }
17 |
18 | public static getDerivedStateFromError(error: Error): State {
19 | // Update state so the next render will show the fallback UI.
20 | return { hasError: true, error }
21 | }
22 |
23 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
24 | console.error('Uncaught error:', error, errorInfo)
25 | }
26 |
27 | public render() {
28 | if (this.state.hasError) {
29 | return (
30 |
48 | {this.state.error?.message || 'An error occurred'}
49 |
50 | )
51 | }
52 |
53 | return this.props.children
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/format.ts:
--------------------------------------------------------------------------------
1 | import prettierPluginEstree from 'prettier/plugins/estree'
2 | import prettierPluginTypescript from 'prettier/plugins/typescript'
3 | import prettier from 'prettier/standalone' // For browser
4 |
5 | export async function format(code: string) {
6 | const formatted = await prettier.format(code, {
7 | parser: 'typescript',
8 | // To use typescript parser, typescript plugin and estree plugin are required
9 | plugins: [prettierPluginTypescript, prettierPluginEstree],
10 | semi: false,
11 | singleQuote: true,
12 | useTabs: false,
13 | tabWidth: 2,
14 | arrowParens: 'always',
15 | trailingComma: 'all',
16 | printWidth: 80,
17 | })
18 |
19 | return formatted
20 | }
21 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | import { type ReadableAtom, type WritableAtom } from 'nanostores'
2 | import { PageNode } from './node-class/node'
3 | import { StudioApp } from './studio-app'
4 |
5 | type Shared = {
6 | /**
7 | * Share data between easel and top window
8 | *
9 | * It can be accessed both from top window and easel iframe.
10 | * They're injected from `EaselWrapper` component.
11 | */
12 | $designMode: WritableAtom
13 | $massMode: WritableAtom
14 | $scale: ReadableAtom
15 | }
16 |
17 | declare global {
18 | interface Window {
19 | /**
20 | * Share page atom between iframe and parent window
21 | */
22 | parentFrame: HTMLIFrameElement
23 |
24 | /**
25 | * Only use inside iframe.
26 | * If you want to access from top window, just import modules.
27 | */
28 | shared: Shared
29 |
30 | ownerApp: StudioApp
31 |
32 | // Node class
33 | pageNode: PageNode
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/ground.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Zoom in/out with mouse wheel.
3 | *
4 | * Implementation reference:
5 | * https://stackoverflow.com/questions/44154041/zoom-to-cursor-without-canvas-in-javascript
6 | */
7 |
8 | import { atom, computed } from 'nanostores'
9 | import { GROUND_ID } from './app/ground'
10 | import { $isAnimatingGround } from './atoms'
11 | import { EASEL_CONTAINER_ID } from './easel/easel-container'
12 | import { PageNode } from './node-class/page'
13 |
14 | const MAX_SCALE = 3
15 | const MIN_SCALE = 0.1
16 |
17 | // const $scale = atom(1)
18 | // const $translate = atom({ x: 0, y: 0 })
19 |
20 | export class Ground {
21 | private static _$scale = atom(1)
22 | private static _$translate = atom({ x: 0, y: 0 })
23 |
24 | private static groundElm: HTMLElement | null = null
25 |
26 | static $scale = computed(Ground._$scale, (scale) => scale)
27 | static $translate = computed(Ground._$translate, (translate) => translate)
28 |
29 | static get element() {
30 | if (!this.groundElm) {
31 | this.groundElm = document.getElementById(GROUND_ID)!
32 | }
33 | return this.groundElm
34 | }
35 |
36 | static get scale() {
37 | return Ground.$scale.get()
38 | }
39 |
40 | static get translate() {
41 | return Ground.$translate.get()
42 | }
43 |
44 | static setScale(
45 | newScale: number,
46 | axis:
47 | | {
48 | x: number
49 | y: number
50 | }
51 | | 'center' = 'center',
52 | animation = false,
53 | ) {
54 | const currentScale = Ground.$scale.get()
55 | const nextScale = Math.min(Math.max(newScale, MIN_SCALE), MAX_SCALE)
56 |
57 | const ratio = 1 - nextScale / currentScale
58 |
59 | const groundRect = Ground.element.getBoundingClientRect()
60 |
61 | const { x: previousX, y: previousY } = Ground.$translate.get()
62 | const offsetX =
63 | axis === 'center' ? groundRect.width / 2 : axis.x - groundRect.left
64 | const offsetY =
65 | axis === 'center' ? groundRect.height / 2 : axis.y - groundRect.top
66 |
67 | const nextX = previousX + (offsetX - previousX) * ratio
68 | const nextY = previousY + (offsetY - previousY) * ratio
69 |
70 | Ground._$translate.set({ x: nextX, y: nextY })
71 | Ground._$scale.set(nextScale)
72 |
73 | if (animation) {
74 | $isAnimatingGround.set(true)
75 | const easelContainer = document.getElementById(EASEL_CONTAINER_ID)!
76 | const time = 250
77 | easelContainer.style.transition = `scale ${time}ms cubic-bezier(0.54, 0.03, 0.09, 0.97), translate ${time}ms cubic-bezier(0.54, 0.03, 0.09, 0.97)`
78 | setTimeout(() => {
79 | easelContainer.style.removeProperty('transition')
80 | $isAnimatingGround.set(false)
81 | }, time)
82 | }
83 | }
84 |
85 | static zoomIn(amount: number, animation = false) {
86 | Ground.setScale(Ground.$scale.get() + amount, 'center', animation)
87 | }
88 |
89 | static zoomOut(amount: number, animation = false) {
90 | Ground.setScale(Ground.$scale.get() - amount, 'center', animation)
91 | }
92 |
93 | static setTranslate(x: number, y: number, animation = false) {
94 | Ground._$translate.set({ x, y })
95 |
96 | if (animation) {
97 | $isAnimatingGround.set(true)
98 | const easelContainer = document.getElementById(EASEL_CONTAINER_ID)!
99 | const time = 250
100 | easelContainer.style.transition = `translate ${time}ms cubic-bezier(0.54, 0.03, 0.09, 0.97)`
101 | setTimeout(() => {
102 | easelContainer.style.removeProperty('transition')
103 | $isAnimatingGround.set(false)
104 | }, time)
105 | }
106 | }
107 |
108 | static focus(page: PageNode, animation = false) {
109 | const pageElm = page.iframeElement
110 |
111 | if (!pageElm) return
112 |
113 | const groundRect = Ground.element.getBoundingClientRect()
114 | const { x: currentX, y: currentY } = this.translate
115 |
116 | const pageRect = pageElm.getBoundingClientRect()
117 |
118 | const nextX = groundRect.left + (groundRect.width - pageRect.width) / 2
119 | const nextY = groundRect.top + (groundRect.height - pageRect.height) / 2
120 |
121 | const shiftX = nextX - pageRect.left
122 | const shiftY = nextY - pageRect.top
123 |
124 | Ground.setTranslate(currentX + shiftX, currentY + shiftY, animation)
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/history.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from 'bun:test'
2 | import { InsertNodeAction } from './action'
3 | import { commandInsertNodes } from './command'
4 | import { History } from './history'
5 | import { Library } from './library'
6 | import { Node } from './node-class/node'
7 | import { PageNode } from './node-class/page'
8 |
9 | const library: Library = {
10 | name: 'studio',
11 | version: '1.0.0',
12 | }
13 |
14 | test('Command insert nodes', () => {
15 | const page = new PageNode({ library, nodeName: 'Page' })
16 | const text1 = new Node({ library, nodeName: 'Text' })
17 | const text2 = new Node({ library, nodeName: 'Text' })
18 |
19 | commandInsertNodes(page, [text1, text2], null)
20 |
21 | expect(page.children.length).toBe(2)
22 | expect(page.children[0]).toBe(text1)
23 | expect(page.children[1]).toBe(text2)
24 |
25 | expect(History.$historyStack.get().length).toBe(1)
26 | expect(History.$historyPointer.get()).toBe(0)
27 |
28 | History.undo()
29 |
30 | expect(page.children.length).toBe(0)
31 | expect(History.$historyPointer.get()).toBe(-1)
32 | expect(History.$historyStack.get().length).toBe(1)
33 |
34 | History.redo()
35 |
36 | expect(page.children.length).toBe(2)
37 | expect(History.$historyPointer.get()).toBe(0)
38 | expect(History.$historyStack.get().length).toBe(1)
39 |
40 | History.undo()
41 |
42 | expect(text1.parent).toBe(null)
43 | expect(text2.parent).toBe(null)
44 | expect(page.children.length).toBe(0)
45 |
46 | commandInsertNodes(page, [text1], null)
47 | commandInsertNodes(page, [text2], text1)
48 |
49 | expect(page.children.length).toBe(2)
50 | expect(page.children[0]).toBe(text2)
51 | expect(page.children[1]).toBe(text1)
52 |
53 | expect(History.$historyStack.get().length).toBe(2)
54 | expect(History.$historyPointer.get()).toBe(1)
55 |
56 | const flex = new Node({ library, nodeName: '' })
57 | commandInsertNodes(page, [flex], text2)
58 |
59 | expect(page.children[0]).toBe(flex)
60 |
61 | commandInsertNodes(flex, [text1], null)
62 |
63 | History.undo()
64 |
65 | expect(flex.children.length).toBe(0)
66 | expect(History.$historyStack.get().length).toBe(4)
67 | expect(History.$historyPointer.get()).toBe(2)
68 | })
69 |
70 | test('Command insert nodes order', () => {
71 | const page = new PageNode({ library, nodeName: 'Page' })
72 |
73 | const node1 = new Node({ library, nodeName: 'Node1' })
74 | const node2 = new Node({ library, nodeName: 'Node2' })
75 |
76 | commandInsertNodes(page, [node1, node2], null)
77 |
78 | History.historyStack[0].actions.forEach((action, i) => {
79 | expect(action).toBeInstanceOf(InsertNodeAction)
80 | })
81 |
82 | expect(page.children.length).toBe(2)
83 | expect(page.children[0]).toBe(node1)
84 | expect(page.children[1]).toBe(node2)
85 |
86 | History.undo()
87 |
88 | expect(page.children.length).toBe(0)
89 |
90 | History.redo()
91 |
92 | expect(page.children.length).toBe(2)
93 | expect(page.children[0]).toBe(node1)
94 | expect(page.children[1]).toBe(node2)
95 | })
96 |
--------------------------------------------------------------------------------
/src/history.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'nanostores'
2 | import { Action } from './action'
3 | import { $hoveredNode, $selectedNodes } from './atoms'
4 | import { Node } from './node-class/node'
5 |
6 | // TODO: limit the history stack for performance
7 |
8 | export type HistoryStackItem = {
9 | actions: Action[]
10 | previousSelectedNodes: Node[]
11 | nextSelectedNodes: Node[]
12 | }
13 |
14 | export class History {
15 | static $historyStack = atom([])
16 | static $historyPointer = atom(-1)
17 |
18 | static get historyStack() {
19 | return History.$historyStack.get()
20 | }
21 |
22 | static set historyStack(historyStack: HistoryStackItem[]) {
23 | History.$historyStack.set(historyStack)
24 | }
25 |
26 | static get historyPointer() {
27 | return History.$historyPointer.get()
28 | }
29 |
30 | static set historyPointer(historyPointer: number) {
31 | History.$historyPointer.set(historyPointer)
32 | }
33 |
34 | static push(historyStackItem: HistoryStackItem) {
35 | if (History.historyPointer < History.historyStack.length - 1) {
36 | History.historyStack = History.historyStack.slice(
37 | 0,
38 | History.historyPointer + 1,
39 | )
40 | }
41 |
42 | History.historyStack = [...History.historyStack, historyStackItem]
43 | History.historyPointer += 1
44 | }
45 |
46 | static undo() {
47 | $hoveredNode.set(null)
48 |
49 | if (History.historyPointer >= 0) {
50 | const stackItem = History.historyStack[History.historyPointer]
51 |
52 | stackItem.actions.toReversed().forEach((action) => action.undo())
53 |
54 | process.nextTick(() => {
55 | $selectedNodes.set([...stackItem.previousSelectedNodes])
56 | })
57 |
58 | History.historyPointer -= 1
59 | }
60 | }
61 |
62 | static redo() {
63 | $hoveredNode.set(null)
64 |
65 | if (History.historyPointer < History.historyStack.length - 1) {
66 | History.historyPointer += 1
67 |
68 | const stackItem = History.historyStack[History.historyPointer]
69 |
70 | stackItem.actions.forEach((action) => action.redo())
71 |
72 | process.nextTick(() => {
73 | $selectedNodes.set([...stackItem.nextSelectedNodes])
74 | })
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/hooks/use-global-events.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $designMode,
3 | $interactiveMode,
4 | $isContextMenuOpen,
5 | $massMode,
6 | $selectedNodes,
7 | $selectionRerenderFlag,
8 | $shortcutsDialogOpen,
9 | } from '@/atoms'
10 | import { Command, commandRemoveNodes } from '@/command'
11 | import { shouldKeepNodeSelection } from '@/data-attributes'
12 | import { EditorState } from '@/editor-state'
13 | import { Ground } from '@/ground'
14 | import { History } from '@/history'
15 | import { studioApp } from '@/studio-app'
16 | import { useEffect } from 'react'
17 |
18 | export function useGlobalEvents() {
19 | useEffect(() => {
20 | /**
21 | * TODO: configuration-based keybindings like VSCode
22 | */
23 | function onKeyDown(e: KeyboardEvent) {
24 | const activeElementTagName = document.activeElement?.tagName
25 |
26 | // Leave text input experience
27 | if (
28 | activeElementTagName === 'INPUT' ||
29 | activeElementTagName === 'TEXTAREA'
30 | ) {
31 | return
32 | }
33 |
34 | if (e.key === 'Escape') {
35 | if ($isContextMenuOpen.get()) {
36 | $isContextMenuOpen.set(false)
37 | } else {
38 | $selectedNodes.set([])
39 | }
40 | } else if (e.key === 'Backspace' || e.key === 'Delete') {
41 | const selectedNodes = $selectedNodes.get()
42 |
43 | if (selectedNodes.length > 0) {
44 | commandRemoveNodes(selectedNodes)
45 | }
46 | } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
47 | e.preventDefault()
48 | if ($selectedNodes.get().length === 0) {
49 | $selectedNodes.set(studioApp.pages)
50 | } else {
51 | $selectedNodes.set(
52 | $selectedNodes.get().flatMap((node) => node.children),
53 | )
54 | }
55 | } else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
56 | e.preventDefault()
57 | $shortcutsDialogOpen.set(!$shortcutsDialogOpen.get())
58 | } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
59 | e.preventDefault()
60 | $designMode.set(!$designMode.get())
61 | } else if (e.key === 'e' && (e.metaKey || e.ctrlKey)) {
62 | e.preventDefault()
63 | EditorState.$drawerOpen.set(!EditorState.$drawerOpen.get())
64 | } else if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
65 | e.preventDefault()
66 | $massMode.set(!$massMode.get())
67 | } else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
68 | e.preventDefault()
69 | $interactiveMode.set(!$interactiveMode.get())
70 | } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
71 | e.preventDefault()
72 | Command.copyNodes()
73 | } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
74 | e.preventDefault()
75 | Command.pasteNodes()
76 | } else if (e.key === 'r' && (e.metaKey || e.ctrlKey)) {
77 | window.location.reload()
78 | } else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
79 | e.preventDefault()
80 | Command.addPage()
81 | } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
82 | e.preventDefault()
83 |
84 | if (e.shiftKey) {
85 | History.redo()
86 | } else {
87 | History.undo()
88 | }
89 | } else if (e.key === '=' && (e.metaKey || e.ctrlKey)) {
90 | e.preventDefault()
91 | Ground.zoomIn(0.2)
92 | } else if (e.key === '-' && (e.metaKey || e.ctrlKey)) {
93 | e.preventDefault()
94 | Ground.zoomOut(0.2)
95 | } else if (e.key === '0' && (e.metaKey || e.ctrlKey)) {
96 | e.preventDefault()
97 | Ground.setScale(1)
98 | }
99 | }
100 |
101 | function onMouseDown(e: MouseEvent) {
102 | // Radix Themes' Select component disables body pointer events
103 | // and make cursor click html element when click outside options.
104 | // This behavior makes the selection disappear.
105 | // So, ignore the event when the target is html element.
106 | if (e.target === document.documentElement) {
107 | return
108 | }
109 |
110 | if (e.target instanceof Element && !shouldKeepNodeSelection(e.target)) {
111 | $selectedNodes.set([])
112 | }
113 | }
114 |
115 | function onContextMenu(e: MouseEvent) {
116 | e.preventDefault()
117 | }
118 |
119 | function onResize() {
120 | $selectionRerenderFlag.set(!$selectionRerenderFlag.get())
121 | }
122 |
123 | /**
124 | * Some well-made accessible components like Radix Dialog automatically change focus to the element.
125 | * To prevent stolen focus, blur the iframe when the window loses its focus.
126 | */
127 | function onBlur() {
128 | const activeElement = document.activeElement
129 |
130 | if (
131 | activeElement instanceof HTMLElement &&
132 | activeElement.tagName === 'IFRAME'
133 | ) {
134 | // Allow focus on iframe when in interaction mode
135 | if (!$interactiveMode.get()) {
136 | process.nextTick(() => {
137 | activeElement.blur()
138 | })
139 | }
140 | }
141 | }
142 |
143 | window.addEventListener('keydown', onKeyDown)
144 | window.addEventListener('mousedown', onMouseDown)
145 | window.addEventListener('contextmenu', onContextMenu)
146 | window.addEventListener('resize', onResize)
147 | window.addEventListener('blur', onBlur)
148 |
149 | return () => {
150 | window.removeEventListener('keydown', onKeyDown)
151 | window.removeEventListener('mousedown', onMouseDown)
152 | window.removeEventListener('contextmenu', onContextMenu)
153 | window.removeEventListener('resize', onResize)
154 | window.removeEventListener('blur', onBlur)
155 | }
156 | }, [])
157 | }
158 |
--------------------------------------------------------------------------------
/src/libraries/radix-themes-3.0.1/components.ts:
--------------------------------------------------------------------------------
1 | import * as radixThemes from '@radix-ui/themes-3.0.1'
2 | import '@radix-ui/themes-3.0.1/styles.css'
3 |
4 | export const components = radixThemes
5 |
--------------------------------------------------------------------------------
/src/libraries/radix-themes-3.0.1/drawer-items.tsx:
--------------------------------------------------------------------------------
1 | import { Library } from '@/library'
2 | import { Node } from '@/node-class/node'
3 | import { NodeUtil } from '@/node-class/node-util'
4 | import { Blockquote, Code, Heading, Text } from '@radix-ui/themes-3.0.1'
5 | import { nodeDefinitions as def } from './node-definitions'
6 |
7 | const studioLibrary: Library = {
8 | name: 'studio',
9 | version: '1.0.0',
10 | }
11 |
12 | const library: Library = {
13 | name: 'radix-themes',
14 | version: '3.0.1',
15 | }
16 |
17 | const createStudioNode = NodeUtil.createNodeFactory(studioLibrary)
18 | const createRadixNode = NodeUtil.createNodeFactory(library)
19 |
20 | function createTextNode(value: string) {
21 | return createStudioNode({ nodeName: 'Text', props: { value } })
22 | }
23 |
24 | export const drawerItems: {
25 | createNode: () => Node
26 | render: () => JSX.Element
27 | }[] = [
28 | {
29 | createNode: () =>
30 | createRadixNode({
31 | nodeName: def.RadixText.nodeName,
32 | children: [createTextNode('Radix Text')],
33 | }),
34 | render: () => Radix Text,
35 | },
36 | {
37 | createNode: () =>
38 | createRadixNode({
39 | nodeName: def.Heading.nodeName,
40 | children: [createTextNode('Heading')],
41 | }),
42 | render: () => Heading,
43 | },
44 | {
45 | createNode: () =>
46 | createRadixNode({
47 | nodeName: def.Code.nodeName,
48 | children: [createTextNode('Radix Code')],
49 | }),
50 | render: () => Code
,
51 | },
52 | {
53 | createNode: () =>
54 | createRadixNode({
55 | nodeName: def.Blockquote.nodeName,
56 | children: [createTextNode('Blockquote')],
57 | }),
58 | render: () => Blockquote
,
59 | },
60 | ]
61 |
--------------------------------------------------------------------------------
/src/libraries/radix-themes-3.0.1/node-definitions.ts:
--------------------------------------------------------------------------------
1 | import { NodeDefinition, Prop } from '@/node-definition'
2 |
3 | const color = [
4 | 'tomato',
5 | 'red',
6 | 'ruby',
7 | 'crimson',
8 | 'pink',
9 | 'plum',
10 | 'purple',
11 | 'violet',
12 | 'iris',
13 | 'indigo',
14 | 'blue',
15 | 'cyan',
16 | 'teal',
17 | 'jade',
18 | 'green',
19 | 'grass',
20 | 'brown',
21 | 'orange',
22 | 'sky',
23 | 'mint',
24 | 'lime',
25 | 'yellow',
26 | 'amber',
27 | 'gold',
28 | 'bronze',
29 | 'gray',
30 | ]
31 | const radius = ['none', 'small', 'medium', 'large', 'full']
32 |
33 | const oneToNine = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
34 |
35 | const zeroToNine = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
36 |
37 | const commonMarginProps: Prop[] = [
38 | {
39 | key: 'm',
40 | format: { type: 'options', options: zeroToNine },
41 | },
42 | {
43 | key: 'mx',
44 | format: { type: 'options', options: zeroToNine },
45 | },
46 | {
47 | key: 'my',
48 | format: { type: 'options', options: zeroToNine },
49 | },
50 | {
51 | key: 'mt',
52 | format: { type: 'options', options: zeroToNine },
53 | },
54 | {
55 | key: 'mr',
56 | format: { type: 'options', options: zeroToNine },
57 | },
58 | {
59 | key: 'mb',
60 | format: { type: 'options', options: zeroToNine },
61 | },
62 | {
63 | key: 'ml',
64 | format: { type: 'options', options: zeroToNine },
65 | },
66 | ]
67 |
68 | const commonLayoutProps: Prop[] = [
69 | ...commonMarginProps,
70 | {
71 | key: 'p',
72 | format: { type: 'options', options: zeroToNine },
73 | },
74 | {
75 | key: 'px',
76 | format: { type: 'options', options: zeroToNine },
77 | },
78 | {
79 | key: 'py',
80 | format: { type: 'options', options: zeroToNine },
81 | },
82 | {
83 | key: 'pt',
84 | format: { type: 'options', options: zeroToNine },
85 | },
86 | {
87 | key: 'pr',
88 | format: { type: 'options', options: zeroToNine },
89 | },
90 | {
91 | key: 'pb',
92 | format: { type: 'options', options: zeroToNine },
93 | },
94 | {
95 | key: 'pl',
96 | format: { type: 'options', options: zeroToNine },
97 | },
98 | {
99 | key: 'position',
100 | format: {
101 | type: 'options',
102 | options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
103 | },
104 | },
105 | {
106 | key: 'inset',
107 | format: { type: 'options', options: ['auto', '0', '50%', '100%'] },
108 | },
109 | {
110 | key: 'top',
111 | format: { type: 'options', options: ['auto', '0', '50%', '100%'] },
112 | },
113 | {
114 | key: 'right',
115 | format: { type: 'options', options: ['auto', '0', '50%', '100%'] },
116 | },
117 | {
118 | key: 'bottom',
119 | format: { type: 'options', options: ['auto', '0', '50%', '100%'] },
120 | },
121 | {
122 | key: 'left',
123 | format: { type: 'options', options: ['auto', '0', '50%', '100%'] },
124 | },
125 | {
126 | key: 'shrink',
127 | format: { type: 'options', options: ['0', '1'] },
128 | },
129 | {
130 | key: 'grow',
131 | format: { type: 'options', options: ['0', '1'] },
132 | },
133 | ]
134 |
135 | export const nodeDefinitions = {
136 | RadixText: {
137 | nodeName: 'RadixText',
138 | mod: 'Text',
139 | gapless: true,
140 | props: [
141 | // TODO: `as` cannot be used in combination with `asChild`
142 | // {
143 | // key: 'asChild',
144 | // type: 'boolean',
145 | // default: false,
146 | // },
147 | {
148 | key: 'as',
149 | format: { type: 'options', options: ['p', 'label', 'div', 'span'] },
150 | default: 'span',
151 | },
152 | {
153 | key: 'size',
154 | format: { type: 'options', options: oneToNine },
155 | },
156 | {
157 | key: 'weight',
158 | format: {
159 | type: 'options',
160 | options: ['light', 'regular', 'medium', 'bold'],
161 | },
162 | },
163 | {
164 | key: 'align',
165 | format: { type: 'options', options: ['left', 'center', 'right'] },
166 | },
167 | {
168 | key: 'trim',
169 | format: {
170 | type: 'options',
171 | options: ['normal', 'start', 'end', 'both'],
172 | },
173 | },
174 | {
175 | key: 'color',
176 | format: { type: 'options', options: color },
177 | },
178 | {
179 | key: 'highContrast',
180 | format: { type: 'boolean' },
181 | },
182 | ],
183 | },
184 | Heading: {
185 | nodeName: 'Heading',
186 | gapless: true,
187 | props: [
188 | {
189 | key: 'as',
190 | format: {
191 | type: 'options',
192 | options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
193 | },
194 | default: 'h1',
195 | },
196 | {
197 | key: 'size',
198 | format: {
199 | type: 'options',
200 | options: oneToNine,
201 | },
202 | default: '6',
203 | },
204 | {
205 | key: 'weight',
206 | format: {
207 | type: 'options',
208 | options: ['light', 'regular', 'medium', 'bold'],
209 | },
210 | default: 'bold',
211 | },
212 | {
213 | key: 'align',
214 | format: { type: 'options', options: ['left', 'center', 'right'] },
215 | },
216 | {
217 | key: 'trim',
218 | format: {
219 | type: 'options',
220 | options: ['normal', 'start', 'end', 'both'],
221 | },
222 | },
223 | {
224 | key: 'color',
225 | format: { type: 'options', options: color },
226 | },
227 | {
228 | key: 'highContrast',
229 | format: { type: 'boolean' },
230 | },
231 | ],
232 | },
233 | Blockquote: {
234 | nodeName: 'Blockquote',
235 | gapless: true,
236 | props: [
237 | {
238 | key: 'size',
239 | format: {
240 | type: 'options',
241 | options: oneToNine,
242 | },
243 | },
244 | {
245 | key: 'weight',
246 | format: {
247 | type: 'options',
248 | options: ['light', 'regular', 'medium', 'bold'],
249 | },
250 | },
251 | {
252 | key: 'color',
253 | format: { type: 'options', options: color },
254 | },
255 | {
256 | key: 'highContrast',
257 | format: { type: 'boolean' },
258 | },
259 | {
260 | key: 'truncate',
261 | format: { type: 'boolean' },
262 | },
263 | {
264 | key: 'wrap',
265 | format: {
266 | type: 'options',
267 | options: ['wrap', 'nowrap', 'pretty', 'balance'],
268 | },
269 | },
270 | ],
271 | },
272 | Code: {
273 | nodeName: 'Code',
274 | gapless: true,
275 | props: [
276 | {
277 | key: 'size',
278 | format: {
279 | type: 'options',
280 | options: oneToNine,
281 | },
282 | },
283 | {
284 | key: 'variant',
285 | format: {
286 | type: 'options',
287 | options: ['solid', 'soft', 'outline', 'ghost'],
288 | },
289 | default: 'soft',
290 | },
291 | {
292 | key: 'weight',
293 | format: {
294 | type: 'options',
295 | options: ['light', 'regular', 'medium', 'bold'],
296 | },
297 | },
298 | {
299 | key: 'color',
300 | format: { type: 'options', options: color },
301 | },
302 | {
303 | key: 'highContrast',
304 | format: { type: 'boolean' },
305 | },
306 | ],
307 | },
308 | } satisfies Record
309 |
--------------------------------------------------------------------------------
/src/libraries/studio-1.0.0/components.tsx:
--------------------------------------------------------------------------------
1 | function Text(props: any) {
2 | return {props.value}
3 | }
4 |
5 | function Page(props: any) {
6 | return {props.children}
7 | }
8 |
9 | function Image(props: any) {
10 | return
11 | }
12 |
13 | export const components = {
14 | Text,
15 | Page,
16 | Image,
17 | }
18 |
--------------------------------------------------------------------------------
/src/libraries/studio-1.0.0/node-definitions.ts:
--------------------------------------------------------------------------------
1 | import { $selectedNodes } from '@/atoms'
2 | import { PageNode } from '@/node-class/page'
3 | import { NodeDefinition } from '@/node-definition'
4 |
5 | export const nodeDefinitions: Record = {
6 | Text: {
7 | mod: 'Text',
8 | nodeName: 'Text',
9 | leaf: true,
10 | props: [
11 | {
12 | key: 'value',
13 | format: { type: 'string' },
14 | default: 'Text',
15 | label: 'Value',
16 | required: true,
17 | },
18 | ],
19 | generateCode: (node) => {
20 | const value = node.$props.get().value
21 |
22 | if (node.parent instanceof PageNode) {
23 | return `${value}`
24 | }
25 |
26 | if ($selectedNodes.get().includes(node)) {
27 | return `\`${value.replace(/`/g, '\\`')}\``
28 | }
29 |
30 | return `${value}`
31 | },
32 | },
33 | Page: {
34 | mod: 'Page',
35 | nodeName: 'Page',
36 | props: [
37 | {
38 | key: 'title',
39 | format: { type: 'string' },
40 | default: 'New Page',
41 | label: 'Value',
42 | },
43 | ],
44 | },
45 | Image: {
46 | mod: 'Image',
47 | nodeName: 'Image',
48 | leaf: true,
49 | props: [
50 | {
51 | key: 'src',
52 | format: { type: 'string' },
53 | },
54 | {
55 | key: 'width',
56 | format: { type: 'string' },
57 | },
58 | ],
59 | },
60 | }
61 |
--------------------------------------------------------------------------------
/src/library-definition.ts:
--------------------------------------------------------------------------------
1 | import { NodeDefinition } from './node-definition'
2 |
3 | export type LibraryDefinition = {
4 | from: string
5 | nodeDefinitions: NodeDefinition[]
6 | }
7 |
--------------------------------------------------------------------------------
/src/library.ts:
--------------------------------------------------------------------------------
1 | export type Library = {
2 | name: string
3 | version: string
4 | }
5 |
6 | export const stringifyLibraryKey = (library: Library) =>
7 | `${library.name}-${library.version}`
8 |
--------------------------------------------------------------------------------
/src/load-node-component-map.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Load components library dynamically.
3 | * TODO: try catch
4 | */
5 | export async function loadNodeComponentMap(library: string) {
6 | const { nodeComponentMap } = await import(`@/__generated__/${library}`)
7 |
8 | return nodeComponentMap
9 | }
10 |
--------------------------------------------------------------------------------
/src/node-class/node-util.ts:
--------------------------------------------------------------------------------
1 | import { Library } from '@/library'
2 | import { InitNode, Node, NodeName, type SerializedNode } from './node'
3 | import { PageNode } from './page'
4 |
5 | const nodeClassMap: Record = {
6 | Page: PageNode,
7 | Text: Node,
8 | }
9 |
10 | export namespace NodeUtil {
11 | export function deserialize(serialized: SerializedNode, clone = false): Node {
12 | const NodeClass = nodeClassMap[serialized.nodeName] ?? Node
13 |
14 | return new NodeClass({
15 | id: clone ? undefined : serialized.id,
16 | library: serialized.library,
17 | nodeName: serialized.nodeName,
18 | props: serialized.props,
19 | style: serialized.style,
20 | children: serialized.children?.map((child) => deserialize(child, clone)),
21 | })
22 | }
23 |
24 | /**
25 | * Create a node factory function with the library.
26 | * This helps you to create a node without passing the library every time.
27 | */
28 | export function createNodeFactory(library: Library) {
29 | return (initNodeWithoutLibrary: Omit): Node =>
30 | new Node({ library, ...initNodeWithoutLibrary })
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/node-class/node.spec.ts:
--------------------------------------------------------------------------------
1 | import { Library } from '@/library'
2 | import { PageNode } from '@/node-class/page'
3 | import { studioApp } from '@/studio-app'
4 | import { expect, test } from 'bun:test'
5 | import { Node } from './node'
6 |
7 | const library: Library = {
8 | name: 'studio',
9 | version: '1.0.0',
10 | }
11 |
12 | test('Node creation', () => {
13 | const frag = new Node({ library, nodeName: '' })
14 | expect(frag.children.length).toBe(0)
15 | expect(frag.parent).toBe(null)
16 | expect(frag.previousSibling).toBe(null)
17 | expect(frag.nextSibling).toBe(null)
18 | })
19 |
20 | test('Children', () => {
21 | const frag = new Node({ library, nodeName: '' })
22 | const text = new Node({ library, nodeName: 'Text' })
23 | frag.append(text)
24 | expect(frag.children.length).toBe(1)
25 | expect(frag.children[0]).toBe(text)
26 | expect(text.parent).toBe(frag)
27 |
28 | const frag2 = new Node({ library, nodeName: '' })
29 | frag.append(frag2)
30 | expect(frag.children.length).toBe(2)
31 | expect(frag.allNestedChildren.length).toBe(2)
32 |
33 | const text2 = new Node({ library, nodeName: 'Text' })
34 | frag2.append(text2)
35 | expect(frag.allNestedChildren.length).toBe(3)
36 | expect(frag2.allNestedChildren.length).toBe(1)
37 | })
38 |
39 | test('Append children', () => {
40 | const frag = new Node({ library, nodeName: '' })
41 | const text1 = new Node({ library, nodeName: 'Text' })
42 | frag.append(text1)
43 | expect(frag.children.length).toBe(1)
44 |
45 | const text2 = new Node({ library, nodeName: 'Text' })
46 | frag.append(text2)
47 | expect(frag.children.length).toBe(2)
48 |
49 | expect(frag.children[0]).toBe(text1)
50 | expect(frag.children[1]).toBe(text2)
51 |
52 | expect(frag.children[0].previousSibling).toBe(null)
53 | expect(frag.children[0].nextSibling).toBe(text2)
54 | expect(frag.children[1].previousSibling).toBe(text1)
55 | expect(frag.children[1].nextSibling).toBe(null)
56 | })
57 |
58 | test('All nodes', () => {
59 | const page = new PageNode({ library, nodeName: 'Page' })
60 | studioApp.addPage(page)
61 | expect(studioApp.allNodes[page.id]).toBe(page)
62 | const text = new Node({ library, nodeName: 'Text' })
63 | page.append(text)
64 | expect(studioApp.allNodes[text.id]).toBe(text)
65 |
66 | studioApp.removePage(page)
67 | })
68 |
69 | test('Move', () => {
70 | const frag = new Node({ library, nodeName: '' })
71 | const frag2 = new Node({ library, nodeName: '' })
72 | const text = new Node({ library, nodeName: 'Text' })
73 |
74 | frag.append(text)
75 | frag2.append(text)
76 |
77 | expect(frag.children.length).toBe(0)
78 | expect(frag2.children.length).toBe(1)
79 | expect(frag2.children[0]).toBe(text)
80 |
81 | const text2 = new Node({ library, nodeName: 'Text' })
82 | frag2.append(text2)
83 |
84 | frag.append(text, text2)
85 | expect(frag.children.length).toBe(2)
86 | expect(frag.children[0]).toBe(text)
87 | expect(frag.children[1]).toBe(text2)
88 | expect(frag2.children.length).toBe(0)
89 | })
90 |
91 | test('Remove children', () => {
92 | const frag = new Node({ library, nodeName: '' })
93 | const text1 = new Node({ library, nodeName: 'Text' })
94 | const text2 = new Node({ library, nodeName: 'Text' })
95 | frag.append(text1, text2)
96 | expect(frag.children.length).toBe(2)
97 |
98 | frag.removeChild(text1)
99 | expect(frag.children.length).toBe(1)
100 | expect(frag.children[0]).toBe(text2)
101 | expect(text1.parent).toBe(null)
102 |
103 | frag.removeChild(text2)
104 | expect(frag.children.length).toBe(0)
105 | expect(text2.parent).toBe(null)
106 | })
107 |
108 | test('Remove', () => {
109 | const frag = new Node({ library, nodeName: '' })
110 | const text1 = new Node({ library, nodeName: 'Text' })
111 | const text2 = new Node({ library, nodeName: 'Text' })
112 | frag.append(text1, text2)
113 | expect(frag.children.length).toBe(2)
114 |
115 | text1.remove()
116 | expect(frag.children.length).toBe(1)
117 | expect(frag.children[0]).toBe(text2)
118 | expect(text1.parent).toBe(null)
119 |
120 | text2.remove()
121 | expect(frag.children.length).toBe(0)
122 | expect(text2.parent).toBe(null)
123 | })
124 |
125 | test('Insert before', () => {
126 | const frag = new Node({ library, nodeName: '' })
127 | const text1 = new Node({ library, nodeName: 'Text' })
128 | const text2 = new Node({ library, nodeName: 'Text' })
129 | frag.append(text1)
130 | frag.insertBefore([text2], text1)
131 | expect(frag.children.length).toBe(2)
132 | expect(frag.children[0]).toBe(text2)
133 | expect(frag.children[1]).toBe(text1)
134 | expect(text2.nextSibling).toBe(text1)
135 | expect(text1.previousSibling).toBe(text2)
136 |
137 | const text3 = new Node({ library, nodeName: 'Text' })
138 | frag.append(text3)
139 | frag.insertBefore([text3], text3.previousSibling)
140 | expect(frag.children.length).toBe(3)
141 | expect(frag.children[0]).toBe(text2)
142 | expect(frag.children[1]).toBe(text3)
143 | expect(frag.children[2]).toBe(text1)
144 |
145 | frag.removeChild(text1)
146 |
147 | frag.insertBefore([text3], text3)
148 | expect(frag.children.length).toBe(2)
149 | expect(frag.children[0]).toBe(text2)
150 | expect(frag.children[1]).toBe(text3)
151 | })
152 |
153 | test('Page creation, removal', () => {
154 | expect(studioApp.pages.length).toBe(0)
155 | expect(studioApp.$pages.get().length).toBe(0)
156 |
157 | const page1 = new PageNode({ library, nodeName: 'Page' })
158 | studioApp.addPage(page1)
159 |
160 | expect(studioApp.$pages.get().length).toBe(1)
161 | expect(studioApp.pages[0]).toBe(page1)
162 |
163 | const page2 = new PageNode({ library, nodeName: 'Page' })
164 | studioApp.addPage(page2)
165 |
166 | expect(studioApp.$pages.get().length).toBe(2)
167 | expect(studioApp.pages[1]).toBe(page2)
168 |
169 | studioApp.removePage(page1)
170 | expect(studioApp.$pages.get().length).toBe(1)
171 | expect(studioApp.pages[0]).toBe(page2)
172 |
173 | expect(page1.parent).toBe(null)
174 |
175 | studioApp.removePage(page2)
176 | expect(studioApp.$pages.get().length).toBe(0)
177 | })
178 |
179 | test('Owner page', () => {
180 | const frag = new Node({ library, nodeName: '' })
181 | const text = new Node({ library, nodeName: 'Text' })
182 | frag.append(text)
183 |
184 | expect(frag.ownerPage).toBe(null)
185 | expect(text.ownerPage).toBe(null)
186 |
187 | const page = new PageNode({ library, nodeName: 'Page' })
188 | page.append(frag)
189 |
190 | expect(frag.ownerPage).toBe(page)
191 | expect(page.ownerPage).toBe(page)
192 |
193 | frag.remove()
194 | expect(frag.ownerPage).toBe(null)
195 | })
196 |
197 | test('Siblings', () => {
198 | const frag = new Node({ library, nodeName: '' })
199 | const text1 = new Node({ library, nodeName: 'Text' })
200 | const text2 = new Node({ library, nodeName: 'Text' })
201 |
202 | frag.append(text1, text2)
203 |
204 | expect(text1.previousSibling).toBe(null)
205 | expect(text1.nextSibling).toBe(text2)
206 | expect(text2.previousSibling).toBe(text1)
207 | expect(text2.nextSibling).toBe(null)
208 | })
209 |
210 | test('Tree', () => {
211 | const page = new PageNode({ library, nodeName: 'Page' })
212 | const frag = new Node({ library, nodeName: '' })
213 |
214 | page.append(frag)
215 |
216 | const text = new Node({ library, nodeName: 'Text' })
217 |
218 | frag.append(text)
219 |
220 | expect(page.ownerPage).toBe(page)
221 | expect(frag.ownerPage).toBe(page)
222 | expect(text.ownerPage).toBe(page)
223 |
224 | frag.remove()
225 |
226 | expect(frag.ownerPage).toBe(null)
227 | expect(text.ownerPage).toBe(null)
228 | expect(text.parent).toBe(frag)
229 | })
230 |
231 | class TestNode extends Node {
232 | readonly nodeName = 'Fragment'
233 | componentName: string | null = null
234 |
235 | slotsInfoArray = [
236 | {
237 | required: false,
238 | key: 'content',
239 | label: 'Content',
240 | },
241 | ]
242 | }
243 |
244 | test('Clone', () => {
245 | const node = new Node({ library, nodeName: '' })
246 | node.$style.setKey('color', 'red')
247 |
248 | expect(node.$style.get().color).toBe('red')
249 |
250 | const cloned = node.clone()
251 | expect(cloned.$style.get().color).toBe('red')
252 | })
253 |
254 | test('Nested clone', () => {
255 | const parent = new Node({ library, nodeName: '' })
256 | const child = new Node({ library, nodeName: '' })
257 | parent.append(child)
258 | child.$style.setKey('color', 'red')
259 | child.$style.setKey('flex', '1')
260 |
261 | const textChild = new Node({
262 | library,
263 | nodeName: 'Text',
264 | props: {
265 | value: 'Hello, world!',
266 | },
267 | })
268 | parent.append(textChild)
269 |
270 | const cloned = parent.clone()
271 | expect(cloned.children[0].$style.get().color).toBe('red')
272 | expect(cloned.children[1].nodeName).toBe('Text')
273 | })
274 |
--------------------------------------------------------------------------------
/src/node-class/node.ts:
--------------------------------------------------------------------------------
1 | import { alphanumericId } from '@/alphanumeric'
2 | import { Library } from '@/library'
3 | import { NodeDefinition } from '@/node-definition'
4 | import { serializeProps } from '@/serialize-props'
5 | import { studioApp } from '@/studio-app'
6 | import { MapStore, atom, map } from 'nanostores'
7 | import { ReactNode } from 'react'
8 | import { PageNode } from './page'
9 |
10 | export type NodeName = 'Page' | 'Text' | (string & {})
11 |
12 | export type InitNode = Omit & {
13 | id?: string
14 | children?: Node[]
15 | }
16 |
17 | export class Node {
18 | /** Unique ID */
19 | id = alphanumericId(7)
20 |
21 | library: Library
22 | nodeName: NodeName
23 |
24 | $studioProps: MapStore<{
25 | label: string
26 | }> = map({})
27 |
28 | $props: MapStore = map({})
29 | $style: MapStore = map({})
30 |
31 | /**
32 | * Do not override this property in subclasses because it has a subscription in the constructor.
33 | */
34 | $children = atom([])
35 |
36 | constructor(initNode: InitNode) {
37 | /**
38 | * Automatically update parent and ownerPage when children are changed.
39 | */
40 | this.$children.subscribe((newChildren, oldChildren) => {
41 | if (oldChildren) {
42 | oldChildren.forEach((child) => {
43 | Node.releaseParent(child)
44 | delete studioApp.allNodes[child.id]
45 | })
46 | }
47 |
48 | newChildren.forEach((child) => {
49 | Node.assignParent(child, this)
50 | studioApp.allNodes[child.id] = child
51 | })
52 | })
53 |
54 | const { id, library, nodeName, children, props, style } = initNode
55 |
56 | this.library = library
57 | this.nodeName = nodeName
58 |
59 | if (id) {
60 | this.id = id
61 | }
62 |
63 | if (props) {
64 | this.$props.set(props)
65 | }
66 |
67 | if (style) {
68 | this.$style.set(style)
69 | }
70 |
71 | // NOTE: assigning children should be done after subscribing $children
72 | if (children) {
73 | this.$children.set(children.map((child) => child.clone()))
74 | }
75 | }
76 |
77 | get definition(): NodeDefinition {
78 | return this.#getDefinition()
79 | }
80 |
81 | #getDefinition(): NodeDefinition {
82 | return studioApp.getNodeDefinition(this.library, this.nodeName)
83 | }
84 |
85 | private onMountCallbacks: ((element: HTMLElement | null) => void)[] = []
86 |
87 | onMount(callback: (element: HTMLElement | null) => void) {
88 | this.onMountCallbacks.push(callback)
89 | }
90 |
91 | executeOnMountCallbacks() {
92 | this.onMountCallbacks.forEach((callback) => callback(this.element))
93 | }
94 |
95 | /**
96 | * A real element that the node represents.
97 | * For page, it is the body of the iframe.
98 | * Otherwise, it is the first child of the wrapper element.
99 | *
100 | * TODO: What about overlay?
101 | *
102 | * Find the element by id from the ownerPage's iframe.
103 | * If the element has display: contents, its first child is the real element.
104 | * Otherwise, the found element is the real element itself.
105 | */
106 | get element(): HTMLElement | null {
107 | const element =
108 | this.ownerPage?.iframeElement?.contentDocument?.getElementById(
109 | `node-${this.id}`,
110 | )
111 |
112 | if (!element) {
113 | return null
114 | }
115 |
116 | if (element?.style.display === 'contents') {
117 | return element.firstElementChild as HTMLElement
118 | }
119 |
120 | return element
121 | }
122 |
123 | get style() {
124 | return this.$style.get()
125 | }
126 |
127 | get props() {
128 | return this.$props.get()
129 | }
130 |
131 | set props(any) {
132 | this.$props?.set(any)
133 | }
134 |
135 | get isRemovable() {
136 | return true
137 | }
138 |
139 | get isMovable() {
140 | return true
141 | }
142 |
143 | get isDroppable() {
144 | return this.definition.leaf !== true
145 | }
146 |
147 | get isDraggable() {
148 | return true
149 | }
150 |
151 | get isSelectable() {
152 | return true
153 | }
154 |
155 | private _ownerPage: PageNode | null = null
156 |
157 | get ownerPage() {
158 | return this._ownerPage
159 | }
160 |
161 | /** Only root node(PageNode)'s parent is null */
162 | private _parent: Node | null = null
163 |
164 | get parent() {
165 | return this._parent
166 | }
167 |
168 | get parents() {
169 | const parents: Node[] = []
170 | let parent = this.parent
171 | while (parent) {
172 | parents.push(parent)
173 | parent = parent.parent
174 | }
175 | return parents
176 | }
177 |
178 | /**
179 | * Release parent and ownerPage from the node and its children including slots.
180 | */
181 | private static releaseParent(node: Node) {
182 | const updateTargets = [node, ...node.allNestedChildren]
183 |
184 | updateTargets.forEach((child) => {
185 | child._ownerPage = null
186 | })
187 |
188 | node._parent = null
189 | }
190 |
191 | /**
192 | * Assign parent and transfer parent's ownerPage to the node and its children including slots.
193 | */
194 | private static assignParent(node: Node, parent: Node) {
195 | if (parent.ownerPage) {
196 | const updateTargets = [node, ...node.allNestedChildren]
197 | updateTargets.forEach((child) => {
198 | child._ownerPage = parent.ownerPage
199 | studioApp.allNodes[child.id] = child
200 | })
201 | }
202 |
203 | node._parent = parent
204 | }
205 |
206 | /**
207 | * Return only iterable children, excluding slots.
208 | */
209 | get children() {
210 | return this.$children.get()
211 | }
212 |
213 | set children(children: Node[]) {
214 | this.$children.set(children)
215 | }
216 |
217 | get allNestedChildren(): Node[] {
218 | return this.children.flatMap((child) => [child, ...child.allNestedChildren])
219 | }
220 |
221 | get previousSibling(): Node | null {
222 | if (this.parent) {
223 | const index = this.parent.children.indexOf(this)
224 | return this.parent.children[index - 1] ?? null
225 | }
226 |
227 | return null
228 | }
229 |
230 | get nextSibling(): Node | null {
231 | if (this.parent) {
232 | const index = this.parent.children.indexOf(this)
233 | return this.parent.children[index + 1] ?? null
234 | }
235 |
236 | return null
237 | }
238 |
239 | public append(...children: Node[]) {
240 | if (children.length === 0) return
241 |
242 | const removableChildren = children.filter(
243 | (child) => child.isRemovable && child !== this,
244 | )
245 |
246 | removableChildren.forEach((child) => {
247 | child.remove()
248 | })
249 |
250 | this.children = [...this.children, ...removableChildren]
251 | }
252 |
253 | public insertBefore(children: Node[] | Node, referenceNode: Node | null) {
254 | const childrenArray = Array.isArray(children) ? children : [children]
255 |
256 | if (childrenArray.length === 0) return
257 |
258 | const removableChildren = childrenArray.filter(
259 | (child) => child.isRemovable && child !== this,
260 | )
261 |
262 | if (!referenceNode) {
263 | this.append(...removableChildren)
264 | return
265 | }
266 |
267 | // Remember referenceNode's nextSibling
268 | const referenceNodeNextSibling = referenceNode.nextSibling
269 |
270 | removableChildren.forEach((child) => {
271 | // TODO: bulk remove by parent to avoid unnecessary re-render
272 | child.remove()
273 | })
274 |
275 | // After removing inserting nodes,
276 | // if inserting nodes include referenceNode, we cannot find referenceNode.
277 | if (removableChildren.includes(referenceNode)) {
278 | // If referenceNode was the last child of the parent, append to the end.
279 | if (referenceNodeNextSibling === null) {
280 | this.append(...removableChildren)
281 | return
282 | }
283 | // Insert before referenceNode's nextSibling
284 | else {
285 | this.insertBefore(removableChildren, referenceNodeNextSibling)
286 | return
287 | }
288 | }
289 |
290 | const referenceIndex = this.children.indexOf(referenceNode)
291 |
292 | this.children = [
293 | ...this.children.slice(0, referenceIndex),
294 | ...removableChildren,
295 | ...this.children.slice(referenceIndex),
296 | ]
297 | }
298 |
299 | public removeChild(child: Node) {
300 | this.removeChildren([child])
301 | }
302 |
303 | public removeChildren(children: Node[]) {
304 | if (children.some((child) => child.parent !== this)) {
305 | throw new Error('Some children are not contained by this node')
306 | }
307 |
308 | const removableChildren = children.filter((child) => child.isRemovable)
309 |
310 | this.children = this.children.filter((c) => !removableChildren.includes(c))
311 | }
312 |
313 | /**
314 | * Remove all iterable children
315 | */
316 | public removeAllChildren() {
317 | this.removeChildren(this.children)
318 | }
319 |
320 | /**
321 | * Remove itself from its parent.
322 | */
323 | remove() {
324 | if (this.parent) {
325 | this.parent.removeChild(this)
326 | }
327 | }
328 |
329 | generateCode(): string {
330 | const definition = this.#getDefinition()
331 |
332 | // If generateCode is defined in the definition, use it.
333 | if (definition.generateCode) {
334 | return definition.generateCode(this)
335 | }
336 |
337 | /** Node's props data */
338 | const props = this.props
339 | /** Convert props data to tsx */
340 | const serializedProps = serializeProps(props, definition.props ?? [])
341 | const componentName = `${definition.mod ?? definition.nodeName}${definition.sub ? `.${definition.sub}` : ''}`
342 |
343 | if (this.children.length === 0) {
344 | return `<${componentName} ${serializedProps} />`
345 | }
346 |
347 | return `<${componentName} ${serializedProps}>
348 | ${this.children.map((child) => child.generateCode()).join('')}
349 | ${componentName}>`
350 | }
351 |
352 | clone(): Node {
353 | return new Node({
354 | library: this.library,
355 | nodeName: this.nodeName,
356 | props: this.props,
357 | style: this.style,
358 | children: this.children.map((child) => child.clone()),
359 | })
360 | }
361 |
362 | serialize(): SerializedNode {
363 | return {
364 | id: this.id,
365 | library: this.library,
366 | nodeName: this.nodeName,
367 | props: this.props,
368 | style: this.$style.get(),
369 | children:
370 | this.children === undefined || this.children.length === 0
371 | ? undefined
372 | : this.children.map((child) => child.serialize()),
373 | }
374 | }
375 |
376 | render = (): ReactNode => {
377 | return null
378 | }
379 | }
380 |
381 | export type SerializedNode = {
382 | id: string
383 | library: Library
384 | nodeName: NodeName
385 | props?: Record
386 | style?: Record
387 | children?: SerializedNode[]
388 | }
389 |
390 | export type NodeComponent = (props: { node: N }) => JSX.Element
391 |
--------------------------------------------------------------------------------
/src/node-class/page.tsx:
--------------------------------------------------------------------------------
1 | import { RemoveNodeAction } from '@/action'
2 | import { StudioApp, studioApp } from '@/studio-app'
3 | import { map } from 'nanostores'
4 | import { Node } from './node'
5 |
6 | export const DEFAULT_PAGE_LABEL = 'New Page'
7 |
8 | export class PageNode extends Node {
9 | public ownerApp: StudioApp = studioApp
10 |
11 | public $props = map({
12 | title: DEFAULT_PAGE_LABEL,
13 | })
14 |
15 | #iframeElement: HTMLIFrameElement | null = null
16 |
17 | get iframeElement() {
18 | return this.#iframeElement
19 | }
20 |
21 | get element() {
22 | return this.#iframeElement?.contentDocument?.body ?? null
23 | }
24 |
25 | private onIframeMountCallbacks: ((
26 | iframeElement: HTMLIFrameElement,
27 | ) => void)[] = []
28 |
29 | onIframeMount(callback: (iframeElement: HTMLIFrameElement) => void) {
30 | this.onIframeMountCallbacks.push(callback)
31 |
32 | // Return a function to unsubscribe
33 | return () => {
34 | this.onIframeMountCallbacks = this.onIframeMountCallbacks.filter(
35 | (cb) => cb !== callback,
36 | )
37 | }
38 | }
39 |
40 | get isDraggable(): boolean {
41 | return false
42 | }
43 |
44 | static attachIframeElement(
45 | pageNode: PageNode,
46 | iframeElement: HTMLIFrameElement,
47 | ) {
48 | pageNode.#iframeElement = iframeElement
49 | pageNode.onIframeMountCallbacks.forEach((callback) =>
50 | callback(iframeElement),
51 | )
52 | }
53 |
54 | static detachIframeElement(pageNode: PageNode) {
55 | pageNode.#iframeElement = null
56 | }
57 |
58 | readonly $dimensions = map<{ width: number; height: number }>({
59 | width: 720,
60 | height: 640,
61 | })
62 | readonly $coordinates = map<{ x: number; y: number }>({
63 | x: 0,
64 | y: 0,
65 | })
66 | readonly $info = map<{ label: string }>({
67 | label: 'New Page',
68 | })
69 |
70 | get dimensions() {
71 | return this.$dimensions.get()
72 | }
73 |
74 | set dimensions(value: { width: number; height: number }) {
75 | this.$dimensions.set(value)
76 | }
77 |
78 | get coordinates() {
79 | return this.$coordinates.get()
80 | }
81 |
82 | set coordinates(value: { x: number; y: number }) {
83 | this.$coordinates.set(value)
84 | }
85 |
86 | get ownerPage(): PageNode {
87 | return this
88 | }
89 |
90 | remove(): RemoveNodeAction {
91 | studioApp.removePage(this)
92 | return new RemoveNodeAction({
93 | removedNode: this,
94 | oldParent: null,
95 | oldNextSibling: null,
96 | })
97 | }
98 |
99 | generateCode(): string {
100 | if (this.children.length === 0) {
101 | return 'null'
102 | }
103 |
104 | const openTag = this.children.length === 1 ? '' : '<>'
105 | const closeTag = this.children.length === 1 ? '' : '>'
106 |
107 | return `${openTag}${this.children.map((child) => child.generateCode()).join('')}${closeTag}`
108 | }
109 |
110 | clone(): PageNode {
111 | return new PageNode({
112 | library: this.library,
113 | nodeName: this.nodeName,
114 | props: this.$props.get(),
115 | style: this.$style.get(),
116 | children: this.children.map((child) => child.clone()),
117 | })
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/node-class/view.tsx:
--------------------------------------------------------------------------------
1 | import { map } from 'nanostores'
2 | import { Node } from './node'
3 |
4 | export class ViewNode extends Node {
5 | readonly nodeName = 'View'
6 |
7 | $additionalProps = map<{
8 | label: string
9 | }>({
10 | label: 'View',
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/src/node-component.module.scss:
--------------------------------------------------------------------------------
1 | .nodeComponentWrapper {
2 | // Allow interactive node handling while keeping the layout of the node
3 | display: contents;
4 | }
5 |
--------------------------------------------------------------------------------
/src/node-definition.ts:
--------------------------------------------------------------------------------
1 | import { Node } from './node-class/node'
2 |
3 | export type PropFormat =
4 | | {
5 | type: 'string' | 'number' | 'boolean' | 'object'
6 | }
7 | | {
8 | type: 'options'
9 | options: (
10 | | {
11 | value: string
12 | label: string
13 | }
14 | | string
15 | )[]
16 | }
17 |
18 | export type Prop = {
19 | key: string
20 | label?: string
21 | // TODO: union
22 | format: PropFormat
23 | required?: boolean
24 | default?: any
25 | props?: Prop[]
26 | }
27 |
28 | /**
29 | * TODO: ReactNode as a prop is not supported yet.
30 | */
31 | export type NodeDefinition = {
32 | /**
33 | * Unique identifier for the node.
34 | */
35 | nodeName: string
36 | /**
37 | * Component import signature.
38 | *
39 | * `import { mod } from 'lib'`
40 | *
41 | * If `mod` is not provided, `nodeName` will be used.
42 | */
43 | mod?: string
44 | /**
45 | * Sub component signature.
46 | *
47 | * @example lib.mod = 'Dialog', componentName = 'Dialog.Root'
48 | */
49 | sub?: string
50 | /**
51 | * Display name for the node.
52 | */
53 | displayName?: string
54 | fragment?: boolean
55 | unselectable?: boolean
56 | /**
57 | * If true, it is not droppable which means it can't have children.
58 | */
59 | leaf?: boolean
60 | portal?: boolean
61 | /**
62 | * If true, it should be rendered inside parent directly instead of passing through renderChildren function.
63 | */
64 | directChild?: boolean
65 | /**
66 | * If true, every child should be rendered directly inside parent.
67 | */
68 | allChildrenDirect?: boolean
69 | gapless?: boolean
70 | props?: Prop[]
71 | generateCode?: (node: Node) => string
72 | }
73 |
--------------------------------------------------------------------------------
/src/node-lib.tsx:
--------------------------------------------------------------------------------
1 | import { Node } from './node-class/node'
2 | import { studioApp } from './studio-app'
3 |
4 | // TODO: handle direct children. display: contents vs direct
5 |
6 | export function getClosestNodeFromElm(elm: Element): Node | null {
7 | // If the target is the body element, it means the target is the page node itself
8 | if (elm.isSameNode(elm.ownerDocument.body)) {
9 | if (!elm.ownerDocument.body.id.startsWith('node-')) {
10 | return null
11 | }
12 |
13 | const nodeId = elm.ownerDocument.body.id.split('-')[1]
14 |
15 | if (!nodeId) {
16 | return null
17 | }
18 |
19 | return studioApp.getNodeById(nodeId)!
20 | }
21 |
22 | const idElm = elm.closest(`[id^="node-"]`)
23 |
24 | if (idElm === null) {
25 | return null
26 | }
27 |
28 | const nodeId = idElm.id.split('-')[1]
29 |
30 | if (!nodeId) {
31 | throw new Error('Node does not have an id')
32 | }
33 |
34 | const node = studioApp.getNodeById(nodeId)
35 |
36 | if (!node) {
37 | return null
38 | }
39 |
40 | return node
41 | }
42 |
43 | /**
44 | * Some containable nodes don't allow to select children nodes. (temporarily)
45 | *
46 | * For example, Text inside Flex is selectable while Text inside Button is not selectable.
47 | */
48 | export function getClosestSelectableNodeFromElm(elm: Element): Node | null {
49 | const closestNode = getClosestNodeFromElm(elm)
50 |
51 | if (!closestNode) {
52 | return null
53 | }
54 |
55 | if (!closestNode.isSelectable) {
56 | // Page node element is always body element.
57 | // But other node elements are first element child of the node wrapper element.
58 | return getClosestSelectableNodeFromElm(
59 | closestNode.element?.parentElement?.parentElement ?? document.body,
60 | )
61 | }
62 |
63 | return closestNode
64 | }
65 |
66 | export function getClosestDraggableNodeSet(elm: Element): Node | null {
67 | const closestNode = getClosestNodeFromElm(elm)
68 |
69 | if (!closestNode) {
70 | return null
71 | }
72 |
73 | if (!closestNode.isDraggable) {
74 | // Page node element is always body element.
75 | // But other node elements are first element child of the node wrapper element.
76 | return getClosestDraggableNodeSet(
77 | closestNode.element?.parentElement?.parentElement ?? document.body,
78 | )
79 | }
80 |
81 | return closestNode
82 | }
83 |
84 | /**
85 | * TODO: check with node property instead of node name
86 | */
87 | export function isUnwrappableNode(node: Node) {
88 | return false
89 |
90 | // if (node.children.length === 0) return false
91 |
92 | // const nodeName = node.nodeName
93 | // const unwrappableNodeNames: NodeName[] = ['RadixFlex', 'RadixContainer']
94 |
95 | // return unwrappableNodeNames.includes(nodeName)
96 | }
97 |
--------------------------------------------------------------------------------
/src/record.ts:
--------------------------------------------------------------------------------
1 | // import { map } from 'nanostores'
2 |
3 | // export type AddRecord = {
4 | // type: 'add'
5 | // $inserted: NodeAtom
6 | // $parent: NodeAtom
7 | // before?: string
8 | // }
9 |
10 | // export type DeleteRecord = {
11 | // type: 'delete'
12 | // $deleted: NodeAtom
13 | // $parent: NodeAtom
14 | // before?: string
15 | // }
16 |
17 | // export type MoveRecord = {
18 | // type: 'move'
19 | // $moved: NodeAtom
20 | // $from: NodeAtom
21 | // fromNextSiblingId?: string
22 | // $to: NodeAtom
23 | // toNextSiblingId?: string
24 | // }
25 |
26 | // export type Record = AddRecord | MoveRecord
27 |
28 | // const $recordsHistory = map<{
29 | // recordsBundle: Record[][]
30 | // currentIndex: number
31 | // }>({
32 | // recordsBundle: [],
33 | // currentIndex: -1,
34 | // })
35 |
36 | // $recordsHistory.subscribe(() => {
37 | // console.log($recordsHistory.get())
38 | // })
39 |
40 | // export function recordActionsBundle(actions: Record[]) {
41 | // const { recordsBundle, currentIndex } = $recordsHistory.get()
42 |
43 | // $recordsHistory.set({
44 | // recordsBundle: [...recordsBundle.slice(0, currentIndex + 1), actions],
45 | // currentIndex: currentIndex + 1,
46 | // })
47 | // }
48 |
49 | // export function undo() {
50 | // const { recordsBundle, currentIndex } = $recordsHistory.get()
51 |
52 | // const lastActions = recordsBundle[currentIndex]
53 |
54 | // if (lastActions) {
55 | // lastActions.forEach(undoSingleAction)
56 | // $recordsHistory.setKey('currentIndex', currentIndex - 1)
57 | // }
58 | // }
59 |
60 | // function undoSingleAction(action: Record) {
61 | // switch (action.type) {
62 | // case 'add':
63 | // deleteNode(action.$inserted)
64 | // break
65 | // case 'move':
66 | // const { $moved, $from, fromNextSiblingId } = action
67 | // insertNodeBefore($moved, $from, fromNextSiblingId)
68 | // break
69 | // }
70 | // }
71 |
72 | // export function redo() {
73 | // const { recordsBundle, currentIndex } = $recordsHistory.get()
74 |
75 | // const nextActions = recordsBundle[currentIndex + 1]
76 |
77 | // if (nextActions) {
78 | // nextActions.forEach(redoSingleAction)
79 | // $recordsHistory.setKey('currentIndex', currentIndex + 1)
80 | // }
81 | // }
82 |
83 | // function redoSingleAction(action: Record) {
84 | // switch (action.type) {
85 | // case 'add':
86 | // console.log('redo add')
87 | // const { $inserted, $parent, before } = action
88 | // insertNodeBefore($inserted, $parent, before)
89 | // // $selectedNodeAtoms.set([$inserted])
90 | // break
91 | // }
92 | // }
93 |
--------------------------------------------------------------------------------
/src/serial.ts:
--------------------------------------------------------------------------------
1 | import { studioApp } from './studio-app'
2 |
3 | export function serializeApp() {
4 | return studioApp.pages.map((page) => page.serialize())
5 | }
6 |
--------------------------------------------------------------------------------
/src/serialize-props.ts:
--------------------------------------------------------------------------------
1 | import { Prop } from './node-definition'
2 |
3 | /**
4 | * TODO: get required information and remove props that are falsy and not required
5 | */
6 | export function serializeProps(
7 | props: Record,
8 | propsDefinition: Prop[],
9 | ) {
10 | return Object.entries(props)
11 | .map(([key, value]) => {
12 | const def = propsDefinition.find((def) => def.key === key)
13 |
14 | // If the prop is optional and the value is same as default, remove it.
15 | if (def && def.required !== true && def.default === value) {
16 | return ''
17 | }
18 |
19 | if (typeof value === 'string') {
20 | if (value === '' && def?.required !== true) {
21 | return ''
22 | }
23 |
24 | return `${key}="${value}"`
25 | }
26 |
27 | if (typeof value === 'boolean') {
28 | return `${key}={${value}}`
29 | }
30 |
31 | if (typeof value === 'number') {
32 | return `${key}={${value}}`
33 | }
34 |
35 | if (typeof value === 'object') {
36 | return `${key}={${JSON.stringify(value)}}`
37 | }
38 |
39 | return ''
40 | })
41 | .join(' ')
42 | }
43 |
--------------------------------------------------------------------------------
/src/shortcuts-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '@nanostores/react'
2 | import { Box, Button, Dialog, Flex, Grid, Kbd, Text } from '@radix-ui/themes'
3 | import { $shortcutsDialogOpen } from './atoms'
4 |
5 | const shortcuts: { action: string; key: string }[] = [
6 | {
7 | action: 'Add a Page',
8 | key: '⌘ P',
9 | },
10 | {
11 | action: 'Remove Node',
12 | key: '⌫',
13 | },
14 | {
15 | action: 'Toggle Design Mode',
16 | key: '⌘ D',
17 | },
18 | {
19 | action: 'Toggle Interaction Mode',
20 | key: '⌘ I',
21 | },
22 | {
23 | action: 'Toggle Text Node Editing',
24 | key: '⌘ E',
25 | },
26 | {
27 | action: 'Toggle Dev Tools',
28 | key: '⌘ V',
29 | },
30 | {
31 | action: 'Undo',
32 | key: '⌘ Z',
33 | },
34 | {
35 | action: 'Redo',
36 | key: '⇧ ⌘ Z',
37 | },
38 | {
39 | action: 'Zoom In',
40 | key: '⌘ +',
41 | },
42 | {
43 | action: 'Zoom Out',
44 | key: '⌘ -',
45 | },
46 | {
47 | action: 'Reset Zoom',
48 | key: '⌘ 0',
49 | },
50 | ]
51 |
52 | export function ShortcutsDialog() {
53 | const open = useStore($shortcutsDialogOpen)
54 |
55 | return (
56 | $shortcutsDialogOpen.set(o)}>
57 |
58 | Keyboard Shortcuts
59 |
60 |
61 | {shortcuts.map((shortcut) => (
62 |
63 |
64 | {shortcut.action}
65 |
66 |
67 | {shortcut.key}
68 |
69 |
70 |
71 |
72 | ))}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/src/shortcuts.ts:
--------------------------------------------------------------------------------
1 | type Alphabet =
2 | | 'a'
3 | | 'b'
4 | | 'c'
5 | | 'd'
6 | | 'e'
7 | | 'f'
8 | | 'g'
9 | | 'h'
10 | | 'i'
11 | | 'j'
12 | | 'k'
13 | | 'l'
14 | | 'm'
15 | | 'n'
16 | | 'o'
17 | | 'p'
18 | | 'q'
19 | | 'r'
20 | | 's'
21 | | 't'
22 | | 'u'
23 | | 'v'
24 | | 'w'
25 | | 'x'
26 | | 'y'
27 | | 'z'
28 |
29 | export type MetaKey = 'shift' | 'ctrl' | 'alt' | 'cmd'
30 |
31 | export type Shortcut =
32 | | `${MetaKey}+${Alphabet}`
33 | | `${MetaKey}+${MetaKey}+${Alphabet}`
34 | | `${MetaKey}+${MetaKey}+${MetaKey}+${Alphabet}`
35 | | `${MetaKey}+${MetaKey}+${MetaKey}+${MetaKey}+${Alphabet}`
36 |
--------------------------------------------------------------------------------
/src/studio-app.tsx:
--------------------------------------------------------------------------------
1 | import { atom, computed } from 'nanostores'
2 | import { Library } from './library'
3 | import { Node, SerializedNode } from './node-class/node'
4 | import { PageNode } from './node-class/page'
5 | import { ViewNode } from './node-class/view'
6 | import { NodeDefinition } from './node-definition'
7 |
8 | type InitApp = Omit & {
9 | pages?: PageNode[]
10 | views?: ViewNode[]
11 | }
12 |
13 | export class StudioApp {
14 | public allNodes: Record = {}
15 |
16 | private _$pages = atom([])
17 | private _$views = atom([])
18 |
19 | readonly $appTitle = atom('Studio App')
20 | readonly $libraries = atom([]) // TODO: auto set latest version
21 |
22 | public readonly $pages = computed(this._$pages, (pages) => pages)
23 | public readonly $views = computed(this._$views, (views) => views)
24 |
25 | $isReady = atom(false)
26 |
27 | private nodeDefinitions: Record> = {}
28 |
29 | constructor() {
30 | this._$pages.subscribe((newPages, oldPages) => {
31 | if (oldPages) {
32 | oldPages.forEach((page) => {
33 | delete this.allNodes[page.id]
34 | })
35 | }
36 |
37 | newPages.forEach((page) => {
38 | this.allNodes[page.id] = page
39 | })
40 | })
41 | }
42 |
43 | initialize(initApp: InitApp) {
44 | this.$appTitle.set(initApp.appTitle)
45 | this.$libraries.set(initApp.libraries)
46 |
47 | if (initApp.pages) {
48 | this._$pages.set(initApp.pages)
49 | }
50 |
51 | if (initApp.views) {
52 | this._$views.set(initApp.views)
53 | }
54 |
55 | Promise.allSettled(
56 | initApp.libraries.map((library) => StudioApp.loadDefinition(library)),
57 | ).then((definitions) => {
58 | if (definitions.some((def) => def.status === 'rejected')) {
59 | throw new Error('Failed to load node definitions')
60 | }
61 |
62 | this.nodeDefinitions = definitions.reduce((acc, def, i) => {
63 | const library = initApp.libraries[i]
64 | const key = `${library.name}-${library.version}`
65 |
66 | if (def.status === 'fulfilled') {
67 | return { ...acc, [key]: def.value }
68 | }
69 |
70 | return acc
71 | }, {})
72 |
73 | this.$isReady.set(true)
74 | })
75 | }
76 |
77 | getNodeDefinition(library: Library, nodeName: string) {
78 | const libraryKey = `${library.name}-${library.version}`
79 | const nodeDefinition = this.nodeDefinitions[libraryKey]?.[nodeName]
80 |
81 | if (!nodeDefinition) {
82 | throw new Error(
83 | `Node definition is not found: ${library.name}-${library.version} ${nodeName}`,
84 | )
85 | }
86 |
87 | return nodeDefinition
88 | }
89 |
90 | getNodeById(nodeId: string) {
91 | const node = this.allNodes[nodeId]
92 |
93 | if (!node) {
94 | console.group('getRenderedNodeById - NOT FOUND')
95 | console.log('allNodes', studioApp.allNodes)
96 | console.warn(`Node is not registered in the app: ${nodeId}`)
97 | console.groupEnd()
98 | return null
99 | }
100 |
101 | return node
102 | }
103 |
104 | get pages() {
105 | return this._$pages.get()
106 | }
107 |
108 | addPage(page: PageNode) {
109 | this._$pages.set([...this.pages, page])
110 |
111 | this.allNodes[page.id] = page
112 | }
113 |
114 | insertPageBefore(page: PageNode, beforePage: PageNode | null) {
115 | if (beforePage === null) {
116 | this.addPage(page)
117 | return
118 | }
119 |
120 | const index = this.pages.indexOf(beforePage)
121 | const pages = [...this.pages]
122 | pages.splice(index, 0, page)
123 |
124 | this.allNodes[page.id] = page
125 | }
126 |
127 | removePage(page: PageNode) {
128 | this._$pages.set(this.pages.filter((p) => p !== page))
129 |
130 | delete this.allNodes[page.id]
131 | }
132 |
133 | get views() {
134 | return this._$views.get()
135 | }
136 |
137 | addView(view: ViewNode) {
138 | this._$views.set([...this.views, view])
139 | }
140 |
141 | removeView(view: ViewNode) {
142 | this._$views.set(this.views.filter((v) => v !== view))
143 | }
144 |
145 | serialize(): SerializedApp {
146 | return {
147 | studioVersion: '1.0.0',
148 | appTitle: this.$appTitle.get(),
149 | libraries: this.$libraries.get(),
150 | pages: this.pages.map((page) => page.serialize()),
151 | views: this.views.map((view) => view.serialize()),
152 | }
153 | }
154 |
155 | static async loadDefinition(library: Library) {
156 | try {
157 | const libraryKey = `${library.name}-${library.version}`
158 | const { nodeDefinitions } = await import(
159 | `@/libraries/${libraryKey}/node-definitions`
160 | )
161 |
162 | return nodeDefinitions as Record
163 | } catch (e) {
164 | throw new Error(
165 | `Failed to load node definitions from ${library.name}-${library.version}`,
166 | )
167 | }
168 | }
169 | }
170 |
171 | export type SerializedApp = {
172 | studioVersion: string
173 | appTitle: string
174 | libraries: Library[]
175 | pages: SerializedNode[]
176 | views: SerializedNode[]
177 | }
178 |
179 | /**
180 | * The global default StudioApp instance
181 | */
182 | export const studioApp = new StudioApp()
183 |
--------------------------------------------------------------------------------
/src/tree/tree.module.scss:
--------------------------------------------------------------------------------
1 | .tree {
2 | position: absolute;
3 | top: 50%;
4 | right: 0;
5 | bottom: 0;
6 | border-top: 1px solid var(--gray-a6);
7 | width: 100%;
8 | background-color: var(--color-background);
9 | }
10 |
11 | .treeNodeContainer {
12 | --tree-node-height: 28px;
13 |
14 | position: relative;
15 | border-radius: 0 var(--radius-2) var(--radius-2) 0;
16 |
17 | // &::before {
18 | // content: '';
19 | // z-index: 1;
20 | // position: absolute;
21 | // top: 0;
22 | // left: 0;
23 | // bottom: 0;
24 | // width: 1px;
25 | // background-color: var(--gray-4);
26 | // pointer-events: none;
27 | // }
28 |
29 | &.hovered {
30 | background-color: var(--orange-3);
31 | // box-shadow: inset 0 0 0 1.5px var(--orange-9);
32 |
33 | &::before {
34 | background-color: var(--orange-9);
35 | }
36 | }
37 |
38 | @at-root :global(.dragging-node) &:hover {
39 | box-shadow: none;
40 | }
41 |
42 | &.selected {
43 | background-color: var(--blue-2);
44 | // box-shadow: inset 0 0 0 1.5px var(--blue-9);
45 |
46 | & > .treeNode {
47 | background-color: var(--blue-3);
48 | box-shadow: none;
49 | }
50 |
51 | &::before {
52 | background-color: var(--blue-9);
53 | }
54 | }
55 | }
56 |
57 | .treeNode {
58 | white-space: nowrap;
59 | height: var(--tree-node-height);
60 |
61 | &.hovered {
62 | // background-color: var(--orange-3);
63 | box-shadow: inset 0 0 0 1px var(--orange-9);
64 | }
65 |
66 | &.selected {
67 | background-color: var(--blue-3);
68 | }
69 | }
70 |
71 | .intervenedDropZoneWrapper {
72 | position: relative;
73 | pointer-events: none;
74 | height: 0px;
75 |
76 | @at-root :global(.dragging-node) & {
77 | pointer-events: auto;
78 | }
79 |
80 | .intervenedDropZone {
81 | position: absolute;
82 | top: 0;
83 | left: 8px;
84 | right: 0;
85 | height: 6px;
86 | transform: translateY(-50%);
87 | // background-color: var(--green-a3);
88 | }
89 | }
90 |
91 | .chevron {
92 | transition: transform 150ms;
93 |
94 | &.folded {
95 | transform: rotate(-90deg);
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/tree/tree.tsx:
--------------------------------------------------------------------------------
1 | import { $hoveredNode, $isDraggingNode, $selectedNodes } from '@/atoms'
2 | import {
3 | keepNodeSelectionAttribute,
4 | makeDropZoneAttributes,
5 | makeNodeAttrs,
6 | } from '@/data-attributes'
7 | import { EditorState } from '@/editor-state'
8 | import {
9 | onMouseDownForDragAndDropNode,
10 | onMouseDownForSelecting,
11 | } from '@/events'
12 | import { Ground } from '@/ground'
13 | import { Node } from '@/node-class/node'
14 | import { PageNode } from '@/node-class/page'
15 | import { studioApp } from '@/studio-app'
16 | import { useStore } from '@nanostores/react'
17 | import { ChevronDownIcon, DotIcon } from '@radix-ui/react-icons'
18 | import { Box, Flex, ScrollArea, Text } from '@radix-ui/themes'
19 | import clsx from 'clsx'
20 | import { useEffect, useRef } from 'react'
21 | import styles from './tree.module.scss'
22 |
23 | /**
24 | * TODO: lock, hide, rename by double click
25 | */
26 | export function Tree() {
27 | const ref = useRef(null!)
28 | const pages = useStore(studioApp.$pages)
29 |
30 | useEffect(() => {
31 | const unsubscribeHoveredNode = $hoveredNode.subscribe((hoveredNode) => {
32 | const elements = ref.current.querySelectorAll(`.${styles.hovered}`)
33 |
34 | elements.forEach((element) => {
35 | element.classList.remove(styles.hovered)
36 | })
37 |
38 | const dom = document.getElementById(`tree-node-${hoveredNode?.id}`)
39 |
40 | dom?.classList.add(styles.hovered)
41 | })
42 |
43 | const unsubscribeSelectionNodes = $selectedNodes.subscribe(
44 | (selectedNodes) => {
45 | process.nextTick(() => {
46 | const elements = ref.current.querySelectorAll(`.${styles.selected}`)
47 |
48 | elements.forEach((element) => {
49 | element.classList.remove(styles.selected)
50 | })
51 |
52 | const doms = selectedNodes.map((node) => {
53 | return document.getElementById(`tree-node-container-${node.id}`)
54 | })
55 |
56 | doms.forEach((dom) => dom?.classList.add(styles.selected))
57 | })
58 | },
59 | )
60 |
61 | return () => {
62 | unsubscribeHoveredNode()
63 | unsubscribeSelectionNodes()
64 | }
65 | }, [])
66 |
67 | return (
68 |
69 |
70 |
71 | {pages.map((page) => (
72 |
73 | ))}
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | function NodeTree({ node, depth }: { node: Node; depth: number }) {
81 | const treeFolded = useStore(EditorState.$treeFoldedNodes, { keys: [node.id] })
82 | const children = useStore(node.$children)
83 |
84 | const nodeLabel = node.nodeName
85 |
86 | return (
87 | <>
88 | {
94 | if (node instanceof PageNode) {
95 | Ground.focus(node, true)
96 | }
97 | }}
98 | onMouseDown={(e) => {
99 | e.stopPropagation()
100 |
101 | const elm = document.getElementById(`tree-node-container-${node.id}`)!
102 | const elmRect = elm.getBoundingClientRect()
103 |
104 | onMouseDownForSelecting(e, node)
105 |
106 | onMouseDownForDragAndDropNode(e, {
107 | draggingNodes: $selectedNodes
108 | .get()
109 | .filter((node) => !(node instanceof PageNode)),
110 | cloneTargetElm: elm,
111 | elmX: e.clientX - elmRect.left,
112 | elmY: e.clientY - elmRect.top,
113 | elementScale: 1,
114 | draggingElm: elm,
115 | })
116 | }}
117 | >
118 | {
126 | if ($isDraggingNode.get()) return
127 |
128 | e.stopPropagation()
129 | $hoveredNode.set(node)
130 | }}
131 | onMouseLeave={(e) => {
132 | e.stopPropagation()
133 | $hoveredNode.set(null)
134 | }}
135 | >
136 | {
142 | if (!node.isDroppable) return
143 |
144 | e.stopPropagation()
145 |
146 | EditorState.$treeFoldedNodes.setKey(node.id, !treeFolded[node.id])
147 | }}
148 | >
149 | {!node.isDroppable || node.children.length === 0 ? (
150 | //
151 |
152 | ) : (
153 |
160 | )}
161 |
162 | {nodeLabel}
163 |
164 |
165 | {!treeFolded[node.id] && children.length > 0 && (
166 | <>
167 | {/* A intervened drop zone before the first child node */}
168 |
181 | {children.map((node) => (
182 |
183 | ))}
184 | >
185 | )}
186 |
187 |
188 | {/* A intervened drop zone after the child */}
189 |
202 | >
203 | )
204 | }
205 |
--------------------------------------------------------------------------------
/src/types/extract-generic.ts:
--------------------------------------------------------------------------------
1 | import { MapStore } from 'nanostores'
2 |
3 | export type ExtractMapStoreGeneric = T extends MapStore ? V : never
4 |
--------------------------------------------------------------------------------
/src/types/guide-dimension.ts:
--------------------------------------------------------------------------------
1 | export type GuideDimension = {
2 | top: number
3 | left: number
4 | width: number
5 | height: number
6 | }
7 |
--------------------------------------------------------------------------------
/src/ui-guides/drop-zone-guide.module.scss:
--------------------------------------------------------------------------------
1 | .dropZoneGuide {
2 | box-shadow: 0 0 0 1.8px var(--green-8);
3 |
4 | background-color: var(--green-a2);
5 |
6 | position: fixed;
7 | z-index: 1000;
8 |
9 | pointer-events: none;
10 | }
11 |
--------------------------------------------------------------------------------
/src/ui-guides/drop-zone-guide.tsx:
--------------------------------------------------------------------------------
1 | import { $dropZone } from '@/atoms'
2 | import { Ground } from '@/ground'
3 | import { useStore } from '@nanostores/react'
4 | import styles from './drop-zone-guide.module.scss'
5 |
6 | export function DropZoneGuide() {
7 | const scale = useStore(Ground.$scale)
8 | const dropZone = useStore($dropZone)
9 |
10 | if (!dropZone) return null
11 |
12 | const { targetNode, dropZoneElm } = dropZone
13 |
14 | const dropZoneRect = dropZoneElm.getBoundingClientRect()
15 | const iframe = targetNode.ownerPage!.iframeElement
16 |
17 | if (iframe) {
18 | const iframeRect = iframe.getBoundingClientRect()
19 |
20 | const width = dropZoneRect.width
21 | const height = dropZoneRect.height
22 |
23 | if (width === 0 || height === 0) return null
24 |
25 | if (dropZoneElm instanceof HTMLElement) {
26 | return (
27 |
36 | )
37 | }
38 |
39 | return (
40 |
49 | )
50 | }
51 |
52 | return (
53 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/ui-guides/hover-guide.module.scss:
--------------------------------------------------------------------------------
1 | .hoverGuide {
2 | box-shadow: 0 0 0 1.5px var(--orange-9);
3 |
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | z-index: 1;
8 |
9 | pointer-events: none;
10 |
11 | will-change: transform;
12 |
13 | // transition: all 250ms cubic-bezier(0.76, 0.11, 0, 0.99);
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui-guides/hover-guide.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $hoverRerenderFlag,
3 | $hoveredNode,
4 | $isAnimatingGround,
5 | $isDraggingNode,
6 | } from '@/atoms'
7 | import { Ground } from '@/ground'
8 | import { PageNode } from '@/node-class/page'
9 | import { GuideDimension } from '@/types/guide-dimension'
10 | import { useStore } from '@nanostores/react'
11 | import { useEffect, useState } from 'react'
12 | import styles from './hover-guide.module.scss'
13 |
14 | const initialDimension: GuideDimension = {
15 | top: 0,
16 | left: 0,
17 | width: 0,
18 | height: 0,
19 | }
20 |
21 | export function HoverGuide() {
22 | const isDraggingNode = useStore($isDraggingNode)
23 | const isMovingGround = useStore($isAnimatingGround)
24 | const hoverRerenderFlag = useStore($hoverRerenderFlag)
25 | const scale = useStore(Ground.$scale)
26 | const translate = useStore(Ground.$translate)
27 | const hoveredNode = useStore($hoveredNode)
28 | const [dimension, setDimension] = useState(initialDimension)
29 |
30 | const [isInvisible, setIsInvisible] = useState(
31 | isDraggingNode || isMovingGround,
32 | )
33 |
34 | useEffect(() => {
35 | if (!hoveredNode || !hoveredNode.ownerPage) {
36 | setDimension(initialDimension)
37 | return
38 | }
39 |
40 | const iframe = hoveredNode.ownerPage.iframeElement
41 | const nodeElm = hoveredNode.element
42 |
43 | if (!iframe || !nodeElm) {
44 | setDimension(initialDimension)
45 | return
46 | }
47 |
48 | const iframeRect = iframe.getBoundingClientRect()
49 | const nodeRect = nodeElm.getBoundingClientRect()
50 |
51 | if (hoveredNode instanceof PageNode) {
52 | setDimension({
53 | top: iframeRect.top,
54 | left: iframeRect.left,
55 | width: iframeRect.width,
56 | height: iframeRect.height,
57 | })
58 | } else {
59 | setDimension({
60 | top: nodeRect.top * scale + iframeRect.top,
61 | left: nodeRect.left * scale + iframeRect.left,
62 | width: nodeRect.width * scale,
63 | height: nodeRect.height * scale,
64 | })
65 | }
66 |
67 | setIsInvisible(isDraggingNode || isMovingGround)
68 | }, [
69 | hoveredNode,
70 | scale,
71 | translate,
72 | hoverRerenderFlag,
73 | isMovingGround,
74 | isDraggingNode,
75 | ])
76 |
77 | if (!hoveredNode) {
78 | return null
79 | }
80 |
81 | if (dimension.width === 0 || dimension.height === 0) {
82 | return null
83 | }
84 |
85 | if (isMovingGround || isDraggingNode) return null
86 |
87 | return (
88 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/src/ui-guides/selection-guide.module.scss:
--------------------------------------------------------------------------------
1 | .selectionGuide {
2 | box-shadow: 0 0 0 1.8px var(--indigo-11);
3 |
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | z-index: 1;
8 |
9 | pointer-events: none;
10 |
11 | will-change: transform;
12 |
13 | // transition: all 200ms cubic-bezier(0.76, 0.11, 0, 0.99);
14 | }
15 |
--------------------------------------------------------------------------------
/src/ui-guides/selection-guide.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $designMode,
3 | $hoveredNode,
4 | $isAnimatingGround,
5 | $isDraggingNode,
6 | $selectedNodes,
7 | $selectionRerenderFlag,
8 | } from '@/atoms'
9 | import { Ground } from '@/ground'
10 | import { Node } from '@/node-class/node'
11 | import { PageNode } from '@/node-class/page'
12 | import { GuideDimension } from '@/types/guide-dimension'
13 | import { useStore } from '@nanostores/react'
14 | import { useEffect, useState } from 'react'
15 | import styles from './selection-guide.module.scss'
16 |
17 | export function selectNode(
18 | e: MouseEvent | KeyboardEvent | React.MouseEvent | React.KeyboardEvent,
19 | willSelectNode: Node,
20 | ) {
21 | if (e.metaKey || e.ctrlKey || e.shiftKey) {
22 | const selectedNodes = $selectedNodes.get()
23 |
24 | if (selectedNodes.includes(willSelectNode)) {
25 | $selectedNodes.set(
26 | selectedNodes.filter((node) => node !== willSelectNode),
27 | )
28 | } else {
29 | $selectedNodes.set([...selectedNodes, willSelectNode])
30 | }
31 | } else {
32 | $selectedNodes.set([willSelectNode])
33 | }
34 | }
35 |
36 | type SelectionGuide = GuideDimension & {
37 | node: Node
38 | }
39 |
40 | export function SelectionGuide() {
41 | useStore($designMode)
42 |
43 | const isDraggingNode = useStore($isDraggingNode)
44 | const isMovingGround = useStore($isAnimatingGround)
45 | const selectionRerenderFlag = useStore($selectionRerenderFlag)
46 |
47 | const [dimensions, setDimensions] = useState<(SelectionGuide | null)[]>([])
48 |
49 | const [isInvisible, setIsInvisible] = useState(
50 | isDraggingNode || isMovingGround,
51 | )
52 |
53 | const scale = useStore(Ground.$scale)
54 | const translate = useStore(Ground.$translate)
55 |
56 | const hoveredNode = useStore($hoveredNode)
57 | const selectedNodes = useStore($selectedNodes)
58 |
59 | useEffect(() => {
60 | const newDimensions = selectedNodes.map((node) => {
61 | if (!node.ownerPage) return null
62 |
63 | const iframe = node.ownerPage.iframeElement
64 | const nodeElm = node.element
65 |
66 | if (!iframe || !nodeElm) {
67 | return null
68 | }
69 |
70 | const iframeRect = iframe.getBoundingClientRect()
71 | const nodeRect = nodeElm.getBoundingClientRect()
72 |
73 | if (node instanceof PageNode) {
74 | return {
75 | top: iframeRect.top,
76 | left: iframeRect.left,
77 | width: iframeRect.width,
78 | height: iframeRect.height,
79 | node,
80 | }
81 | }
82 |
83 | const dimension: SelectionGuide = {
84 | top: nodeRect.top * scale + iframeRect.top,
85 | left: nodeRect.left * scale + iframeRect.left,
86 | width: nodeRect.width * scale,
87 | height: nodeRect.height * scale,
88 | node,
89 | }
90 |
91 | return dimension
92 | })
93 |
94 | setDimensions(newDimensions)
95 |
96 | setIsInvisible(isDraggingNode || isMovingGround)
97 | }, [
98 | selectedNodes,
99 | scale,
100 | translate,
101 | selectionRerenderFlag,
102 | isMovingGround,
103 | isDraggingNode,
104 | ])
105 |
106 | return dimensions.map((dimension, i) => {
107 | if (!dimension || dimension.width === 0 || dimension.height === 0)
108 | return null
109 |
110 | return (
111 |
122 | )
123 | })
124 | }
125 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules", ".next"]
27 | }
28 |
--------------------------------------------------------------------------------