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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user