Add Tiptap inline-preview editor, layout improvements, and README

Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes
and tasks. Headings, bold, lists, and code blocks now render inline while
editing. Tags and wikilinks get visual highlighting via decoration plugins,
and autocomplete uses @tiptap/suggestion with heading disambiguation.

Layout: pin navbar with app-shell flex column (100dvh), sticky editor
toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with
full-height section selector and prompt. Increase max-width to 960px
across all views. Hide assist controls during streaming/review.

Other fixes: markdown paste handling, auto-syncing assist sections via
body watcher, trailing newline on AI accept, ChatView height fix.

Add README.md describing the app, usage guide, and technical overview.
Update summary.md with Phase 5.2 roadmap and current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:24:35 -05:00
parent cbfdf5289e
commit 586026bff6
29 changed files with 2088 additions and 438 deletions
-22
View File
@@ -12,10 +12,6 @@ const chatStore = useChatStore();
const router = useRouter();
const mobileMenuOpen = ref(false);
const emit = defineEmits<{
toggleChatPanel: [];
}>();
const statusLabel = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
if (chatStore.modelStatus === "not_found") return "Model downloading...";
@@ -68,9 +64,6 @@ router.afterEach(() => {
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
</span>
<button class="btn-chat-panel" @click="emit('toggleChatPanel')" title="Open chat panel">
&#x1F4AC;
</button>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
@@ -169,20 +162,6 @@ router.afterEach(() => {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.btn-chat-panel {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1rem;
color: var(--color-text);
line-height: 1;
margin-left: 0.25rem;
}
.btn-chat-panel:hover {
background: var(--color-bg-card);
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
@@ -267,7 +246,6 @@ router.afterEach(() => {
border-top: 1px solid var(--color-border);
width: 100%;
}
.btn-chat-panel,
.theme-toggle,
.btn-logout {
min-height: 44px;
+78 -13
View File
@@ -1,18 +1,78 @@
<script setup lang="ts">
const emit = defineEmits<{
insert: [before: string, after: string, placeholder: string];
import type { Editor } from "@tiptap/vue-3";
const props = defineProps<{
editor: Editor | null;
}>();
const buttons = [
{ label: "B", before: "**", after: "**", placeholder: "bold", title: "Bold" },
{ label: "I", before: "_", after: "_", placeholder: "italic", title: "Italic" },
{ label: "H1", before: "# ", after: "", placeholder: "Heading", title: "Heading 1" },
{ label: "H2", before: "## ", after: "", placeholder: "Heading", title: "Heading 2" },
{ label: "Link", before: "[", after: "](url)", placeholder: "text", title: "Link" },
{ label: "UL", before: "- ", after: "", placeholder: "item", title: "Bullet List" },
{ label: "OL", before: "1. ", after: "", placeholder: "item", title: "Numbered List" },
{ label: "`", before: "`", after: "`", placeholder: "code", title: "Inline Code" },
{ label: "```", before: "```\n", after: "\n```", placeholder: "code block", title: "Code Block" },
{
label: "B",
title: "Bold",
command: () => props.editor?.chain().focus().toggleBold().run(),
isActive: () => props.editor?.isActive("bold") ?? false,
},
{
label: "I",
title: "Italic",
command: () => props.editor?.chain().focus().toggleItalic().run(),
isActive: () => props.editor?.isActive("italic") ?? false,
},
{
label: "H1",
title: "Heading 1",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false,
},
{
label: "H2",
title: "Heading 2",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false,
},
{
label: "Link",
title: "Link",
command: () => {
const ed = props.editor;
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().unsetLink().run();
} else {
const url = prompt("URL:");
if (url) {
ed.chain().focus().setLink({ href: url }).run();
}
}
},
isActive: () => props.editor?.isActive("link") ?? false,
},
{
label: "UL",
title: "Bullet List",
command: () => props.editor?.chain().focus().toggleBulletList().run(),
isActive: () => props.editor?.isActive("bulletList") ?? false,
},
{
label: "OL",
title: "Numbered List",
command: () => props.editor?.chain().focus().toggleOrderedList().run(),
isActive: () => props.editor?.isActive("orderedList") ?? false,
},
{
label: "`",
title: "Inline Code",
command: () => props.editor?.chain().focus().toggleCode().run(),
isActive: () => props.editor?.isActive("code") ?? false,
},
{
label: "```",
title: "Code Block",
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive("codeBlock") ?? false,
},
];
</script>
@@ -21,9 +81,9 @@ const buttons = [
<button
v-for="btn in buttons"
:key="btn.label"
class="md-btn"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
@click="emit('insert', btn.before, btn.after, btn.placeholder)"
@click="btn.command()"
>
{{ btn.label }}
</button>
@@ -50,4 +110,9 @@ const buttons = [
.md-btn:hover {
background: var(--color-bg-secondary);
}
.md-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, watch } from "vue";
export interface SuggestionItem {
label: string;
value: string;
}
const props = defineProps<{
items: SuggestionItem[];
command: (item: SuggestionItem) => void;
}>();
const selectedIndex = ref(0);
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
function onKeyDown(event: KeyboardEvent): boolean {
if (event.key === "ArrowUp") {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value - 1 + props.items.length) % props.items.length;
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectItem(selectedIndex.value);
return true;
}
return false;
}
function selectItem(index: number) {
const item = props.items[index];
if (item) {
props.command(item);
}
}
defineExpose({ onKeyDown });
</script>
<template>
<ul v-if="items.length" class="ac-dropdown suggestion-dropdown">
<li
v-for="(item, i) in items"
:key="item.value"
:class="['ac-item', { active: i === selectedIndex }]"
@mousedown.prevent="selectItem(i)"
>
{{ item.label }}
</li>
</ul>
</template>
<style scoped>
.suggestion-dropdown {
position: relative;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { watch, onBeforeUnmount } from "vue";
import { Editor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer";
import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { TagSuggestion } from "@/extensions/TagSuggestion";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
fetchTags?: (query: string) => Promise<string[]>;
}>(),
{
placeholder: "",
fetchTags: undefined,
}
);
const emit = defineEmits<{
"update:modelValue": [value: string];
selectionChange: [payload: { text: string; start: number; end: number }];
}>();
let updatingFromProp = false;
let lastEmittedMarkdown = props.modelValue;
function markdownToHtml(md: string): string {
const html = marked(md) as string;
return DOMPurify.sanitize(html);
}
let editor: Editor | null = null;
try {
editor = new Editor({
content: markdownToHtml(props.modelValue),
editable: true,
editorProps: {
handlePaste: (_view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text || !editor) return false;
// If clipboard already has HTML (e.g. copying from a webpage), let Tiptap handle it
const html = event.clipboardData?.getData("text/html");
if (html) return false;
// Check if the pasted text looks like it contains markdown formatting
const hasMd = /(?:^#{1,6}\s|^\s*[-*+]\s|^\s*\d+\.\s|^\s*>|```|\*\*|__|~~|\[.+\]\(.+\))/m.test(text);
if (!hasMd) return false;
// Convert markdown to HTML and insert as formatted content
const converted = markdownToHtml(text);
editor.chain().focus().deleteSelection().insertContent(converted).run();
return true;
},
},
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
Placeholder.configure({
placeholder: props.placeholder,
}),
TagDecoration,
WikilinkDecoration,
TagSuggestion.configure({
fetchTags: props.fetchTags ?? (async () => []),
}),
WikilinkSuggestion,
],
onUpdate({ editor: ed }) {
if (updatingFromProp) return;
const md = serializeToMarkdown(ed.getJSON());
lastEmittedMarkdown = md;
emit("update:modelValue", md);
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
if (from === to) return;
const selectedText = ed.state.doc.textBetween(from, to, "\n");
if (!selectedText) return;
const md = lastEmittedMarkdown;
const idx = md.indexOf(selectedText);
if (idx !== -1) {
emit("selectionChange", {
text: selectedText,
start: idx,
end: idx + selectedText.length,
});
}
},
});
} catch (e) {
console.error("Tiptap editor creation failed:", e);
}
onBeforeUnmount(() => {
editor?.destroy();
});
// Watch for external modelValue changes (e.g. AI assist accept)
watch(
() => props.modelValue,
(newVal) => {
if (!editor) return;
if (newVal === lastEmittedMarkdown) return;
updatingFromProp = true;
editor.commands.setContent(markdownToHtml(newVal));
lastEmittedMarkdown = newVal;
updatingFromProp = false;
}
);
defineExpose({ editor });
</script>
<template>
<div class="tiptap-wrapper tiptap-editor">
<EditorContent v-if="editor" :editor="editor" class="prose" />
<div v-else class="editor-error">Editor failed to load. Check console.</div>
</div>
</template>
<style scoped>
.editor-error {
padding: 1rem;
color: var(--color-danger);
font-size: 0.9rem;
}
</style>