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
+3 -3
View File
@@ -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%;
+1 -1
View File
@@ -137,7 +137,7 @@ async function newChat() {
<style scoped>
.home {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+349 -157
View File
@@ -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>
+1 -1
View File
@@ -162,7 +162,7 @@ async function convertToTask() {
<style scoped>
.viewer {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+1 -1
View File
@@ -133,7 +133,7 @@ function onOffsetUpdate(offset: number) {
<style scoped>
.notes-list {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+12 -5
View File
@@ -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;
}
+381 -189
View File
@@ -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>
+1 -1
View File
@@ -192,7 +192,7 @@ function onTagClick(tag: string) {
<style scoped>
.viewer {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
+1 -1
View File
@@ -170,7 +170,7 @@ function onOffsetUpdate(offset: number) {
<style scoped>
.tasks-list {
max-width: 720px;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}