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
+35
View File
@@ -0,0 +1,35 @@
import { Extension } from "@tiptap/vue-3";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const TAG_RE = /(?<=\s|^)#([\w]+(?:\/[\w]+)*)/g;
export const TagDecoration = Extension.create({
name: "tagDecoration",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("tagDecoration"),
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
TAG_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = TAG_RE.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "inline-tag" })
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
});
+61
View File
@@ -0,0 +1,61 @@
import { Extension } from "@tiptap/vue-3";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
export interface TagSuggestionOptions {
fetchTags: (query: string) => Promise<string[]>;
}
const tagSuggestionPluginKey = new PluginKey("tagSuggestion");
export const TagSuggestion = Extension.create<TagSuggestionOptions>({
name: "tagSuggestion",
addOptions() {
return {
fetchTags: async () => [],
};
},
addProseMirrorPlugins() {
return [
Suggestion<SuggestionItem>({
editor: this.editor,
pluginKey: tagSuggestionPluginKey,
char: "#",
allowSpaces: false,
startOfLine: false,
allow: ({ state, range }) => {
// Don't trigger at the start of a text block — the user is
// likely typing a heading (# / ## / ### …), not a tag.
const $from = state.doc.resolve(range.from);
const textBefore = $from.parent.textContent.slice(0, $from.parentOffset);
if (/^#*$/.test(textBefore)) return false;
return true;
},
items: async ({ query }): Promise<SuggestionItem[]> => {
try {
const tags = await this.options.fetchTags(query);
return tags.slice(0, 8).map((t) => ({
label: `#${t}`,
value: t,
}));
} catch {
return [];
}
},
command: ({ editor, range, props: item }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`#${item.value} `)
.run();
},
render: createSuggestionRenderer,
}),
];
},
});
@@ -0,0 +1,35 @@
import { Extension } from "@tiptap/vue-3";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
export const WikilinkDecoration = Extension.create({
name: "wikilinkDecoration",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("wikilinkDecoration"),
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
WIKILINK_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = WIKILINK_RE.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, { class: "wikilink" })
);
}
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,49 @@
import { Extension } from "@tiptap/vue-3";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
import { apiGet } from "@/api/client";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
interface NoteListResponse {
notes: Array<{ id: number; title: string }>;
}
const wikilinkSuggestionPluginKey = new PluginKey("wikilinkSuggestion");
export const WikilinkSuggestion = Extension.create({
name: "wikilinkSuggestion",
addProseMirrorPlugins() {
return [
Suggestion<SuggestionItem>({
editor: this.editor,
pluginKey: wikilinkSuggestionPluginKey,
char: "[[",
allowSpaces: true,
items: async ({ query }): Promise<SuggestionItem[]> => {
try {
const data = await apiGet<NoteListResponse>(
`/api/notes?q=${encodeURIComponent(query)}&limit=8`
);
return data.notes.map((n) => ({
label: n.title || "Untitled",
value: n.title || "Untitled",
}));
} catch {
return [];
}
},
command: ({ editor, range, props: item }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`[[${item.value}]]`)
.run();
},
render: createSuggestionRenderer,
}),
];
},
});
@@ -0,0 +1,79 @@
import { createApp, type App } from "vue";
import SuggestionDropdown from "@/components/SuggestionDropdown.vue";
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
interface DropdownExposed {
onKeyDown: (e: KeyboardEvent) => boolean;
}
export function createSuggestionRenderer() {
let app: App | null = null;
let el: HTMLElement | null = null;
let componentRef: DropdownExposed | null = null;
let commandFn: ((item: SuggestionItem) => void) | null = null;
function mountDropdown(items: SuggestionItem[]) {
if (!el) return;
if (app) app.unmount();
app = createApp(SuggestionDropdown, {
items,
command: (item: SuggestionItem) => commandFn?.(item),
});
const instance = app.mount(el);
componentRef = instance as unknown as DropdownExposed;
}
return {
onStart(props: SuggestionProps) {
commandFn = (item: SuggestionItem) => props.command(item);
el = document.createElement("div");
el.style.position = "fixed";
el.style.zIndex = "100";
document.body.appendChild(el);
mountDropdown(props.items as SuggestionItem[]);
updatePosition(props);
},
onUpdate(props: SuggestionProps) {
commandFn = (item: SuggestionItem) => props.command(item);
mountDropdown(props.items as SuggestionItem[]);
updatePosition(props);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return componentRef?.onKeyDown(props.event) ?? false;
},
onExit() {
cleanup();
},
};
function updatePosition(props: SuggestionProps) {
if (!el || !props.clientRect) return;
const rect = props.clientRect();
if (!rect) return;
el.style.top = rect.bottom + "px";
el.style.left = rect.left + "px";
}
function cleanup() {
if (app) {
app.unmount();
app = null;
}
if (el) {
el.remove();
el = null;
}
componentRef = null;
commandFn = null;
}
}