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(),
|
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
|
||||||
isActive: () => props.editor?.isActive("codeBlock") ?? false,
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
import { apiGet, apiPatch, apiDelete } from "@/api/client";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { useAutoSave } from "@/composables/useAutoSave";
|
import { useAutoSave } from "@/composables/useAutoSave";
|
||||||
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number;
|
projectId: number;
|
||||||
@@ -44,6 +46,59 @@ const saving = ref(false);
|
|||||||
|
|
||||||
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
|
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() {
|
async function loadProjectNotes() {
|
||||||
listLoading.value = true;
|
listLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +124,9 @@ async function openNote(id: number) {
|
|||||||
noteTags.value = note.tags ?? [];
|
noteTags.value = note.tags ?? [];
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
tagSuggestions.dismissTagSuggestions();
|
tagSuggestions.dismissTagSuggestions();
|
||||||
|
linkSuggestions.value = [];
|
||||||
view.value = "editor";
|
view.value = "editor";
|
||||||
|
fetchLinkSuggestions();
|
||||||
emit("note-loaded", note.id);
|
emit("note-loaded", note.id);
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to load note", "error");
|
toast.show("Failed to load note", "error");
|
||||||
@@ -161,7 +218,7 @@ function formatDate(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(noteTitle, () => { dirty.value = true; });
|
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(noteTags, () => { dirty.value = true; });
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -176,6 +233,7 @@ watch(
|
|||||||
|
|
||||||
useAutoSave(dirty, saving, saveNote, 60_000);
|
useAutoSave(dirty, saving, saveNote, 60_000);
|
||||||
onMounted(loadProjectNotes);
|
onMounted(loadProjectNotes);
|
||||||
|
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -197,6 +255,7 @@ onMounted(loadProjectNotes);
|
|||||||
v-model="noteTitle"
|
v-model="noteTitle"
|
||||||
class="note-title-input"
|
class="note-title-input"
|
||||||
placeholder="Note title"
|
placeholder="Note title"
|
||||||
|
@keydown.ctrl.s.prevent="saveNote"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,8 +288,28 @@ onMounted(loadProjectNotes);
|
|||||||
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-area">
|
<!-- Formatting toolbar -->
|
||||||
<TiptapEditor v-model="noteBody" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -276,7 +355,7 @@ onMounted(loadProjectNotes);
|
|||||||
title="Delete note"
|
title="Delete note"
|
||||||
@click="requestDelete(note.id, $event)"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -443,6 +522,63 @@ onMounted(loadProjectNotes);
|
|||||||
}
|
}
|
||||||
.btn-dismiss-suggestions:hover { color: var(--color-text); }
|
.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 {
|
.editor-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user