Remove [[wiki-links]], backlinks and the graph
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.
Two things deliberately kept, because they were serving recall and only
incidentally serving links:
- `/api/notes/titles` and the titles store. The command palette lists them so
you can jump to a note by name. `resolve()` — the name→note lookup that only
linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
filenames. What that name is FOR changed; that it exists did not.
`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.
Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
This commit is contained in:
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
|
||||
import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo";
|
||||
import type { DeviceToken, ImportResult, Repo } from "./repo";
|
||||
|
||||
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
||||
|
||||
@@ -51,7 +51,6 @@ export const local: Repo = {
|
||||
list: (query) => invoke<Note[]>("notes_list", { query }),
|
||||
get: (id) => invoke<Note>("notes_get", { id }),
|
||||
create: (input) => invoke<Note>("notes_create", { input }),
|
||||
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
|
||||
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
|
||||
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||
@@ -73,8 +72,6 @@ export const local: Repo = {
|
||||
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||
search: (q) => invoke<Note[]>("notes_search", { q }),
|
||||
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
|
||||
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
|
||||
},
|
||||
|
||||
savedFilters: {
|
||||
|
||||
@@ -49,10 +49,6 @@ export interface ChecklistItemChanges {
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface Backlink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
source: string;
|
||||
@@ -97,7 +93,6 @@ export interface NotesRepo {
|
||||
list(query: NoteListQuery): Promise<Note[]>;
|
||||
get(id: string): Promise<Note>;
|
||||
create(input: NoteCreateInput): Promise<Note>;
|
||||
createTitled(title: string): Promise<Note>;
|
||||
update(id: string, changes: NoteChanges): Promise<Note>;
|
||||
completeReminder(id: string): Promise<Note>;
|
||||
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
||||
@@ -119,8 +114,6 @@ export interface NotesRepo {
|
||||
reminders(): Promise<Note[]>;
|
||||
titles(): Promise<TitleEntry[]>;
|
||||
search(q: string): Promise<Note[]>;
|
||||
backlinks(id: string): Promise<Backlink[]>;
|
||||
linkSearch(q: string): Promise<TitleEntry[]>;
|
||||
}
|
||||
|
||||
export interface SavedFiltersRepo {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type {
|
||||
Backlink,
|
||||
DeviceToken,
|
||||
ImportResult,
|
||||
NoteChanges,
|
||||
@@ -80,7 +79,6 @@ export const rest: Repo = {
|
||||
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
|
||||
get: (id) => api.get<Note>(`/api/notes/${id}`),
|
||||
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
|
||||
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
|
||||
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
|
||||
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
||||
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
||||
@@ -103,9 +101,6 @@ export const rest: Repo = {
|
||||
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
||||
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
|
||||
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
|
||||
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
|
||||
linkSearch: async (q) =>
|
||||
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
|
||||
},
|
||||
|
||||
savedFilters: {
|
||||
|
||||
@@ -49,7 +49,6 @@ const shortcuts = [
|
||||
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
|
||||
{ label: "Search", keys: ["/"] },
|
||||
{ label: "Go to Board", keys: ["g", "b"] },
|
||||
{ label: "Go to Graph", keys: ["g", "g"] },
|
||||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||||
{ label: "Go to Timeline", keys: ["g", "t"] },
|
||||
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
|
||||
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
|
||||
void router.push("/");
|
||||
return;
|
||||
}
|
||||
if (e.key === "g") {
|
||||
e.preventDefault();
|
||||
void router.push("/graph");
|
||||
return;
|
||||
}
|
||||
if (e.key === "r") {
|
||||
e.preventDefault();
|
||||
void router.push("/reminders");
|
||||
@@ -207,7 +201,7 @@ watch(
|
||||
*
|
||||
* Keyed off the route name rather than each view declaring its own title, so the
|
||||
* label sits in one place and can't go missing (the board and search never had one)
|
||||
* or drift in styling (timeline, reminders and graph each had their own h1).
|
||||
* or drift in styling (timeline and reminders each had their own h1).
|
||||
*
|
||||
* A label lens is named by the label itself — "Groceries" is what the user came
|
||||
* looking for; "Label" would tell them nothing they didn't already know.
|
||||
@@ -224,8 +218,6 @@ const lensName = computed<string>(() => {
|
||||
return "Timeline";
|
||||
case "reminders":
|
||||
return "Reminders";
|
||||
case "graph":
|
||||
return "Graph";
|
||||
case "label":
|
||||
// The store may not have loaded yet on a deep link; fall back rather than
|
||||
// flashing an empty slot.
|
||||
@@ -403,9 +395,6 @@ async function signOut() {
|
||||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||||
<Icon name="note" /> Notes
|
||||
</RouterLink>
|
||||
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
|
||||
<Icon name="graph" /> Graph
|
||||
</RouterLink>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||||
@@ -522,7 +511,7 @@ async function signOut() {
|
||||
BoardView, so keying on the route would remount it — blanking the board
|
||||
and refetching, which is precisely the page-change feeling this is meant
|
||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||||
(board ↔ search ↔ timeline ↔ graph), and moving between the board's own
|
||||
(board ↔ search ↔ timeline), and moving between the board's own
|
||||
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
|
||||
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
|
||||
const list: Row[] = [
|
||||
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
|
||||
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
|
||||
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
|
||||
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
||||
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
|
||||
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
|
||||
|
||||
@@ -16,7 +16,6 @@ const paths: Record<string, string> = {
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
||||
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
|
||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
|
||||
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||
|
||||
@@ -1,65 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotesStore, type NoteLinkRef } from "../stores/notes";
|
||||
import { useTitlesStore } from "../stores/titles";
|
||||
import type { InlineToken } from "../notes/markdown";
|
||||
|
||||
const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const notes = useNotesStore();
|
||||
const titles = useTitlesStore();
|
||||
|
||||
/**
|
||||
* Where a [[link]] token actually points, according to the server.
|
||||
*
|
||||
* Keyed on the normalized written text, which is what survives in the body — the
|
||||
* server resolved it to an id when the link was saved, so this keeps working after
|
||||
* the target has been renamed and the written text has gone stale.
|
||||
*/
|
||||
const bound = computed(() => {
|
||||
const map = new Map<string, NoteLinkRef>();
|
||||
for (const l of props.links ?? []) map.set(l.norm, l);
|
||||
return map;
|
||||
});
|
||||
|
||||
/** What to SHOW for a link: the target's current name, else the text as written. */
|
||||
function label(token: string): string {
|
||||
return bound.value.get(token.trim().toLowerCase())?.title ?? token;
|
||||
}
|
||||
|
||||
// Open the target via the board's ?open=<id> mechanism, creating the note first if
|
||||
// the link names one that doesn't exist — which is a supported way to make a note.
|
||||
async function follow(token: string) {
|
||||
const hit = bound.value.get(token.trim().toLowerCase());
|
||||
if (hit) {
|
||||
void router.push({ path: "/", query: { open: hit.id } });
|
||||
return;
|
||||
}
|
||||
// No server binding: either this is running offline against the local store, or
|
||||
// the link genuinely resolves to nothing. The name index answers the first case.
|
||||
await titles.load();
|
||||
const byName = titles.resolve(token);
|
||||
const id = byName ? byName.id : (await notes.createTitled(token)).id;
|
||||
if (!byName) await titles.reload();
|
||||
void router.push({ path: "/", query: { open: id } });
|
||||
}
|
||||
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
|
||||
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
|
||||
// of that.
|
||||
defineProps<{ tokens: InlineToken[] }>();
|
||||
</script>
|
||||
|
||||
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
||||
spaces are preserved and no extra spaces are introduced. -->
|
||||
<template
|
||||
><template v-for="(t, i) in tokens" :key="i"
|
||||
><span
|
||||
v-if="t.type === 'link'"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
|
||||
@click.stop="follow(t.value)"
|
||||
@keydown.enter.stop.prevent="follow(t.value)"
|
||||
>{{ label(t.value) }}</span
|
||||
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||
><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
||||
><code
|
||||
v-else-if="t.type === 'code'"
|
||||
|
||||
@@ -2,38 +2,35 @@
|
||||
import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
import MarkdownInline from "./MarkdownInline.vue";
|
||||
import type { NoteLinkRef } from "../stores/notes";
|
||||
|
||||
// `links` is the owning note's resolved [[links]], passed straight through to every
|
||||
// inline run — only MarkdownInline uses it, but only this component knows the note.
|
||||
const props = defineProps<{ text: string; links?: NoteLinkRef[] }>();
|
||||
const props = defineProps<{ text: string }>();
|
||||
const blocks = computed(() => parseMarkdown(props.text));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 break-words">
|
||||
<template v-for="(b, i) in blocks" :key="i">
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h3>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||
<blockquote
|
||||
v-else-if="b.type === 'quote'"
|
||||
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" :links="links" />
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
</blockquote>
|
||||
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
</ul>
|
||||
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
</ol>
|
||||
<pre
|
||||
v-else-if="b.type === 'pre'"
|
||||
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
||||
>{{ b.value ?? "" }}</pre
|
||||
>
|
||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></p>
|
||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
{{ note.title }}
|
||||
</h3>
|
||||
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<MarkdownText :text="note.body" :links="note.links" />
|
||||
<MarkdownText :text="note.body" />
|
||||
</div>
|
||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||
Empty note
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
||||
import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
@@ -27,7 +26,6 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
|
||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
const titles = useTitlesStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const title = ref(props.note?.title ?? "");
|
||||
@@ -40,7 +38,6 @@ const root = ref<HTMLElement | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref("");
|
||||
const backlinks = ref<{ id: string; title: string }[]>([]);
|
||||
|
||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
||||
@@ -163,7 +160,6 @@ function resetCompose(): void {
|
||||
labelList.value = [];
|
||||
createKind.value = "text";
|
||||
baseline.value = { title: null, body: "", color: "default" };
|
||||
linkMenu.value = false;
|
||||
uploadError.value = "";
|
||||
}
|
||||
|
||||
@@ -242,22 +238,7 @@ function onBackdropMousedown(): void {
|
||||
void dismiss();
|
||||
}
|
||||
|
||||
async function loadBacklinks(): Promise<void> {
|
||||
if (!noteId.value) {
|
||||
backlinks.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backlinks.value = await repo.notes.backlinks(noteId.value);
|
||||
} catch {
|
||||
backlinks.value = [];
|
||||
}
|
||||
}
|
||||
watch(() => noteId.value, loadBacklinks);
|
||||
|
||||
onMounted(async () => {
|
||||
void titles.load();
|
||||
void loadBacklinks();
|
||||
await nextTick();
|
||||
const el = bodyInput.value;
|
||||
el?.focus();
|
||||
@@ -265,99 +246,6 @@ onMounted(async () => {
|
||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||
});
|
||||
|
||||
// ---- outgoing links (edit mode) ----
|
||||
//
|
||||
// Two sources, in order. The note's SAVED links carry the server's binding, so a
|
||||
// target that has since been renamed still resolves and is listed under its current
|
||||
// name. A link just typed into the textarea has no saved row yet, and the name index
|
||||
// is the best that can be said about it until the note is saved.
|
||||
const outgoingLinks = computed(() => {
|
||||
const boundByNorm = new Map((props.note?.links ?? []).map((l) => [l.norm, l]));
|
||||
const re = /\[\[([^[\]]+)\]\]/g;
|
||||
const seen = new Set<string>();
|
||||
const out: { title: string; id: string | null }[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(body.value)) !== null) {
|
||||
const t = match[1].trim();
|
||||
const key = t.toLowerCase();
|
||||
if (t && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
const hit = boundByNorm.get(key);
|
||||
out.push(hit ? { title: hit.title, id: hit.id } : { title: t, id: titles.resolve(t)?.id ?? null });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
async function openLink(link: { title: string; id: string | null }) {
|
||||
if (link.id) {
|
||||
emit("navigate", link.id);
|
||||
return;
|
||||
}
|
||||
const created = await notes.createTitled(link.title);
|
||||
await titles.reload();
|
||||
emit("navigate", created.id);
|
||||
}
|
||||
|
||||
// ---- [[ link autocomplete in the body textarea ----
|
||||
const linkMenu = ref(false);
|
||||
const linkQuery = ref("");
|
||||
const linkStart = ref(-1);
|
||||
const linkSelected = ref(0);
|
||||
const linkMatches = ref<TitleEntry[]>([]);
|
||||
let linkTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function refreshLinkMatches() {
|
||||
if (linkTimer) clearTimeout(linkTimer);
|
||||
const q = linkQuery.value.trim();
|
||||
linkTimer = setTimeout(async () => {
|
||||
try {
|
||||
const results = await repo.notes.linkSearch(q);
|
||||
linkMatches.value = results.filter((r) => r.id !== noteId.value).slice(0, 8);
|
||||
} catch {
|
||||
linkMatches.value = [];
|
||||
}
|
||||
linkSelected.value = 0;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function onBodyInput() {
|
||||
const el = bodyInput.value;
|
||||
if (!el) return;
|
||||
const caret = el.selectionStart ?? 0;
|
||||
const text = body.value.slice(0, caret);
|
||||
const open = text.lastIndexOf("[[");
|
||||
if (open === -1) {
|
||||
linkMenu.value = false;
|
||||
return;
|
||||
}
|
||||
const between = text.slice(open + 2);
|
||||
if (between.includes("]") || between.includes("\n")) {
|
||||
linkMenu.value = false;
|
||||
return;
|
||||
}
|
||||
linkQuery.value = between;
|
||||
linkStart.value = open;
|
||||
linkSelected.value = 0;
|
||||
linkMenu.value = true;
|
||||
refreshLinkMatches();
|
||||
}
|
||||
|
||||
function insertLink(t: string) {
|
||||
const el = bodyInput.value;
|
||||
const caret = el?.selectionStart ?? body.value.length;
|
||||
const before = body.value.slice(0, linkStart.value);
|
||||
const after = body.value.slice(caret);
|
||||
const insertion = `[[${t}]]`;
|
||||
body.value = before + insertion + after;
|
||||
linkMenu.value = false;
|
||||
const pos = before.length + insertion.length;
|
||||
void nextTick(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
function onBodyKeydown(e: KeyboardEvent) {
|
||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||
@@ -365,25 +253,6 @@ function onBodyKeydown(e: KeyboardEvent) {
|
||||
void commitAndContinue();
|
||||
return;
|
||||
}
|
||||
if (!linkMenu.value || linkMatches.value.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
linkSelected.value = Math.max(linkSelected.value - 1, 0);
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
const m = linkMatches.value[linkSelected.value];
|
||||
if (m) {
|
||||
e.preventDefault();
|
||||
insertLink(m.title);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
// Close only the menu — don't let Esc bubble to the frame's close/commit.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkMenu.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTitleEnter(e: KeyboardEvent) {
|
||||
@@ -694,37 +563,15 @@ function revPreview(rev: NoteRevision): string {
|
||||
@keydown.enter="onTitleEnter"
|
||||
/>
|
||||
|
||||
<div v-if="!showChecklist" class="relative">
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@input="onBodyInput"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<ul
|
||||
v-if="linkMenu && linkMatches.length"
|
||||
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<li v-for="(m, i) in linkMatches" :key="m.id">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
|
||||
:class="
|
||||
i === linkSelected
|
||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
||||
"
|
||||
@mousemove="linkSelected = i"
|
||||
@mousedown.prevent="insertLink(m.title)"
|
||||
>
|
||||
<span class="truncate">{{ m.title }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="!showChecklist"
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||
|
||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||
@@ -793,43 +640,6 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isCreate && (outgoingLinks.length || backlinks.length)"
|
||||
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||
>
|
||||
<div v-if="outgoingLinks.length">
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="link in outgoingLinks"
|
||||
:key="link.title"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
link.id
|
||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
|
||||
"
|
||||
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
|
||||
@click="openLink(link)"
|
||||
>
|
||||
{{ link.title }}<span v-if="!link.id" class="opacity-60">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="backlinks.length">
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="b in backlinks"
|
||||
:key="b.id"
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
|
||||
@click="emit('navigate', b.id)"
|
||||
>
|
||||
{{ b.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
|
||||
//
|
||||
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
|
||||
// - `list` is the host's local note array, tried first when resolving a navigated
|
||||
// [[wiki-link]] target before falling back to the store, then a fetch — so views
|
||||
// that keep their own list (reminders, timeline, search, graph) still resolve
|
||||
// note id before falling back to the store, then a fetch — so views
|
||||
// that keep their own list (reminders, timeline, search) still resolve
|
||||
// locally without duplicating the lookup.
|
||||
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
|
||||
const notes = useNotesStore();
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// stays plain text; this only formats what's shown. We render the parsed tree as
|
||||
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
|
||||
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
|
||||
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
|
||||
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
|
||||
// left as plain text and never mistaken for a heading.
|
||||
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
|
||||
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
|
||||
// a heading.
|
||||
|
||||
export interface InlineToken {
|
||||
type: "text" | "bold" | "italic" | "code" | "link";
|
||||
type: "text" | "bold" | "italic" | "code";
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ export interface Block {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// Order matters: links + code are matched before emphasis so their contents aren't
|
||||
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
|
||||
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
||||
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
|
||||
// bold (**) before italic (*). Emphasis does not nest (v1).
|
||||
//
|
||||
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
|
||||
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
|
||||
// renders as the literal characters someone typed, which is what it always was.
|
||||
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
||||
|
||||
export function parseInline(text: string): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
|
||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
|
||||
const raw = m[0];
|
||||
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
|
||||
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const router = createRouter({
|
||||
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
||||
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
|
||||
@@ -63,27 +63,11 @@ export interface NoteRevision {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// One resolved [[wiki-link]] out of a note: the normalized text as WRITTEN, and the
|
||||
// note it actually points at with that note's name as it stands NOW.
|
||||
//
|
||||
// The server sends this because the client can no longer work it out. Resolution used
|
||||
// to be a name lookup in the titles index, which only held together because renaming
|
||||
// a note rewrote the link text inside every note that linked to it. Links are bound
|
||||
// by id now and bodies are left alone, so the written text can name something the
|
||||
// target is no longer called — and only the server holds the binding.
|
||||
export interface NoteLinkRef {
|
||||
/** The link text as written, trimmed and lowercased — the key a token matches on. */
|
||||
norm: string;
|
||||
id: string;
|
||||
/** The target's CURRENT name, which is what gets rendered. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string | null;
|
||||
// The note's display NAME: explicit title, else its first body line (server-derived).
|
||||
// Every note has one, so body-only notes are still nameable + [[link]]-able.
|
||||
// Every note has one, so a body-only note still has something to be called.
|
||||
display_title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
@@ -101,11 +85,6 @@ export interface Note {
|
||||
items: ChecklistItem[];
|
||||
attachments: Attachment[];
|
||||
previews: LinkPreview[];
|
||||
// Absent offline: the desktop's local store derives links at query time and has no
|
||||
// resolution to send. Rendering falls back to the titles index there, which is
|
||||
// exactly right for a store where nothing else can have renamed the target behind
|
||||
// this client's back.
|
||||
links?: NoteLinkRef[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -245,12 +224,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function createTitled(title: string): Promise<Note> {
|
||||
const created = await repo.notes.createTitled(title);
|
||||
reconcile(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function reorder(orderedIds: string[]): Promise<void> {
|
||||
// Optimistically assign positions matching the backend (total - index), sort,
|
||||
// then persist.
|
||||
@@ -327,7 +300,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
deletePreview,
|
||||
importNotes,
|
||||
fetchOne,
|
||||
createTitled,
|
||||
reorder,
|
||||
trash,
|
||||
restore,
|
||||
|
||||
@@ -7,7 +7,12 @@ export interface TitleEntry {
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side.
|
||||
// Owner's {id, name} index of every non-trashed note.
|
||||
//
|
||||
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for,
|
||||
// because the command palette lists it so someone can jump straight to a note by
|
||||
// name. That is recall, which is what this app is for; `resolve()` went with the
|
||||
// links.
|
||||
export const useTitlesStore = defineStore("titles", () => {
|
||||
const items = ref<TitleEntry[]>([]);
|
||||
const loaded = ref(false);
|
||||
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
|
||||
await load();
|
||||
}
|
||||
|
||||
function resolve(title: string): TitleEntry | null {
|
||||
const norm = title.trim().toLowerCase();
|
||||
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
|
||||
}
|
||||
|
||||
return { items, loaded, load, reload, resolve };
|
||||
return { items, loaded, load, reload };
|
||||
});
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user