CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Android (Tauri) / Android APK (debug) (push) Successful in 3m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m57s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Which lens you're looking at is a property of the space, not of a page you navigated to — so the name now sits in the bar that never moves, beside the app name, and stays put while everything beneath it re-filters. It replaces three per-view <h1>s that each sat in a different place with slightly different markup (timeline, reminders, graph) and, more to the point, were absent entirely on the board and in search — the two lenses people spend the most time in had no name at all. A label lens is named by the label itself, because "Groceries" is what the user came looking for and "Label" tells them nothing. Shown at every width rather than hidden on small screens, which was my first cut and would have been a regression: deleting the per-view titles while hiding the shell one leaves a phone with no lens name anywhere, and Android is a peer surface now. Below `sm` the app name is already hidden, so the lens name simply takes the space it vacates — you know which app you're in; what you need is which lens. The h1s on Settings, Sync, Account, Login and Register are untouched: those routes render outside the shell entirely, so they have no chrome to be named by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
421 lines
12 KiB
Vue
421 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { api } from "../api/client";
|
|
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
|
import { useNoteEditor } from "../composables/useNoteEditor";
|
|
import AsyncState from "../components/AsyncState.vue";
|
|
import NoteEditor from "../components/NoteEditor.vue";
|
|
|
|
type NodeKind = "note" | "label";
|
|
|
|
interface GNode {
|
|
id: string;
|
|
title: string;
|
|
color: string;
|
|
kind: NodeKind;
|
|
labelId?: string;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
}
|
|
interface GEdge {
|
|
source: string;
|
|
target: string;
|
|
kind?: string;
|
|
}
|
|
|
|
const WIDTH = 1000;
|
|
const HEIGHT = 700;
|
|
|
|
const router = useRouter();
|
|
const allNodes = ref<GNode[]>([]);
|
|
const edges = ref<GEdge[]>([]);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
// Editor host: resolve a node id against the store, else fetch (shared controller).
|
|
const { editing, close: closeEditor, navigate } = useNoteEditor();
|
|
// Unlinked notes float in the space by default — the graph is a gentle overview,
|
|
// not a links-only surface. Label hubs are on by default so tags cluster notes.
|
|
const showAll = ref(true);
|
|
const showLabels = ref(true);
|
|
|
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
const gRef = ref<SVGGElement | null>(null);
|
|
|
|
// Pan/zoom applied to the inner <g>.
|
|
const scale = ref(1);
|
|
const tx = ref(0);
|
|
const ty = ref(0);
|
|
|
|
let frame = 0;
|
|
let raf = 0;
|
|
|
|
// Label membership edges drop out when the label hubs are hidden.
|
|
const visibleEdges = computed(() =>
|
|
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
|
|
);
|
|
|
|
const connectedIds = computed(() => {
|
|
const s = new Set<string>();
|
|
for (const e of visibleEdges.value) {
|
|
s.add(e.source);
|
|
s.add(e.target);
|
|
}
|
|
return s;
|
|
});
|
|
|
|
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
|
|
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
|
|
const activeNodes = computed(() =>
|
|
allNodes.value.filter((n) => {
|
|
if (n.kind === "label" && !showLabels.value) return false;
|
|
if (showAll.value) return true;
|
|
return connectedIds.value.has(n.id);
|
|
}),
|
|
);
|
|
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
|
|
|
|
const edgeLines = computed(() => {
|
|
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
|
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
|
|
for (const e of visibleEdges.value) {
|
|
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
|
const s = byId.get(e.source);
|
|
const t = byId.get(e.target);
|
|
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
|
|
}
|
|
return out;
|
|
});
|
|
|
|
function fill(color: string): string {
|
|
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
|
|
}
|
|
|
|
async function loadGraph() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const res = await api.get<{
|
|
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
|
|
edges: GEdge[];
|
|
}>("/api/graph");
|
|
const cx = WIDTH / 2;
|
|
const cy = HEIGHT / 2;
|
|
const count = Math.max(res.nodes.length, 1);
|
|
allNodes.value = res.nodes.map((n, i) => {
|
|
const angle = (i / count) * Math.PI * 2;
|
|
return {
|
|
id: n.id,
|
|
title: n.title,
|
|
color: n.color,
|
|
kind: n.kind,
|
|
labelId: n.label_id,
|
|
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
vx: 0,
|
|
vy: 0,
|
|
};
|
|
});
|
|
edges.value = res.edges;
|
|
} catch (e) {
|
|
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
|
|
allNodes.value = [];
|
|
edges.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
reheat();
|
|
}
|
|
|
|
function reheat() {
|
|
frame = 0;
|
|
cancelAnimationFrame(raf);
|
|
raf = 0;
|
|
if (activeNodes.value.length) simulate();
|
|
}
|
|
|
|
function simulate() {
|
|
const list = activeNodes.value;
|
|
const cx = WIDTH / 2;
|
|
const cy = HEIGHT / 2;
|
|
const byId = new Map(list.map((n) => [n.id, n]));
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
for (let j = i + 1; j < list.length; j++) {
|
|
const a = list[i];
|
|
const b = list[j];
|
|
let dx = a.x - b.x;
|
|
let dy = a.y - b.y;
|
|
let d2 = dx * dx + dy * dy;
|
|
if (d2 < 0.01) {
|
|
d2 = 0.01;
|
|
dx = Math.random();
|
|
dy = Math.random();
|
|
}
|
|
const d = Math.sqrt(d2);
|
|
const force = 6000 / d2;
|
|
const fx = (dx / d) * force;
|
|
const fy = (dy / d) * force;
|
|
a.vx += fx;
|
|
a.vy += fy;
|
|
b.vx -= fx;
|
|
b.vy -= fy;
|
|
}
|
|
}
|
|
|
|
for (const e of visibleEdges.value) {
|
|
const s = byId.get(e.source);
|
|
const t = byId.get(e.target);
|
|
if (!s || !t) continue;
|
|
const dx = t.x - s.x;
|
|
const dy = t.y - s.y;
|
|
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
// Label-membership springs sit a touch longer so hubs ring their notes.
|
|
const rest = e.kind === "label" ? 150 : 130;
|
|
const diff = (d - rest) * 0.02;
|
|
const fx = (dx / d) * diff;
|
|
const fy = (dy / d) * diff;
|
|
s.vx += fx;
|
|
s.vy += fy;
|
|
t.vx -= fx;
|
|
t.vy -= fy;
|
|
}
|
|
|
|
for (const n of list) {
|
|
if (n === dragNode) {
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
continue; // pinned to the cursor while dragging
|
|
}
|
|
n.vx += (cx - n.x) * 0.002;
|
|
n.vy += (cy - n.y) * 0.002;
|
|
n.vx *= 0.85;
|
|
n.vy *= 0.85;
|
|
n.x += n.vx;
|
|
n.y += n.vy;
|
|
}
|
|
|
|
frame++;
|
|
// Keep running while cooling, or indefinitely while a node is being dragged.
|
|
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
|
|
}
|
|
|
|
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
|
|
let dragNode: GNode | null = null;
|
|
let dragMoved = false;
|
|
let downPos = { x: 0, y: 0 };
|
|
let panning = false;
|
|
let panLast = { x: 0, y: 0 };
|
|
|
|
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
|
|
const ctm = el?.getScreenCTM();
|
|
if (!ctm) return { x: 0, y: 0 };
|
|
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
|
return { x: p.x, y: p.y };
|
|
}
|
|
|
|
function onNodeDown(n: GNode, e: MouseEvent) {
|
|
e.stopPropagation();
|
|
dragNode = n;
|
|
dragMoved = false;
|
|
downPos = { x: e.clientX, y: e.clientY };
|
|
reheat();
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function onBgDown(e: MouseEvent) {
|
|
panning = true;
|
|
panLast = toLocal(svgRef.value, e);
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function onMove(e: MouseEvent) {
|
|
if (dragNode) {
|
|
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
|
|
const p = toLocal(gRef.value, e);
|
|
dragNode.x = p.x;
|
|
dragNode.y = p.y;
|
|
} else if (panning) {
|
|
const p = toLocal(svgRef.value, e);
|
|
tx.value += p.x - panLast.x;
|
|
ty.value += p.y - panLast.y;
|
|
panLast = p;
|
|
}
|
|
}
|
|
|
|
function onUp() {
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
const node = dragNode;
|
|
const moved = dragMoved;
|
|
dragNode = null;
|
|
panning = false;
|
|
// A press without a drag is a click.
|
|
if (node && !moved) clickNode(node);
|
|
}
|
|
|
|
function clickNode(n: GNode) {
|
|
// Label hub → jump to that label's board lens (one space, many lenses).
|
|
if (n.kind === "label" && n.labelId) {
|
|
void router.push(`/label/${n.labelId}`);
|
|
return;
|
|
}
|
|
void navigate(n.id);
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
const vb = toLocal(svgRef.value, e);
|
|
const gx = (vb.x - tx.value) / scale.value;
|
|
const gy = (vb.y - ty.value) / scale.value;
|
|
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
|
|
tx.value = vb.x - gx * scale.value;
|
|
ty.value = vb.y - gy * scale.value;
|
|
}
|
|
|
|
function resetView() {
|
|
scale.value = 1;
|
|
tx.value = 0;
|
|
ty.value = 0;
|
|
}
|
|
|
|
function toggleAll() {
|
|
showAll.value = !showAll.value;
|
|
reheat();
|
|
}
|
|
|
|
function toggleLabels() {
|
|
showLabels.value = !showLabels.value;
|
|
reheat();
|
|
}
|
|
|
|
onMounted(loadGraph);
|
|
onBeforeUnmount(() => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col p-4">
|
|
<!-- Titled by the shell's persistent lens name (task 1913). -->
|
|
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
|
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
|
|
Show labels
|
|
</label>
|
|
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
|
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
|
Show unlinked notes
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@click="resetView"
|
|
>
|
|
Reset view
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<AsyncState
|
|
:loading="loading"
|
|
:error="error || undefined"
|
|
error-title="Couldn't load the graph"
|
|
@retry="loadGraph"
|
|
>
|
|
<div v-if="allNodes.length === 0" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">
|
|
Create notes and link them with
|
|
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">
|
|
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
|
|
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
|
|
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
|
|
show all notes
|
|
</button>.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
|
>
|
|
<svg
|
|
ref="svgRef"
|
|
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
|
class="h-full w-full cursor-grab select-none touch-none"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
@mousedown="onBgDown"
|
|
@wheel="onWheel"
|
|
>
|
|
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
|
|
<line
|
|
v-for="(l, i) in edgeLines"
|
|
:key="`e${i}`"
|
|
:x1="l.x1"
|
|
:y1="l.y1"
|
|
:x2="l.x2"
|
|
:y2="l.y2"
|
|
class="stroke-neutral-300 dark:stroke-neutral-700"
|
|
:stroke-width="l.label ? 1 : 1.5"
|
|
:stroke-dasharray="l.label ? '3 3' : undefined"
|
|
/>
|
|
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
|
|
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
|
|
<circle
|
|
v-if="n.kind === 'label'"
|
|
:cx="n.x"
|
|
:cy="n.y"
|
|
r="12"
|
|
:fill="fill(n.color)"
|
|
fill-opacity="0.9"
|
|
class="stroke-neutral-50 dark:stroke-neutral-950"
|
|
stroke-width="3"
|
|
/>
|
|
<circle
|
|
v-else
|
|
:cx="n.x"
|
|
:cy="n.y"
|
|
r="8"
|
|
:fill="fill(n.color)"
|
|
class="stroke-neutral-50 dark:stroke-neutral-950"
|
|
stroke-width="1.5"
|
|
/>
|
|
<text
|
|
:x="n.x"
|
|
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
|
|
text-anchor="middle"
|
|
:class="
|
|
n.kind === 'label'
|
|
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
|
|
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
|
|
"
|
|
>
|
|
{{ n.title }}
|
|
</text>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</AsyncState>
|
|
|
|
<template v-if="editing">
|
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="navigate" />
|
|
</template>
|
|
</div>
|
|
</template>
|