pi-weave 0.1.13 → 0.1.14
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/cache/workspace.ts +70 -16
- package/src/core/graph/build.ts +33 -2
- package/src/core/graph/current.ts +9 -2
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +4 -0
- package/src/core/types.ts +13 -0
- package/src/core/vault.ts +98 -8
- package/src/core/view/detail.ts +4 -0
- package/src/web/client/dist/app.js +61 -60
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +24 -0
- package/src/web/client/note/note.model.ts +20 -0
- package/src/web/client/shell/Columns.tsx +29 -12
- package/src/web/client/shell/Divider.tsx +18 -0
- package/src/web/client/shell/Shell.tsx +27 -4
- package/src/web/client/shell/cssvars.ts +8 -0
- package/src/web/client/shell/drag.model.ts +43 -0
- package/src/web/client/shell/layout.model.ts +138 -0
- package/src/web/client/shell/shell.model.ts +19 -5
- package/src/web/client/shell/theme.ts +14 -13
- package/src/web/client/shell/viewport.ts +11 -0
- package/src/web/server/page.ts +1 -0
- package/src/web/server/routes.ts +36 -5
package/src/web/client/main.tsx
CHANGED
|
@@ -28,7 +28,7 @@ if (host !== null) {
|
|
|
28
28
|
// writes nothing — the media query already answered.
|
|
29
29
|
applyPrePaintTheme();
|
|
30
30
|
const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
|
|
31
|
-
render(<Shell cwd={boot.cwd} platform={navigator.platform} />, host);
|
|
31
|
+
render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Apply the stored theme choice to `<html>` synchronously, pre-render. */
|
|
@@ -35,6 +35,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
|
35
35
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
36
36
|
import {
|
|
37
37
|
CREATED_WORD,
|
|
38
|
+
artifactKeyOfNode,
|
|
38
39
|
EDITED_WORD,
|
|
39
40
|
EMPTY_PREVIEW,
|
|
40
41
|
PREVIEW_ID,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
previewPlacement,
|
|
48
49
|
reducePreview,
|
|
49
50
|
renderNote,
|
|
51
|
+
selectedArtifactPath,
|
|
50
52
|
tagLabel,
|
|
51
53
|
wikiIndex,
|
|
52
54
|
wikilinkTargetOf,
|
|
@@ -117,6 +119,7 @@ function Header({
|
|
|
117
119
|
|
|
118
120
|
export function Note(props: NoteProps) {
|
|
119
121
|
const note = props.note?.note ?? null;
|
|
122
|
+
const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
|
|
120
123
|
const empty = noteEmptyMessage(props.selectedId, note);
|
|
121
124
|
|
|
122
125
|
// Both hook calls sit before the empty-return so the hook order cannot
|
|
@@ -185,6 +188,27 @@ export function Note(props: NoteProps) {
|
|
|
185
188
|
element.style.setProperty(`--${PREVIEW_Y}`, `${spot.y}px`);
|
|
186
189
|
}, [preview, card]);
|
|
187
190
|
|
|
191
|
+
if (artifactPath !== null) {
|
|
192
|
+
const artifact = props.graph?.model.nodes.find((node) => node.id === props.selectedId);
|
|
193
|
+
const title = artifact?.label ?? artifactPath;
|
|
194
|
+
const artifactKey = artifact === undefined ? artifactPath : artifactKeyOfNode(artifact) ?? artifactPath;
|
|
195
|
+
return (
|
|
196
|
+
<article key={artifactKey} class="weave-note weave-note-artifact">
|
|
197
|
+
<header class="weave-note-head">
|
|
198
|
+
<h3 class="weave-note-title">{title}</h3>
|
|
199
|
+
<p class="weave-note-meta"><span class="weave-note-time">{artifactPath}</span></p>
|
|
200
|
+
</header>
|
|
201
|
+
<iframe
|
|
202
|
+
key={artifactKey}
|
|
203
|
+
class="weave-artifact-frame"
|
|
204
|
+
title={title}
|
|
205
|
+
sandbox="allow-scripts"
|
|
206
|
+
src={`/api/artifact/${encodeURIComponent(artifactPath)}`}
|
|
207
|
+
/>
|
|
208
|
+
</article>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
188
212
|
if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
|
|
189
213
|
|
|
190
214
|
// Keyed on the slug, not the body digest: the article is the scroll
|
|
@@ -256,6 +256,26 @@ export function slugOfNode(node: WireGraphNode): string | null {
|
|
|
256
256
|
return slug === "" ? null : slug;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** The vault-relative HTML path inside an `artifact:<path>` file node. */
|
|
260
|
+
export function artifactPathOfNode(node: WireGraphNode): string | null {
|
|
261
|
+
if (node.kind !== "file" || !node.id.startsWith("artifact:")) return null;
|
|
262
|
+
const path = node.detail.path;
|
|
263
|
+
return path !== undefined && /\.html?$/i.test(path) ? path : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Stable iframe key that changes when the artifact on disk changes. */
|
|
267
|
+
export function artifactKeyOfNode(node: WireGraphNode): string | null {
|
|
268
|
+
const path = artifactPathOfNode(node);
|
|
269
|
+
return path === null ? null : `${path}:${node.detail.updated ?? ""}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Resolve the selected HTML artifact, if the graph has one. */
|
|
273
|
+
export function selectedArtifactPath(payload: GraphPayload | null, selectedId: string | null): string | null {
|
|
274
|
+
if (payload === null || selectedId === null) return null;
|
|
275
|
+
const node = payload.model.nodes.find((candidate) => candidate.id === selectedId);
|
|
276
|
+
return node === undefined ? null : artifactPathOfNode(node);
|
|
277
|
+
}
|
|
278
|
+
|
|
259
279
|
/**
|
|
260
280
|
* Build the index for rendering the note with slug `slug`.
|
|
261
281
|
*
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* the viewport changes without a resize listener or persisted layout state.
|
|
2
|
+
* The resizable column grid and context rail. The shell supplies resolved
|
|
3
|
+
* widths and only the columns allowed by the current responsive breakpoint.
|
|
5
4
|
*/
|
|
6
5
|
|
|
6
|
+
import { Fragment } from "preact";
|
|
7
|
+
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
7
8
|
import { Graph } from "../graph/Graph";
|
|
8
9
|
import type { ColorScheme } from "../graph/graph.model";
|
|
9
10
|
import type { PositionStorage } from "../graph/positions";
|
|
@@ -12,11 +13,19 @@ import type { SchemeHost } from "../graph/scheme";
|
|
|
12
13
|
import { Note } from "../note/Note";
|
|
13
14
|
import { Tree } from "../tree/Tree";
|
|
14
15
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
15
|
-
import
|
|
16
|
+
import { applyVars } from "./cssvars";
|
|
17
|
+
import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
|
|
18
|
+
import { columnVars } from "./layout.model";
|
|
16
19
|
import { ContextRail } from "./ContextRail";
|
|
17
|
-
import { emptyStateFor } from "./shell.model";
|
|
20
|
+
import { columnSlots, emptyStateFor, type ColumnSlot } from "./shell.model";
|
|
21
|
+
import { Divider } from "./Divider";
|
|
18
22
|
|
|
19
23
|
export interface ColumnsProps {
|
|
24
|
+
resolved: readonly ResolvedColumn[];
|
|
25
|
+
onDown: (divider: DividerId, clientX: number, pointerId: number) => void;
|
|
26
|
+
onMove: (clientX: number) => void;
|
|
27
|
+
onUp: () => void;
|
|
28
|
+
onKey: (divider: DividerId, key: string) => void;
|
|
20
29
|
/** The §1.3 context bus, as the columns see it. */
|
|
21
30
|
graph: GraphPayload | null;
|
|
22
31
|
note: NotePayload | null;
|
|
@@ -95,12 +104,20 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
|
95
104
|
);
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
function Slot({ slot, props }: { slot: ColumnSlot; props: ColumnsProps }) {
|
|
108
|
+
const divider = slot.divider;
|
|
109
|
+
return <Fragment>
|
|
110
|
+
<Column id={slot.column.id} props={props} />
|
|
111
|
+
{divider === null ? null : <Divider id={divider} label={`Resize ${slot.column.id} column`}
|
|
112
|
+
onDown={(x, pointerId) => props.onDown(divider, x, pointerId)} onMove={props.onMove} onUp={props.onUp}
|
|
113
|
+
onKey={(key) => props.onKey(divider, key)} />}
|
|
114
|
+
</Fragment>;
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
export function Columns(props: ColumnsProps) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
</div>
|
|
105
|
-
);
|
|
118
|
+
const grid = useRef<HTMLDivElement | null>(null);
|
|
119
|
+
useLayoutEffect(() => { applyVars(grid.current, columnVars(props.resolved)); }, [props.resolved]);
|
|
120
|
+
return <div class="weave-grid" ref={grid} data-columns={props.resolved.length}>
|
|
121
|
+
{columnSlots(props.resolved).map((slot) => <Slot key={slot.column.id} slot={slot} props={props} />)}
|
|
122
|
+
</div>;
|
|
106
123
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DividerId } from "./layout.model";
|
|
2
|
+
|
|
3
|
+
export interface DividerProps {
|
|
4
|
+
id: DividerId;
|
|
5
|
+
label: string;
|
|
6
|
+
onDown: (clientX: number, pointerId: number) => void;
|
|
7
|
+
onMove: (clientX: number) => void;
|
|
8
|
+
onUp: () => void;
|
|
9
|
+
onKey: (key: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Divider(props: DividerProps) {
|
|
13
|
+
return <div class="weave-divider" role="separator" aria-orientation="vertical" aria-label={props.label} tabIndex={0}
|
|
14
|
+
onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); props.onDown(event.clientX, event.pointerId); }}
|
|
15
|
+
onPointerMove={(event) => props.onMove(event.clientX)}
|
|
16
|
+
onPointerUp={(event) => { event.currentTarget.releasePointerCapture(event.pointerId); props.onUp(); }}
|
|
17
|
+
onKeyDown={(event) => props.onKey(event.key)} />;
|
|
18
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The workspace shell.
|
|
3
3
|
*
|
|
4
|
-
* Header,
|
|
4
|
+
* Header, resizable columns, context rail, status bar. This component holds
|
|
5
5
|
* the wiring and nothing else — every value it renders comes from pure
|
|
6
6
|
* functions in the shell models,
|
|
7
7
|
* and the fetch/poll loop is `workspace.ts`. What is left here is hooks:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* would let `⌘K` stack a palette on top of itself.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { useEffect, useRef, useState } from "preact/hooks";
|
|
20
|
+
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
21
21
|
import { fetchJson } from "../api.dom";
|
|
22
22
|
import { openNote } from "../api";
|
|
23
23
|
import type { ColorScheme } from "../graph/graph.model";
|
|
@@ -31,15 +31,19 @@ import type { WorkspaceHandle } from "../workspace";
|
|
|
31
31
|
import { startWorkspace } from "../workspace";
|
|
32
32
|
import { Columns } from "./Columns";
|
|
33
33
|
import { deeplinkSelection, formatHash } from "./deeplink.model";
|
|
34
|
+
import { dividerHandlers } from "./drag.model";
|
|
34
35
|
import { Header } from "./Header";
|
|
35
36
|
import { HelpOverlay } from "./HelpOverlay";
|
|
36
37
|
import { watchKeys } from "./keys";
|
|
37
38
|
import { focusSelector, runShellAction } from "./keys.model";
|
|
38
39
|
import { StatusBar } from "./StatusBar";
|
|
40
|
+
import type { LayoutState } from "./layout.model";
|
|
41
|
+
import { breakpointFor, loadLayout, resolveColumns, saveLayout } from "./layout.model";
|
|
39
42
|
import type { OverlayId } from "./shell.model";
|
|
40
43
|
import { TICK_MS, looksApple, searchShortcut, statusBarModel, summarize } from "./shell.model";
|
|
41
44
|
import { cycleTheme, effectiveScheme, loadTheme, saveTheme, themeAttr, themeButton } from "./theme.model";
|
|
42
45
|
import type { ThemeChoice } from "./theme.model";
|
|
46
|
+
import { watchViewport } from "./viewport";
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* Write the choice onto `<html>`, so the sheet's attribute branch and the
|
|
@@ -53,6 +57,8 @@ function applyThemeAttr(choice: ReturnType<typeof themeAttr>): void {
|
|
|
53
57
|
export interface ShellProps {
|
|
54
58
|
/** From the page bootstrap. Shown in the status bar. */
|
|
55
59
|
cwd: string;
|
|
60
|
+
/** `window.innerWidth` at mount, used for the first breakpoint/layout. */
|
|
61
|
+
initialWidth: number;
|
|
56
62
|
/** `navigator.platform`, for the `⌘K` vs `Ctrl K` hint. */
|
|
57
63
|
platform: string;
|
|
58
64
|
}
|
|
@@ -60,6 +66,8 @@ export interface ShellProps {
|
|
|
60
66
|
export function Shell(props: ShellProps) {
|
|
61
67
|
const [workspaceState, setWorkspaceState] = useState<WorkspaceState>(initialWorkspaceState);
|
|
62
68
|
const [overlay, setOverlay] = useState<OverlayId>(null);
|
|
69
|
+
const [width, setWidth] = useState(props.initialWidth);
|
|
70
|
+
const [layout, setLayout] = useState<LayoutState>(() => loadLayout(localStorage, props.initialWidth));
|
|
63
71
|
// The theme: what the user picked, and what the OS is currently saying. Two
|
|
64
72
|
// states because they answer different questions — `theme` changes on a
|
|
65
73
|
// button press or the `t` key, `systemScheme` on an OS flip while the user
|
|
@@ -72,8 +80,8 @@ export function Shell(props: ShellProps) {
|
|
|
72
80
|
const fit = useRef<(() => void) | null>(null);
|
|
73
81
|
// The global key listener reads through this so a handler registered at
|
|
74
82
|
// mount still sees the current overlay.
|
|
75
|
-
const live = useRef({ overlay, selectedId: workspaceState.selectedId });
|
|
76
|
-
live.current = { overlay, selectedId: workspaceState.selectedId };
|
|
83
|
+
const live = useRef({ overlay, selectedId: workspaceState.selectedId, layout, width });
|
|
84
|
+
live.current = { overlay, selectedId: workspaceState.selectedId, layout, width };
|
|
77
85
|
|
|
78
86
|
useEffect(() => {
|
|
79
87
|
const handle = startWorkspace({ fetch: fetchJson, state: workspaceState, setState: setWorkspaceState });
|
|
@@ -81,6 +89,8 @@ export function Shell(props: ShellProps) {
|
|
|
81
89
|
return () => handle.stop();
|
|
82
90
|
}, []);
|
|
83
91
|
|
|
92
|
+
useEffect(() => watchViewport(window, setWidth), []);
|
|
93
|
+
|
|
84
94
|
// §1.3 continuity: a reload keeps the note you were reading. Saving is
|
|
85
95
|
// gated on the restore decision so the mount-time `null` cannot wipe the
|
|
86
96
|
// saved id before the first graph arrives to validate it against.
|
|
@@ -152,6 +162,14 @@ export function Shell(props: ShellProps) {
|
|
|
152
162
|
[],
|
|
153
163
|
);
|
|
154
164
|
|
|
165
|
+
const resolved = useMemo(() => resolveColumns(layout, width, breakpointFor(width)), [layout, width]);
|
|
166
|
+
const drag = useMemo(() => dividerHandlers({
|
|
167
|
+
layout: () => live.current.layout,
|
|
168
|
+
width: () => live.current.width,
|
|
169
|
+
setLayout,
|
|
170
|
+
persist: (next) => void saveLayout(localStorage, next),
|
|
171
|
+
}), []);
|
|
172
|
+
|
|
155
173
|
return (
|
|
156
174
|
<>
|
|
157
175
|
<Header
|
|
@@ -163,6 +181,11 @@ export function Shell(props: ShellProps) {
|
|
|
163
181
|
onTheme={() => setTheme((current) => cycleTheme(current))}
|
|
164
182
|
/>
|
|
165
183
|
<Columns
|
|
184
|
+
resolved={resolved}
|
|
185
|
+
onDown={drag.onDown}
|
|
186
|
+
onMove={drag.onMove}
|
|
187
|
+
onUp={drag.onUp}
|
|
188
|
+
onKey={drag.onKey}
|
|
166
189
|
scheme={effectiveScheme(theme, systemScheme)}
|
|
167
190
|
bootFailed={workspaceState.graphFailed}
|
|
168
191
|
graph={workspaceState.graph}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface StyleTarget { setProperty(property: string, value: string): void }
|
|
2
|
+
export interface StyledElement { readonly style: StyleTarget }
|
|
3
|
+
|
|
4
|
+
export function applyVars(element: StyledElement | null, vars: readonly (readonly [string, string])[]): number {
|
|
5
|
+
if (element === null) return 0;
|
|
6
|
+
for (const [name, value] of vars) element.style.setProperty(name, value);
|
|
7
|
+
return vars.length;
|
|
8
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { COLUMNS, resizeAt } from "./layout.model";
|
|
2
|
+
import type { DividerId, LayoutState } from "./layout.model";
|
|
3
|
+
|
|
4
|
+
export interface DragState { readonly divider: DividerId; readonly origin: number; readonly base: LayoutState; readonly pointerId: number }
|
|
5
|
+
export function beginDrag(divider: DividerId, origin: number, base: LayoutState, pointerId: number): DragState { return { divider, origin, base, pointerId }; }
|
|
6
|
+
export function dragTo(drag: DragState, clientX: number, available: number): LayoutState { return resizeAt(drag.base, drag.divider, clientX - drag.origin, available); }
|
|
7
|
+
export function dragChanged(drag: DragState, final: LayoutState): boolean {
|
|
8
|
+
return COLUMNS.some((id) => drag.base.fractions[id] !== final.fractions[id]);
|
|
9
|
+
}
|
|
10
|
+
export const NUDGE_PX = 24;
|
|
11
|
+
export function nudgeFor(key: string): number { return key === "ArrowLeft" ? -NUDGE_PX : key === "ArrowRight" ? NUDGE_PX : 0; }
|
|
12
|
+
|
|
13
|
+
export interface DragHost {
|
|
14
|
+
layout(): LayoutState;
|
|
15
|
+
width(): number;
|
|
16
|
+
setLayout(next: LayoutState): void;
|
|
17
|
+
persist(layout: LayoutState): void;
|
|
18
|
+
}
|
|
19
|
+
export interface DividerHandlers {
|
|
20
|
+
onDown(divider: DividerId, clientX: number, pointerId: number): void;
|
|
21
|
+
onMove(clientX: number): void;
|
|
22
|
+
onUp(): void;
|
|
23
|
+
onKey(divider: DividerId, key: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function dividerHandlers(host: DragHost): DividerHandlers {
|
|
27
|
+
let active: DragState | null = null;
|
|
28
|
+
return {
|
|
29
|
+
onDown(divider, clientX, pointerId) { active = beginDrag(divider, clientX, host.layout(), pointerId); },
|
|
30
|
+
onMove(clientX) { if (active !== null) host.setLayout(dragTo(active, clientX, host.width())); },
|
|
31
|
+
onUp() {
|
|
32
|
+
const drag = active;
|
|
33
|
+
active = null;
|
|
34
|
+
if (drag !== null && dragChanged(drag, host.layout())) host.persist(host.layout());
|
|
35
|
+
},
|
|
36
|
+
onKey(divider, key) {
|
|
37
|
+
const next = resizeAt(host.layout(), divider, nudgeFor(key), host.width());
|
|
38
|
+
if (next === host.layout()) return;
|
|
39
|
+
host.setLayout(next);
|
|
40
|
+
host.persist(next);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Pure model for the resizable workspace columns. */
|
|
2
|
+
|
|
3
|
+
export type ColumnId = "tree" | "note" | "graph";
|
|
4
|
+
export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
|
|
5
|
+
export type Columns<T> = { readonly [K in ColumnId]: T };
|
|
6
|
+
export const MIN_WIDTHS: Columns<number> = { tree: 180, note: 320, graph: 260 };
|
|
7
|
+
export const DEFAULT_FRACTIONS: Columns<number> = { tree: 0.22, note: 0.46, graph: 0.32 };
|
|
8
|
+
|
|
9
|
+
export type Breakpoint = "wide" | "medium" | "narrow";
|
|
10
|
+
export const BREAKPOINT_MEDIUM = 1100;
|
|
11
|
+
export const BREAKPOINT_NARROW = 800;
|
|
12
|
+
|
|
13
|
+
export function breakpointFor(width: number): Breakpoint {
|
|
14
|
+
if (!Number.isFinite(width) || width < BREAKPOINT_NARROW) return "narrow";
|
|
15
|
+
return width < BREAKPOINT_MEDIUM ? "medium" : "wide";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function columnsAt(breakpoint: Breakpoint): readonly ColumnId[] {
|
|
19
|
+
return breakpoint === "wide" ? COLUMNS : breakpoint === "medium" ? ["tree", "note"] : ["note"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isCollapsed(breakpoint: Breakpoint, column: ColumnId): boolean {
|
|
23
|
+
return !columnsAt(breakpoint).includes(column);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LayoutState { readonly fractions: Columns<number> }
|
|
27
|
+
|
|
28
|
+
function normalizeOver(ids: readonly ColumnId[], fractions: Columns<number>, floors: Columns<number>): Columns<number> {
|
|
29
|
+
const clean: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const id of ids) {
|
|
32
|
+
const value = fractions[id];
|
|
33
|
+
clean[id] = Number.isFinite(value) && value > 0 ? value : DEFAULT_FRACTIONS[id];
|
|
34
|
+
total += clean[id];
|
|
35
|
+
}
|
|
36
|
+
for (const id of ids) clean[id] /= total;
|
|
37
|
+
let deficit = 0;
|
|
38
|
+
let slack = 0;
|
|
39
|
+
const atFloor: Record<ColumnId, boolean> = { tree: false, note: false, graph: false };
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const floor = floors[id];
|
|
42
|
+
if (clean[id] < floor) {
|
|
43
|
+
deficit += floor - clean[id];
|
|
44
|
+
clean[id] = floor;
|
|
45
|
+
atFloor[id] = true;
|
|
46
|
+
} else slack += clean[id] - floor;
|
|
47
|
+
}
|
|
48
|
+
if (deficit > 0 && slack > 0) {
|
|
49
|
+
const rate = Math.min(deficit, slack) / slack;
|
|
50
|
+
for (const id of ids) if (!atFloor[id]) clean[id] -= (clean[id] - floors[id]) * rate;
|
|
51
|
+
}
|
|
52
|
+
return { tree: clean.tree, note: clean.note, graph: clean.graph };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeFractions(fractions: Columns<number>, minShare: Columns<number>): Columns<number> {
|
|
56
|
+
return normalizeOver(COLUMNS, fractions, minShare);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function minShares(available: number): Columns<number> {
|
|
60
|
+
const width = Number.isFinite(available) && available > 0 ? available : 1;
|
|
61
|
+
const raw = { tree: MIN_WIDTHS.tree / width, note: MIN_WIDTHS.note / width, graph: MIN_WIDTHS.graph / width };
|
|
62
|
+
const total = raw.tree + raw.note + raw.graph;
|
|
63
|
+
if (total <= 0.9) return raw;
|
|
64
|
+
const scale = 0.9 / total;
|
|
65
|
+
return { tree: raw.tree * scale, note: raw.note * scale, graph: raw.graph * scale };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeLayout(fractions: Columns<number>, available: number): LayoutState {
|
|
69
|
+
return { fractions: normalizeFractions(fractions, minShares(available)) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function defaultLayout(available: number): LayoutState {
|
|
73
|
+
return makeLayout(DEFAULT_FRACTIONS, available);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedColumn { readonly id: ColumnId; readonly width: number }
|
|
77
|
+
|
|
78
|
+
export function resolveColumns(state: LayoutState, available: number, breakpoint: Breakpoint): readonly ResolvedColumn[] {
|
|
79
|
+
const width = Number.isFinite(available) && available > 0 ? available : 0;
|
|
80
|
+
const shares = normalizeOver(columnsAt(breakpoint), state.fractions, minShares(width));
|
|
81
|
+
return columnsAt(breakpoint).map((id) => ({ id, width: shares[id] * width }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type DividerId = "tree" | "note";
|
|
85
|
+
export const DIVIDERS: readonly DividerId[] = ["tree", "note"];
|
|
86
|
+
export function dividerPair(divider: DividerId): readonly [ColumnId, ColumnId] {
|
|
87
|
+
return divider === "tree" ? ["tree", "note"] : ["note", "graph"];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resizeAt(state: LayoutState, divider: DividerId, deltaPx: number, available: number): LayoutState {
|
|
91
|
+
const width = Number.isFinite(available) && available > 0 ? available : 0;
|
|
92
|
+
if (width === 0 || !Number.isFinite(deltaPx) || deltaPx === 0) return state;
|
|
93
|
+
const [left, right] = dividerPair(divider);
|
|
94
|
+
const floors = minShares(width);
|
|
95
|
+
const delta = Math.max(-(Math.max(0, state.fractions[left] - floors[left])), Math.min(Math.max(0, state.fractions[right] - floors[right]), deltaPx / width));
|
|
96
|
+
if (delta === 0) return state;
|
|
97
|
+
return { fractions: normalizeFractions({ ...state.fractions, [left]: state.fractions[left] + delta, [right]: state.fractions[right] - delta }, floors) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface LayoutStorage { getItem(key: string): string | null; setItem(key: string, value: string): void }
|
|
101
|
+
export const LAYOUT_STORAGE_KEY = "pi-weave.layout.v1";
|
|
102
|
+
const round4 = (value: number): number => Math.round(value * 10000) / 10000;
|
|
103
|
+
|
|
104
|
+
export function serializeLayout(state: LayoutState): string {
|
|
105
|
+
return JSON.stringify({ v: 1, tree: round4(state.fractions.tree), note: round4(state.fractions.note), graph: round4(state.fractions.graph) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function deserializeLayout(raw: string | null, available: number): LayoutState | null {
|
|
109
|
+
if (raw === null) return null;
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
112
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
113
|
+
const record = parsed as Record<string, unknown>;
|
|
114
|
+
if (record["v"] !== 1) return null;
|
|
115
|
+
const fractions = {} as Record<ColumnId, number>;
|
|
116
|
+
for (const id of COLUMNS) {
|
|
117
|
+
const value = record[id];
|
|
118
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
119
|
+
fractions[id] = value;
|
|
120
|
+
}
|
|
121
|
+
return makeLayout(fractions, available);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function loadLayout(storage: LayoutStorage, available: number): LayoutState {
|
|
125
|
+
try { return deserializeLayout(storage.getItem(LAYOUT_STORAGE_KEY), available) ?? defaultLayout(available); }
|
|
126
|
+
catch { return defaultLayout(available); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function saveLayout(storage: LayoutStorage, state: LayoutState): boolean {
|
|
130
|
+
try { storage.setItem(LAYOUT_STORAGE_KEY, serializeLayout(state)); return true; }
|
|
131
|
+
catch { return false; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function columnVar(column: ColumnId): string { return `--weave-col-${column}`; }
|
|
135
|
+
export function columnValue(width: number): string { return `${Math.round(width)}px`; }
|
|
136
|
+
export function columnVars(resolved: readonly ResolvedColumn[]): readonly (readonly [string, string])[] {
|
|
137
|
+
return resolved.map((column) => [columnVar(column.id), columnValue(column.width)] as const);
|
|
138
|
+
}
|
|
@@ -15,11 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
|
|
18
|
-
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
/** Columns in keyboard and visual order. */
|
|
22
|
-
export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
|
|
18
|
+
import { COLUMNS, type ColumnId, type DividerId, type ResolvedColumn } from "./layout.model";
|
|
19
|
+
export { COLUMNS };
|
|
20
|
+
export type { ColumnId };
|
|
23
21
|
|
|
24
22
|
// --- the header summary --------------------------------------------------------
|
|
25
23
|
|
|
@@ -108,6 +106,22 @@ export const CONTEXT_EMPTY: EmptyStateCopy = {
|
|
|
108
106
|
title: "Context",
|
|
109
107
|
};
|
|
110
108
|
|
|
109
|
+
export interface ColumnSlot {
|
|
110
|
+
readonly column: ResolvedColumn;
|
|
111
|
+
readonly divider: DividerId | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function columnSlots(resolved: readonly ResolvedColumn[]): readonly ColumnSlot[] {
|
|
115
|
+
return resolved.map((column, index) => ({
|
|
116
|
+
column,
|
|
117
|
+
divider: index < resolved.length - 1 && isDivider(column.id) ? column.id : null,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isDivider(column: ColumnId): column is DividerId {
|
|
122
|
+
return column !== "graph";
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
// --- the status bar -------------------------------------------------------------------
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -245,20 +245,20 @@ body{font-size:var(--weave-px-base)}
|
|
|
245
245
|
.weave-theme:hover{color:var(--weave-fg);background:var(--weave-line)}
|
|
246
246
|
|
|
247
247
|
/* the grid -------------------------------------------------------------- */
|
|
248
|
-
/*
|
|
249
|
-
|
|
250
|
-
.weave-grid{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
@media (max-width:799px){
|
|
259
|
-
.weave-grid{grid-template-columns:1fr}
|
|
260
|
-
.weave-col-tree{display:none}
|
|
248
|
+
/* Widths arrive as custom properties from layout.model.ts. Shell/layout.model
|
|
249
|
+
selects the breakpoint; CSS consumes the resulting data-columns shape. */
|
|
250
|
+
.weave-grid{display:grid;min-height:0;overflow:hidden;background:var(--weave-line)}
|
|
251
|
+
.weave-grid[data-columns="3"]{grid-template-columns:var(--weave-col-tree,22%) 1px var(--weave-col-note,46%) 1px var(--weave-col-graph,32%)}
|
|
252
|
+
.weave-grid[data-columns="2"]{grid-template-columns:var(--weave-col-tree,32%) 1px var(--weave-col-note,68%)}
|
|
253
|
+
.weave-grid[data-columns="1"]{grid-template-columns:1fr}
|
|
254
|
+
|
|
255
|
+
/* dividers -------------------------------------------------------------- */
|
|
256
|
+
.weave-divider{
|
|
257
|
+
width:1px;cursor:col-resize;touch-action:none;background:var(--weave-line);position:relative;z-index:2;
|
|
261
258
|
}
|
|
259
|
+
.weave-divider::after{content:"";position:absolute;inset:0 -4px}
|
|
260
|
+
.weave-divider:hover{background:var(--weave-faint)}
|
|
261
|
+
.weave-divider:focus-visible{outline:2px solid var(--weave-accent);outline-offset:-1px}
|
|
262
262
|
|
|
263
263
|
.weave-col{
|
|
264
264
|
display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;
|
|
@@ -374,6 +374,7 @@ body{font-size:var(--weave-px-base)}
|
|
|
374
374
|
.weave-note-generated{--weave-spine:var(--weave-faint)}
|
|
375
375
|
.weave-note{border-left:2px solid var(--weave-spine,transparent)}
|
|
376
376
|
.weave-note-empty{flex:1;margin:0;padding:14px var(--weave-note-gutter);color:var(--weave-dim);max-width:44ch;line-height:1.5;background:var(--weave-page)}
|
|
377
|
+
.weave-artifact-frame{display:block;flex:1;width:100%;min-height:320px;border:0;background:#fff}
|
|
377
378
|
/* The head pins itself (P6.3): a long note scrolls its prose under the title,
|
|
378
379
|
and the title is what says you are still in the right document — the
|
|
379
380
|
alternative, a title that scrolls away, is how a reader ends up annotating
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ViewportHost {
|
|
2
|
+
readonly innerWidth: number;
|
|
3
|
+
addEventListener(type: "resize", listener: () => void): void;
|
|
4
|
+
removeEventListener(type: "resize", listener: () => void): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function watchViewport(host: ViewportHost, onChange: (width: number) => void): () => void {
|
|
8
|
+
const listener = (): void => onChange(host.innerWidth);
|
|
9
|
+
host.addEventListener("resize", listener);
|
|
10
|
+
return () => host.removeEventListener("resize", listener);
|
|
11
|
+
}
|
package/src/web/server/page.ts
CHANGED
package/src/web/server/routes.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
|
|
9
9
|
* | GET | `/api/note/:slug` | {@link NotePayload} |
|
|
10
10
|
* | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
|
|
11
|
+
* | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
|
|
11
12
|
* | GET | `/api/search?q=` | {@link SearchPayload} |
|
|
12
13
|
* | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
|
|
13
14
|
*
|
|
@@ -28,10 +29,11 @@
|
|
|
28
29
|
* would tell a browser to hand a rebinding attacker's JavaScript the
|
|
29
30
|
* response body it otherwise could not read. A test asserts the absence.
|
|
30
31
|
*
|
|
31
|
-
* **No path resolution of its own.** `/api/okf/:rel` and `/api/note/:slug`
|
|
32
|
-
* carry untrusted path fragments straight from the URL.
|
|
33
|
-
* the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf
|
|
34
|
-
*
|
|
32
|
+
* **No path resolution of its own.** `/api/okf/:rel`, `/api/artifact/:rel` and `/api/note/:slug`
|
|
33
|
+
* carry untrusted path fragments straight from the URL. All three are handed to
|
|
34
|
+
* the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf`,
|
|
35
|
+
* `resolveHtmlPath` anchors under the vault notes directory, and
|
|
36
|
+
* `resolveNotePath` (via `getNote`) rejects unsafe slugs.
|
|
35
37
|
*/
|
|
36
38
|
|
|
37
39
|
import { createHash } from "node:crypto";
|
|
@@ -43,7 +45,7 @@ import { readOkfFileForView } from "../../core/graph/current";
|
|
|
43
45
|
import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
|
|
44
46
|
import { openNoteInEditor } from "../../core/openInEditor";
|
|
45
47
|
import type { Note } from "../../core/types";
|
|
46
|
-
import { getNote, searchNotes } from "../../core/vault";
|
|
48
|
+
import { getNote, resolveHtmlPath, searchNotes } from "../../core/vault";
|
|
47
49
|
import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
|
|
48
50
|
import type {
|
|
49
51
|
GraphPayload,
|
|
@@ -372,6 +374,9 @@ async function route(
|
|
|
372
374
|
if (method === "GET" && path.startsWith("/api/okf/")) {
|
|
373
375
|
return sendOkf(deps, path.slice("/api/okf/".length), res);
|
|
374
376
|
}
|
|
377
|
+
if (method === "GET" && path.startsWith("/api/artifact/")) {
|
|
378
|
+
return sendArtifact(deps, path.slice("/api/artifact/".length), res);
|
|
379
|
+
}
|
|
375
380
|
if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
|
|
376
381
|
if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
|
|
377
382
|
sendText(res, 404, "not found\n");
|
|
@@ -576,6 +581,32 @@ async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promi
|
|
|
576
581
|
sendJson(res, 200, payload, { "cache-control": "no-store" });
|
|
577
582
|
}
|
|
578
583
|
|
|
584
|
+
/** Serve a vault HTML artifact as a sandboxed iframe document. */
|
|
585
|
+
async function sendArtifact(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
|
|
586
|
+
const path = resolveHtmlPath(deps.vaultRoot, rel);
|
|
587
|
+
if (path === null) {
|
|
588
|
+
sendJson(res, 404, { error: "no such artifact" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
let body: Buffer;
|
|
592
|
+
try {
|
|
593
|
+
body = await readFile(path);
|
|
594
|
+
} catch {
|
|
595
|
+
sendJson(res, 404, { error: "no such artifact" });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
res.writeHead(200, {
|
|
599
|
+
...baseHeaders(),
|
|
600
|
+
"content-type": "text/html; charset=utf-8",
|
|
601
|
+
// The iframe is a preview, not an extension of the workspace origin.
|
|
602
|
+
// `sandbox allow-scripts` keeps demos interactive while retaining an
|
|
603
|
+
// opaque origin: scripts cannot reach the workspace or its cookies.
|
|
604
|
+
"content-security-policy": "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:",
|
|
605
|
+
"cache-control": "no-store",
|
|
606
|
+
});
|
|
607
|
+
res.end(body);
|
|
608
|
+
}
|
|
609
|
+
|
|
579
610
|
async function sendSearch(deps: RouteDeps, query: URLSearchParams, res: ServerResponse): Promise<void> {
|
|
580
611
|
const q = query.get("q") ?? "";
|
|
581
612
|
// `searchNotes` already returns `[]` for an empty query, so a missing `q`
|