pi-weave 0.3.11 → 0.3.13
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/package.json +1 -1
- package/src/core/index.ts +3 -0
- package/src/core/vault.ts +79 -5
- package/src/core/view/tree.ts +2 -2
- package/src/web/client/api.ts +4 -0
- package/src/web/client/dist/app.js +53 -53
- package/src/web/client/graph/Graph.tsx +27 -4
- package/src/web/client/graph/graph.model.ts +41 -0
- package/src/web/client/graph/renderer.ts +35 -11
- package/src/web/client/shell/shell.model.ts +3 -2
- package/src/web/client/tree/Tree.tsx +8 -6
- package/src/web/client/tree/tree.model.ts +22 -7
- package/src/web/server/routes.ts +25 -3
|
@@ -154,6 +154,18 @@ export function Graph(props: GraphProps) {
|
|
|
154
154
|
// mount frames the *starting* positions, which a settling simulation then
|
|
155
155
|
// pushes past the edges — a two-note vault opened with clipped circles.
|
|
156
156
|
const fitted = useRef(false);
|
|
157
|
+
// A re-frame asked for while the layout was still moving, done when it rests.
|
|
158
|
+
const reframe = useRef(false);
|
|
159
|
+
/**
|
|
160
|
+
* Frame the graph for where it is shown: the full view fits everything, and
|
|
161
|
+
* the Context card's note graph opens on the open note and its neighbours,
|
|
162
|
+
* since you read it while on that note (`renderer.ts`'s `focus`).
|
|
163
|
+
*/
|
|
164
|
+
const frame = useCallback(() => {
|
|
165
|
+
const { full: whole, model: shown } = live.current;
|
|
166
|
+
if (whole) renderer.current?.fit();
|
|
167
|
+
else renderer.current?.focus(shown.highlight);
|
|
168
|
+
}, []);
|
|
157
169
|
/**
|
|
158
170
|
* Arm the simulation clock — idempotent.
|
|
159
171
|
*
|
|
@@ -171,9 +183,10 @@ export function Graph(props: GraphProps) {
|
|
|
171
183
|
engine.tick();
|
|
172
184
|
// Asleep: let the clock die. The next pin re-arms it.
|
|
173
185
|
if (!engine.awake()) {
|
|
174
|
-
if (!fitted.current) {
|
|
186
|
+
if (!fitted.current || reframe.current) {
|
|
175
187
|
fitted.current = true;
|
|
176
|
-
|
|
188
|
+
reframe.current = false;
|
|
189
|
+
frame();
|
|
177
190
|
}
|
|
178
191
|
return;
|
|
179
192
|
}
|
|
@@ -220,8 +233,8 @@ export function Graph(props: GraphProps) {
|
|
|
220
233
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
221
234
|
const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
|
|
222
235
|
trailRef.current = props.trail ?? NO_GLOWS;
|
|
223
|
-
const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
224
|
-
live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
236
|
+
const live = useRef({ view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
237
|
+
live.current = { view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
225
238
|
|
|
226
239
|
useEffect(() => {
|
|
227
240
|
const instance = props.renderer(scheme);
|
|
@@ -320,6 +333,16 @@ export function Graph(props: GraphProps) {
|
|
|
320
333
|
renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
|
|
321
334
|
}, [picture]);
|
|
322
335
|
|
|
336
|
+
// Re-frame when another note opens under the Context card's graph, or the
|
|
337
|
+
// graph moves between the card and the full view. A click in the full view
|
|
338
|
+
// leaves the camera where it is. Before the first layout rests, the settle
|
|
339
|
+
// frames it; while the layout is still moving, the frame waits for it.
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!fitted.current) return;
|
|
342
|
+
if (clock.current !== null) reframe.current = true;
|
|
343
|
+
else frame();
|
|
344
|
+
}, [full, full ? null : props.selectedId]);
|
|
345
|
+
|
|
323
346
|
useEffect(() => {
|
|
324
347
|
renderer.current?.setTrail(trailRef.current);
|
|
325
348
|
}, [props.trail]);
|
|
@@ -545,6 +545,47 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
|
|
|
545
545
|
return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
/** Where sigma's camera looks, in the unit square a {@link ViewBox} is normalized onto. */
|
|
549
|
+
export interface CameraTarget {
|
|
550
|
+
readonly x: number;
|
|
551
|
+
readonly y: number;
|
|
552
|
+
/** Sigma's zoom: 1 shows the whole box, smaller is closer. */
|
|
553
|
+
readonly ratio: number;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Room a focus leaves around what it frames, as a multiple of its span. */
|
|
557
|
+
export const FOCUS_MARGIN = 1.2;
|
|
558
|
+
|
|
559
|
+
/** The smallest span a focus frames, so a note with no links is not blown up to fill the card. */
|
|
560
|
+
export const FOCUS_MIN_SPAN = COLLIDE_RADIUS * 10;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The camera that frames `points` in a view frozen on `box`.
|
|
564
|
+
*
|
|
565
|
+
* The Context card's note graph is read while you are on a note, so it opens
|
|
566
|
+
* on that note and its neighbours instead of the whole vault. This is the
|
|
567
|
+
* arithmetic. Sigma normalizes the box onto a unit square centred on 0.5 (its
|
|
568
|
+
* longer side becomes 1), and a camera ratio of 1 shows that whole square. So
|
|
569
|
+
* the camera's centre is the points' centre in that square, and its ratio is
|
|
570
|
+
* their padded span as a share of the box. Never above 1: a focus does not
|
|
571
|
+
* zoom out past the fit.
|
|
572
|
+
*
|
|
573
|
+
* `null` when there is nothing to frame: no box, or no finite point.
|
|
574
|
+
*/
|
|
575
|
+
export function focusCamera(box: ViewBox | null, points: readonly Point[]): CameraTarget | null {
|
|
576
|
+
const around = frameBox(points);
|
|
577
|
+
if (box === null || around === null) return null;
|
|
578
|
+
const extent = Math.max(box.x[1] - box.x[0], box.y[1] - box.y[0]);
|
|
579
|
+
const scale = extent > 0 && Number.isFinite(extent) ? extent : 1;
|
|
580
|
+
const span = Math.max(around.x[1] - around.x[0], around.y[1] - around.y[0], FOCUS_MIN_SPAN);
|
|
581
|
+
const centre = (range: readonly [number, number]): number => (range[0] + range[1]) / 2;
|
|
582
|
+
return {
|
|
583
|
+
x: 0.5 + (centre(around.x) - centre(box.x)) / scale,
|
|
584
|
+
y: 0.5 + (centre(around.y) - centre(box.y)) / scale,
|
|
585
|
+
ratio: Math.min(1, (span * FOCUS_MARGIN) / scale),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
548
589
|
/** The empty graph. What the column renders before the first payload lands. */
|
|
549
590
|
export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
|
|
550
591
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphTheme } from "../../shared/themes";
|
|
2
2
|
import type { Point } from "../../shared/layout";
|
|
3
|
-
import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
-
import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
3
|
+
import type { CameraTarget, EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
+
import { edgeReducer, fadeStep, focusCamera, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
5
5
|
import type { ProjectedGraph } from "./project";
|
|
6
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
7
7
|
|
|
@@ -68,7 +68,7 @@ export interface SigmaLike {
|
|
|
68
68
|
*/
|
|
69
69
|
setCustomBBox(box: ViewBox | null): unknown;
|
|
70
70
|
refresh(): unknown;
|
|
71
|
-
getCamera(): { animatedReset(): Promise<void> };
|
|
71
|
+
getCamera(): { animatedReset(): Promise<void>; animate(state: CameraTarget): Promise<void> };
|
|
72
72
|
kill(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -169,6 +169,20 @@ export function sigmaRenderer(
|
|
|
169
169
|
/** The box to freeze the view on, from the graph's *current* positions. */
|
|
170
170
|
const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Repaint once a camera move lands. Sigma leaves edges out of frames drawn
|
|
174
|
+
* while the camera moves (`hideEdgesOnMove`) and repaints when the camera
|
|
175
|
+
* changes; a move onto where the camera already is changes nothing, so the
|
|
176
|
+
* frame drawn during it, without edges, stayed on screen until something
|
|
177
|
+
* else repainted. A renderer destroyed mid-move paints nothing, and a
|
|
178
|
+
* rejection from a camera killed mid-flight is noise.
|
|
179
|
+
*/
|
|
180
|
+
const settle = (instance: SigmaLike, move: Promise<void>): void => {
|
|
181
|
+
move.then(() => {
|
|
182
|
+
if (sigma === instance) instance.refresh();
|
|
183
|
+
}, () => {});
|
|
184
|
+
};
|
|
185
|
+
|
|
172
186
|
/**
|
|
173
187
|
* Push the current highlight into sigma's reducers (§7.4).
|
|
174
188
|
*
|
|
@@ -346,14 +360,24 @@ export function sigmaRenderer(
|
|
|
346
360
|
const instance = sigma;
|
|
347
361
|
if (instance === null) return;
|
|
348
362
|
instance.setCustomBBox(boxOf());
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
settle(instance, instance.getCamera().animatedReset());
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Frame these nodes instead of the whole graph — the Context card's note
|
|
368
|
+
* graph opening on the note you are reading (`focusCamera`). Re-frames on
|
|
369
|
+
* the current positions first, like `fit`, so the camera and the box
|
|
370
|
+
* agree. Nothing to frame (`null`, or no drawn node among `ids`) is a fit.
|
|
371
|
+
*/
|
|
372
|
+
focus(ids: ReadonlySet<string> | null) {
|
|
373
|
+
const instance = sigma;
|
|
374
|
+
if (instance === null) return;
|
|
375
|
+
const box = boxOf();
|
|
376
|
+
const at = positionsOf(graph);
|
|
377
|
+
const points = [...(ids ?? [])].flatMap((id) => at.get(id) ?? []);
|
|
378
|
+
const target = focusCamera(box, points);
|
|
379
|
+
instance.setCustomBBox(box);
|
|
380
|
+
settle(instance, target === null ? instance.getCamera().animatedReset() : instance.getCamera().animate(target));
|
|
357
381
|
},
|
|
358
382
|
|
|
359
383
|
positions() {
|
|
@@ -21,9 +21,10 @@ import { kindIcon } from "../tree/tree.model";
|
|
|
21
21
|
import type { IconName } from "./icons.model";
|
|
22
22
|
export const NOTE_DRAG_TYPE = "application/x-weave-note";
|
|
23
23
|
|
|
24
|
-
/** Pane drops open only note IDs that still exist in this workspace. */
|
|
24
|
+
/** Pane drops open only note and HTML artifact IDs that still exist in this workspace. */
|
|
25
25
|
export function noteDropId(graph: GraphPayload | null, id: string): string | null {
|
|
26
|
-
|
|
26
|
+
const kind = id.startsWith("note:") ? "note" : id.startsWith("artifact:") ? "file" : null;
|
|
27
|
+
return kind !== null && graph?.model.nodes.some((node) => node.id === id && node.kind === kind) ? id : null;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export type ColumnId = "tree" | "note" | "graph";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { NOTE_DRAG_TYPE } from "../shell/shell.model";
|
|
4
4
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
5
|
-
import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
|
|
5
|
+
import { createFolder, deleteArtifact, deleteFolder, deleteNote, moveArtifact, moveNote, renameArtifact, renameFolder, renameNote } from "../api";
|
|
6
6
|
import { fetchJson } from "../api.dom";
|
|
7
7
|
import { isTextEntry, type KeyTarget } from "../shell/keys.model";
|
|
8
8
|
import { ICON_BOX, ICON_STROKE, ICONS } from "../shell/icons.model";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
deleteNeedsConfirmation,
|
|
22
22
|
depthVar,
|
|
23
23
|
dropFolder,
|
|
24
|
+
movableTreeRow,
|
|
24
25
|
initialTreeView,
|
|
25
26
|
newFolderParent,
|
|
26
27
|
newFolderPath,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
treeEmptyMessage,
|
|
42
43
|
treeKey,
|
|
43
44
|
} from "./tree.model";
|
|
45
|
+
import type { TreeTarget } from "./tree.model";
|
|
44
46
|
|
|
45
47
|
export interface TreeProps {
|
|
46
48
|
graph: GraphPayload | null;
|
|
@@ -91,7 +93,7 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
|
|
|
91
93
|
aria-expanded={view.hasKids ? view.expanded : undefined}
|
|
92
94
|
style={depthVar(view.depth)}
|
|
93
95
|
onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}
|
|
94
|
-
draggable={view.id
|
|
96
|
+
draggable={movableTreeRow(view.id) !== null}
|
|
95
97
|
aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
|
|
96
98
|
onDragStart={(event) => { event.dataTransfer?.setData("text/plain", view.id); event.dataTransfer?.setData(NOTE_DRAG_TYPE, view.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; }}
|
|
97
99
|
onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
|
|
@@ -210,7 +212,7 @@ export function Tree(props: TreeProps) {
|
|
|
210
212
|
// Before the request: asking after a delete is asking about a file that
|
|
211
213
|
// is already gone, and "keep editing" would strand the draft on a 404.
|
|
212
214
|
if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
|
|
213
|
-
await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
|
|
215
|
+
await run(target.type === "note" ? deleteNote(fetchJson, target.path) : target.type === "artifact" ? deleteArtifact(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
|
|
214
216
|
}
|
|
215
217
|
};
|
|
216
218
|
|
|
@@ -222,7 +224,7 @@ export function Tree(props: TreeProps) {
|
|
|
222
224
|
* the row shows no count for, so it takes two — and the second click's label
|
|
223
225
|
* names the folder, which is the chance to notice the wrong row was hit.
|
|
224
226
|
*/
|
|
225
|
-
const clickDelete = (target:
|
|
227
|
+
const clickDelete = (target: Pick<TreeTarget, "type">): void => {
|
|
226
228
|
if (menu === null) return;
|
|
227
229
|
if (deleteNeedsConfirmation(target) && menu.armed !== true) {
|
|
228
230
|
setMenu({ ...menu, armed: true });
|
|
@@ -259,7 +261,7 @@ export function Tree(props: TreeProps) {
|
|
|
259
261
|
// An open draft is a different question; `onMutate` asks that one.
|
|
260
262
|
if (target !== null && name && name !== current.label) {
|
|
261
263
|
if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
|
|
262
|
-
void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
|
|
264
|
+
void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : target.type === "artifact" ? renameArtifact(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
|
|
263
265
|
}
|
|
264
266
|
};
|
|
265
267
|
|
|
@@ -297,7 +299,7 @@ export function Tree(props: TreeProps) {
|
|
|
297
299
|
setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
|
|
298
300
|
}}
|
|
299
301
|
>
|
|
300
|
-
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id);
|
|
302
|
+
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); const moving = movableTreeRow(dragged); if (moving === null || folder === undefined) return; if (moving.type === "artifact") { void run(moveArtifact(fetchJson, moving.slug, folder)); return; } const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, moving.slug, folder)); }} />)}
|
|
301
303
|
</ul>
|
|
302
304
|
) : <p class="weave-tree-empty">{empty}</p>}
|
|
303
305
|
<p class="weave-tree-count">{rowCountLabel(rows)}</p>
|
|
@@ -131,7 +131,7 @@ export const DRAFT_FOLDER_ID = "vfolder:\u0000draft";
|
|
|
131
131
|
* it; on a *note*, a sibling of that note — which is what "new folder here"
|
|
132
132
|
* means when "here" is a file. Returns the parent path, `""` for the root.
|
|
133
133
|
*/
|
|
134
|
-
export function newFolderParent(target:
|
|
134
|
+
export function newFolderParent(target: TreeTarget): string {
|
|
135
135
|
if (target.type === "vault") return "";
|
|
136
136
|
if (target.type === "folder") return target.path;
|
|
137
137
|
return target.path.split("/").slice(0, -1).join("/");
|
|
@@ -225,25 +225,40 @@ export function dropFolder(id: string): string | null | undefined {
|
|
|
225
225
|
return id.startsWith("vfolder:") ? id.slice("vfolder:".length) : undefined;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
/** A row the tree can drag into a folder: a vault note or a vault HTML artifact. */
|
|
229
|
+
export function movableTreeRow(id: string): { type: "note" | "artifact"; slug: string } | null {
|
|
230
|
+
if (id.startsWith("note:")) return { type: "note", slug: id.slice("note:".length) };
|
|
231
|
+
if (id.startsWith("artifact:")) return { type: "artifact", slug: id.slice("artifact:".length) };
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A tree row the file actions (rename, delete, new folder) can act on. */
|
|
236
|
+
export interface TreeTarget {
|
|
237
|
+
type: "note" | "artifact" | "folder" | "vault";
|
|
238
|
+
path: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function mutableTreeRow(id: string): TreeTarget | null {
|
|
229
242
|
if (id === "vault") return { type: "vault", path: "" };
|
|
230
243
|
if (id.startsWith("note:")) return { type: "note", path: id.slice("note:".length) };
|
|
244
|
+
if (id.startsWith("artifact:")) return { type: "artifact", path: id.slice("artifact:".length) };
|
|
231
245
|
if (id.startsWith("vfolder:")) return { type: "folder", path: id.slice("vfolder:".length) };
|
|
232
246
|
return null;
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
/** Note slugs a note or folder mutation can invalidate. */
|
|
236
|
-
export function affectedNoteSlugs(graph: GraphPayload | null, target:
|
|
250
|
+
export function affectedNoteSlugs(graph: GraphPayload | null, target: TreeTarget): string[] {
|
|
237
251
|
if (target.type === "note") return [target.path];
|
|
238
252
|
if (target.type !== "folder" || graph === null) return [];
|
|
239
253
|
const prefix = `note:${target.path}/`;
|
|
240
254
|
return graph.model.nodes.filter((node) => node.kind === "note" && node.id.startsWith(prefix)).map((node) => node.id.slice(5));
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
export function deletesSelection(selectedId: string | null, target:
|
|
257
|
+
export function deletesSelection(selectedId: string | null, target: TreeTarget): boolean {
|
|
244
258
|
if (selectedId === null) return false;
|
|
245
259
|
if (target.type === "note") return selectedId === `note:${target.path}`;
|
|
246
|
-
if (target.type === "
|
|
260
|
+
if (target.type === "artifact") return selectedId === `artifact:${target.path}`;
|
|
261
|
+
if (target.type === "folder") return selectedId === `vfolder:${target.path}` || ["vfolder:", "note:", "artifact:"].some((kind) => selectedId.startsWith(`${kind}${target.path}/`));
|
|
247
262
|
return false;
|
|
248
263
|
}
|
|
249
264
|
|
|
@@ -260,7 +275,7 @@ export function deletesSelection(selectedId: string | null, target: { type: "not
|
|
|
260
275
|
* The confirmation is {@link deleteItemLabel}: the same menu item, clicked
|
|
261
276
|
* twice, with the second click's label saying exactly what it will destroy.
|
|
262
277
|
*/
|
|
263
|
-
export function deleteNeedsConfirmation(target:
|
|
278
|
+
export function deleteNeedsConfirmation(target: Pick<TreeTarget, "type">): boolean {
|
|
264
279
|
return target.type === "folder";
|
|
265
280
|
}
|
|
266
281
|
|
|
@@ -274,7 +289,7 @@ export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vau
|
|
|
274
289
|
* safety mechanism — it is the user's chance to notice they right-clicked the
|
|
275
290
|
* wrong row.
|
|
276
291
|
*/
|
|
277
|
-
export function deleteItemLabel(target:
|
|
292
|
+
export function deleteItemLabel(target: Pick<TreeTarget, "type">, label: string, armed: boolean): string {
|
|
278
293
|
if (!deleteNeedsConfirmation(target)) return "Delete";
|
|
279
294
|
return armed ? `Delete “${label}” and everything in it` : "Delete…";
|
|
280
295
|
}
|
package/src/web/server/routes.ts
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* | POST | `/api/history-settings` | turn note history on or off |
|
|
22
22
|
* | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
|
|
23
23
|
* | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
|
|
24
|
+
* | POST | `/api/artifact/:rel/move` | move a vault HTML artifact |
|
|
25
|
+
* | POST | `/api/artifact/:rel/rename` | rename a vault HTML artifact |
|
|
26
|
+
* | DELETE | `/api/artifact/:rel` | delete a vault HTML artifact |
|
|
24
27
|
* | GET | `/api/search?q=` | {@link SearchPayload} |
|
|
25
28
|
* | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
|
|
26
29
|
*
|
|
@@ -64,7 +67,7 @@ import { openNoteInEditor } from "../../core/openInEditor";
|
|
|
64
67
|
import { recallTrail, type RecallTrail } from "../../core/trail";
|
|
65
68
|
import type { Note } from "../../core/types";
|
|
66
69
|
import type { VaultMutationResult } from "../../core/vault";
|
|
67
|
-
import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
|
|
70
|
+
import { addNote, createFolder, deleteFolder, deleteNote, deleteHtmlArtifact, getNote, moveHtmlArtifact, moveNote, renameHtmlArtifact, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
|
|
68
71
|
import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
|
|
69
72
|
import type {
|
|
70
73
|
GraphPayload,
|
|
@@ -434,6 +437,12 @@ async function route(
|
|
|
434
437
|
if (method === "GET" && path.startsWith("/api/artifact/")) {
|
|
435
438
|
return sendArtifact(deps, path.slice("/api/artifact/".length), res);
|
|
436
439
|
}
|
|
440
|
+
if (method === "POST" && path.startsWith("/api/artifact/") && /\/(move|rename)$/.test(path)) {
|
|
441
|
+
return changeArtifact(deps, path.slice("/api/artifact/".length), req, res);
|
|
442
|
+
}
|
|
443
|
+
if (method === "DELETE" && path.startsWith("/api/artifact/")) {
|
|
444
|
+
return sendMutation(res, await deleteHtmlArtifact(deps.vaultRoot, path.slice("/api/artifact/".length)), "artifact");
|
|
445
|
+
}
|
|
437
446
|
if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
|
|
438
447
|
if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
|
|
439
448
|
sendText(res, 404, "not found\n");
|
|
@@ -705,9 +714,22 @@ async function routeFolder(deps: RouteDeps, method: string, target: string, req:
|
|
|
705
714
|
return false;
|
|
706
715
|
}
|
|
707
716
|
|
|
708
|
-
|
|
717
|
+
/** `POST /api/artifact/:rel/move` and `/rename`; `rel` still carries the suffix. */
|
|
718
|
+
async function changeArtifact(deps: RouteDeps, target: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
719
|
+
const rename = target.endsWith("/rename");
|
|
720
|
+
const rel = target.slice(0, target.lastIndexOf("/"));
|
|
721
|
+
const body = await readJsonBody(req);
|
|
722
|
+
const fields = typeof body === "object" && body !== null ? body as { folder?: unknown; name?: unknown } : {};
|
|
723
|
+
if (rename) {
|
|
724
|
+
if (typeof fields.name !== "string") sendJson(res, 400, { error: "expected { name: string }" });
|
|
725
|
+
else sendMutation(res, await renameHtmlArtifact(deps.vaultRoot, rel, fields.name), "artifact");
|
|
726
|
+
} else if (fields.folder !== null && typeof fields.folder !== "string") sendJson(res, 400, { error: "expected { folder: string | null }" });
|
|
727
|
+
else sendMutation(res, await moveHtmlArtifact(deps.vaultRoot, rel, fields.folder), "artifact");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function sendMutation(res: ServerResponse, result: VaultMutationResult, kind: "note" | "artifact" = "note"): void {
|
|
709
731
|
if (result.ok) {
|
|
710
|
-
const id = result.slug === undefined ? result.path === undefined ? undefined : `vfolder:${result.path}` :
|
|
732
|
+
const id = result.slug === undefined ? result.path === undefined ? undefined : `vfolder:${result.path}` : `${kind}:${result.slug}`;
|
|
711
733
|
sendJson(res, 200, { ok: true, ...(id === undefined ? {} : { id }) });
|
|
712
734
|
return;
|
|
713
735
|
}
|