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:
+25
-26
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { onMounted, onUnmounted, watch } from "vue";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
@@ -11,25 +9,9 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
|
||||
useTheme();
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const chatPanelOpen = ref(false);
|
||||
|
||||
const contextNoteId = computed(() => {
|
||||
const id = route.params.id;
|
||||
if (!id) return null;
|
||||
const path = route.path;
|
||||
if (path.startsWith("/notes/") || path.startsWith("/tasks/")) {
|
||||
return Number(id);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function toggleChatPanel() {
|
||||
chatPanelOpen.value = !chatPanelOpen.value;
|
||||
}
|
||||
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
@@ -65,16 +47,33 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<AppHeader @toggle-chat-panel="toggleChatPanel" />
|
||||
<router-view />
|
||||
<ChatPanel
|
||||
v-if="chatPanelOpen"
|
||||
:context-note-id="contextNoteId"
|
||||
@close="chatPanelOpen = false"
|
||||
/>
|
||||
<div class="app-shell">
|
||||
<AppHeader />
|
||||
<div class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view />
|
||||
</template>
|
||||
<ToastNotification />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-shell > .app-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,3 +143,29 @@
|
||||
filter: brightness(0.9);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Tiptap editor */
|
||||
.tiptap-editor .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 200px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--color-text-muted, var(--color-text-secondary));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-wrapper {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tiptap-wrapper:focus-within {
|
||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
💬
|
||||
</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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ref, computed, watch, type Ref } from "vue";
|
||||
import { apiStreamPost } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import {
|
||||
parseMarkdownSections,
|
||||
type MarkdownSection,
|
||||
} from "@/utils/sectionParser";
|
||||
|
||||
export type AssistState = "idle" | "streaming" | "review";
|
||||
|
||||
export interface AssistTarget {
|
||||
text: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const state = ref<AssistState>("idle");
|
||||
const sections = ref<MarkdownSection[]>([]);
|
||||
const selectedSection = ref<MarkdownSection | null>(null);
|
||||
const customSelection = ref<{ start: number; end: number; text: string } | null>(null);
|
||||
const instruction = ref("");
|
||||
const streamingText = ref("");
|
||||
const proposedText = ref("");
|
||||
const error = ref("");
|
||||
|
||||
// Snapshot of body at the time a section/selection was chosen
|
||||
let bodySnapshot = "";
|
||||
|
||||
const target = computed<AssistTarget | null>(() => {
|
||||
if (customSelection.value) {
|
||||
return {
|
||||
text: customSelection.value.text,
|
||||
startOffset: customSelection.value.start,
|
||||
endOffset: customSelection.value.end,
|
||||
};
|
||||
}
|
||||
if (selectedSection.value) {
|
||||
return {
|
||||
text: selectedSection.value.content,
|
||||
startOffset: selectedSection.value.startOffset,
|
||||
endOffset: selectedSection.value.endOffset,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
target.value !== null &&
|
||||
instruction.value.trim().length > 0 &&
|
||||
chatStore.chatReady &&
|
||||
state.value !== "streaming"
|
||||
);
|
||||
|
||||
function refreshSections() {
|
||||
sections.value = parseMarkdownSections(body.value);
|
||||
}
|
||||
|
||||
function selectSection(section: MarkdownSection) {
|
||||
customSelection.value = null;
|
||||
selectedSection.value = section;
|
||||
bodySnapshot = body.value;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function selectTextRange(start: number, end: number) {
|
||||
if (start === end) return;
|
||||
selectedSection.value = null;
|
||||
customSelection.value = {
|
||||
start,
|
||||
end,
|
||||
text: body.value.slice(start, end),
|
||||
};
|
||||
bodySnapshot = body.value;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedSection.value = null;
|
||||
customSelection.value = null;
|
||||
instruction.value = "";
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || !target.value) return;
|
||||
|
||||
state.value = "streaming";
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
await apiStreamPost(
|
||||
"/api/notes/assist",
|
||||
{
|
||||
body: body.value,
|
||||
target_section: target.value.text,
|
||||
instruction: instruction.value,
|
||||
},
|
||||
(data) => {
|
||||
if (data.chunk) {
|
||||
streamingText.value += data.chunk as string;
|
||||
}
|
||||
if (data.done) {
|
||||
proposedText.value = (data.full_text as string) || streamingText.value;
|
||||
state.value = "review";
|
||||
}
|
||||
if (data.error) {
|
||||
error.value = data.error as string;
|
||||
state.value = "idle";
|
||||
}
|
||||
}
|
||||
);
|
||||
// If stream ended without a done event, use accumulated text
|
||||
if (state.value === "streaming") {
|
||||
if (streamingText.value) {
|
||||
proposedText.value = streamingText.value;
|
||||
state.value = "review";
|
||||
} else {
|
||||
state.value = "idle";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Request failed";
|
||||
state.value = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function accept(): string {
|
||||
if (!target.value || !proposedText.value) return body.value;
|
||||
|
||||
const t = target.value;
|
||||
|
||||
// Validate that the body hasn't changed at the target offsets
|
||||
if (body.value !== bodySnapshot) {
|
||||
const currentSlice = body.value.slice(t.startOffset, t.endOffset);
|
||||
if (currentSlice !== t.text) {
|
||||
error.value = "The document changed since this suggestion was made. Please clear and try again.";
|
||||
return body.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proposed text ends with a newline for clean separation
|
||||
let text = proposedText.value;
|
||||
if (!text.endsWith("\n")) {
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
const newBody =
|
||||
body.value.slice(0, t.startOffset) +
|
||||
text +
|
||||
body.value.slice(t.endOffset);
|
||||
|
||||
// Reset state
|
||||
selectedSection.value = null;
|
||||
customSelection.value = null;
|
||||
streamingText.value = "";
|
||||
proposedText.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
|
||||
return newBody;
|
||||
}
|
||||
|
||||
function reject() {
|
||||
proposedText.value = "";
|
||||
streamingText.value = "";
|
||||
error.value = "";
|
||||
state.value = "idle";
|
||||
// Keep selection + instruction for retry
|
||||
}
|
||||
|
||||
// Keep sections in sync with body automatically
|
||||
watch(body, () => {
|
||||
refreshSections();
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
state,
|
||||
sections,
|
||||
selectedSection,
|
||||
customSelection,
|
||||
target,
|
||||
instruction,
|
||||
streamingText,
|
||||
proposedText,
|
||||
error,
|
||||
canSubmit,
|
||||
refreshSections,
|
||||
selectSection,
|
||||
selectTextRange,
|
||||
clearSelection,
|
||||
submit,
|
||||
accept,
|
||||
reject,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { JSONContent } from "@tiptap/vue-3";
|
||||
|
||||
export function serializeToMarkdown(doc: JSONContent): string {
|
||||
if (doc.type !== "doc") return "";
|
||||
return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd();
|
||||
}
|
||||
|
||||
function serializeNodes(nodes: JSONContent[], listIndent = 0): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
out += serializeNode(nodes[i], listIndent);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeNode(node: JSONContent, listIndent = 0): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return serializeInline(node.content || []) + "\n\n";
|
||||
|
||||
case "heading": {
|
||||
const level = node.attrs?.level ?? 1;
|
||||
const prefix = "#".repeat(level) + " ";
|
||||
return prefix + serializeInline(node.content || []) + "\n\n";
|
||||
}
|
||||
|
||||
case "bulletList":
|
||||
return (
|
||||
serializeBulletList(node.content || [], listIndent) +
|
||||
(listIndent === 0 ? "\n" : "")
|
||||
);
|
||||
|
||||
case "orderedList":
|
||||
return (
|
||||
serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) +
|
||||
(listIndent === 0 ? "\n" : "")
|
||||
);
|
||||
|
||||
case "listItem":
|
||||
// Handled by bulletList/orderedList
|
||||
return "";
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = node.attrs?.language || "";
|
||||
const code = getTextContent(node);
|
||||
return "```" + lang + "\n" + code + "\n```\n\n";
|
||||
}
|
||||
|
||||
case "blockquote": {
|
||||
const inner = serializeNodes(node.content || []);
|
||||
return (
|
||||
inner
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => "> " + line)
|
||||
.join("\n") + "\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
case "horizontalRule":
|
||||
return "---\n\n";
|
||||
|
||||
case "hardBreak":
|
||||
return "\n";
|
||||
|
||||
case "text":
|
||||
return applyMarks(node.text || "", node.marks || []);
|
||||
|
||||
default:
|
||||
// Unknown node — try to serialize children
|
||||
if (node.content) {
|
||||
return serializeNodes(node.content);
|
||||
}
|
||||
return node.text || "";
|
||||
}
|
||||
}
|
||||
|
||||
function serializeBulletList(
|
||||
items: JSONContent[],
|
||||
indent: number
|
||||
): string {
|
||||
let out = "";
|
||||
for (const item of items) {
|
||||
if (item.type !== "listItem") continue;
|
||||
const children = item.content || [];
|
||||
const prefix = " ".repeat(indent) + "- ";
|
||||
out += serializeListItem(children, prefix, indent);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeOrderedList(
|
||||
items: JSONContent[],
|
||||
indent: number,
|
||||
start: number
|
||||
): string {
|
||||
let out = "";
|
||||
let num = start;
|
||||
for (const item of items) {
|
||||
if (item.type !== "listItem") continue;
|
||||
const children = item.content || [];
|
||||
const prefix = " ".repeat(indent) + num + ". ";
|
||||
out += serializeListItem(children, prefix, indent);
|
||||
num++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeListItem(
|
||||
children: JSONContent[],
|
||||
prefix: string,
|
||||
indent: number
|
||||
): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.type === "paragraph") {
|
||||
if (i === 0) {
|
||||
out += prefix + serializeInline(child.content || []) + "\n";
|
||||
} else {
|
||||
out +=
|
||||
" ".repeat(indent + 1) +
|
||||
serializeInline(child.content || []) +
|
||||
"\n";
|
||||
}
|
||||
} else if (child.type === "bulletList") {
|
||||
out += serializeBulletList(child.content || [], indent + 1);
|
||||
} else if (child.type === "orderedList") {
|
||||
out += serializeOrderedList(
|
||||
child.content || [],
|
||||
indent + 1,
|
||||
child.attrs?.start ?? 1
|
||||
);
|
||||
} else {
|
||||
out += serializeNode(child, indent + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeInline(nodes: JSONContent[]): string {
|
||||
let out = "";
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") {
|
||||
out += applyMarks(node.text || "", node.marks || []);
|
||||
} else if (node.type === "hardBreak") {
|
||||
out += "\n";
|
||||
} else if (node.content) {
|
||||
out += serializeInline(node.content);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyMarks(text: string, marks: NonNullable<JSONContent["marks"]>): string {
|
||||
let result = text;
|
||||
for (const mark of marks) {
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
result = `**${result}**`;
|
||||
break;
|
||||
case "italic":
|
||||
result = `_${result}_`;
|
||||
break;
|
||||
case "strike":
|
||||
result = `~~${result}~~`;
|
||||
break;
|
||||
case "code":
|
||||
result = "`" + result + "`";
|
||||
break;
|
||||
case "link":
|
||||
result = `[${result}](${mark.attrs?.href || ""})`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTextContent(node: JSONContent): string {
|
||||
if (node.text) return node.text;
|
||||
if (!node.content) return "";
|
||||
return node.content.map(getTextContent).join("");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface MarkdownSection {
|
||||
heading: string;
|
||||
level: number;
|
||||
content: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.*)$/gm;
|
||||
|
||||
export function parseMarkdownSections(body: string): MarkdownSection[] {
|
||||
const sections: MarkdownSection[] = [];
|
||||
const matches: { index: number; level: number; heading: string }[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = HEADING_RE.exec(body)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
level: match[1].length,
|
||||
heading: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
// Preamble: text before the first heading
|
||||
if (matches.length === 0) {
|
||||
// Entire body is a single preamble section
|
||||
if (body.length > 0) {
|
||||
sections.push({
|
||||
heading: "",
|
||||
level: 0,
|
||||
content: body,
|
||||
startOffset: 0,
|
||||
endOffset: body.length,
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (matches[0].index > 0) {
|
||||
sections.push({
|
||||
heading: "",
|
||||
level: 0,
|
||||
content: body.slice(0, matches[0].index),
|
||||
startOffset: 0,
|
||||
endOffset: matches[0].index,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i].index;
|
||||
const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
|
||||
sections.push({
|
||||
heading: matches[i].heading,
|
||||
level: matches[i].level,
|
||||
content: body.slice(start, end),
|
||||
startOffset: start,
|
||||
endOffset: end,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -479,7 +479,7 @@ function excludeAutoNote(noteId: number) {
|
||||
<style scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: calc(100vh - 49px);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -612,7 +612,7 @@ function excludeAutoNote(noteId: number) {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 848px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -732,7 +732,7 @@ function excludeAutoNote(noteId: number) {
|
||||
|
||||
/* Input wrapper */
|
||||
.input-wrapper {
|
||||
max-width: 848px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
|
||||
@@ -137,7 +137,7 @@ async function newChat() {
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useAutocomplete } from "@/composables/useAutocomplete";
|
||||
import { useAssist } from "@/composables/useAssist";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -17,7 +19,10 @@ const body = ref("");
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
return (editorRef.value?.editor as Editor | undefined) ?? null;
|
||||
});
|
||||
|
||||
const noteId = computed(() =>
|
||||
route.params.id ? Number(route.params.id) : null
|
||||
@@ -26,18 +31,29 @@ const isEditing = computed(() => noteId.value !== null);
|
||||
|
||||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
|
||||
// Autocomplete
|
||||
const {
|
||||
acItems,
|
||||
acVisible,
|
||||
acIndex,
|
||||
acTop,
|
||||
acLeft,
|
||||
detectTrigger,
|
||||
accept: acAccept,
|
||||
dismiss: acDismiss,
|
||||
onKeydown: acOnKeydown,
|
||||
} = useAutocomplete(textareaRef, body, (q) => store.fetchAllTags(q));
|
||||
// AI Assist
|
||||
const assist = useAssist(body);
|
||||
|
||||
const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value));
|
||||
const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value));
|
||||
|
||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
||||
assist.selectTextRange(payload.start, payload.end);
|
||||
}
|
||||
|
||||
function handleAssistAccept() {
|
||||
const newBody = assist.accept();
|
||||
if (newBody !== body.value) {
|
||||
body.value = newBody;
|
||||
markDirty();
|
||||
toast.show("Section updated");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTarget(text: string, max = 60): string {
|
||||
const first = text.split("\n")[0];
|
||||
return first.length > max ? first.slice(0, max) + "..." : first;
|
||||
}
|
||||
|
||||
// Track saved state for dirty detection
|
||||
let savedTitle = "";
|
||||
@@ -47,48 +63,9 @@ function markDirty() {
|
||||
dirty.value = title.value !== savedTitle || body.value !== savedBody;
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.max(el.scrollHeight, 200) + "px";
|
||||
}
|
||||
|
||||
function onBodyInput() {
|
||||
function onBodyUpdate(newVal: string) {
|
||||
body.value = newVal;
|
||||
markDirty();
|
||||
autoGrow();
|
||||
detectTrigger();
|
||||
}
|
||||
|
||||
function onTextareaKeydown(e: KeyboardEvent) {
|
||||
acOnKeydown(e);
|
||||
}
|
||||
|
||||
function onTextareaBlur() {
|
||||
setTimeout(acDismiss, 150);
|
||||
}
|
||||
|
||||
function insertAtCursor(before: string, after: string, placeholder: string) {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
showPreview.value = false;
|
||||
nextTick(() => {
|
||||
el.focus();
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const selected = body.value.slice(start, end);
|
||||
const insert = selected || placeholder;
|
||||
body.value =
|
||||
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const newStart = start + before.length;
|
||||
const newEnd = newStart + insert.length;
|
||||
el.setSelectionRange(newStart, newEnd);
|
||||
el.focus();
|
||||
autoGrow();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -101,7 +78,6 @@ onMounted(async () => {
|
||||
savedBody = body.value;
|
||||
}
|
||||
}
|
||||
nextTick(autoGrow);
|
||||
});
|
||||
|
||||
async function save() {
|
||||
@@ -183,74 +159,124 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="editor">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">Back</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<main class="editor-layout">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">Back</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
/>
|
||||
|
||||
<div class="editor-tabs">
|
||||
<button
|
||||
:class="['tab', { active: !showPreview }]"
|
||||
@click="showPreview = false"
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: showPreview }]"
|
||||
@click="showPreview = true"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
|
||||
|
||||
<div class="textarea-wrapper" v-show="!showPreview">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="body"
|
||||
placeholder="Write your note in Markdown... Use #tags inline"
|
||||
class="body-input"
|
||||
@input="onBodyInput"
|
||||
@keydown="onTextareaKeydown"
|
||||
@blur="onTextareaBlur"
|
||||
></textarea>
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
<ul
|
||||
v-if="acVisible"
|
||||
class="ac-dropdown"
|
||||
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, i) in acItems"
|
||||
:key="item.value"
|
||||
:class="['ac-item', { active: i === acIndex }]"
|
||||
@mousedown.prevent="acAccept(i)"
|
||||
<div class="editor-tabs">
|
||||
<button
|
||||
:class="['tab', { active: !showPreview }]"
|
||||
@click="showPreview = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
</ul>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: showPreview }]"
|
||||
@click="showPreview = true"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showPreview"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
></div>
|
||||
<div class="editor-scroll">
|
||||
<div v-show="!showPreview">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Write your note in Markdown... Use #tags inline"
|
||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showPreview"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- AI Assist -->
|
||||
<aside class="assist-panel">
|
||||
<div class="assist-panel-header">
|
||||
<h3 class="assist-panel-title">AI Assist</h3>
|
||||
</div>
|
||||
|
||||
<div class="assist-panel-body">
|
||||
<div v-if="assist.state.value === 'idle'" class="assist-controls">
|
||||
<div class="assist-sections">
|
||||
<div
|
||||
v-for="(section, i) in assist.sections.value"
|
||||
:key="i"
|
||||
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
|
||||
@click="assist.selectSection(section)"
|
||||
>
|
||||
{{ section.heading || '(preamble)' }}
|
||||
</div>
|
||||
<div v-if="assist.sections.value.length === 0" class="assist-empty">
|
||||
No sections found. Add headings (## ...) or select text.
|
||||
</div>
|
||||
</div>
|
||||
<div class="assist-input" v-if="assist.target.value">
|
||||
<div class="assist-target-preview">
|
||||
Editing: {{ truncateTarget(assist.target.value.text) }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="assist.instruction.value"
|
||||
placeholder="What should I do with this section?"
|
||||
class="assist-instruction"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="assist-input-actions">
|
||||
<button
|
||||
class="btn-generate"
|
||||
@click="assist.submit()"
|
||||
:disabled="!assist.canSubmit.value"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="assist.error.value" class="assist-error">
|
||||
{{ assist.error.value }}
|
||||
</div>
|
||||
|
||||
<div v-if="assist.state.value === 'streaming'" class="assist-preview">
|
||||
<div class="assist-proposed prose" v-html="renderedStreaming"></div>
|
||||
<span class="typing-indicator">...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assist.state.value === 'review'" class="assist-review">
|
||||
<div class="assist-proposed prose" v-html="renderedProposal"></div>
|
||||
<div class="assist-actions">
|
||||
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
||||
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
@@ -269,14 +295,31 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
/* ── Layout ── */
|
||||
.editor-layout {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
.editor-scroll {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Toolbar & inputs ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -334,23 +377,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.textarea-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.body-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
min-height: 200px;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.preview-pane {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
@@ -358,29 +384,182 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
min-height: 200px;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
.ac-dropdown {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
flex: 0 0 33.33%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -4px 16px var(--color-shadow);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
max-height: 200px;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.assist-panel-header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
}
|
||||
.assist-panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
min-width: 160px;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ac-item {
|
||||
padding: 0.4rem 0.75rem;
|
||||
.assist-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.assist-sections {
|
||||
flex: 0 0 40%;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.assist-section-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ac-item:hover,
|
||||
.ac-item.active {
|
||||
.assist-section-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.assist-section-item.selected {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assist-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.assist-input {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assist-target-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-instruction {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.assist-input-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-generate {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.assist-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.assist-preview,
|
||||
.assist-review {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.assist-proposed {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
color: var(--color-text-secondary);
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.btn-accept {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-reject {
|
||||
padding: 0.4rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -426,4 +605,17 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: #fff;
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.assist-panel {
|
||||
flex-basis: 45%;
|
||||
}
|
||||
.assist-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
.assist-sections {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -162,7 +162,7 @@ async function convertToTask() {
|
||||
|
||||
<style scoped>
|
||||
.viewer {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ function onOffsetUpdate(offset: number) {
|
||||
|
||||
<style scoped>
|
||||
.notes-list {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,17 @@ const MODEL_CATALOG: ModelInfo[] = [
|
||||
category: "Uncensored / Creative Writing",
|
||||
},
|
||||
{
|
||||
name: "mythomist",
|
||||
description: "A 7B model specifically tuned for creative and narrative writing including mature themes.",
|
||||
size: "4.1 GB",
|
||||
bestFor: "Creative fiction, narrative writing",
|
||||
name: "nollama/mythomax-l2-13b",
|
||||
description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.",
|
||||
size: "7.4 GB",
|
||||
bestFor: "Creative fiction, roleplay, narrative writing",
|
||||
category: "Uncensored / Creative Writing",
|
||||
},
|
||||
{
|
||||
name: "mattw/mythalion",
|
||||
description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.",
|
||||
size: "7.4 GB",
|
||||
bestFor: "Creative writing, character dialogue",
|
||||
category: "Uncensored / Creative Writing",
|
||||
},
|
||||
{
|
||||
@@ -453,7 +460,7 @@ async function handleRestoreFile(event: Event) {
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useAutocomplete } from "@/composables/useAutocomplete";
|
||||
import { useAssist } from "@/composables/useAssist";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -23,7 +25,10 @@ const dueDate = ref("");
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
return (editorRef.value?.editor as Editor | undefined) ?? null;
|
||||
});
|
||||
|
||||
const taskId = computed(() =>
|
||||
route.params.id ? Number(route.params.id) : null
|
||||
@@ -32,18 +37,29 @@ const isEditing = computed(() => taskId.value !== null);
|
||||
|
||||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
|
||||
// Autocomplete
|
||||
const {
|
||||
acItems,
|
||||
acVisible,
|
||||
acIndex,
|
||||
acTop,
|
||||
acLeft,
|
||||
detectTrigger,
|
||||
accept: acAccept,
|
||||
dismiss: acDismiss,
|
||||
onKeydown: acOnKeydown,
|
||||
} = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
|
||||
// AI Assist
|
||||
const assist = useAssist(body);
|
||||
|
||||
const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value));
|
||||
const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value));
|
||||
|
||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
||||
assist.selectTextRange(payload.start, payload.end);
|
||||
}
|
||||
|
||||
function handleAssistAccept() {
|
||||
const newBody = assist.accept();
|
||||
if (newBody !== body.value) {
|
||||
body.value = newBody;
|
||||
markDirty();
|
||||
toast.show("Section updated");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTarget(text: string, max = 60): string {
|
||||
const first = text.split("\n")[0];
|
||||
return first.length > max ? first.slice(0, max) + "..." : first;
|
||||
}
|
||||
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
@@ -60,48 +76,9 @@ function markDirty() {
|
||||
dueDate.value !== savedDueDate;
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.max(el.scrollHeight, 200) + "px";
|
||||
}
|
||||
|
||||
function onBodyInput() {
|
||||
function onBodyUpdate(newVal: string) {
|
||||
body.value = newVal;
|
||||
markDirty();
|
||||
autoGrow();
|
||||
detectTrigger();
|
||||
}
|
||||
|
||||
function onTextareaKeydown(e: KeyboardEvent) {
|
||||
acOnKeydown(e);
|
||||
}
|
||||
|
||||
function onTextareaBlur() {
|
||||
setTimeout(acDismiss, 150);
|
||||
}
|
||||
|
||||
function insertAtCursor(before: string, after: string, placeholder: string) {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
showPreview.value = false;
|
||||
nextTick(() => {
|
||||
el.focus();
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const selected = body.value.slice(start, end);
|
||||
const insert = selected || placeholder;
|
||||
body.value =
|
||||
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const newStart = start + before.length;
|
||||
const newEnd = newStart + insert.length;
|
||||
el.setSelectionRange(newStart, newEnd);
|
||||
el.focus();
|
||||
autoGrow();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -120,7 +97,6 @@ onMounted(async () => {
|
||||
savedDueDate = dueDate.value;
|
||||
}
|
||||
}
|
||||
nextTick(autoGrow);
|
||||
});
|
||||
|
||||
async function save() {
|
||||
@@ -203,104 +179,154 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="editor">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">Back</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<main class="editor-layout">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">Back</router-link>
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
class="title-input"
|
||||
@input="markDirty"
|
||||
/>
|
||||
|
||||
<div class="editor-tabs">
|
||||
<button
|
||||
:class="['tab', { active: !showPreview }]"
|
||||
@click="showPreview = false"
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: showPreview }]"
|
||||
@click="showPreview = true"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="status" @change="markDirty" class="field-select">
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
<select v-model="priority" @change="markDirty" class="field-select">
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Due Date</label>
|
||||
<input
|
||||
v-model="dueDate"
|
||||
type="date"
|
||||
class="field-input"
|
||||
@input="markDirty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
|
||||
|
||||
<div class="textarea-wrapper" v-show="!showPreview">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="body"
|
||||
placeholder="Describe this task... Use #tags inline"
|
||||
class="body-input"
|
||||
@input="onBodyInput"
|
||||
@keydown="onTextareaKeydown"
|
||||
@blur="onTextareaBlur"
|
||||
></textarea>
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
<ul
|
||||
v-if="acVisible"
|
||||
class="ac-dropdown"
|
||||
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, i) in acItems"
|
||||
:key="item.value"
|
||||
:class="['ac-item', { active: i === acIndex }]"
|
||||
@mousedown.prevent="acAccept(i)"
|
||||
<div class="editor-tabs">
|
||||
<button
|
||||
:class="['tab', { active: !showPreview }]"
|
||||
@click="showPreview = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
</ul>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: showPreview }]"
|
||||
@click="showPreview = true"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showPreview"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
></div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="status" @change="markDirty" class="field-select">
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
<select v-model="priority" @change="markDirty" class="field-select">
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Due Date</label>
|
||||
<input
|
||||
v-model="dueDate"
|
||||
type="date"
|
||||
class="field-input"
|
||||
@input="markDirty"
|
||||
<div class="editor-scroll">
|
||||
<div v-show="!showPreview">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Describe this task... Use #tags inline"
|
||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showPreview"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- AI Assist -->
|
||||
<aside class="assist-panel">
|
||||
<div class="assist-panel-header">
|
||||
<h3 class="assist-panel-title">AI Assist</h3>
|
||||
</div>
|
||||
|
||||
<div class="assist-panel-body">
|
||||
<div v-if="assist.state.value === 'idle'" class="assist-controls">
|
||||
<div class="assist-sections">
|
||||
<div
|
||||
v-for="(section, i) in assist.sections.value"
|
||||
:key="i"
|
||||
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
|
||||
@click="assist.selectSection(section)"
|
||||
>
|
||||
{{ section.heading || '(preamble)' }}
|
||||
</div>
|
||||
<div v-if="assist.sections.value.length === 0" class="assist-empty">
|
||||
No sections found. Add headings (## ...) or select text.
|
||||
</div>
|
||||
</div>
|
||||
<div class="assist-input" v-if="assist.target.value">
|
||||
<div class="assist-target-preview">
|
||||
Editing: {{ truncateTarget(assist.target.value.text) }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="assist.instruction.value"
|
||||
placeholder="What should I do with this section?"
|
||||
class="assist-instruction"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="assist-input-actions">
|
||||
<button
|
||||
class="btn-generate"
|
||||
@click="assist.submit()"
|
||||
:disabled="!assist.canSubmit.value"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="assist.error.value" class="assist-error">
|
||||
{{ assist.error.value }}
|
||||
</div>
|
||||
|
||||
<div v-if="assist.state.value === 'streaming'" class="assist-preview">
|
||||
<div class="assist-proposed prose" v-html="renderedStreaming"></div>
|
||||
<span class="typing-indicator">...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assist.state.value === 'review'" class="assist-review">
|
||||
<div class="assist-proposed prose" v-html="renderedProposal"></div>
|
||||
<div class="assist-actions">
|
||||
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
||||
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
||||
@@ -318,14 +344,31 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
/* ── Layout ── */
|
||||
.editor-layout {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
.editor-scroll {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Toolbar & inputs ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -383,23 +426,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.textarea-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.body-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
min-height: 200px;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.preview-pane {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
@@ -407,29 +433,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
min-height: 200px;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
.ac-dropdown {
|
||||
position: fixed;
|
||||
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);
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -456,6 +459,182 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
flex: 0 0 33.33%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -4px 16px var(--color-shadow);
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.assist-panel-header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
}
|
||||
.assist-panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.assist-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.assist-sections {
|
||||
flex: 0 0 40%;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.assist-section-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-section-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.assist-section-item.selected {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assist-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.assist-input {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assist-target-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-instruction {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.assist-input-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-generate {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.assist-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.assist-preview,
|
||||
.assist-review {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.assist-proposed {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
color: var(--color-text-secondary);
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.btn-accept {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-reject {
|
||||
padding: 0.4rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -501,4 +680,17 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: #fff;
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.assist-panel {
|
||||
flex-basis: 45%;
|
||||
}
|
||||
.assist-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
.assist-sections {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -192,7 +192,7 @@ function onTagClick(tag: string) {
|
||||
|
||||
<style scoped>
|
||||
.viewer {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ function onOffsetUpdate(offset: number) {
|
||||
|
||||
<style scoped>
|
||||
.tasks-list {
|
||||
max-width: 720px;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user