@lupinum/board-core 1.0.0-beta.4 → 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +46 -25
- package/dist/agent/AGENTS.md +53 -57
- package/dist/agent/pages/docs/build-features/connections.md +333 -2
- package/dist/agent/pages/docs/build-features/custom-node-renderers.md +366 -2
- package/dist/agent/pages/docs/build-features/read-only-and-command-guards.md +278 -2
- package/dist/agent/pages/docs/build-features/save-and-load.md +264 -2
- package/dist/agent/pages/docs/build-features/selection-and-keyboard.md +257 -2
- package/dist/agent/pages/docs/build-features/theming.md +285 -2
- package/dist/agent/pages/docs/evaluate/how-nuxt-board-works.md +451 -2
- package/dist/agent/pages/docs/project/contributing.md +4 -4
- package/dist/agent/pages/docs/project/support-and-security.md +1 -1
- package/dist/agent/pages/docs/reference/events-and-errors.md +279 -2
- package/dist/agent/pages/docs/reference/package-overview.md +2 -2
- package/dist/agent/pages/docs/solutions/mind-map.md +448 -2
- package/dist/agent/pages/docs/solutions/nuxt-application.md +214 -2
- package/dist/agent/pages/docs/solutions/planning-board.md +281 -2
- package/dist/agent/pages/docs/solutions/read-only-viewer.md +278 -2
- package/dist/agent/pages/docs/solutions/workflow-builder.md +435 -2
- package/dist/agent/pages/docs/start-building/customize-your-first-node.md +381 -2
- package/dist/agent/pages/docs/start-building/installation.md +6 -14
- package/dist/agent/pages/docs/understand-the-system/commands-and-transactions.md +456 -2
- package/dist/agent/pages/docs/understand-the-system/document-and-session-state.md +348 -2
- package/dist/agent/pages/docs/understand-the-system/persistence-and-json-canvas.md +445 -2
- package/package.json +3 -2
- package/dist/agent/manifest.json +0 -320
|
@@ -15,8 +15,372 @@ source: "ginko-content"
|
|
|
15
15
|
|
|
16
16
|
Custom renderers make nodes look like your product, not the default text box. The board shell still handles positioning, selection, resize handles, and z-ordering — your renderer only draws content.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
## Live example source
|
|
19
|
+
|
|
20
|
+
Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
|
|
21
|
+
|
|
22
|
+
### app/components/demos/RendererBoardDemo.vue
|
|
23
|
+
|
|
24
|
+
````vue
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { onMounted } from 'vue'
|
|
27
|
+
import { createBoardEngine } from '@lupinum/board-core'
|
|
28
|
+
import type { BoardRendererRegistry } from '@lupinum/vue-board'
|
|
29
|
+
import { createDemoDocument } from '../../utils/demoDocument'
|
|
30
|
+
import DocsInsightNode from './DocsInsightNode.vue'
|
|
31
|
+
import DocsMetricNode from './DocsMetricNode.vue'
|
|
32
|
+
|
|
33
|
+
const engine = createBoardEngine({
|
|
34
|
+
grid: { size: 24, majorEvery: 4, snap: true, pattern: 'cross' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const renderers: BoardRendererRegistry = {
|
|
38
|
+
text: DocsInsightNode,
|
|
39
|
+
group: DocsMetricNode,
|
|
40
|
+
}
|
|
41
|
+
let insightCount = 0
|
|
42
|
+
let metricCount = 0
|
|
43
|
+
|
|
44
|
+
function seed() {
|
|
45
|
+
engine.loadDocument(
|
|
46
|
+
createDemoDocument({
|
|
47
|
+
camera: { x: 0, y: 0, z: 1 },
|
|
48
|
+
grid: engine.getGridSettings(),
|
|
49
|
+
selection: [],
|
|
50
|
+
nextZIndex: 4,
|
|
51
|
+
nodes: [
|
|
52
|
+
{
|
|
53
|
+
id: 'why',
|
|
54
|
+
type: 'text',
|
|
55
|
+
x: 80,
|
|
56
|
+
y: 80,
|
|
57
|
+
width: 250,
|
|
58
|
+
height: 160,
|
|
59
|
+
text: 'Why custom renderers\nSwap the default card view for a Vue component while keeping JSON Canvas node data explicit.',
|
|
60
|
+
zIndex: 1,
|
|
61
|
+
locked: false,
|
|
62
|
+
visible: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'metric',
|
|
66
|
+
type: 'group',
|
|
67
|
+
x: 390,
|
|
68
|
+
y: 90,
|
|
69
|
+
width: 230,
|
|
70
|
+
height: 160,
|
|
71
|
+
label:
|
|
72
|
+
'Adoption\n83%\n+12%\nThe renderer can look nothing like the default text card.',
|
|
73
|
+
zIndex: 2,
|
|
74
|
+
locked: false,
|
|
75
|
+
visible: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'flow',
|
|
79
|
+
type: 'text',
|
|
80
|
+
x: 660,
|
|
81
|
+
y: 130,
|
|
82
|
+
width: 230,
|
|
83
|
+
height: 100,
|
|
84
|
+
text: 'Default JSON Canvas\nRenderers are keyed by real node type.',
|
|
85
|
+
zIndex: 3,
|
|
86
|
+
locked: false,
|
|
87
|
+
visible: true,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
{ mode: 'replace' },
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addInsight() {
|
|
96
|
+
insightCount++
|
|
97
|
+
engine.createNode({
|
|
98
|
+
type: 'text',
|
|
99
|
+
x: 160 + (insightCount % 3) * 120,
|
|
100
|
+
y: 80 + (insightCount % 2) * 130,
|
|
101
|
+
width: 250,
|
|
102
|
+
height: 160,
|
|
103
|
+
text: 'New insight\nUse a text node with a renderer registered for the JSON Canvas text type.',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addMetric() {
|
|
108
|
+
metricCount++
|
|
109
|
+
engine.createNode({
|
|
110
|
+
type: 'group',
|
|
111
|
+
x: 180 + (metricCount % 3) * 140,
|
|
112
|
+
y: 90 + (metricCount % 2) * 120,
|
|
113
|
+
width: 230,
|
|
114
|
+
height: 160,
|
|
115
|
+
label:
|
|
116
|
+
'Confidence\n91%\n+4%\nDifferent JSON Canvas node types can have their own layout.',
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onMounted(async () => {
|
|
121
|
+
seed()
|
|
122
|
+
await engine.zoomToFit(72, false)
|
|
123
|
+
})
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div class="demo-frame">
|
|
128
|
+
<div class="demo-toolbar">
|
|
129
|
+
<button class="demo-danger" @click="seed">Reset</button>
|
|
130
|
+
<button class="demo-primary" @click="addInsight">Add insight</button>
|
|
131
|
+
<button @click="addMetric">Add metric</button>
|
|
132
|
+
<button @click="engine.zoomToFit(72, false)">Zoom to fit</button>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<BoardRoot :engine="engine" :renderers="renderers" style="height: 340px" />
|
|
136
|
+
</div>
|
|
137
|
+
</template>
|
|
138
|
+
````
|
|
139
|
+
|
|
140
|
+
### app/utils/demoDocument.ts
|
|
141
|
+
|
|
142
|
+
````ts
|
|
143
|
+
import type {
|
|
144
|
+
BoardNode,
|
|
145
|
+
BoardNodeMetadata,
|
|
146
|
+
Camera,
|
|
147
|
+
GridSettings,
|
|
148
|
+
JsonCanvasDocument,
|
|
149
|
+
JsonCanvasNode,
|
|
150
|
+
NodeId,
|
|
151
|
+
} from '@lupinum/board-core'
|
|
152
|
+
|
|
153
|
+
type DemoNode = Omit<
|
|
154
|
+
Pick<
|
|
155
|
+
BoardNode,
|
|
156
|
+
| 'id'
|
|
157
|
+
| 'type'
|
|
158
|
+
| 'x'
|
|
159
|
+
| 'y'
|
|
160
|
+
| 'width'
|
|
161
|
+
| 'height'
|
|
162
|
+
| 'color'
|
|
163
|
+
| 'text'
|
|
164
|
+
| 'file'
|
|
165
|
+
| 'subpath'
|
|
166
|
+
| 'url'
|
|
167
|
+
| 'label'
|
|
168
|
+
| 'background'
|
|
169
|
+
| 'backgroundStyle'
|
|
170
|
+
| 'zIndex'
|
|
171
|
+
| 'locked'
|
|
172
|
+
| 'visible'
|
|
173
|
+
| 'parentId'
|
|
174
|
+
>,
|
|
175
|
+
'id' | 'parentId'
|
|
176
|
+
> & {
|
|
177
|
+
id: NodeId | string
|
|
178
|
+
parentId?: NodeId | string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface DemoDocumentInput {
|
|
182
|
+
nodes: readonly DemoNode[]
|
|
183
|
+
camera?: Camera
|
|
184
|
+
grid?: GridSettings
|
|
185
|
+
selection?: readonly (NodeId | string)[]
|
|
186
|
+
nextZIndex?: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
|
|
190
|
+
const base = {
|
|
191
|
+
id: node.id as NodeId,
|
|
192
|
+
type: node.type,
|
|
193
|
+
x: node.x,
|
|
194
|
+
y: node.y,
|
|
195
|
+
width: node.width,
|
|
196
|
+
height: node.height,
|
|
197
|
+
...(node.color ? { color: node.color } : {}),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (node.type) {
|
|
201
|
+
case 'file':
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
type: 'file',
|
|
205
|
+
file: node.file ?? '',
|
|
206
|
+
...(node.subpath ? { subpath: node.subpath } : {}),
|
|
207
|
+
}
|
|
208
|
+
case 'link':
|
|
209
|
+
return {
|
|
210
|
+
...base,
|
|
211
|
+
type: 'link',
|
|
212
|
+
url: node.url ?? '',
|
|
213
|
+
}
|
|
214
|
+
case 'group':
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
type: 'group',
|
|
218
|
+
...((node.label ?? node.text)
|
|
219
|
+
? { label: node.label ?? node.text }
|
|
220
|
+
: {}),
|
|
221
|
+
...(node.background ? { background: node.background } : {}),
|
|
222
|
+
...(node.backgroundStyle
|
|
223
|
+
? { backgroundStyle: node.backgroundStyle }
|
|
224
|
+
: {}),
|
|
225
|
+
}
|
|
226
|
+
case 'text':
|
|
227
|
+
default:
|
|
228
|
+
return {
|
|
229
|
+
...base,
|
|
230
|
+
type: 'text',
|
|
231
|
+
text: node.text ?? '',
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
|
|
237
|
+
return {
|
|
238
|
+
zIndex: node.zIndex,
|
|
239
|
+
locked: node.locked,
|
|
240
|
+
visible: node.visible,
|
|
241
|
+
...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function createDemoDocument(
|
|
246
|
+
input: DemoDocumentInput,
|
|
247
|
+
): JsonCanvasDocument {
|
|
248
|
+
return {
|
|
249
|
+
nodes: input.nodes.map(toJsonCanvasNode),
|
|
250
|
+
'x-lupinum-board': {
|
|
251
|
+
...(input.camera ? { camera: input.camera } : {}),
|
|
252
|
+
...(input.grid ? { grid: input.grid } : {}),
|
|
253
|
+
...(input.selection
|
|
254
|
+
? { selection: input.selection.map((id) => id as NodeId) }
|
|
255
|
+
: {}),
|
|
256
|
+
nextZIndex:
|
|
257
|
+
input.nextZIndex ??
|
|
258
|
+
input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
|
|
259
|
+
nodes: Object.fromEntries(
|
|
260
|
+
input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
|
|
261
|
+
),
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
````
|
|
266
|
+
|
|
267
|
+
### app/components/demos/DocsInsightNode.vue
|
|
268
|
+
|
|
269
|
+
````vue
|
|
270
|
+
<script setup lang="ts">
|
|
271
|
+
import type { BoardNode } from '@lupinum/board-core'
|
|
272
|
+
|
|
273
|
+
defineProps<{
|
|
274
|
+
node: BoardNode
|
|
275
|
+
selected: boolean
|
|
276
|
+
}>()
|
|
277
|
+
</script>
|
|
278
|
+
|
|
279
|
+
<template>
|
|
280
|
+
<div
|
|
281
|
+
class="size-full rounded-[inherit] p-4 text-left"
|
|
282
|
+
:class="selected ? 'ring-2 ring-primary/60 ring-inset' : ''"
|
|
283
|
+
style="
|
|
284
|
+
background: linear-gradient(
|
|
285
|
+
135deg,
|
|
286
|
+
color-mix(in srgb, var(--ui-primary) 16%, transparent),
|
|
287
|
+
color-mix(in srgb, var(--ui-primary) 3%, white) 55%,
|
|
288
|
+
color-mix(in srgb, var(--ui-primary) 10%, transparent)
|
|
289
|
+
);
|
|
290
|
+
box-shadow:
|
|
291
|
+
inset 0 1px 0 0 color-mix(in srgb, var(--ui-primary) 16%, transparent),
|
|
292
|
+
inset 0 0 0 1px color-mix(in srgb, var(--ui-primary) 10%, transparent);
|
|
293
|
+
"
|
|
294
|
+
>
|
|
295
|
+
<div
|
|
296
|
+
class="text-[10px] font-mono uppercase tracking-[0.18em]"
|
|
297
|
+
style="color: var(--ui-primary)"
|
|
298
|
+
>
|
|
299
|
+
Insight node
|
|
300
|
+
</div>
|
|
301
|
+
<div class="mt-2 text-sm font-semibold text-highlighted">
|
|
302
|
+
{{ node.text?.split('\n')[0] ?? 'Untitled' }}
|
|
303
|
+
</div>
|
|
304
|
+
<div class="mt-2 text-xs leading-5 text-muted">
|
|
305
|
+
{{
|
|
306
|
+
node.text?.split('\n').slice(1).join('\n') ||
|
|
307
|
+
'Use custom renderers to swap the default card view for any Vue component.'
|
|
308
|
+
}}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</template>
|
|
312
|
+
````
|
|
313
|
+
|
|
314
|
+
### app/components/demos/DocsMetricNode.vue
|
|
315
|
+
|
|
316
|
+
````vue
|
|
317
|
+
<script setup lang="ts">
|
|
318
|
+
import { computed } from 'vue'
|
|
319
|
+
import type { BoardNode } from '@lupinum/board-core'
|
|
320
|
+
|
|
321
|
+
const props = defineProps<{
|
|
322
|
+
node: BoardNode
|
|
323
|
+
selected: boolean
|
|
324
|
+
}>()
|
|
325
|
+
|
|
326
|
+
const data = computed(() => {
|
|
327
|
+
const [label = 'Metric', value = '-', delta = '', caption = ''] =
|
|
328
|
+
props.node.label?.split('\n') ?? []
|
|
329
|
+
return { label, value, delta, caption }
|
|
330
|
+
})
|
|
331
|
+
const deltaPositive = computed(() => {
|
|
332
|
+
const d = String(data.value.delta ?? '')
|
|
333
|
+
return d.startsWith('+')
|
|
334
|
+
})
|
|
335
|
+
</script>
|
|
336
|
+
|
|
337
|
+
<template>
|
|
338
|
+
<div
|
|
339
|
+
class="flex h-full flex-col justify-between rounded-md border p-4"
|
|
340
|
+
:class="[selected ? 'ring-2 ring-primary' : 'border-default']"
|
|
341
|
+
style="
|
|
342
|
+
background: linear-gradient(
|
|
343
|
+
160deg,
|
|
344
|
+
color-mix(in srgb, var(--ui-primary) 8%, white),
|
|
345
|
+
white 50%,
|
|
346
|
+
color-mix(in srgb, var(--ui-primary) 5%, white)
|
|
347
|
+
);
|
|
348
|
+
box-shadow:
|
|
349
|
+
inset 0 1px 0 0 color-mix(in srgb, var(--ui-primary) 12%, transparent),
|
|
350
|
+
0 12px 40px rgba(15, 23, 42, 0.07),
|
|
351
|
+
0 1px 3px rgba(15, 23, 42, 0.04);
|
|
352
|
+
"
|
|
353
|
+
>
|
|
354
|
+
<div class="flex items-center justify-between gap-3">
|
|
355
|
+
<span
|
|
356
|
+
class="text-[0.65rem] font-semibold uppercase tracking-[0.28em] text-dimmed"
|
|
357
|
+
>{{ data.label }}</span
|
|
358
|
+
>
|
|
359
|
+
<span
|
|
360
|
+
class="rounded-full px-2.5 py-1 text-[0.7rem] font-semibold tracking-wide ring-1"
|
|
361
|
+
:class="
|
|
362
|
+
deltaPositive
|
|
363
|
+
? 'bg-emerald-50 text-emerald-700 ring-emerald-200/60'
|
|
364
|
+
: 'bg-amber-50 text-amber-700 ring-amber-200/60'
|
|
365
|
+
"
|
|
366
|
+
>
|
|
367
|
+
{{ data.delta }}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
<div>
|
|
371
|
+
<p
|
|
372
|
+
class="text-3xl font-bold tracking-tight"
|
|
373
|
+
style="color: var(--ui-primary)"
|
|
374
|
+
>
|
|
375
|
+
{{ data.value }}
|
|
376
|
+
</p>
|
|
377
|
+
<p class="mt-2 text-sm leading-6 text-muted">
|
|
378
|
+
{{ data.caption }}
|
|
379
|
+
</p>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
</template>
|
|
383
|
+
````
|
|
20
384
|
|
|
21
385
|
## Choose the data shape first
|
|
22
386
|
|
|
@@ -31,8 +31,284 @@ This is not a third-party plugin surface. The supported consumer API is the
|
|
|
31
31
|
package API exposed by each first-party package, such as `engine.plugins.history` and
|
|
32
32
|
`engine.plugins.connections`.
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
## Live example source
|
|
35
|
+
|
|
36
|
+
Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
|
|
37
|
+
|
|
38
|
+
### app/components/demos/ReadOnlyToggleDemo.vue
|
|
39
|
+
|
|
40
|
+
````vue
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
import { onMounted, ref, watch } from 'vue'
|
|
43
|
+
import { createBoardEngine } from '@lupinum/board-core'
|
|
44
|
+
import { createDemoDocument } from '../../utils/demoDocument'
|
|
45
|
+
|
|
46
|
+
const engine = createBoardEngine({
|
|
47
|
+
grid: { size: 20, majorEvery: 5, snap: true, pattern: 'dot' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const isReadOnly = ref(true)
|
|
51
|
+
const blockedCount = ref(0)
|
|
52
|
+
let unsubscribeReadOnly: (() => void) | null = null
|
|
53
|
+
|
|
54
|
+
const blockedCommands = new Set([
|
|
55
|
+
'beginNodeDrag',
|
|
56
|
+
'beginResize',
|
|
57
|
+
'beginTextEdit',
|
|
58
|
+
'createNode',
|
|
59
|
+
'updateNode',
|
|
60
|
+
'deleteNode',
|
|
61
|
+
'moveNode',
|
|
62
|
+
'resizeNode',
|
|
63
|
+
'deleteSelected',
|
|
64
|
+
'duplicateNodes',
|
|
65
|
+
'translateSelectedNodes',
|
|
66
|
+
'sendToBack',
|
|
67
|
+
'lockNode',
|
|
68
|
+
'unlockNode',
|
|
69
|
+
'commitTextEdit',
|
|
70
|
+
'copySelected',
|
|
71
|
+
'pasteClipboard',
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
function seed() {
|
|
75
|
+
engine.loadDocument(
|
|
76
|
+
createDemoDocument({
|
|
77
|
+
camera: { x: 0, y: 0, z: 1 },
|
|
78
|
+
grid: engine.getGridSettings(),
|
|
79
|
+
nodes: [
|
|
80
|
+
{
|
|
81
|
+
id: 'ro-a',
|
|
82
|
+
type: 'text',
|
|
83
|
+
x: 80,
|
|
84
|
+
y: 90,
|
|
85
|
+
width: 220,
|
|
86
|
+
height: 100,
|
|
87
|
+
text: 'Node',
|
|
88
|
+
zIndex: 1,
|
|
89
|
+
locked: false,
|
|
90
|
+
visible: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'ro-b',
|
|
94
|
+
type: 'text',
|
|
95
|
+
x: 380,
|
|
96
|
+
y: 190,
|
|
97
|
+
width: 220,
|
|
98
|
+
height: 100,
|
|
99
|
+
text: 'Node',
|
|
100
|
+
zIndex: 2,
|
|
101
|
+
locked: false,
|
|
102
|
+
visible: true,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
selection: [],
|
|
106
|
+
nextZIndex: 3,
|
|
107
|
+
}),
|
|
108
|
+
{ mode: 'replace' },
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function applyReadOnly() {
|
|
113
|
+
unsubscribeReadOnly?.()
|
|
114
|
+
unsubscribeReadOnly = null
|
|
115
|
+
|
|
116
|
+
if (!isReadOnly.value) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
unsubscribeReadOnly = engine.addCommandGuard(({ name }) => {
|
|
121
|
+
if (blockedCommands.has(name)) {
|
|
122
|
+
blockedCount.value += 1
|
|
123
|
+
return 'Board is read-only.'
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
watch(isReadOnly, applyReadOnly)
|
|
130
|
+
|
|
131
|
+
onMounted(async () => {
|
|
132
|
+
seed()
|
|
133
|
+
applyReadOnly()
|
|
134
|
+
await engine.zoomToFit(60, false)
|
|
135
|
+
})
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<template>
|
|
139
|
+
<div class="demo-frame">
|
|
140
|
+
<div class="demo-toolbar">
|
|
141
|
+
<button class="demo-danger" @click="seed">Reset</button>
|
|
142
|
+
<button class="demo-primary" @click="isReadOnly = !isReadOnly">
|
|
143
|
+
{{ isReadOnly ? 'Switch to edit mode' : 'Switch to read-only' }}
|
|
144
|
+
</button>
|
|
145
|
+
<span class="demo-toolbar-note">
|
|
146
|
+
{{
|
|
147
|
+
isReadOnly
|
|
148
|
+
? 'Mutations blocked — try dragging or deleting.'
|
|
149
|
+
: 'Mutations allowed.'
|
|
150
|
+
}}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div class="grid gap-0 lg:grid-cols-[minmax(0,1fr)_280px]">
|
|
155
|
+
<BoardRoot :engine="engine" style="height: 360px" />
|
|
156
|
+
|
|
157
|
+
<div
|
|
158
|
+
class="border-t border-default bg-elevated p-4 lg:border-t-0 lg:border-l"
|
|
159
|
+
>
|
|
160
|
+
<div
|
|
161
|
+
class="inline-flex rounded-full px-3 py-2 text-sm font-semibold"
|
|
162
|
+
:class="
|
|
163
|
+
isReadOnly
|
|
164
|
+
? 'bg-amber-50 text-amber-700'
|
|
165
|
+
: 'bg-emerald-50 text-emerald-700'
|
|
166
|
+
"
|
|
167
|
+
>
|
|
168
|
+
{{ isReadOnly ? 'Read-only' : 'Editable' }}
|
|
169
|
+
</div>
|
|
170
|
+
<div class="mt-5 rounded-md border border-default bg-default p-4">
|
|
171
|
+
<p
|
|
172
|
+
class="text-xs font-semibold uppercase tracking-[0.28em] text-dimmed"
|
|
173
|
+
>
|
|
174
|
+
Blocked attempts
|
|
175
|
+
</p>
|
|
176
|
+
<p class="mt-2 text-2xl font-bold tracking-tight text-highlighted">
|
|
177
|
+
{{ blockedCount }}
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</template>
|
|
184
|
+
````
|
|
185
|
+
|
|
186
|
+
### app/utils/demoDocument.ts
|
|
187
|
+
|
|
188
|
+
````ts
|
|
189
|
+
import type {
|
|
190
|
+
BoardNode,
|
|
191
|
+
BoardNodeMetadata,
|
|
192
|
+
Camera,
|
|
193
|
+
GridSettings,
|
|
194
|
+
JsonCanvasDocument,
|
|
195
|
+
JsonCanvasNode,
|
|
196
|
+
NodeId,
|
|
197
|
+
} from '@lupinum/board-core'
|
|
198
|
+
|
|
199
|
+
type DemoNode = Omit<
|
|
200
|
+
Pick<
|
|
201
|
+
BoardNode,
|
|
202
|
+
| 'id'
|
|
203
|
+
| 'type'
|
|
204
|
+
| 'x'
|
|
205
|
+
| 'y'
|
|
206
|
+
| 'width'
|
|
207
|
+
| 'height'
|
|
208
|
+
| 'color'
|
|
209
|
+
| 'text'
|
|
210
|
+
| 'file'
|
|
211
|
+
| 'subpath'
|
|
212
|
+
| 'url'
|
|
213
|
+
| 'label'
|
|
214
|
+
| 'background'
|
|
215
|
+
| 'backgroundStyle'
|
|
216
|
+
| 'zIndex'
|
|
217
|
+
| 'locked'
|
|
218
|
+
| 'visible'
|
|
219
|
+
| 'parentId'
|
|
220
|
+
>,
|
|
221
|
+
'id' | 'parentId'
|
|
222
|
+
> & {
|
|
223
|
+
id: NodeId | string
|
|
224
|
+
parentId?: NodeId | string
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface DemoDocumentInput {
|
|
228
|
+
nodes: readonly DemoNode[]
|
|
229
|
+
camera?: Camera
|
|
230
|
+
grid?: GridSettings
|
|
231
|
+
selection?: readonly (NodeId | string)[]
|
|
232
|
+
nextZIndex?: number
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
|
|
236
|
+
const base = {
|
|
237
|
+
id: node.id as NodeId,
|
|
238
|
+
type: node.type,
|
|
239
|
+
x: node.x,
|
|
240
|
+
y: node.y,
|
|
241
|
+
width: node.width,
|
|
242
|
+
height: node.height,
|
|
243
|
+
...(node.color ? { color: node.color } : {}),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
switch (node.type) {
|
|
247
|
+
case 'file':
|
|
248
|
+
return {
|
|
249
|
+
...base,
|
|
250
|
+
type: 'file',
|
|
251
|
+
file: node.file ?? '',
|
|
252
|
+
...(node.subpath ? { subpath: node.subpath } : {}),
|
|
253
|
+
}
|
|
254
|
+
case 'link':
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
type: 'link',
|
|
258
|
+
url: node.url ?? '',
|
|
259
|
+
}
|
|
260
|
+
case 'group':
|
|
261
|
+
return {
|
|
262
|
+
...base,
|
|
263
|
+
type: 'group',
|
|
264
|
+
...((node.label ?? node.text)
|
|
265
|
+
? { label: node.label ?? node.text }
|
|
266
|
+
: {}),
|
|
267
|
+
...(node.background ? { background: node.background } : {}),
|
|
268
|
+
...(node.backgroundStyle
|
|
269
|
+
? { backgroundStyle: node.backgroundStyle }
|
|
270
|
+
: {}),
|
|
271
|
+
}
|
|
272
|
+
case 'text':
|
|
273
|
+
default:
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: node.text ?? '',
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
|
|
283
|
+
return {
|
|
284
|
+
zIndex: node.zIndex,
|
|
285
|
+
locked: node.locked,
|
|
286
|
+
visible: node.visible,
|
|
287
|
+
...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function createDemoDocument(
|
|
292
|
+
input: DemoDocumentInput,
|
|
293
|
+
): JsonCanvasDocument {
|
|
294
|
+
return {
|
|
295
|
+
nodes: input.nodes.map(toJsonCanvasNode),
|
|
296
|
+
'x-lupinum-board': {
|
|
297
|
+
...(input.camera ? { camera: input.camera } : {}),
|
|
298
|
+
...(input.grid ? { grid: input.grid } : {}),
|
|
299
|
+
...(input.selection
|
|
300
|
+
? { selection: input.selection.map((id) => id as NodeId) }
|
|
301
|
+
: {}),
|
|
302
|
+
nextZIndex:
|
|
303
|
+
input.nextZIndex ??
|
|
304
|
+
input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
|
|
305
|
+
nodes: Object.fromEntries(
|
|
306
|
+
input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
````
|
|
36
312
|
|
|
37
313
|
## Command guards
|
|
38
314
|
|