Workspace note editor: toolbar, link suggestions, wikilink button, Ctrl+S

- Add MarkdownToolbar to the workspace note editor (was missing entirely)
- Add [[ button to MarkdownToolbar so wikilinks are discoverable in all editors;
  clicking inserts [[ which immediately triggers the WikilinkSuggestion dropdown
- Add link suggestions strip: polls /api/notes/link-suggestions 2.5s after edit,
  shows unlinked note-title mentions as clickable chips to wrap in [[...]], plus
  "All" button to apply everything at once — directly addresses the heading→wikilink
  workflow (note title appearing as heading text gets detected and offered for linking)
- Add Ctrl+S keyboard shortcut on title input and editor area to save
- Replace ✕ delete icon on note list items with trash can SVG (consistency with task panel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:26:30 -05:00
parent 320e879788
commit 0e9ab84afb
2 changed files with 148 additions and 6 deletions
@@ -73,6 +73,12 @@ const buttons = [
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive("codeBlock") ?? false,
},
{
label: "[[",
title: "Insert wikilink (type note title to search)",
command: () => props.editor?.chain().focus().insertContent("[[").run(),
isActive: () => false,
},
];
</script>
+142 -6
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { apiGet, apiPatch, apiDelete } from "@/api/client";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import type { Editor } from "@tiptap/vue-3";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { useAutoSave } from "@/composables/useAutoSave";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useNotesStore } from "@/stores/notes";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const props = defineProps<{
projectId: number;
@@ -44,6 +46,59 @@ const saving = ref(false);
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
// TipTap editor ref (for MarkdownToolbar)
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() =>
(editorRef.value?.editor as Editor | undefined) ?? null
);
// Link suggestions
interface LinkSuggestion { note_id: number; title: string; count: number; }
const linkSuggestions = ref<LinkSuggestion[]>([]);
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
async function fetchLinkSuggestions() {
if (!editingId.value || !noteBody.value) { linkSuggestions.value = []; return; }
try {
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
body: noteBody.value,
project_id: props.projectId,
exclude_note_id: editingId.value,
});
linkSuggestions.value = res.suggestions;
} catch {
linkSuggestions.value = [];
}
}
function scheduleLinkCheck() {
if (linkCheckTimer) clearTimeout(linkCheckTimer);
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2500);
}
function applyWikilinkToText(text: string, title: string): string {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(/(\[\[[^\]]*\]\])/);
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
return parts.map((part, i) =>
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
).join('');
}
function applyLink(s: LinkSuggestion) {
noteBody.value = applyWikilinkToText(noteBody.value, s.title);
dirty.value = true;
linkSuggestions.value = linkSuggestions.value.filter(x => x.note_id !== s.note_id);
}
function applyAllLinks() {
let text = noteBody.value;
for (const s of linkSuggestions.value) text = applyWikilinkToText(text, s.title);
noteBody.value = text;
dirty.value = true;
linkSuggestions.value = [];
}
async function loadProjectNotes() {
listLoading.value = true;
try {
@@ -69,7 +124,9 @@ async function openNote(id: number) {
noteTags.value = note.tags ?? [];
dirty.value = false;
tagSuggestions.dismissTagSuggestions();
linkSuggestions.value = [];
view.value = "editor";
fetchLinkSuggestions();
emit("note-loaded", note.id);
} catch {
toast.show("Failed to load note", "error");
@@ -161,7 +218,7 @@ function formatDate(iso: string): string {
}
watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; });
watch(
@@ -176,6 +233,7 @@ watch(
useAutoSave(dirty, saving, saveNote, 60_000);
onMounted(loadProjectNotes);
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
</script>
<template>
@@ -197,6 +255,7 @@ onMounted(loadProjectNotes);
v-model="noteTitle"
class="note-title-input"
placeholder="Note title"
@keydown.ctrl.s.prevent="saveNote"
/>
</div>
@@ -229,8 +288,28 @@ onMounted(loadProjectNotes);
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
</div>
<div class="editor-area">
<TiptapEditor v-model="noteBody" />
<!-- Formatting toolbar -->
<div class="toolbar-row">
<MarkdownToolbar :editor="tiptapEditor" />
</div>
<!-- Link suggestions strip -->
<div v-if="linkSuggestions.length" class="link-suggest-strip">
<span class="link-suggest-label">Links:</span>
<span
v-for="s in linkSuggestions"
:key="s.note_id"
class="link-suggest-chip"
:title="`Appears ${s.count}× unlinked`"
>
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" @click="linkSuggestions = []"></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote">
<TiptapEditor ref="editorRef" v-model="noteBody" />
</div>
</template>
@@ -276,7 +355,7 @@ onMounted(loadProjectNotes);
title="Delete note"
@click="requestDelete(note.id, $event)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</li>
@@ -443,6 +522,63 @@ onMounted(loadProjectNotes);
}
.btn-dismiss-suggestions:hover { color: var(--color-text); }
.toolbar-row {
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.link-suggest-strip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.link-suggest-label {
font-size: 0.7rem;
color: var(--color-text-muted);
flex-shrink: 0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.link-suggest-chip {
display: inline-flex;
}
.btn-chip-link {
background: none;
border: 1px solid var(--color-primary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
color: var(--color-primary);
cursor: pointer;
font-family: monospace;
white-space: nowrap;
}
.btn-chip-link:hover {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.btn-link-all {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
margin-left: 0.1rem;
}
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
.editor-area {
flex: 1;
overflow-y: auto;