Files
HelixNotes/src/lib/components/Editor.svelte
T
Yuri Karamian bbcca553ce v1.0.3 - In-note search, note history navigation, keyboard shortcuts
- Find in note (Ctrl+F): search within current note with match highlighting and navigation
- Global vault search moved to Ctrl+Shift+F
- Note history navigation: Alt+Left/Right and mouse back/forward buttons
- Search icon added to editor toolbar
- Escape closes Settings and Info panels
- Updated Info panel with new keyboard shortcuts
- Deb install type detection with APT upgrade instructions
2026-02-10 19:38:51 +01:00

5317 lines
182 KiB
Svelte

<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Highlight from '@tiptap/extension-highlight';
import Typography from '@tiptap/extension-typography';
import Underline from '@tiptap/extension-underline';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import { Color } from '@tiptap/extension-color';
import { TextStyle } from '@tiptap/extension-text-style';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-details';
import TextAlign from '@tiptap/extension-text-align';
import { common, createLowlight } from 'lowlight';
import MarkdownIt from 'markdown-it';
import markdownItMark from 'markdown-it-mark';
import markdownItSup from 'markdown-it-sup';
import markdownItSub from 'markdown-it-sub';
import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { DOMSerializer } from '@tiptap/pm/model';
import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, copyFileTo } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, quickAccessPaths, notes } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event';
import { debounce } from '$lib/utils/debounce';
import GraphView from './GraphView.svelte';
let editorElement = $state<HTMLDivElement>(null!);
let sourceElement = $state<HTMLTextAreaElement>(null!);
let editor: Editor | null = null;
let editorReady = $state(false);
let sourceContent = $state('');
let loadedPath = '';
let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false;
let isLoadingNote = false;
let lastSourceMode = $sourceMode;
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
let titleWasStripped = false;
let strippedTitle = '';
let strippedHeadingPrefix = '';
let headingDropdown = $state(false);
let colorDropdown = $state(false);
let highlightDropdown = $state(false);
let alignDropdown = $state(false);
let insertDropdown = $state(false);
let editorState = $state(0);
// AI
let aiMenu = $state<{ x: number; y: number } | null>(null);
let aiLoading = $state(false);
let aiResult = $state<string | null>(null);
let aiError = $state<string | null>(null);
let aiSelectionFrom = $state(0);
let aiSelectionTo = $state(0);
let aiSelectedText = $state('');
let aiCustomPrompt = $state('');
let aiShowCustom = $state(false);
let aiTranslateMenu = $state(false);
let aiWholeNote = $state(false);
let aiEmptyNote = $state(false);
let aiOriginalMarkdown = $state('');
let aiMediaPlaceholders = $state<Map<string, string>>(new Map());
// View mode (read-only)
let readOnly = $state(false);
let readOnlyFlash = $state(false);
// Version history
let showHistory = $state(false);
let showGraph = $state(false);
let historyVersions = $state<VersionEntry[]>([]);
let historyPreview = $state<string | null>(null);
let historySelected = $state<VersionEntry | null>(null);
let historyLoading = $state(false);
// In-note search
let noteSearchOpen = $state(false);
let noteSearchQuery = $state('');
let noteSearchIndex = $state(0);
let noteSearchResults = $state<{from: number, to: number}[]>([]);
let noteSearchInput = $state<HTMLInputElement>(null!);
const noteSearchPluginKey = new PluginKey('noteSearch');
// Slash commands
let slashMenu = $state<{ x: number; y: number; query: string; from: number; to: number } | null>(null);
let slashSelectedIndex = $state(0);
let slashTablePicker = $state(false);
let slashTableHover = $state({ rows: 0, cols: 0 });
interface SlashCommand {
label: string;
aliases: string[];
icon: string;
action: () => void;
}
function getSlashCommands(): SlashCommand[] {
return [
{ label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: 'Heading 2', aliases: ['h2', 'heading2', 'subtitle'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: 'Heading 3', aliases: ['h3', 'heading3'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 01-2 2m2 0a2 2 0 01-2 2c-1.5 0-3.5 0-3.5-1.5"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run() },
{ label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>', action: () => editor?.chain().focus().toggleBulletList().run() },
{ label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="9" font-size="8" fill="currentColor" stroke="none">1</text><text x="1" y="15" font-size="8" fill="currentColor" stroke="none">2</text><text x="1" y="21" font-size="8" fill="currentColor" stroke="none">3</text></svg>', action: () => editor?.chain().focus().toggleOrderedList().run() },
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() },
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => editor?.chain().focus().setDetails().run() },
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
];
}
let slashFiltered = $derived.by(() => {
const commands = getSlashCommands();
if (!slashMenu) return commands;
const q = slashMenu.query.toLowerCase();
if (!q) return commands;
return commands.filter(cmd =>
cmd.label.toLowerCase().includes(q) ||
cmd.aliases.some(a => a.includes(q))
);
});
let titleInput = $state<HTMLInputElement>(null!);
let linkModal = $state(false);
let linkModalUrl = $state('');
let linkModalInput = $state<HTMLInputElement>(null!);
let textContextMenu = $state<{ x: number; y: number } | null>(null);
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 });
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string } | null>(null);
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common);
const codeLanguages = lowlight.listLanguages().sort();
const mdit = MarkdownIt({ html: true, linkify: false, breaks: false })
.use(markdownItMark)
.use(markdownItSup)
.use(markdownItSub);
function normalizePath(p: string): string {
const parts = p.split('/');
const resolved: string[] = [];
for (const seg of parts) {
if (seg === '..') {
resolved.pop();
} else if (seg !== '.') {
resolved.push(seg);
}
}
return resolved.join('/');
}
const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
size: {
default: 'full',
parseHTML: (element: HTMLElement) => element.getAttribute('data-size') || 'full',
renderHTML: (attributes: Record<string, any>) => {
return { 'data-size': attributes.size };
},
},
};
},
});
function cellColorAttributes() {
return {
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-bg-color') || element.style.backgroundColor || null,
renderHTML: (attributes: Record<string, any>) => {
if (!attributes.backgroundColor) return {};
const bg = attributes.backgroundColor;
// Determine if we need light text for dark backgrounds
const darkBgs = ['#1e293b', '#374151', '#7f1d1d', '#713f12', '#14532d', '#1e3a5f', '#4c1d95', '#831843', '#0c4a6e', '#064e3b'];
const needsLight = darkBgs.includes(bg);
const style = needsLight
? `background-color: ${bg}; color: #f1f5f9`
: `background-color: ${bg}`;
return { style, 'data-bg-color': bg };
},
},
};
}
const CustomTableCell = TableCell.extend({
addAttributes() {
return { ...this.parent?.(), ...cellColorAttributes() };
},
});
const CustomTableHeader = TableHeader.extend({
addAttributes() {
return { ...this.parent?.(), ...cellColorAttributes() };
},
});
const PdfEmbed = TiptapNode.create({
name: 'pdfEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
src: { default: null },
name: { default: 'file.pdf' },
};
},
parseHTML() {
return [{
tag: 'div[data-pdf-src]',
getAttrs: (el: HTMLElement) => ({
src: el.getAttribute('data-pdf-src'),
name: el.getAttribute('data-pdf-name') || 'file.pdf',
}),
}];
},
renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes.src || '';
const name = HTMLAttributes.name || 'file.pdf';
const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
const displaySrc = convertFileSrc(absPath);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed' }),
['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }],
['p', { class: 'pdf-label' }, name],
];
},
});
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
const rect = triggerEl.getBoundingClientRect();
codeLangDropdown = { pos, x: rect.right, y: rect.bottom + 4, current };
}
function selectCodeLang(lang: string) {
if (!editor || !codeLangDropdown) return;
const { pos } = codeLangDropdown;
// Find the codeBlock node at this position
const resolved = editor.state.doc.resolve(pos);
const node = resolved.parent;
if (node.type.name === 'codeBlock') {
editor.chain().focus().updateAttributes('codeBlock', { language: lang || null }).run();
}
codeLangDropdown = null;
}
function closeCodeLangDropdown() {
codeLangDropdown = null;
}
// ── In-note search extension ──
const NoteSearchExtension = Extension.create({
name: 'noteSearch',
addProseMirrorPlugins() {
return [
new Plugin({
key: noteSearchPluginKey,
state: {
init() { return DecorationSet.empty; },
apply(tr, old) {
const meta = tr.getMeta(noteSearchPluginKey);
if (meta !== undefined) return meta;
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('codeBlockLanguageSelect'),
props: {
decorations: (state) => {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === 'codeBlock') {
const wrapper = document.createElement('div');
wrapper.className = 'code-lang-wrapper';
const btn = document.createElement('button');
btn.className = 'code-lang-btn';
if (node.attrs.language) {
btn.textContent = node.attrs.language;
} else {
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
}
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openCodeLangDropdown(pos + 1, node.attrs.language || '', btn);
});
btn.addEventListener('mousedown', (e) => e.stopPropagation());
wrapper.appendChild(btn);
decorations.push(Decoration.widget(pos + 1, wrapper, { side: -1, ignoreSelection: true }));
}
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
});
function executeSlashCommand(index: number) {
const items = slashFiltered;
if (index < 0 || index >= items.length || !slashMenu || !editor) return;
const cmd = items[index];
// Table opens a sub-picker instead of closing
if (cmd.label === 'Table') {
slashTablePicker = true;
slashTableHover = { rows: 0, cols: 0 };
slashSelectedIndex = 0;
// Delete slash text after setting flag so onTransaction doesn't close menu
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run();
return;
}
// Delete the slash trigger text (/ + query)
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run();
slashMenu = null;
slashSelectedIndex = 0;
// Execute after the deletion is applied
tick().then(() => cmd.action());
}
function slashInsertTable(rows: number, cols: number) {
if (!editor) return;
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
closeSlashMenu();
}
function closeSlashMenu() {
slashMenu = null;
slashSelectedIndex = 0;
slashTablePicker = false;
slashTableHover = { rows: 0, cols: 0 };
}
function updateSlashMenu() {
if (!editor) return;
if (slashTablePicker) return; // Table picker is open, don't interfere
const { state } = editor;
const { selection } = state;
const resolvedFrom = selection.$from;
// Only in empty-ish context (paragraph, heading)
const parentNode = resolvedFrom.parent;
if (parentNode.type.name !== 'paragraph' && parentNode.type.name !== 'heading') {
closeSlashMenu();
return;
}
const textBefore = parentNode.textContent.slice(0, resolvedFrom.parentOffset);
// Match "/" at start of line or after whitespace
const match = textBefore.match(/(^|\s)\/([^\s]*)$/);
if (!match) {
closeSlashMenu();
return;
}
const query = match[2];
const slashOffset = textBefore.length - match[0].length + (match[1].length); // position of "/"
const from = resolvedFrom.start() + slashOffset;
const to = resolvedFrom.pos;
// Get cursor coordinates for menu positioning
const coords = editor.view.coordsAtPos(from);
let x = coords.left;
let y = coords.bottom + 4;
// Keep menu within viewport
if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
if (y + 300 > window.innerHeight) y = coords.top - 304;
slashMenu = { x, y, query, from, to };
slashSelectedIndex = 0;
}
const SlashCommands = Extension.create({
name: 'slashCommands',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('slashCommands'),
props: {
handleKeyDown: (_view, event) => {
if (!slashMenu) return false;
if (slashTablePicker) {
if (event.key === 'Escape') {
event.preventDefault();
closeSlashMenu();
return true;
}
if (event.key === 'Tab') {
event.preventDefault();
if (slashTableHover.rows > 0 && slashTableHover.cols > 0) {
slashInsertTable(slashTableHover.rows, slashTableHover.cols);
}
return true;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
slashTableHover = { rows: Math.max(1, slashTableHover.rows), cols: Math.min(10, (slashTableHover.cols || 0) + 1) };
return true;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
slashTableHover = { rows: Math.max(1, slashTableHover.rows), cols: Math.max(1, slashTableHover.cols - 1) };
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
slashTableHover = { rows: Math.min(8, (slashTableHover.rows || 0) + 1), cols: Math.max(1, slashTableHover.cols) };
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
slashTableHover = { rows: Math.max(1, slashTableHover.rows - 1), cols: Math.max(1, slashTableHover.cols) };
return true;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (slashTableHover.rows > 0 && slashTableHover.cols > 0) {
slashInsertTable(slashTableHover.rows, slashTableHover.cols);
}
return true;
}
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
slashSelectedIndex = (slashSelectedIndex + 1) % Math.max(1, slashFiltered.length);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
slashSelectedIndex = (slashSelectedIndex - 1 + slashFiltered.length) % Math.max(1, slashFiltered.length);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (slashFiltered.length > 0) {
event.preventDefault();
executeSlashCommand(slashSelectedIndex);
return true;
}
closeSlashMenu();
return false;
}
if (event.key === 'Escape') {
event.preventDefault();
closeSlashMenu();
return true;
}
return false;
},
},
}),
];
},
});
// ── Wiki-links ──
let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null);
let wikiLinkSelectedIndex = $state(0);
let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]);
let wikiLinkFiltered = $derived.by(() => {
if (!wikiLinkMenu) return wikiLinkTitlesCache;
const q = wikiLinkMenu.query.toLowerCase();
if (!q) return wikiLinkTitlesCache;
return wikiLinkTitlesCache.filter(entry =>
entry.title.toLowerCase().includes(q)
);
});
async function refreshWikiLinkTitles() {
try {
wikiLinkTitlesCache = await getAllNoteTitles();
} catch (e) {
console.error('Failed to load note titles:', e);
}
}
function closeWikiLinkMenu() {
wikiLinkMenu = null;
wikiLinkSelectedIndex = 0;
}
function insertWikiLink(entry: NoteTitleEntry) {
if (!editor || !wikiLinkMenu) return;
const { from } = wikiLinkMenu;
// Delete the [[ trigger and query text
const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from, to }).run();
// Insert the wiki-link mark
tick().then(() => {
if (!editor) return;
editor.chain().focus()
.insertContent({
type: 'text',
text: entry.title,
marks: [{ type: 'wikiLink', attrs: { title: entry.title, path: entry.path } }],
})
.run();
});
closeWikiLinkMenu();
}
function executeWikiLinkCommand(index: number) {
const items = wikiLinkFiltered;
if (index < 0 || index >= items.length) return;
insertWikiLink(items[index]);
}
const WikiLink = TiptapMark.create({
name: 'wikiLink',
inclusive: false,
excludes: 'link',
addAttributes() {
return {
title: { default: null },
path: { default: null },
};
},
parseHTML() {
return [
{ tag: 'span[data-wiki-link]' },
{ tag: 'a[data-wiki-link]' },
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
return ['span', {
'data-wiki-link': '',
'data-path': HTMLAttributes.path || '',
'data-title': HTMLAttributes.title || '',
class: 'wiki-link',
}, 0];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('wikiLinkClick'),
props: {
handleDOMEvents: {
click: (view, event) => {
const target = event.target as HTMLElement;
const wikiLinkEl = target.closest?.('span[data-wiki-link]') as HTMLElement | null;
if (wikiLinkEl) {
event.preventDefault();
event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title);
return true;
}
return false;
},
},
},
}),
];
},
});
const WikiLinkAutocomplete = Extension.create({
name: 'wikiLinkAutocomplete',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('wikiLinkAutocomplete'),
props: {
handleKeyDown: (_view, event) => {
if (!wikiLinkMenu) return false;
if (event.key === 'ArrowDown') {
event.preventDefault();
wikiLinkSelectedIndex = (wikiLinkSelectedIndex + 1) % Math.max(1, wikiLinkFiltered.length);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
wikiLinkSelectedIndex = (wikiLinkSelectedIndex - 1 + wikiLinkFiltered.length) % Math.max(1, wikiLinkFiltered.length);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (wikiLinkFiltered.length > 0) {
event.preventDefault();
executeWikiLinkCommand(wikiLinkSelectedIndex);
return true;
}
closeWikiLinkMenu();
return false;
}
if (event.key === 'Escape') {
event.preventDefault();
closeWikiLinkMenu();
return true;
}
return false;
},
handleTextInput: (view, from, to, text) => {
if (!$appConfig?.enable_wiki_links) return false;
// Detect ]] closing: auto-resolve the current text as a wiki-link
if (text === ']' && wikiLinkMenu) {
const state = view.state;
const textBefore = state.doc.textBetween(wikiLinkMenu.from, state.selection.from);
if (textBefore.endsWith(']')) {
// User typed ]] — resolve the query as a link title
const query = textBefore.slice(2, -1); // strip the [[ and trailing ]
if (query.trim()) {
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === query.toLowerCase());
if (match) {
insertWikiLink(match);
} else {
// Insert as unresolved wiki-link (no path)
const menuFrom = wikiLinkMenu.from;
const curTo = state.selection.from;
closeWikiLinkMenu();
// Delete [[ text + query + the trailing ]
editor?.chain().focus().deleteRange({ from: menuFrom, to: curTo }).run();
tick().then(() => {
editor?.chain().focus().insertContent({
type: 'text',
text: query,
marks: [{ type: 'wikiLink', attrs: { title: query, path: '' } }],
}).run();
});
}
} else {
closeWikiLinkMenu();
}
return true;
}
}
return false;
},
},
}),
];
},
});
function updateWikiLinkMenu() {
if (!editor || !$appConfig?.enable_wiki_links) return;
const { state } = editor;
const { selection } = state;
const resolvedFrom = selection.$from;
const parentNode = resolvedFrom.parent;
if (parentNode.type.name !== 'paragraph' && parentNode.type.name !== 'heading') {
closeWikiLinkMenu();
return;
}
const textBefore = parentNode.textContent.slice(0, resolvedFrom.parentOffset);
// Match [[ at start of line or after whitespace
const match = textBefore.match(/\[\[([^\]]*)$/);
if (!match) {
closeWikiLinkMenu();
return;
}
const query = match[1];
const bracketOffset = textBefore.length - match[0].length;
const from = resolvedFrom.start() + bracketOffset;
const coords = editor.view.coordsAtPos(from);
let x = coords.left;
let y = coords.bottom + 4;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 300 > window.innerHeight) y = coords.top - 304;
wikiLinkMenu = { x, y, query, from };
wikiLinkSelectedIndex = 0;
}
async function navigateToWikiLink(path: string, title: string) {
if (!path) {
// Unresolved link — try to find by title
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === title.toLowerCase());
if (match) {
path = match.path;
} else {
// Offer to create the note
const notebookRel = $activeNotePath
? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/')
: null;
try {
const { createNote } = await import('$lib/api');
const newNote = await createNote(notebookRel || null, title);
// Navigate to the new note
const content = await readNote(newNote.path);
$activeNote = { ...content, content: content.content };
$activeNotePath = newNote.path;
} catch (e) {
console.error('Failed to create note:', e);
}
return;
}
}
try {
const content = await readNote(path);
$activeNote = { ...content, content: content.content };
$activeNotePath = path;
} catch (e) {
console.error('Failed to navigate to wiki-link:', e);
}
}
const textColors = [
{ name: 'Default', value: '' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Amber', value: '#f59e0b' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Pink', value: '#ec4899' },
];
const highlightColors = [
{ name: 'Yellow', value: 'rgba(250, 230, 100, 0.25)', swatch: '#f5e050' },
{ name: 'Green', value: 'rgba(100, 210, 130, 0.22)', swatch: '#5cc870' },
{ name: 'Blue', value: 'rgba(100, 170, 240, 0.22)', swatch: '#6aabf0' },
{ name: 'Purple', value: 'rgba(180, 130, 240, 0.22)', swatch: '#a878e8' },
{ name: 'Pink', value: 'rgba(240, 140, 180, 0.22)', swatch: '#e88aaa' },
{ name: 'Red', value: 'rgba(240, 120, 120, 0.22)', swatch: '#e07070' },
{ name: 'Orange', value: 'rgba(240, 170, 90, 0.25)', swatch: '#e8a050' },
{ name: 'Cyan', value: 'rgba(80, 210, 230, 0.22)', swatch: '#50cce0' },
];
const cellColors = [
{ name: 'None', value: '' },
{ name: 'Light Red', value: '#fde8e8' },
{ name: 'Light Orange', value: '#fef3e2' },
{ name: 'Light Yellow', value: '#fef9e7' },
{ name: 'Light Green', value: '#e6f8e0' },
{ name: 'Light Blue', value: '#e0f0fe' },
{ name: 'Light Purple', value: '#f0e6fe' },
{ name: 'Light Pink', value: '#fde8f0' },
{ name: 'Light Gray', value: '#f3f4f6' },
{ name: 'Dark Red', value: '#7f1d1d' },
{ name: 'Dark Amber', value: '#713f12' },
{ name: 'Dark Green', value: '#14532d' },
{ name: 'Dark Blue', value: '#1e3a5f' },
{ name: 'Dark Purple', value: '#4c1d95' },
{ name: 'Dark Pink', value: '#831843' },
{ name: 'Dark Teal', value: '#064e3b' },
{ name: 'Dark Cyan', value: '#0c4a6e' },
{ name: 'Slate', value: '#1e293b' },
{ name: 'Gray', value: '#374151' },
];
function resolveImageSrc(src: string): string {
// Already an absolute URL or data URI
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:')) {
return src;
}
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution
let decoded = decodeURIComponent(src);
// Fix multiple leading slashes (from broken saves)
if (decoded.match(/^\/{2,}/)) {
decoded = decoded.replace(/^\/{2,}/, '/');
}
if (decoded.startsWith('/')) {
return convertFileSrc(normalizePath(decoded));
}
// Paths containing .helixnotes/ are vault-root relative (our own attachments)
if (decoded.includes('.helixnotes/')) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) {
// Extract from .helixnotes/ onward in case of prefixed subdir paths
const idx = decoded.indexOf('.helixnotes/');
return convertFileSrc(`${vaultRoot}/${decoded.substring(idx)}`);
}
}
// Standard markdown: resolve relative paths against the note's directory
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return convertFileSrc(normalizePath(`${noteDir}/${decoded}`));
}
// Last fallback: vault root
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) {
return convertFileSrc(normalizePath(`${vaultRoot}/${decoded}`));
}
return src;
}
const autoSave = debounce(async () => {
if (!$activeNote || !$activeNotePath || !$editorDirty) return;
try {
const body = $sourceMode
? restoreTitleH1(sourceContent)
: editorToMarkdown();
// Safety: never save empty/near-empty body over a note that had real content
const trimmed = body.replace(/^#.*\n?/, '').trim();
if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) {
console.warn('Auto-save blocked: refusing to overwrite note with empty content');
return;
}
await saveNote($activeNotePath, $activeNote.meta, body);
$editorDirty = false;
} catch (e) {
console.error('Auto-save failed:', e);
}
}, 500);
export async function forceSave() {
if (!$activeNote || !$activeNotePath) return;
try {
const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown();
const trimmed = body.replace(/^#.*\n?/, '').trim();
if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) {
console.warn('Force-save blocked: refusing to overwrite note with empty content');
return;
}
await saveNote($activeNotePath, $activeNote.meta, body);
$editorDirty = false;
} catch (e) {
console.error('Save failed:', e);
}
}
function toggleReadOnly() {
readOnly = !readOnly;
if (editor) {
// Save before entering read-only so nothing is lost
if (readOnly && $editorDirty) forceSave();
editor.setEditable(!readOnly);
}
}
async function toggleHistory() {
showHistory = !showHistory;
historyPreview = null;
historySelected = null;
if (showHistory && $activeNote) {
historyLoading = true;
try {
historyVersions = await getNoteVersions($activeNote.meta.id);
} catch (e) {
console.error('Failed to load versions:', e);
historyVersions = [];
}
historyLoading = false;
}
}
async function previewVersion(v: VersionEntry) {
if (!$activeNote) return;
historySelected = v;
try {
historyPreview = await getNoteVersionContent($activeNote.meta.id, v.timestamp);
} catch (e) {
console.error('Failed to load version:', e);
}
}
async function restoreVersion() {
if (!$activeNote || !$activeNotePath || !historySelected) return;
try {
const raw = historyPreview ?? await getNoteVersionContent($activeNote.meta.id, historySelected.timestamp);
// The raw content includes frontmatter — parse out the body
const fmEnd = raw.indexOf('---', 4);
const body = fmEnd > 0 ? raw.substring(raw.indexOf('\n', fmEnd) + 1) : raw;
if (editor) {
editor.commands.setContent(markdownToHtml(body));
}
$editorDirty = true;
autoSave();
historyPreview = null;
historySelected = null;
showHistory = false;
} catch (e) {
console.error('Failed to restore version:', e);
}
}
async function handleCreateVersion() {
if (!$activeNote || !$activeNotePath) return;
// Save current content first so the snapshot is up to date
await forceSave();
try {
await createVersion($activeNotePath, $activeNote.meta.id);
// Refresh the version list
historyVersions = await getNoteVersions($activeNote.meta.id);
} catch (e) {
console.error('Failed to create version:', e);
}
}
function formatVersionDate(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
} catch {
return iso;
}
}
function formatVersionSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
function clearEditorHistory() {
if (!editor) return;
// Recreate editor state with same doc/schema/plugins but fresh plugin state (clears undo/redo)
const { doc, schema, plugins } = editor.state;
const newState = EditorState.create({ doc, schema, plugins });
editor.view.updateState(newState);
}
export function focusTitle() {
tick().then(() => {
if (titleInput) {
titleInput.focus();
titleInput.select();
}
});
}
export function loadNote(path: string, content: string) {
loadedPath = path;
lastSourceMode = $sourceMode;
isLoadingNote = true;
// Apply default view mode when switching notes — but new notes always open in edit mode
const isNewNote = $activeNote?.meta.title === 'Untitled' && !content.replace(/^---[\s\S]*?---\s*/, '').trim();
const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false);
readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly);
if ($sourceMode) {
sourceContent = stripTitleH1(content);
isLoadingNote = false;
} else if (editorElement && editor) {
// Editor already exists, just swap content
const html = markdownToHtml(content);
ignoreNextUpdate = true;
editor.commands.setContent(html);
// Clear undo/redo history so it doesn't bleed across notes
clearEditorHistory();
// Clear loading flag after setContent updates have fired
tick().then(() => { isLoadingNote = false; });
} else {
// Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init.
pendingContent = content;
isLoadingNote = false;
}
}
function stripTitleH1(md: string): string {
const title = $activeNote?.meta.title;
if (!$appConfig?.hide_title_in_body || !title) {
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
}
// Find the first non-empty line
const lines = md.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
// Check if it's a heading (any level) matching the note title
// Normalize: lowercase, collapse whitespace, strip common separators (- — _)
const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' ');
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match && normalize(match[2]) === normalize(title)) {
titleWasStripped = true;
strippedTitle = title.trim();
strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##")
lines.splice(i, 1);
// Also remove a trailing blank line after the heading if present
if (i < lines.length && lines[i].trim() === '') {
lines.splice(i, 1);
}
return lines.join('\n');
}
break; // First non-empty line isn't a matching heading, stop
}
titleWasStripped = false;
strippedTitle = '';
strippedHeadingPrefix = '';
return md;
}
function restoreTitleH1(md: string): string {
if (!titleWasStripped || !strippedTitle) return md;
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
}
function editorToMarkdown(): string {
if (!editor) return '';
const md = prosemirrorToMarkdown(editor.state.doc);
return restoreTitleH1(md);
}
function prosemirrorToMarkdown(doc: any): string {
const parts: string[] = [];
let prevType = '';
doc.forEach((node: any) => {
// Skip empty paragraphs right after code blocks (TipTap cursor placeholder)
if (node.type.name === 'paragraph' && node.childCount === 0 && prevType === 'codeBlock') {
prevType = node.type.name;
return;
}
parts.push(serializeNode(node));
prevType = node.type.name;
});
return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}
function serializeNode(node: any): string {
switch (node.type.name) {
case 'paragraph': {
const align = node.attrs.textAlign;
if (align && align !== 'left') {
return `<p style="text-align: ${align}">${serializeInline(node)}</p>\n`;
}
return serializeInline(node) + '\n';
}
case 'heading': {
const align = node.attrs.textAlign;
if (align && align !== 'left') {
return `<h${node.attrs.level} style="text-align: ${align}">${serializeInline(node)}</h${node.attrs.level}>\n`;
}
return '#'.repeat(node.attrs.level) + ' ' + serializeInline(node) + '\n';
}
case 'codeBlock': {
const lang = node.attrs.language || '';
const code = node.textContent.replace(/\n+$/, '');
return '```' + lang + '\n' + code + '\n```\n';
}
case 'blockquote': {
const blocks: string[] = [];
node.forEach((child: any) => {
const lines = serializeNode(child).replace(/\n$/, '').split('\n');
blocks.push(lines.map((l: string) => '> ' + l).join('\n'));
});
return blocks.join('\n>\n') + '\n';
}
case 'bulletList': {
const items: string[] = [];
node.forEach((child: any) => items.push('- ' + serializeListItem(child)));
return items.join('') + '\n';
}
case 'orderedList': {
const items: string[] = [];
let i = node.attrs.start || 1;
node.forEach((child: any) => { items.push(`${i++}. ` + serializeListItem(child)); });
return items.join('') + '\n';
}
case 'taskList': {
const items: string[] = [];
node.forEach((child: any) => {
const checked = child.attrs.checked ? 'x' : ' ';
items.push(`- [${checked}] ` + serializeListItem(child));
});
return items.join('') + '\n';
}
case 'listItem':
case 'taskItem':
return serializeListItem(node);
case 'horizontalRule':
return '---\n';
case 'table': {
// Preserve tables as raw HTML
const tempDiv = document.createElement('div');
const frag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
tempDiv.appendChild(frag);
return tempDiv.innerHTML + '\n';
}
case 'pdfEmbed': {
const src = node.attrs.src || '';
const name = node.attrs.name || '';
return `<div data-pdf-src="${src}" data-pdf-name="${name}" class="pdf-embed"></div>\n`;
}
case 'details': {
// Preserve details as raw HTML
const detDiv = document.createElement('div');
const detFrag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
detDiv.appendChild(detFrag);
return detDiv.innerHTML + '\n';
}
case 'image': {
const src = stripAssetSrc(node.attrs.src || '');
const alt = node.attrs.alt || '';
const size = node.attrs['data-size'] || node.attrs.size || 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
return `![${alt}${sizeSuffix}](${src})`;
}
default:
return node.textContent || '';
}
}
function serializeListItem(node: any): string {
const parts: string[] = [];
node.forEach((child: any) => {
if (child.type.name === 'paragraph') {
parts.push(serializeInline(child));
} else {
parts.push(serializeNode(child));
}
});
return parts.join('\n') + '\n';
}
function serializeInline(node: any): string {
if (node.childCount === 0) return '';
const parts: string[] = [];
node.forEach((child: any) => {
if (child.isText) {
let text = child.text || '';
// Apply marks
for (const mark of child.marks) {
switch (mark.type.name) {
case 'bold': text = `**${text}**`; break;
case 'italic': text = `*${text}*`; break;
case 'strike': text = `~~${text}~~`; break;
case 'code': text = `\`${text}\``; break;
case 'underline': text = `<u>${text}</u>`; break;
case 'subscript': text = `~${text}~`; break;
case 'superscript': text = `^${text}^`; break;
case 'highlight': text = `==${text}==`; break;
case 'link': text = `[${text}](${mark.attrs.href})`; break;
case 'wikiLink': text = `[[${mark.attrs.title || text}]]`; break;
}
}
parts.push(text);
} else if (child.type.name === 'image') {
const src = stripAssetSrc(child.attrs.src || '');
const alt = child.attrs.alt || '';
const size = child.attrs['data-size'] || child.attrs.size || 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
parts.push(`![${alt}${sizeSuffix}](${src})`);
} else if (child.type.name === 'hardBreak') {
parts.push('\n');
}
});
return parts.join('');
}
function autofocus(el: HTMLElement) {
requestAnimationFrame(() => el.focus());
}
// ── In-note search functions ──
function updateNoteSearch(query: string) {
if (!editor) return;
if (!query) {
noteSearchResults = [];
noteSearchIndex = 0;
const tr = editor.state.tr.setMeta(noteSearchPluginKey, DecorationSet.empty);
editor.view.dispatch(tr);
return;
}
const results: {from: number, to: number}[] = [];
const lowerQuery = query.toLowerCase();
editor.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
const text = node.text.toLowerCase();
let idx = text.indexOf(lowerQuery);
while (idx !== -1) {
results.push({ from: pos + idx, to: pos + idx + query.length });
idx = text.indexOf(lowerQuery, idx + 1);
}
});
noteSearchResults = results;
if (noteSearchIndex >= results.length) noteSearchIndex = 0;
applySearchDecorations();
}
function applySearchDecorations() {
if (!editor) return;
const decorations = noteSearchResults.map((m, i) =>
Decoration.inline(m.from, m.to, { class: i === noteSearchIndex ? 'note-search-match note-search-active' : 'note-search-match' })
);
const decoSet = DecorationSet.create(editor.state.doc, decorations);
const tr = editor.state.tr.setMeta(noteSearchPluginKey, decoSet);
editor.view.dispatch(tr);
scrollToCurrentMatch();
}
function scrollToCurrentMatch() {
if (!editor || noteSearchResults.length === 0) return;
const match = noteSearchResults[noteSearchIndex];
editor.commands.setTextSelection(match.from);
editor.commands.scrollIntoView();
}
function noteSearchNext() {
if (noteSearchResults.length === 0) return;
noteSearchIndex = (noteSearchIndex + 1) % noteSearchResults.length;
applySearchDecorations();
}
function noteSearchPrev() {
if (noteSearchResults.length === 0) return;
noteSearchIndex = (noteSearchIndex - 1 + noteSearchResults.length) % noteSearchResults.length;
applySearchDecorations();
}
export function openNoteSearch() {
noteSearchOpen = true;
}
function closeNoteSearch() {
noteSearchOpen = false;
noteSearchQuery = '';
noteSearchResults = [];
noteSearchIndex = 0;
if (editor) {
const tr = editor.state.tr.setMeta(noteSearchPluginKey, DecorationSet.empty);
editor.view.dispatch(tr);
editor.commands.focus();
}
}
function stripAssetSrc(src: string): string {
// blob: URLs are not persistable — they were temporary browser references
if (src.startsWith('blob:')) return '';
// Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src;
let absPath = '';
try {
const url = new URL(src);
absPath = decodeURIComponent(url.pathname);
} catch {
return src;
}
// Clean up any leading double/triple slashes (URL parsing artifact)
absPath = absPath.replace(/^\/{2,}/, '/');
// Make relative to note directory (matches how resolveImageSrc works)
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
if (absPath.startsWith(noteDir + '/')) {
return absPath.substring(noteDir.length + 1);
}
}
// Fallback: make relative to vault root
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
return absPath.substring(vaultRoot.length + 1);
}
return absPath;
}
function htmlToMarkdown(html: string): string {
let md = html;
// Code blocks MUST be converted before inline code to avoid corruption
md = md.replace(/<pre><code[^>]*class="language-(\w+)"[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, lang, code) => {
const stripped = code.replace(/<[^>]+>/g, '');
const decoded = stripped.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
return '```' + lang + '\n' + decoded + '\n```\n';
});
md = md.replace(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
const stripped = code.replace(/<[^>]+>/g, '');
const decoded = stripped.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
return '```\n' + decoded + '\n```\n';
});
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n');
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n');
md = md.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n');
md = md.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n');
md = md.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i>(.*?)<\/i>/gi, '*$1*');
md = md.replace(/<s>(.*?)<\/s>/gi, '~~$1~~');
md = md.replace(/<del>(.*?)<\/del>/gi, '~~$1~~');
md = md.replace(/<u>(.*?)<\/u>/gi, '<u>$1</u>');
md = md.replace(/<sub>(.*?)<\/sub>/gi, '~$1~');
md = md.replace(/<sup>(.*?)<\/sup>/gi, '^$1^');
md = md.replace(/<code>(.*?)<\/code>/gi, '`$1`');
md = md.replace(/<mark>(.*?)<\/mark>/gi, '==$1==');
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
return content
.replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n')
.replace(/<br\s*\/?>/gi, '\n> ');
});
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
md = md.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src="([^"]*)"/);
const altMatch = match.match(/alt="([^"]*)"/);
const sizeMatch = match.match(/data-size="([^"]*)"/);
const src = srcMatch ? stripAssetSrc(srcMatch[1]) : '';
const alt = altMatch ? altMatch[1] : '';
const size = sizeMatch ? sizeMatch[1] : 'full';
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
return `![${alt}${sizeSuffix}](${src})`;
});
// Preserve PDF embeds as raw HTML
const pdfs: string[] = [];
md = md.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*>[\s\S]*?<\/div>/gi, (match, src) => {
// Store with the relative path — we strip convertFileSrc URLs on save
const nameMatch = match.match(/data-pdf-name="([^"]*)"/);
const name = nameMatch ? nameMatch[1] : src.split('/').pop() || 'file.pdf';
pdfs.push(`<div data-pdf-src="${src}" data-pdf-name="${name}" class="pdf-embed"></div>`);
return `\n%%PDF_${pdfs.length - 1}%%\n`;
});
// Preserve details/accordion blocks as raw HTML
const detailsBlocks: string[] = [];
md = md.replace(/<details[\s\S]*?<\/details>/gi, (match) => {
detailsBlocks.push(match);
return `\n%%DETAILS_${detailsBlocks.length - 1}%%\n`;
});
// Preserve tables as raw HTML (markdown tables are too limited)
const tables: string[] = [];
md = md.replace(/<table[\s\S]*?<\/table>/gi, (match) => {
tables.push(match);
return `\n%%TABLE_${tables.length - 1}%%\n`;
});
md = md.replace(/<hr\s*\/?>/gi, '---\n');
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, content) => {
return content.replace(/<li[^>]*><p[^>]*>(.*?)<\/p><\/li>/gi, '- $1\n')
.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
});
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, content) => {
let i = 0;
return content.replace(/<li[^>]*><p[^>]*>(.*?)<\/p><\/li>/gi, () => `${++i}. $1\n`)
.replace(/<li[^>]*>(.*?)<\/li>/gi, () => `${++i}. $1\n`);
});
md = md.replace(/<li[^>]*data-checked="true"[^>]*>(.*?)<\/li>/gi, '- [x] $1\n');
md = md.replace(/<li[^>]*data-checked="false"[^>]*>(.*?)<\/li>/gi, '- [ ] $1\n');
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
md = md.replace(/<br\s*\/?>/gi, '\n');
md = md.replace(/<[^>]+>/g, '');
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/\n{3,}/g, '\n\n');
// Restore table HTML
tables.forEach((table, i) => {
md = md.replace(`%%TABLE_${i}%%`, '\n' + table + '\n');
});
// Restore PDF embeds
pdfs.forEach((pdf, i) => {
md = md.replace(`%%PDF_${i}%%`, '\n' + pdf + '\n');
});
// Restore details/accordion blocks
detailsBlocks.forEach((block, i) => {
md = md.replace(`%%DETAILS_${i}%%`, '\n' + block + '\n');
});
return md.trim() + '\n';
}
function markdownToHtml(md: string): string {
let src = stripTitleH1(md);
// Pre-process: convert [[Note Title]] wiki-links to HTML anchors
if ($appConfig?.enable_wiki_links) {
src = src.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const trimmed = title.trim();
// Try to resolve the title to a path from cache
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === trimmed.toLowerCase());
const path = match ? match.path : '';
return `<span data-wiki-link data-path="${escapeHtml(path)}" data-title="${escapeHtml(trimmed)}" class="wiki-link">${escapeHtml(trimmed)}</span>`;
});
}
// Pre-process: fix image paths with multiple leading slashes (from broken saves)
src = src.replace(/!\[([^\]]*)\]\((\/{2,})(home\/)/g, '![$1](/home/');
// Pre-process: percent-encode spaces in image URLs so markdown-it parses them correctly
src = src.replace(/!\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (match, alt, url) => {
return `![${alt}](${url.replace(/ /g, '%20')})`;
});
// Pre-process: transform PDF embed divs into iframes before markdown-it (it passes HTML through)
src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => {
const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`);
const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
});
// Pre-process: convert task list syntax before markdown-it (it doesn't know TipTap's format)
src = src.replace(/^- \[x\]\s+(.+)$/gm, '- <tiptask checked="true">$1</tiptask>');
src = src.replace(/^- \[ \]\s+(.+)$/gm, '- <tiptask checked="false">$1</tiptask>');
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
let html = mdit.render(src);
// Post-process: strip trailing newlines inside code blocks (markdown-it adds them, TipTap shows them as blank lines)
html = html.replace(/<code([^>]*)>\n?/g, '<code$1>');
html = html.replace(/\n<\/code>/g, '</code>');
// Post-process: convert task list items to TipTap format
html = html.replace(/<li><tiptask checked="(true|false)">([\s\S]*?)<\/tiptask><\/li>/gi, (_, checked, content) => {
return `<li data-type="taskItem" data-checked="${checked}">${content}</li>`;
});
html = html.replace(/<ul>\s*(<li data-type="taskItem")/gi, '<ul data-type="taskList">$1');
// Post-process: resolve image src paths and parse size attribute
html = html.replace(/<img\s+src="([^"]*)"(?:\s+alt="([^"]*)")?[^>]*\/?>/gi, (_, imgSrc, altRaw) => {
let alt = altRaw || '';
let size = 'full';
const sizeMatch = alt.match(/^(.*?)\|size=(small|medium|full)$/);
if (sizeMatch) {
alt = sizeMatch[1];
size = sizeMatch[2];
}
return `<img src="${resolveImageSrc(imgSrc)}" alt="${alt}" data-size="${size}">`;
});
return html;
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// When editorElement appears in DOM (first note selected), initialize TipTap
$effect(() => {
if (editorElement && !editor && pendingContent !== null) {
createEditor(pendingContent);
pendingContent = null;
}
});
// React to activeNotePath changes from external sources (e.g. search panel)
$effect(() => {
const path = $activeNotePath;
const note = $activeNote;
if (!path) {
// Note was deselected (e.g. deleted) — destroy editor so it reinits on next note
destroyEditor();
loadedPath = '';
return;
}
if (note && path !== loadedPath) {
loadNote(path, note.content);
}
});
function destroyEditor() {
if (editor) {
editor.destroy();
editor = null;
}
editorReady = false;
closeSlashMenu();
}
function createEditor(content: string) {
if (!editorElement) return;
destroyEditor();
const html = markdownToHtml(content);
editor = new Editor({
element: editorElement,
extensions: [
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'detailsSummary') return 'Section title...';
if (node.type.name === 'detailsContent') return 'Content...';
return 'Start writing...';
},
}),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
CustomTableCell,
CustomTableHeader,
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:') }),
CustomImage.configure({ HTMLAttributes: { class: 'editor-image' } }),
Highlight.configure({ multicolor: true }),
Typography,
Underline,
Subscript,
Superscript,
TextStyle,
Color,
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, defaultLanguage: 'text' }),
CodeBlockLanguageSelect,
PdfEmbed,
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
DetailsSummary,
DetailsContent,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
content: html,
editorProps: {
attributes: { class: 'editor-content' },
handleDrop: (_view, event) => handleFileDrop(event),
handlePaste: (_view, event) => handleFilePaste(event),
},
onTransaction: () => {
editorState++;
updateSlashMenu();
updateWikiLinkMenu();
},
onUpdate: () => {
if (ignoreNextUpdate || isLoadingNote) {
ignoreNextUpdate = false;
return;
}
$editorDirty = true;
autoSave();
// Fix any blob: URLs from pasted web images
fixBlobImages();
},
});
editorReady = true;
// Pre-load note titles for wiki-link autocomplete
if ($appConfig?.enable_wiki_links) {
refreshWikiLinkTitles();
}
}
function addLinkFromToolbar() {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href || '';
linkModalUrl = previousUrl;
linkModal = true;
tick().then(() => linkModalInput?.focus());
}
function linkModalConfirm() {
if (!editor) return;
const url = linkModalUrl.trim();
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
linkModal = false;
linkModalUrl = '';
}
function linkModalCancel() {
linkModal = false;
linkModalUrl = '';
editor?.chain().focus().run();
}
function insertTable(rows: number, cols: number) {
if (!editor) return;
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
tablePickerOpen = false;
tablePickerHover = { rows: 0, cols: 0 };
}
function setTextColor(color: string) {
if (!editor) return;
if (color === '') {
editor.chain().focus().unsetColor().run();
} else {
editor.chain().focus().setColor(color).run();
}
colorDropdown = false;
}
function setHighlightColor(color: string) {
if (!editor) return;
if (color === '') {
editor.chain().focus().unsetHighlight().run();
} else {
editor.chain().focus().setHighlight({ color }).run();
}
highlightDropdown = false;
}
function handleEditorClick(event: MouseEvent) {
imageToolbar = null;
const target = event.target as HTMLElement;
// Wiki-link click — navigate to linked note
const wikiLinkEl = target.closest('span[data-wiki-link]') as HTMLElement | null;
if (wikiLinkEl) {
event.preventDefault();
event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title);
return;
}
// Image click — show size toolbar
if (target.tagName === 'IMG' && editor) {
event.preventDefault();
event.stopPropagation();
const pos = editor.view.posAtDOM(target, 0);
const rect = target.getBoundingClientRect();
const node = editor.state.doc.nodeAt(pos);
const currentSize = node?.attrs.size || 'full';
imageToolbar = { pos, x: rect.left + rect.width / 2, y: rect.top - 8, size: currentSize };
// Move cursor after the image to clear ProseMirror's node selection highlight
const afterPos = pos + (node?.nodeSize || 1);
editor.chain().setTextSelection(afterPos).run();
return;
}
}
function setImageSize(size: string) {
if (!editor || !imageToolbar) return;
const { pos } = imageToolbar;
const tr = editor.state.tr.setNodeAttribute(pos, 'size', size);
editor.view.dispatch(tr);
imageToolbar = { ...imageToolbar, size };
$editorDirty = true;
autoSave();
}
function handleEditorContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest('a');
if (anchor) {
const href = anchor.getAttribute('href');
if (href) {
event.preventDefault();
event.stopPropagation();
linkContextMenu = { x: event.clientX, y: event.clientY, href, anchor };
return;
}
}
// Check if inside a table cell
const cell = target.closest('td, th');
if (cell) {
event.preventDefault();
event.stopPropagation();
let x = event.clientX;
let y = event.clientY;
const menuWidth = 220;
const menuHeight = 520;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
tableContextMenu = { x, y };
return;
}
// Show text context menu for any right-click in editor
event.preventDefault();
event.stopPropagation();
// Position menu, adjusting if it would overflow the viewport
let x = event.clientX;
let y = event.clientY;
const menuWidth = 220;
const menuHeight = 640;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
textContextMenu = { x, y };
}
function closeTextContextMenu() {
textContextMenu = null;
ctxHeadingSubmenu = false;
}
function closeTableContextMenu() {
tableContextMenu = null;
}
function tblAddRowBefore() { editor?.chain().focus().addRowBefore().run(); closeTableContextMenu(); }
function tblAddRowAfter() { editor?.chain().focus().addRowAfter().run(); closeTableContextMenu(); }
function tblDeleteRow() { editor?.chain().focus().deleteRow().run(); closeTableContextMenu(); }
function tblAddColBefore() { editor?.chain().focus().addColumnBefore().run(); closeTableContextMenu(); }
function tblAddColAfter() { editor?.chain().focus().addColumnAfter().run(); closeTableContextMenu(); }
function tblDeleteCol() { editor?.chain().focus().deleteColumn().run(); closeTableContextMenu(); }
function tblMergeCells() { editor?.chain().focus().mergeCells().run(); closeTableContextMenu(); }
function tblSplitCell() { editor?.chain().focus().splitCell().run(); closeTableContextMenu(); }
function tblToggleHeaderRow() { editor?.chain().focus().toggleHeaderRow().run(); closeTableContextMenu(); }
function tblToggleHeaderCol() { editor?.chain().focus().toggleHeaderColumn().run(); closeTableContextMenu(); }
function tblDeleteTable() { editor?.chain().focus().deleteTable().run(); closeTableContextMenu(); }
function tblSetCellColor(color: string) {
if (!editor) return;
if (color === '') {
editor.chain().focus().setCellAttribute('backgroundColor', null).run();
} else {
editor.chain().focus().setCellAttribute('backgroundColor', color).run();
}
closeTableContextMenu();
}
async function ctxCut() {
if (!editor) return;
const { from, to } = editor.state.selection;
if (from === to) { closeTextContextMenu(); return; }
const text = editor.state.doc.textBetween(from, to, '\n');
await navigator.clipboard.writeText(text);
editor.chain().focus().deleteSelection().run();
closeTextContextMenu();
}
async function ctxCopy() {
if (!editor) return;
const { from, to } = editor.state.selection;
if (from === to) { closeTextContextMenu(); return; }
const text = editor.state.doc.textBetween(from, to, '\n');
await navigator.clipboard.writeText(text);
closeTextContextMenu();
}
async function ctxPaste() {
if (!editor) return;
try {
const text = await navigator.clipboard.readText();
if (text) editor.chain().focus().insertContent(text).run();
} catch (e) {
console.error('Paste failed:', e);
}
closeTextContextMenu();
}
function ctxSelectAll() {
if (!editor) return;
editor.chain().focus().selectAll().run();
closeTextContextMenu();
}
let ctxHeadingSubmenu = $state(false);
function ctxSetHeading(level: number) {
editor?.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 | 4 }).run();
closeTextContextMenu();
}
function ctxSetParagraph() {
editor?.chain().focus().setParagraph().run();
closeTextContextMenu();
}
function ctxBold() {
editor?.chain().focus().toggleBold().run();
closeTextContextMenu();
}
function ctxItalic() {
editor?.chain().focus().toggleItalic().run();
closeTextContextMenu();
}
function ctxUnderline() {
editor?.chain().focus().toggleUnderline().run();
closeTextContextMenu();
}
function ctxStrike() {
editor?.chain().focus().toggleStrike().run();
closeTextContextMenu();
}
function ctxLink() {
closeTextContextMenu();
addLinkFromToolbar();
}
function ctxHighlight() {
editor?.chain().focus().toggleHighlight({ color: highlightColors[0].value }).run();
closeTextContextMenu();
}
function ctxCode() {
editor?.chain().focus().toggleCode().run();
closeTextContextMenu();
}
function ctxCodeBlock() {
editor?.chain().focus().toggleCodeBlock().run();
closeTextContextMenu();
}
function ctxBlockquote() {
editor?.chain().focus().toggleBlockquote().run();
closeTextContextMenu();
}
function ctxDetails() {
editor?.chain().focus().setDetails().run();
closeTextContextMenu();
}
function ctxBulletList() {
editor?.chain().focus().toggleBulletList().run();
closeTextContextMenu();
}
function ctxOrderedList() {
editor?.chain().focus().toggleOrderedList().run();
closeTextContextMenu();
}
function ctxTaskList() {
editor?.chain().focus().toggleTaskList().run();
closeTextContextMenu();
}
// ── AI Actions ──
function openAiMenu() {
if (!editor) return;
closeTextContextMenu();
editor.commands.focus();
const { from, to } = editor.state.selection;
const hasSelection = from !== to;
if (hasSelection) {
const selectedText = editor.state.doc.textBetween(from, to, '\n');
if (!selectedText.trim()) return;
aiSelectionFrom = from;
aiSelectionTo = to;
aiSelectedText = selectedText;
aiWholeNote = false;
} else {
// No selection — use the full note content
const fullMarkdown = editorToMarkdown();
if (!fullMarkdown.trim()) {
// Empty note — allow AI to generate content from a prompt
aiEmptyNote = true;
aiWholeNote = true;
aiSelectedText = '';
aiSelectionFrom = 0;
aiSelectionTo = 0;
} else {
aiEmptyNote = false;
aiSelectionFrom = 0;
aiSelectionTo = editor.state.doc.content.size;
aiOriginalMarkdown = fullMarkdown;
// Replace images, PDF embeds, and HTML tags with placeholders so AI doesn't mangle them
const placeholders = new Map<string, string>();
let idx = 0;
let textForAi = fullMarkdown;
// Images: ![alt](src)
textForAi = textForAi.replace(/!\[[^\]]*\]\([^)]*\)/g, (match) => {
const key = `__MEDIA_${idx++}__`;
placeholders.set(key, match);
return key;
});
// PDF embeds
textForAi = textForAi.replace(/<div[^>]*class="pdf-embed"[^>]*>[\s\S]*?<\/div>/gi, (match) => {
const key = `__MEDIA_${idx++}__`;
placeholders.set(key, match);
return key;
});
// Inline HTML img tags
textForAi = textForAi.replace(/<img[^>]*\/?>/gi, (match) => {
const key = `__MEDIA_${idx++}__`;
placeholders.set(key, match);
return key;
});
aiMediaPlaceholders = placeholders;
aiSelectedText = textForAi.trim();
if (!aiSelectedText) return;
aiWholeNote = true;
}
}
aiResult = null;
aiError = null;
aiLoading = false;
aiShowCustom = false;
aiTranslateMenu = false;
aiCustomPrompt = '';
if (hasSelection) {
const coords = editor.view.coordsAtPos(from);
let x = coords.left;
let y = coords.top - 8;
const menuWidth = 220;
const menuHeight = 400;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y - menuHeight < 0) y = coords.bottom + 8;
else y = y - menuHeight;
if (y < 4) y = 4;
aiMenu = { x, y };
} else {
// Center the menu in the editor area
const editorRect = editorElement?.getBoundingClientRect();
const menuWidth = 220;
const menuHeight = 400;
let x = editorRect ? editorRect.left + (editorRect.width - menuWidth) / 2 : window.innerWidth / 2 - menuWidth / 2;
let y = editorRect ? editorRect.top + 60 : 100;
if (x < 4) x = 4;
if (y < 4) y = 4;
aiMenu = { x, y };
}
}
function closeAiMenu() {
aiMenu = null;
aiLoading = false;
aiResult = null;
aiError = null;
aiShowCustom = false;
aiTranslateMenu = false;
aiWholeNote = false;
aiEmptyNote = false;
aiOriginalMarkdown = '';
aiMediaPlaceholders = new Map();
}
async function runAiAction(action: string, customPrompt?: string) {
if (!editor) return;
if (!aiEmptyNote && !aiSelectedText.trim()) return;
// For empty notes, force action to 'custom' with a generate instruction
if (aiEmptyNote) {
action = 'custom';
customPrompt = `Generate a note based on this prompt. Start your response with a title on the first line (no # prefix), then a blank line, then the content in markdown. Prompt: ${customPrompt || aiCustomPrompt}`;
aiSelectedText = '(empty note)';
}
aiLoading = true;
aiResult = '';
aiError = null;
aiShowCustom = false;
aiTranslateMenu = false;
const requestId = crypto.randomUUID();
const unlisten = await listen<AiStreamEvent>('ai-stream', (event) => {
const data = event.payload;
if (data.event_type === 'text' && data.text) {
aiResult = (aiResult ?? '') + data.text;
} else if (data.event_type === 'done') {
aiLoading = false;
unlisten();
} else if (data.event_type === 'error') {
aiError = data.error ?? 'Unknown error';
aiLoading = false;
unlisten();
}
});
try {
await aiAsk(action, aiSelectedText, customPrompt ?? null, requestId);
} catch (e) {
aiError = String(e);
aiLoading = false;
unlisten();
}
}
async function aiApplyResult() {
if (!editor || !aiResult) return;
// Save a version snapshot before applying AI changes
if ($activeNotePath && $activeNote && !aiEmptyNote) {
try {
await forceSave();
await createVersion($activeNotePath, $activeNote.meta.id);
} catch (e) {
console.error('Failed to create version before AI apply:', e);
}
}
if (aiEmptyNote) {
// Parse title from first line, rest is content
const lines = aiResult.split('\n');
let title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Untitled';
const body = lines.slice(1).join('\n').replace(/^\n+/, '');
if ($activeNote) {
$activeNote.meta.title = title;
}
editor.commands.setContent(markdownToHtml(body));
} else if (aiWholeNote) {
// Restore media placeholders back to original markdown
let finalMarkdown = aiResult;
for (const [key, original] of aiMediaPlaceholders) {
finalMarkdown = finalMarkdown.replace(key, original);
}
// Replace entire document — convert markdown back to HTML for TipTap
editor.commands.setContent(markdownToHtml(finalMarkdown));
} else {
// Replace the selected range with the AI result (convert markdown → HTML so TipTap renders it properly)
const html = markdownToHtml(aiResult);
editor.chain().focus()
.setTextSelection({ from: aiSelectionFrom, to: aiSelectionTo })
.deleteSelection()
.insertContent(html)
.run();
}
$editorDirty = true;
autoSave();
closeAiMenu();
}
function aiDiscard() {
closeAiMenu();
editor?.commands.focus();
}
function closeLinkContextMenu() {
linkContextMenu = null;
}
function linkMenuOpen() {
if (!linkContextMenu) return;
const href = linkContextMenu.href;
closeLinkContextMenu();
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch(console.error);
} else {
const decoded = decodeURIComponent(href);
let absPath = decoded;
if (!decoded.startsWith('/')) {
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch(console.error);
}
}
function linkMenuCopy() {
if (!linkContextMenu) return;
navigator.clipboard.writeText(linkContextMenu.href).catch(console.error);
closeLinkContextMenu();
}
function linkMenuEdit() {
if (!linkContextMenu || !editor) return;
const anchor = linkContextMenu.anchor;
const href = linkContextMenu.href;
closeLinkContextMenu();
// Select the link text so the modal edits the right link
const pos = editor.view.posAtDOM(anchor, 0);
if (pos >= 0) {
editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run();
}
linkModalUrl = href;
linkModal = true;
tick().then(() => linkModalInput?.focus());
}
function linkMenuRemove() {
if (!linkContextMenu || !editor) return;
const anchor = linkContextMenu.anchor;
const pos = editor.view.posAtDOM(anchor, 0);
if (pos >= 0) {
editor.chain()
.focus()
.setTextSelection(pos)
.extendMarkRange('link')
.unsetLink()
.run();
$editorDirty = true;
autoSave();
}
closeLinkContextMenu();
}
function resolveHrefToAbsPath(href: string): string {
const decoded = decodeURIComponent(href);
if (decoded.startsWith('/')) return decoded;
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return decoded;
}
function isFileLink(href: string): boolean {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) return false;
const ext = href.split('.').pop()?.toLowerCase() ?? '';
return ['pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'txt', 'rtf', 'odt', 'ods', 'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'exe', 'dmg', 'apk', 'iso', 'epub'].includes(ext);
}
async function linkMenuSaveAs() {
if (!linkContextMenu) return;
const href = linkContextMenu.href;
closeLinkContextMenu();
const absPath = resolveHrefToAbsPath(href);
const filename = absPath.split('/').pop() || 'file';
const dest = await saveDialog({ defaultPath: filename });
if (dest) {
try {
await copyFileTo(absPath, dest);
} catch (e) {
console.error('Failed to save file:', e);
}
}
}
function handleFileDrop(event: DragEvent): boolean {
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return false;
event.preventDefault();
for (const file of Array.from(files)) {
if (file.type.startsWith('image/')) {
insertImage(file);
} else if (file.type === 'application/pdf') {
insertPdf(file);
} else {
insertFileAttachment(file);
}
}
return true;
}
function handleFilePaste(event: ClipboardEvent): boolean {
const items = event.clipboardData?.items;
if (!items) return false;
for (const item of Array.from(items)) {
const file = item.getAsFile();
if (!file) continue;
event.preventDefault();
if (item.type.startsWith('image/')) {
insertImage(file);
} else if (file.type === 'application/pdf') {
insertPdf(file);
} else {
insertFileAttachment(file);
}
return true;
}
return false;
}
async function insertImage(file: File) {
try {
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const relativePath = await saveImage(file.name, data);
if (editor) {
const displaySrc = resolveImageSrc(relativePath);
editor.chain().focus().setImage({ src: displaySrc }).run();
}
} catch (e) {
console.error('Failed to insert image:', e);
}
}
async function insertPdf(file: File) {
try {
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const relativePath = await saveAttachment(file.name, data);
if (!editor) return;
const usePdfPreview = $appConfig?.pdf_preview ?? false;
if (usePdfPreview) {
editor.chain().focus().insertContent({
type: 'pdfEmbed',
attrs: { src: relativePath, name: file.name },
}).run();
} else {
const sizeKB = Math.round(file.size / 1024);
const label = `${file.name} (${sizeKB} kB)`;
editor.chain().focus()
.insertContent(`<a href="${relativePath}">${label}</a> `)
.run();
}
} catch (e) {
console.error('Failed to insert PDF:', e);
}
}
async function saveBlobImage(blobUrl: string): Promise<string | null> {
try {
const resp = await fetch(blobUrl);
const blob = await resp.blob();
const ext = blob.type.split('/')[1] || 'png';
const name = `pasted-image.${ext}`;
const buffer = await blob.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const relativePath = await saveImage(name, data);
return resolveImageSrc(relativePath);
} catch (e) {
console.error('Failed to save blob image:', e);
return null;
}
}
async function fixBlobImages() {
if (!editor) return;
const { doc, tr } = editor.state;
let changed = false;
const promises: Array<{ pos: number; blobUrl: string }> = [];
doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs.src?.startsWith('blob:')) {
promises.push({ pos, blobUrl: node.attrs.src });
}
});
for (const { pos, blobUrl } of promises) {
const savedSrc = await saveBlobImage(blobUrl);
if (savedSrc && editor) {
const currentTr = editor.state.tr;
// Re-find the node since positions may have shifted
let found = false;
editor.state.doc.descendants((node, nodePos) => {
if (!found && node.type.name === 'image' && node.attrs.src === blobUrl) {
currentTr.setNodeAttribute(nodePos, 'src', savedSrc);
found = true;
changed = true;
}
});
if (found) editor.view.dispatch(currentTr);
}
}
if (changed) {
$editorDirty = true;
autoSave();
}
}
async function insertFileAttachment(file: File) {
try {
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const relativePath = await saveAttachment(file.name, data);
if (editor) {
const sizeKB = Math.round(file.size / 1024);
const label = `${file.name} (${sizeKB} kB)`;
editor.chain().focus()
.insertContent(`<a href="${relativePath}">${label}</a> `)
.run();
}
} catch (e) {
console.error('Failed to insert attachment:', e);
}
}
// Source mode toggle — only react to explicit user toggle, not note switches
$effect(() => {
const isSource = $sourceMode;
// Only act if we have a loaded note
if (!loadedPath) return;
if (isSource && !lastSourceMode) {
// Switching TO source: extract markdown from editor
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
lastSourceMode = true;
} else if (!isSource && lastSourceMode) {
// Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone),
// wait for DOM to swap textarea→div, then create editor on new element.
destroyEditor();
const content = sourceContent || ($activeNote?.content ?? '');
lastSourceMode = false;
tick().then(() => {
if (editorElement && !editor) {
createEditor(content);
}
});
}
});
// Tauri drag-drop listener for OS file drops (browser DragEvent doesn't have files in Tauri)
let unlistenDragDrop: (() => void) | null = null;
$effect(() => {
const appWindow = getCurrentWindow();
appWindow.onDragDropEvent((event) => {
if (event.payload.type !== 'drop' || !editor || !$activeNote) return;
const paths = event.payload.paths;
for (const filePath of paths) {
const name = filePath.split('/').pop() || 'file';
const ext = name.split('.').pop()?.toLowerCase() || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext)) {
readFile(filePath).then((data) => {
saveImage(name, Array.from(data)).then((relativePath) => {
if (editor) {
editor.chain().focus().setImage({ src: resolveImageSrc(relativePath) }).run();
}
});
}).catch((e) => console.error('Failed to drop image:', e));
} else if (ext === 'pdf') {
readFile(filePath).then((data) => {
saveAttachment(name, Array.from(data)).then((relativePath) => {
if (!editor) return;
const usePdfPreview = $appConfig?.pdf_preview ?? false;
if (usePdfPreview) {
editor.chain().focus().insertContent({
type: 'pdfEmbed',
attrs: { src: relativePath, name },
}).run();
} else {
editor.chain().focus().insertContent(`<a href="${relativePath}">${name}</a> `).run();
}
});
}).catch((e) => console.error('Failed to drop PDF:', e));
} else {
readFile(filePath).then((data) => {
saveAttachment(name, Array.from(data)).then((relativePath) => {
if (editor) {
editor.chain().focus().insertContent(`<a href="${relativePath}">${name}</a> `).run();
}
});
}).catch((e) => console.error('Failed to drop file:', e));
}
}
}).then((unlisten) => {
unlistenDragDrop = unlisten;
});
return () => {
unlistenDragDrop?.();
};
});
onDestroy(() => {
destroyEditor();
});
</script>
<div class="editor-container">
{#if !$activeNote}
<div class="empty-editor">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="8" y="6" width="32" height="36" rx="4" stroke="var(--text-tertiary)" stroke-width="2" fill="none" />
<path d="M16 16h16M16 22h12M16 28h16M16 34h8" stroke="var(--text-tertiary)" stroke-width="1.5" stroke-linecap="round" />
</svg>
</div>
<p>Select a note or create a new one</p>
<div class="shortcuts-hint">
<span><kbd>Ctrl</kbd>+<kbd>N</kbd> New note</span>
<span><kbd>Ctrl</kbd>+<kbd>P</kbd> Quick open</span>
</div>
</div>
{:else}
<div class="editor-toolbar">
<div class="editor-title">
<input
bind:this={titleInput}
type="text"
readonly={readOnly}
value={$activeNote.meta.title}
onkeydown={(e) => {
if (e.key === 'Tab') {
e.preventDefault();
editor?.commands.focus('start');
}
if (e.key === 'Enter') {
e.preventDefault();
editor?.commands.focus('start');
}
}}
onchange={(e) => {
if ($activeNote) {
const newTitle = (e.target as HTMLInputElement).value;
$activeNote.meta.title = newTitle;
// Update the note list entry so the 2nd panel reflects the change
notes.update(list => list.map(n =>
n.path === $activeNotePath ? { ...n, meta: { ...n.meta, title: newTitle } } : n
));
$editorDirty = true;
autoSave();
}
}}
/>
</div>
<div class="toolbar-actions">
{#if $editorDirty}
<span class="save-indicator">Unsaved</span>
{/if}
{#if readOnly}
<span class="readonly-indicator">View Mode</span>
{/if}
<button
class="icon-btn"
class:active={noteSearchOpen}
onclick={() => noteSearchOpen ? closeNoteSearch() : openNoteSearch()}
title="Find in note (Ctrl+F)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
<button
class="icon-btn"
class:active={readOnly}
class:flash={readOnlyFlash}
onclick={toggleReadOnly}
title={readOnly ? 'Switch to Edit Mode' : 'Switch to View Mode'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if readOnly}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
{:else}
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
{/if}
</svg>
</button>
<button
class="icon-btn"
class:active={$activeNote?.meta.pinned}
onclick={() => {
if ($activeNote) {
$activeNote.meta.pinned = !$activeNote.meta.pinned;
$editorDirty = true;
autoSave();
}
}}
title={$activeNote?.meta.pinned ? 'Unpin note' : 'Pin note'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5"/>
<path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg>
</button>
<button
class="icon-btn"
class:active={isQuickAccess}
onclick={async () => {
if (!noteRelativePath) return;
try {
if (isQuickAccess) {
await removeQuickAccess(noteRelativePath);
} else {
await addQuickAccess(noteRelativePath);
}
const qa = await getQuickAccess();
$quickAccessPaths = qa.map(n => n.relative_path);
} catch (e) {
console.error('Quick access toggle failed:', e);
}
}}
title={isQuickAccess ? 'Remove from Quick Access' : 'Add to Quick Access'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={isQuickAccess ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<button
class="icon-btn"
class:active={showHistory}
onclick={toggleHistory}
title="Version history"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</button>
{#if $appConfig?.enable_wiki_links}
<button
class="icon-btn"
class:active={showGraph}
onclick={() => showGraph = !showGraph}
title="Graph View"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/>
<line x1="8.5" y1="7.5" x2="15.5" y2="16.5"/><line x1="15.5" y1="7.5" x2="8.5" y2="16.5"/>
</svg>
</button>
{/if}
{#if $appConfig?.ai_provider && ($appConfig?.ai_api_key || $appConfig?.openai_api_key)}
<button
class="icon-btn"
onclick={openAiMenu}
title="AI Actions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
</svg>
</button>
{/if}
<button
class="icon-btn"
class:active={$sourceMode}
onclick={() => ($sourceMode = !$sourceMode)}
title="Toggle Markdown Editor"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" />
</svg>
</button>
</div>
</div>
<div class="editor-body-wrapper">
{#if noteSearchOpen}
<div class="note-search-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5;flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
bind:this={noteSearchInput}
type="text"
class="note-search-input"
placeholder="Find in note..."
bind:value={noteSearchQuery}
oninput={() => updateNoteSearch(noteSearchQuery)}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? noteSearchPrev() : noteSearchNext(); }
if (e.key === 'Escape') { e.preventDefault(); closeNoteSearch(); }
}}
use:autofocus
/>
<span class="note-search-count">
{#if noteSearchQuery && noteSearchResults.length > 0}
{noteSearchIndex + 1} / {noteSearchResults.length}
{:else if noteSearchQuery}
No results
{/if}
</span>
<button class="note-search-btn" onclick={noteSearchPrev} title="Previous (Shift+Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button class="note-search-btn" onclick={noteSearchNext} title="Next (Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="note-search-btn" onclick={closeNoteSearch} title="Close (Esc)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/if}
<div class="editor-body">
{#if $sourceMode}
<textarea
class="source-editor"
bind:this={sourceElement}
bind:value={sourceContent}
oninput={() => {
$editorDirty = true;
autoSave();
}}
spellcheck="false"
></textarea>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu} onkeydown={(e) => {
if (readOnly && !e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
readOnlyFlash = true;
setTimeout(() => { readOnlyFlash = false; }, 600);
}
}}></div>
{/if}
</div>
{#if showHistory}
<div class="history-panel">
<div class="history-header">
<h3>Version History</h3>
<div class="history-header-actions">
<button class="history-create-btn" onclick={handleCreateVersion} title="Save current state as a version">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button class="history-close" onclick={() => { showHistory = false; historyPreview = null; historySelected = null; }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{#if historyLoading}
<div class="history-empty">Loading...</div>
{:else if historyVersions.length === 0}
<div class="history-empty">No versions yet. Versions are created automatically as you edit (at least 5 minutes apart).</div>
{:else}
<div class="history-list">
{#each historyVersions as v}
<button
class="history-item"
class:active={historySelected?.timestamp === v.timestamp}
onclick={() => previewVersion(v)}
>
<span class="history-date">{formatVersionDate(v.timestamp)}</span>
<span class="history-size">{formatVersionSize(v.size)}</span>
</button>
{/each}
</div>
{/if}
{#if historySelected && historyPreview !== null}
<div class="history-actions">
<button class="history-restore-btn" onclick={restoreVersion}>
Restore this version
</button>
</div>
{/if}
</div>
{/if}
</div>
{#if editorReady && !$sourceMode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="editor-formatting-bar" onclick={() => { headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }}>
<!-- Insert (+) dropdown -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn insert-btn" onclick={(e) => { e.stopPropagation(); insertDropdown = !insertDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; }} title="Insert">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
{#if insertDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown insert-dropdown" onclick={(e) => e.stopPropagation()}>
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-image-input')?.click(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
Image
</button>
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-file-input')?.click(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
File
</button>
<button onclick={() => { insertDropdown = false; tablePickerOpen = true; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
Table
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setHorizontalRule().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>
Horizontal Rule
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleCodeBlock().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Code Block
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().toggleBlockquote().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg>
Quote
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setDetails().run(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
Collapsible Section
</button>
</div>
{/if}
</div>
<div class="fmt-sep"></div>
<!-- Heading dropdown -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('heading'))} onclick={(e) => { e.stopPropagation(); headingDropdown = !headingDropdown; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Heading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16M4 6v12M20 6v12"/></svg>
</button>
{#if headingDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown" onclick={(e) => e.stopPropagation()}>
<button class:active={(editorState, editor.isActive('heading', { level: 1 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); headingDropdown = false; }}>Heading 1</button>
<button class:active={(editorState, editor.isActive('heading', { level: 2 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); headingDropdown = false; }}>Heading 2</button>
<button class:active={(editorState, editor.isActive('heading', { level: 3 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); headingDropdown = false; }}>Heading 3</button>
<button class:active={(editorState, editor.isActive('heading', { level: 4 }))} onclick={() => { editor?.chain().focus().toggleHeading({ level: 4 }).run(); headingDropdown = false; }}>Heading 4</button>
<button class:active={(editorState, editor.isActive('paragraph'))} onclick={() => { editor?.chain().focus().setParagraph().run(); headingDropdown = false; }}>Paragraph</button>
</div>
{/if}
</div>
<div class="fmt-sep"></div>
<!-- Text formatting -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bold'))} onclick={() => editor?.chain().focus().toggleBold().run()} title="Bold (Ctrl+B)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z" stroke="currentColor" stroke-width="1" fill="none"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('italic'))} onclick={() => editor?.chain().focus().toggleItalic().run()} title="Italic (Ctrl+I)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline (Ctrl+U)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough (Ctrl+Shift+X)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-3 3 3 3 0 003 3h6"/><line x1="4" y1="12" x2="20" y2="12"/><path d="M8 20h7a3 3 0 003-3 3 3 0 00-3-3H8"/></svg>
</button>
<!-- Text color -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); colorDropdown = !colorDropdown; headingDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Text Color">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2l5 0M12 2l-5 17M17 19H5M15 7l4 12"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('textStyle').color || 'var(--accent)'}"></span>
</button>
{#if colorDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
{#each textColors as color}
<button class="color-swatch" title={color.name} onclick={() => setTextColor(color.value)} style="background: {color.value || 'var(--text-primary)'}">
{#if (color.value === '' && !editor.getAttributes('textStyle').color) || editor.getAttributes('textStyle').color === color.value}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="fmt-sep"></div>
<!-- Link -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link (Ctrl+K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List (Ctrl+Shift+8)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Ordered List (Ctrl+Shift+7)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="8" font-size="8" fill="currentColor" stroke="none" font-weight="600">1</text><text x="1" y="14" font-size="8" fill="currentColor" stroke="none" font-weight="600">2</text><text x="1" y="20" font-size="8" fill="currentColor" stroke="none" font-weight="600">3</text></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List (Ctrl+Shift+9)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Undo / Redo -->
<button class="fmt-btn" onclick={() => editor?.chain().focus().undo().run()} title="Undo (Ctrl+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
<button class="fmt-btn" onclick={() => editor?.chain().focus().redo().run()} title="Redo (Ctrl+Shift+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Code & Code Block -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title="Inline Code (Ctrl+E)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title="Code Block (Ctrl+Alt+C)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16"/><polyline points="15 8 19 12 15 16"/></svg>
</button>
<!-- Blockquote -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title="Quote (Ctrl+Shift+B)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg>
</button>
<!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => editor?.chain().focus().setDetails().run()} title="Collapsible Section">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
</button>
<!-- Table -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); tablePickerOpen = !tablePickerOpen; headingDropdown = false; colorDropdown = false; highlightDropdown = false; alignDropdown = false; insertDropdown = false; }} title="Insert Table">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
{#if tablePickerOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown table-picker-dropdown" onclick={(e) => e.stopPropagation()}>
<div class="table-picker-grid">
{#each Array(8) as _, r}
{#each Array(10) as _, c}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="table-picker-cell"
class:active={r < tablePickerHover.rows && c < tablePickerHover.cols}
onmouseenter={() => tablePickerHover = { rows: r + 1, cols: c + 1 }}
onclick={() => insertTable(r + 1, c + 1)}
></div>
{/each}
{/each}
</div>
<div class="table-picker-label">
{tablePickerHover.rows > 0 ? `${tablePickerHover.rows} x ${tablePickerHover.cols}` : 'Select size'}
</div>
</div>
{/if}
</div>
<!-- Horizontal Rule -->
<button class="fmt-btn" onclick={() => editor?.chain().focus().setHorizontalRule().run()} title="Horizontal Rule">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Highlight -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Highlight (Ctrl+Shift+H)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/><path d="M15 5l3 3"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
</button>
{#if highlightDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown color-grid-dropdown" onclick={(e) => e.stopPropagation()}>
{#each highlightColors as color}
<button class="color-swatch" title={color.name} onclick={() => setHighlightColor(color.value)} style="background: {color.swatch}">
{#if editor.isActive('highlight', { color: color.value })}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</button>
{/each}
<button class="color-swatch" title="Remove highlight" onclick={() => setHighlightColor('')} style="background: var(--bg-tertiary);">
{#if !editor.isActive('highlight')}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{/if}
</button>
</div>
{/if}
</div>
<!-- Subscript & Superscript -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('subscript'))} onclick={() => editor?.chain().focus().toggleSubscript().run()} title="Subscript">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><text x="2" y="14" font-size="14" fill="currentColor" stroke="none" font-weight="600">x</text><text x="14" y="20" font-size="10" fill="currentColor" stroke="none">2</text></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('superscript'))} onclick={() => editor?.chain().focus().toggleSuperscript().run()} title="Superscript">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><text x="2" y="16" font-size="14" fill="currentColor" stroke="none" font-weight="600">x</text><text x="14" y="10" font-size="10" fill="currentColor" stroke="none">2</text></svg>
</button>
<div class="fmt-sep"></div>
<!-- Text Alignment -->
<div class="fmt-dropdown-wrap">
<button class="fmt-btn" onclick={(e) => { e.stopPropagation(); alignDropdown = !alignDropdown; headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; insertDropdown = false; }} title="Text Alignment">
{#if (editorState, editor.isActive({ textAlign: 'center' }))}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="10" x2="18" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="6" y1="18" x2="18" y2="18"/></svg>
{:else if (editorState, editor.isActive({ textAlign: 'right' }))}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="10" x2="21" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="9" y1="18" x2="21" y2="18"/></svg>
{:else if (editorState, editor.isActive({ textAlign: 'justify' }))}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="10" x2="15" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="3" y1="18" x2="15" y2="18"/></svg>
{/if}
</button>
{#if alignDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fmt-dropdown align-dropdown" onclick={(e) => e.stopPropagation()}>
<button class:active={(editorState, editor.isActive({ textAlign: 'left' }))} onclick={() => { editor?.chain().focus().setTextAlign('left').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="10" x2="15" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="3" y1="18" x2="15" y2="18"/></svg>
Left
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'center' }))} onclick={() => { editor?.chain().focus().setTextAlign('center').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="10" x2="18" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="6" y1="18" x2="18" y2="18"/></svg>
Center
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'right' }))} onclick={() => { editor?.chain().focus().setTextAlign('right').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="10" x2="21" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="9" y1="18" x2="21" y2="18"/></svg>
Right
</button>
<button class:active={(editorState, editor.isActive({ textAlign: 'justify' }))} onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); alignDropdown = false; }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Justify
</button>
</div>
{/if}
</div>
<div class="fmt-sep"></div>
<!-- Indent / Outdent -->
<button class="fmt-btn" onclick={() => {
if (!editor) return;
// Try list indent first — run() returns true if it succeeded
const sank = editor.chain().focus().sinkListItem('listItem').run();
if (!sank) {
const sankTask = editor.chain().focus().sinkListItem('taskItem').run();
if (!sankTask && editor.state.selection.empty) {
editor.chain().focus().insertContent('\t').run();
}
}
}} title="Indent (Tab)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="3 9 7 11.5 3 14"/></svg>
</button>
<button class="fmt-btn" onclick={() => {
if (!editor) return;
const lifted = editor.chain().focus().liftListItem('listItem').run();
if (!lifted) {
const liftedTask = editor.chain().focus().liftListItem('taskItem').run();
if (!liftedTask && editor.state.selection.empty) {
// Remove leading tab/spaces from current line
const { from } = editor.state.selection;
const pos = editor.state.doc.resolve(from);
const lineStart = pos.start(pos.depth);
const lineText = editor.state.doc.textBetween(lineStart, pos.end(pos.depth));
if (lineText.startsWith('\t')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 1);
return true;
}).run();
} else if (lineText.startsWith(' ')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 4);
return true;
}).run();
} else if (lineText.startsWith(' ')) {
editor.chain().focus().command(({ tr }) => {
tr.delete(lineStart, lineStart + 2);
return true;
}).run();
}
}
}
}} title="Outdent (Shift+Tab)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="21" y2="4"/><line x1="11" y1="9" x2="21" y2="9"/><line x1="11" y1="14" x2="21" y2="14"/><line x1="3" y1="19" x2="21" y2="19"/><polyline points="7 9 3 11.5 7 14"/></svg>
</button>
</div>
{/if}
{/if}
<!-- Hidden file inputs for Insert dropdown -->
<input type="file" id="insert-image-input" accept="image/*" style="display:none" onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) insertImage(file);
(e.target as HTMLInputElement).value = '';
}} />
<input type="file" id="insert-file-input" style="display:none" onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
if (file.type.startsWith('image/')) insertImage(file);
else if (file.type === 'application/pdf') insertPdf(file);
else insertFileAttachment(file);
}
(e.target as HTMLInputElement).value = '';
}} />
</div>
{#if linkContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-context-overlay" onclick={closeLinkContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-context-menu" style="left: {linkContextMenu.x}px; top: {linkContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<div class="link-context-url">{linkContextMenu.href}</div>
<button onclick={linkMenuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
Open Link
</button>
{#if isFileLink(linkContextMenu.href)}
<button onclick={linkMenuSaveAs}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Save As...
</button>
{/if}
<button onclick={linkMenuCopy}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy Link
</button>
<button onclick={linkMenuEdit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit Link
</button>
<div class="link-context-sep"></div>
<button class="danger" onclick={linkMenuRemove}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
Remove Link
</button>
</div>
</div>
{/if}
{#if textContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="text-ctx-overlay" onclick={closeTextContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="text-ctx-menu" style="left: {textContextMenu.x}px; top: {textContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<button onclick={ctxCut}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
Cut
<span class="text-ctx-shortcut">Ctrl+X</span>
</button>
<button onclick={ctxCopy}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
<span class="text-ctx-shortcut">Ctrl+C</span>
</button>
<button onclick={ctxPaste}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
Paste
<span class="text-ctx-shortcut">Ctrl+V</span>
</button>
<div class="text-ctx-sep"></div>
<button onclick={ctxSelectAll}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h8"/></svg>
Select All
<span class="text-ctx-shortcut">Ctrl+A</span>
</button>
<div class="text-ctx-sep"></div>
<!-- Heading submenu -->
<div class="text-ctx-submenu-wrap" onmouseenter={() => ctxHeadingSubmenu = true} onmouseleave={() => ctxHeadingSubmenu = false}>
<button class="has-submenu">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16M4 6v12M20 6v12"/></svg>
Heading
<svg class="submenu-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg>
</button>
{#if ctxHeadingSubmenu}
<div class="text-ctx-submenu">
<button class:active={(editorState, editor?.isActive('heading', { level: 1 }))} onclick={() => ctxSetHeading(1)}>Heading 1</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 2 }))} onclick={() => ctxSetHeading(2)}>Heading 2</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 3 }))} onclick={() => ctxSetHeading(3)}>Heading 3</button>
<button class:active={(editorState, editor?.isActive('heading', { level: 4 }))} onclick={() => ctxSetHeading(4)}>Heading 4</button>
<div class="text-ctx-sep"></div>
<button class:active={(editorState, editor?.isActive('paragraph'))} onclick={ctxSetParagraph}>Paragraph</button>
</div>
{/if}
</div>
<div class="text-ctx-sep"></div>
<button onclick={ctxBold}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
Bold
<span class="text-ctx-shortcut">Ctrl+B</span>
</button>
<button onclick={ctxItalic}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
Italic
<span class="text-ctx-shortcut">Ctrl+I</span>
</button>
<button onclick={ctxUnderline}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
Underline
<span class="text-ctx-shortcut">Ctrl+U</span>
</button>
<button onclick={ctxStrike}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-3 3 3 3 0 003 3h6"/><line x1="4" y1="12" x2="20" y2="12"/><path d="M8 20h7a3 3 0 003-3 3 3 0 00-3-3H8"/></svg>
Strikethrough
</button>
<button onclick={ctxHighlight}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Highlight
</button>
<div class="text-ctx-sep"></div>
<button onclick={ctxLink}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Add Link
<span class="text-ctx-shortcut">Ctrl+K</span>
</button>
<button onclick={ctxCode}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Inline Code
</button>
<button onclick={ctxCodeBlock}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16"/><polyline points="15 8 19 12 15 16"/></svg>
Code Block
</button>
<button onclick={ctxBlockquote}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg>
Quote
</button>
<button onclick={ctxDetails}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
Collapsible Section
</button>
<div class="text-ctx-sep"></div>
<button onclick={ctxBulletList}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg>
Bullet List
</button>
<button onclick={ctxOrderedList}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="8" font-size="8" fill="currentColor" stroke="none" font-weight="600">1</text><text x="1" y="14" font-size="8" fill="currentColor" stroke="none" font-weight="600">2</text><text x="1" y="20" font-size="8" fill="currentColor" stroke="none" font-weight="600">3</text></svg>
Numbered List
</button>
<button onclick={ctxTaskList}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg>
Task List
</button>
{#if $appConfig?.ai_provider && ($appConfig?.ai_api_key || $appConfig?.openai_api_key)}
<div class="text-ctx-sep"></div>
<button onclick={openAiMenu}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a4 4 0 014 4v1a1 1 0 001 1h1a4 4 0 010 8h-1a1 1 0 00-1 1v1a4 4 0 01-8 0v-1a1 1 0 00-1-1H6a4 4 0 010-8h1a1 1 0 001-1V6a4 4 0 014-4z" />
<circle cx="12" cy="12" r="2" />
</svg>
AI Actions
</button>
{/if}
</div>
</div>
{/if}
{#if tableContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-overlay" onclick={closeTableContextMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="table-ctx-menu" style="left: {tableContextMenu.x}px; top: {tableContextMenu.y}px" onclick={(e) => e.stopPropagation()}>
<button onclick={tblAddRowBefore}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 3v3"/><polyline points="9 4.5 12 2 15 4.5"/></svg>
Add Row Above
</button>
<button onclick={tblAddRowAfter}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/><path d="M12 21v-3"/><polyline points="9 19.5 12 22 15 19.5"/></svg>
Add Row Below
</button>
<button class="danger" onclick={tblDeleteRow}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Delete Row
</button>
<div class="table-ctx-sep"></div>
<button onclick={tblAddColBefore}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v18M12 3v18M18 3v18"/><path d="M3 12h3"/><polyline points="4.5 9 2 12 4.5 15"/></svg>
Add Column Left
</button>
<button onclick={tblAddColAfter}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v18M12 3v18M18 3v18"/><path d="M21 12h-3"/><polyline points="19.5 9 22 12 19.5 15"/></svg>
Add Column Right
</button>
<button class="danger" onclick={tblDeleteCol}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
Delete Column
</button>
<div class="table-ctx-sep"></div>
<button onclick={tblMergeCells}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M14 9l-4 3 4 3"/></svg>
Merge Cells
</button>
<button onclick={tblSplitCell}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 3v18"/><path d="M8 9l4 3-4 3"/><path d="M16 9l-4 3 4 3"/></svg>
Split Cell
</button>
<div class="table-ctx-sep"></div>
<button onclick={tblToggleHeaderRow}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><rect x="4" y="4" width="16" height="4" rx="1" fill="currentColor" opacity="0.2"/></svg>
Toggle Header Row
</button>
<button onclick={tblToggleHeaderCol}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><rect x="4" y="4" width="4" height="16" rx="1" fill="currentColor" opacity="0.2"/></svg>
Toggle Header Column
</button>
<div class="table-ctx-sep"></div>
<div class="table-ctx-color-label">Cell Color</div>
<div class="table-ctx-colors">
{#each cellColors as color}
<button
class="table-ctx-color-swatch"
title={color.name}
style="background: {color.value || 'var(--bg-primary)'}; {color.value === '' ? 'border: 1px dashed var(--border-color);' : ''}"
onclick={() => tblSetCellColor(color.value)}
></button>
{/each}
</div>
<div class="table-ctx-sep"></div>
<button class="danger" onclick={tblDeleteTable}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
Delete Table
</button>
</div>
</div>
{/if}
{#if imageToolbar}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="img-toolbar" style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
</div>
</div>
{/if}
{#if codeLangDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-dropdown" style="left: {codeLangDropdown.x}px; top: {codeLangDropdown.y}px" onclick={(e) => e.stopPropagation()}>
<button
class="code-lang-option"
class:active={codeLangDropdown.current === ''}
onclick={() => selectCodeLang('')}
>auto</button>
{#each codeLanguages as lang}
<button
class="code-lang-option"
class:active={codeLangDropdown.current === lang}
onclick={() => selectCodeLang(lang)}
>{lang}</button>
{/each}
</div>
</div>
{/if}
{#if slashMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu-overlay" onclick={closeSlashMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="slash-menu" style="left: {slashMenu.x}px; top: {slashMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if slashTablePicker}
<div class="slash-table-picker">
<div class="slash-table-picker-grid">
{#each Array(8) as _, r}
{#each Array(10) as _, c}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="table-picker-cell"
class:active={r < slashTableHover.rows && c < slashTableHover.cols}
onmouseenter={() => slashTableHover = { rows: r + 1, cols: c + 1 }}
onmousedown={(e) => { e.preventDefault(); slashInsertTable(r + 1, c + 1); }}
></div>
{/each}
{/each}
</div>
<div class="slash-table-picker-label">
{slashTableHover.rows > 0 ? `${slashTableHover.rows} x ${slashTableHover.cols}` : 'Select table size'}
</div>
</div>
{:else if slashFiltered.length === 0}
<div class="slash-menu-empty">No matching commands</div>
{:else}
{#each slashFiltered as cmd, i}
<button
class="slash-menu-item"
class:selected={i === slashSelectedIndex}
onmouseenter={() => slashSelectedIndex = i}
onmousedown={(e) => { e.preventDefault(); executeSlashCommand(i); }}
>
<span class="slash-menu-icon">{@html cmd.icon}</span>
<span class="slash-menu-label">{cmd.label}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
{#if wikiLinkMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-menu" style="left: {wikiLinkMenu.x}px; top: {wikiLinkMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if wikiLinkFiltered.length === 0}
<div class="wiki-link-empty">
{wikiLinkMenu.query ? 'No matching notes' : 'Type to search notes...'}
</div>
{:else}
{#each wikiLinkFiltered.slice(0, 12) as entry, i}
<button
class="wiki-link-item"
class:selected={i === wikiLinkSelectedIndex}
onmouseenter={() => wikiLinkSelectedIndex = i}
onmousedown={(e) => { e.preventDefault(); insertWikiLink(entry); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="wiki-link-title">{entry.title}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
{#if aiMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu-overlay" onclick={closeAiMenu}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu" style="left: {aiMenu.x}px; top: {aiMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if aiResult !== null || aiLoading}
<!-- Result view -->
<div class="ai-result-header">
<span class="ai-result-title">
{#if aiLoading}
<svg class="ai-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Generating...
{:else}
AI Result
{/if}
</span>
<button class="ai-result-close" onclick={closeAiMenu}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
{#if aiError}
<div class="ai-error">{aiError}</div>
{:else}
<div class="ai-result-body">{aiResult}</div>
{/if}
{#if !aiLoading && aiResult && !aiError}
<div class="ai-result-actions">
<button class="ai-action-btn apply" onclick={aiApplyResult}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{aiEmptyNote ? 'Insert Note' : aiWholeNote ? 'Apply to Note' : 'Replace'}
</button>
<button class="ai-action-btn discard" onclick={aiDiscard}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
Discard
</button>
</div>
{/if}
{:else if aiShowCustom}
<!-- Custom prompt input -->
<div class="ai-custom-header">
<button class="ai-back-btn" onclick={() => aiShowCustom = false}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span>Custom Prompt</span>
</div>
<div class="ai-custom-body">
<textarea
class="ai-custom-input"
placeholder="Tell AI what to do with the selected text..."
bind:value={aiCustomPrompt}
onkeydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runAiAction('custom', aiCustomPrompt); } }}
use:autofocus
></textarea>
<button class="ai-custom-submit" onclick={() => runAiAction('custom', aiCustomPrompt)} disabled={!aiCustomPrompt.trim()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
Send
</button>
</div>
{:else if aiTranslateMenu}
<!-- Translate submenu -->
<div class="ai-custom-header">
<button class="ai-back-btn" onclick={() => aiTranslateMenu = false}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span>Translate to</span>
</div>
<button class="ai-menu-item" onclick={() => runAiAction('translate_en')}>English</button>
<button class="ai-menu-item" onclick={() => runAiAction('translate_nl')}>Dutch</button>
<button class="ai-menu-item" onclick={() => runAiAction('translate_de')}>German</button>
<button class="ai-menu-item" onclick={() => runAiAction('translate_fr')}>French</button>
<button class="ai-menu-item" onclick={() => runAiAction('translate_es')}>Spanish</button>
{:else if aiEmptyNote}
<!-- Empty note — generate from prompt -->
<div class="ai-menu-label">Generate Note</div>
<div class="ai-custom-body">
<textarea
class="ai-custom-input"
placeholder="Describe the note you want to create..."
bind:value={aiCustomPrompt}
onkeydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runAiAction('custom', aiCustomPrompt); } }}
use:autofocus
></textarea>
<button class="ai-custom-submit" onclick={() => runAiAction('custom', aiCustomPrompt)} disabled={!aiCustomPrompt.trim()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/></svg>
Generate
</button>
</div>
{:else}
<!-- Action list -->
<div class="ai-menu-label">{aiWholeNote ? 'AI Actions (Entire Note)' : 'AI Actions'}</div>
<button class="ai-menu-item" onclick={() => runAiAction('improve')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Improve Writing
</button>
<button class="ai-menu-item" onclick={() => runAiAction('fix_grammar')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Fix Grammar
</button>
<button class="ai-menu-item" onclick={() => runAiAction('shorter')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/><path d="M4 6h10"/></svg>
Make Shorter
</button>
<button class="ai-menu-item" onclick={() => runAiAction('longer')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg>
Make Longer
</button>
<div class="ai-menu-sep"></div>
<button class="ai-menu-item" onclick={() => runAiAction('professional')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2"/></svg>
Professional Tone
</button>
<button class="ai-menu-item" onclick={() => runAiAction('friendly')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Friendly Tone
</button>
<div class="ai-menu-sep"></div>
<button class="ai-menu-item" onclick={() => runAiAction('summarize')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="10" x2="16" y2="10"/><line x1="4" y1="14" x2="12" y2="14"/></svg>
Summarize
</button>
<button class="ai-menu-item" onclick={() => runAiAction('explain')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Explain
</button>
<button class="ai-menu-item" onclick={() => aiTranslateMenu = true}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
Translate
<span class="ai-menu-arrow">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</button>
<div class="ai-menu-sep"></div>
<button class="ai-menu-item" onclick={() => aiShowCustom = true}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
Custom Prompt...
</button>
{/if}
</div>
</div>
{/if}
{#if linkModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-modal-overlay" onclick={linkModalCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="link-modal" onclick={(e) => e.stopPropagation()}>
<div class="link-modal-header">
<svg width="28" height="28" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" />
<circle cx="16" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="16" cy="32" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="32" r="3.5" fill="white" opacity="0.9" />
<line x1="19" y1="18" x2="29" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
<line x1="29" y1="18" x2="19" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
</svg>
<span>Insert Link</span>
</div>
<input
type="text"
class="link-modal-input"
bind:this={linkModalInput}
bind:value={linkModalUrl}
onkeydown={(e) => {
if (e.key === 'Enter') linkModalConfirm();
if (e.key === 'Escape') linkModalCancel();
}}
placeholder="https://example.com"
/>
<div class="link-modal-actions">
<button class="link-modal-btn cancel" onclick={linkModalCancel}>Cancel</button>
<button class="link-modal-btn confirm" onclick={linkModalConfirm}>
{linkModalUrl ? 'Apply' : 'Remove Link'}
</button>
</div>
</div>
</div>
{/if}
{#if showGraph}
<GraphView onclose={() => showGraph = false} onnavigate={(path, title) => { showGraph = false; navigateToWikiLink(path, title); }} />
{/if}
<style>
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-editor);
}
.empty-editor {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
gap: 12px;
}
.empty-icon {
opacity: 0.5;
}
.empty-editor p {
font-size: 14px;
}
.shortcuts-hint {
display: flex;
gap: 16px;
font-size: 12px;
margin-top: 8px;
}
.shortcuts-hint kbd {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 1px 5px;
font-size: 11px;
font-family: inherit;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px 4px;
flex-shrink: 0;
}
.editor-title {
flex: 1;
}
.editor-title input {
width: 100%;
border: none;
background: none;
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
outline: none;
padding: 0;
-webkit-user-select: text !important;
user-select: text !important;
}
.editor-title input::placeholder {
color: var(--text-tertiary);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.save-indicator {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.readonly-indicator {
font-size: 11px;
color: var(--accent);
background: var(--accent-light);
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.icon-btn.flash {
animation: flash-icon 0.6s ease;
}
@keyframes flash-icon {
0%, 100% { color: var(--text-tertiary); }
25%, 75% { color: var(--accent); transform: scale(1.2); }
}
.icon-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-btn.active {
color: var(--text-accent);
background: var(--accent-light);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.editor-formatting-bar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 20px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
}
.fmt-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 5px 7px;
border-radius: 4px;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
position: relative;
}
.fmt-btn :global(svg) {
width: 18px;
height: 18px;
}
.fmt-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.fmt-btn.active {
background: var(--accent-light);
color: var(--text-accent);
}
.fmt-sep {
width: 1px;
height: 16px;
background: var(--border-color);
margin: 0 3px;
flex-shrink: 0;
}
.fmt-dropdown-wrap {
position: relative;
}
.fmt-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 4px;
z-index: 100;
min-width: 140px;
}
.fmt-dropdown button {
display: block;
width: 100%;
padding: 6px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 4px;
text-align: left;
}
.fmt-dropdown button:hover {
background: var(--bg-hover);
}
.fmt-dropdown button.active {
color: var(--text-accent);
background: var(--accent-light);
}
.color-indicator {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 2px;
border-radius: 1px;
}
.color-grid-dropdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 8px;
min-width: auto;
width: 140px;
}
.color-grid-dropdown .color-swatch {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
min-width: 0;
}
.color-grid-dropdown .color-swatch:hover {
transform: scale(1.15);
}
.insert-dropdown {
min-width: 180px;
}
.insert-dropdown button {
display: flex;
align-items: center;
gap: 8px;
}
.insert-btn {
color: var(--accent);
}
.align-dropdown {
min-width: 120px;
}
.align-dropdown button {
display: flex;
align-items: center;
gap: 8px;
}
.editor-body-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.note-search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.note-search-input {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
outline: none;
min-width: 0;
}
.note-search-input::placeholder {
color: var(--text-secondary);
}
.note-search-count {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.note-search-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.note-search-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
:global(.note-search-match) {
background: rgba(255, 200, 0, 0.3);
border-radius: 2px;
}
:global(.note-search-active) {
background: rgba(255, 150, 0, 0.6);
}
.editor-body {
flex: 1;
overflow-y: auto;
padding: 8px 20px;
min-width: 0;
}
.editor-body:has(.source-editor) {
overflow: hidden;
}
.history-panel {
width: 240px;
border-left: 1px solid var(--border-light);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-light);
}
.history-header h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
margin: 0;
}
.history-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.history-create-btn,
.history-close {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.history-create-btn:hover,
.history-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.history-empty {
padding: 16px 14px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 4px 6px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: none;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.history-item:hover {
background: var(--bg-hover);
}
.history-item.active {
background: var(--accent-light);
}
.history-date {
font-size: 12px;
color: var(--text-primary);
}
.history-item.active .history-date {
color: var(--accent);
font-weight: 500;
}
.history-size {
font-size: 11px;
color: var(--text-tertiary);
}
.history-actions {
padding: 8px 10px;
border-top: 1px solid var(--border-light);
}
.history-restore-btn {
width: 100%;
padding: 7px 12px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent-light);
color: var(--accent);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.history-restore-btn:hover {
background: var(--accent);
color: white;
}
.source-editor {
width: 100%;
height: 100%;
border: none;
background: none;
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px);
line-height: 1.6;
resize: none;
outline: none;
padding: 0;
user-select: text;
}
.tiptap-wrapper {
height: 100%;
user-select: text;
}
:global(.tiptap-wrapper .tiptap) {
outline: none;
min-height: 100%;
user-select: text;
font-size: var(--editor-font-size, 14px);
font-family: var(--editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
overflow: hidden;
}
:global(.tiptap-wrapper .tiptap p) {
margin: 0 0 0.75em;
line-height: 1.65;
}
:global(.tiptap-wrapper .tiptap h1) {
font-size: 1.75em;
font-weight: 700;
margin: 1.5em 0 0.5em;
line-height: 1.2;
}
:global(.tiptap-wrapper .tiptap h2) {
font-size: 1.4em;
font-weight: 600;
margin: 1.25em 0 0.5em;
line-height: 1.3;
}
:global(.tiptap-wrapper .tiptap h3) {
font-size: 1.2em;
font-weight: 600;
margin: 1em 0 0.5em;
line-height: 1.3;
}
:global(.tiptap-wrapper .tiptap h4) {
font-size: 1.05em;
font-weight: 600;
margin: 0.9em 0 0.4em;
line-height: 1.35;
}
:global(.tiptap-wrapper .tiptap h1:first-child),
:global(.tiptap-wrapper .tiptap h2:first-child),
:global(.tiptap-wrapper .tiptap h3:first-child),
:global(.tiptap-wrapper .tiptap h4:first-child) {
margin-top: 0;
}
:global(.tiptap-wrapper .tiptap strong) {
font-weight: 600;
}
:global(.tiptap-wrapper .tiptap code) {
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.9em;
}
:global(.tiptap-wrapper .tiptap pre) {
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
border-radius: 8px;
padding: 16px;
margin: 1em 0;
overflow-x: auto;
position: relative;
}
:global(.tiptap-wrapper .tiptap pre code) {
background: none;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
:global(.tiptap-wrapper .tiptap pre .code-lang-wrapper) {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
}
:global(.tiptap-wrapper .tiptap pre .code-lang-btn) {
padding: 2px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
font-size: 11px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer;
outline: none;
opacity: 0.4;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
:global(.tiptap-wrapper .tiptap pre:hover .code-lang-btn) {
opacity: 0.7;
}
:global(.tiptap-wrapper .tiptap pre .code-lang-btn:hover) {
opacity: 1;
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-secondary);
}
.code-lang-overlay {
position: fixed;
inset: 0;
z-index: 2000;
}
.code-lang-dropdown {
position: fixed;
transform: translateX(-100%);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 4px;
max-height: 280px;
overflow-y: auto;
min-width: 130px;
z-index: 2001;
}
.code-lang-dropdown::-webkit-scrollbar {
width: 5px;
}
.code-lang-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.code-lang-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.code-lang-option {
display: block;
width: 100%;
padding: 5px 10px;
border: none;
background: none;
color: var(--text-primary);
font-size: 12px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer;
border-radius: 4px;
text-align: left;
}
.code-lang-option:hover {
background: var(--bg-hover);
}
.code-lang-option.active {
color: var(--text-accent);
background: var(--accent-light);
}
:global(.tiptap-wrapper .tiptap blockquote) {
border-left: 3px solid var(--accent);
padding-left: 16px;
margin: 1em 0;
color: var(--text-secondary);
}
:global(.tiptap-wrapper .tiptap [data-type="details"]) {
margin: 1em 0;
border: 1px solid var(--border-color);
border-radius: 8px;
position: relative;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] > button) {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
position: absolute;
top: 8px;
left: 6px;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
border-radius: 4px;
transition: background 0.15s;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] > button:hover) {
background: var(--bg-tertiary);
}
:global(.tiptap-wrapper .tiptap [data-type="details"] > button::after) {
content: '▶';
font-size: 10px;
transition: transform 0.2s;
}
:global(.tiptap-wrapper .tiptap [data-type="details"].is-open > button::after) {
transform: rotate(90deg);
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary) {
padding: 10px 14px 10px 32px;
font-weight: 600;
background: var(--bg-secondary);
transition: background 0.15s;
list-style: none;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary::-webkit-details-marker) {
display: none;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary:hover) {
background: var(--bg-tertiary);
}
:global(.tiptap-wrapper .tiptap [data-type="details"] [data-type="detailsContent"]) {
padding: 10px 14px;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] [data-type="detailsContent"] > p:first-child) {
margin-top: 0;
}
:global(.tiptap-wrapper .tiptap [data-type="details"] [data-type="detailsContent"] > p:last-child) {
margin-bottom: 0;
}
:global(.tiptap-wrapper .tiptap ul),
:global(.tiptap-wrapper .tiptap ol) {
padding-left: 24px;
margin: 0.5em 0;
}
:global(.tiptap-wrapper .tiptap ul:not([data-type="taskList"])) {
list-style-type: disc;
}
:global(.tiptap-wrapper .tiptap ul:not([data-type="taskList"]) ul) {
list-style-type: circle;
}
:global(.tiptap-wrapper .tiptap ul:not([data-type="taskList"]) ul ul) {
list-style-type: square;
}
:global(.tiptap-wrapper .tiptap ol) {
list-style-type: decimal;
}
:global(.tiptap-wrapper .tiptap ol ol) {
list-style-type: lower-alpha;
}
:global(.tiptap-wrapper .tiptap ol ol ol) {
list-style-type: lower-roman;
}
:global(.tiptap-wrapper .tiptap li) {
margin: 0.25em 0;
line-height: 1.65;
}
:global(.tiptap-wrapper .tiptap li p) {
margin: 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"]) {
list-style: none;
padding-left: 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li) {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label) {
display: flex;
align-items: center;
margin-top: 4px;
flex-shrink: 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]) {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
position: relative;
flex-shrink: 0;
background: var(--bg-primary);
transition: background 0.15s, border-color 0.15s;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked) {
background: var(--accent);
border-color: var(--accent);
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked::after) {
content: '';
position: absolute;
left: 3px;
top: 0px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:hover) {
border-color: var(--accent);
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li > div) {
flex: 1;
min-width: 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div) {
text-decoration: line-through;
color: var(--text-tertiary);
}
:global(.tiptap-wrapper .tiptap hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1.5em 0;
}
:global(.tiptap-wrapper .tiptap a) {
color: var(--text-accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--text-accent) 40%, transparent);
}
:global(.tiptap-wrapper .tiptap a:hover) {
text-decoration-color: var(--text-accent);
}
:global(.tiptap-wrapper .tiptap img) {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1em 0;
cursor: pointer;
}
:global(.tiptap-wrapper .tiptap img:hover) {
outline: none;
}
:global(.tiptap-wrapper .tiptap img.ProseMirror-selectednode) {
outline: none !important;
box-shadow: none !important;
background: none !important;
}
:global(.tiptap-wrapper .tiptap .ProseMirror-selectednode) {
outline: none !important;
}
:global(.tiptap-wrapper .tiptap img::selection) {
background: transparent;
}
:global(.tiptap-wrapper .tiptap img::-moz-selection) {
background: transparent;
}
:global(.tiptap-wrapper .tiptap img[data-size="small"]) {
max-width: 33%;
}
:global(.tiptap-wrapper .tiptap img[data-size="medium"]) {
max-width: 65%;
}
:global(.tiptap-wrapper .tiptap img[data-size="full"]) {
max-width: 100%;
}
.img-toolbar-overlay {
position: fixed;
inset: 0;
z-index: 1500;
}
.img-toolbar {
position: fixed;
transform: translateX(-50%) translateY(-100%);
display: flex;
gap: 2px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 3px;
z-index: 1501;
}
.img-toolbar button {
padding: 5px 14px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
border-radius: 5px;
}
.img-toolbar button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.img-toolbar button.active {
background: var(--accent);
color: white;
}
:global(.tiptap-wrapper .tiptap mark) {
padding: 1px 3px;
border-radius: 3px;
color: #333 !important;
}
:global(.dark .tiptap-wrapper .tiptap mark) {
color: #eee !important;
}
:global(.tiptap-wrapper .tiptap .tableWrapper) {
overflow-x: auto;
margin: 1em 0;
}
:global(.tiptap-wrapper .tiptap table) {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
overflow: hidden;
}
:global(.tiptap-wrapper .tiptap th),
:global(.tiptap-wrapper .tiptap td) {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
vertical-align: top;
position: relative;
min-width: 80px;
box-sizing: border-box;
}
:global(.tiptap-wrapper .tiptap th) {
background: var(--bg-tertiary);
font-weight: 600;
}
:global(.tiptap-wrapper .tiptap td > p),
:global(.tiptap-wrapper .tiptap th > p) {
margin: 0;
}
:global(.tiptap-wrapper .tiptap .selectedCell::after) {
content: "";
position: absolute;
inset: 0;
background: var(--accent-light);
pointer-events: none;
z-index: 1;
}
:global(.tiptap-wrapper .tiptap .column-resize-handle) {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: var(--accent);
pointer-events: none;
z-index: 2;
}
:global(.tiptap-wrapper .tiptap.resize-cursor) {
cursor: col-resize;
}
:global(.tiptap-wrapper .tiptap > .is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] .is-empty::before) {
content: none;
}
.link-context-overlay {
position: fixed;
inset: 0;
z-index: 1500;
}
.link-context-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 180px;
z-index: 1501;
}
.link-context-url {
padding: 6px 12px;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
border-bottom: 1px solid var(--border-light);
margin-bottom: 4px;
}
.link-context-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.link-context-menu button:hover {
background: var(--bg-hover);
}
.link-context-menu button.danger {
color: var(--danger);
}
.link-context-menu button.danger:hover {
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.link-context-sep {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.link-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.link-modal {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: 20px;
width: 400px;
max-width: 90vw;
}
.link-modal-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.link-modal-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.link-modal-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
.link-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.link-modal-btn {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.link-modal-btn.cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.link-modal-btn.cancel:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.link-modal-btn.confirm {
background: var(--accent);
color: white;
}
.link-modal-btn.confirm:hover {
opacity: 0.9;
}
/* Text context menu */
.text-ctx-overlay {
position: fixed;
inset: 0;
z-index: 1500;
}
.text-ctx-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 200px;
max-height: calc(100vh - 16px);
overflow-y: auto;
z-index: 1501;
}
.text-ctx-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.text-ctx-menu button:hover {
background: var(--bg-hover);
}
.text-ctx-shortcut {
margin-left: auto;
font-size: 11px;
color: var(--text-tertiary);
font-family: inherit;
}
.text-ctx-sep {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.text-ctx-submenu-wrap {
position: relative;
}
.text-ctx-submenu-wrap > button.has-submenu {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.text-ctx-submenu-wrap > button.has-submenu:hover {
background: var(--bg-hover);
}
.text-ctx-submenu-wrap .submenu-arrow {
margin-left: auto;
color: var(--text-tertiary);
}
.text-ctx-submenu {
position: absolute;
left: 100%;
top: -4px;
margin-left: 2px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 140px;
z-index: 1502;
}
.text-ctx-submenu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.text-ctx-submenu button:hover {
background: var(--bg-hover);
}
.text-ctx-submenu button.active {
color: var(--accent);
font-weight: 600;
}
/* Table grid size picker */
.table-picker-dropdown {
padding: 8px;
min-width: auto;
width: auto;
}
.table-picker-grid {
display: grid;
grid-template-columns: repeat(10, 18px);
grid-template-rows: repeat(8, 18px);
gap: 2px;
}
.table-picker-cell {
width: 18px;
height: 18px;
border: 1px solid var(--border-color);
border-radius: 2px;
cursor: pointer;
transition: background 0.05s;
background: var(--bg-secondary);
}
.table-picker-cell:hover,
.table-picker-cell.active {
background: var(--accent-light);
border-color: var(--accent);
}
.table-picker-label {
text-align: center;
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
font-weight: 500;
}
/* Table context menu */
.table-ctx-overlay {
position: fixed;
inset: 0;
z-index: 1500;
}
.table-ctx-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 200px;
max-height: calc(100vh - 16px);
overflow-y: auto;
z-index: 1501;
}
.table-ctx-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.table-ctx-menu button:hover {
background: var(--bg-hover);
}
.table-ctx-menu button.danger {
color: var(--danger);
}
.table-ctx-menu button.danger:hover {
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.table-ctx-sep {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.table-ctx-color-label {
padding: 4px 12px 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.table-ctx-colors {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 4px 10px 6px;
}
.table-ctx-color-swatch {
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: transform 0.1s;
padding: 0;
}
.table-ctx-color-swatch:hover {
transform: scale(1.15);
border-color: var(--accent);
}
/* PDF embeds */
:global(.tiptap .pdf-embed) {
margin: 12px 0;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg-secondary);
}
:global(.tiptap .pdf-embed iframe) {
display: block;
border: none;
width: 100%;
}
:global(.tiptap .pdf-embed .pdf-label) {
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
border-top: 1px solid var(--border);
margin: 0;
}
/* Slash commands menu */
.slash-menu-overlay {
position: fixed;
inset: 0;
z-index: 1500;
}
.slash-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 220px;
max-height: 300px;
overflow-y: auto;
z-index: 1501;
}
.slash-menu::-webkit-scrollbar {
width: 4px;
}
.slash-menu::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
.slash-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: var(--bg-hover);
}
.slash-menu-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
flex-shrink: 0;
}
.slash-menu-label {
flex: 1;
}
.slash-menu-empty {
padding: 12px 16px;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
}
.slash-table-picker {
padding: 8px;
}
.slash-table-picker-grid {
display: grid;
grid-template-columns: repeat(10, 18px);
grid-template-rows: repeat(8, 18px);
gap: 2px;
}
.slash-table-picker-label {
text-align: center;
font-size: 11px;
color: var(--text-secondary);
margin-top: 6px;
font-weight: 500;
}
/* AI Menu */
.ai-menu-overlay {
position: fixed;
inset: 0;
z-index: 1600;
}
.ai-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 320px;
max-width: 480px;
z-index: 1601;
max-height: 80vh;
overflow-y: auto;
}
.ai-menu-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ai-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
border-radius: 6px;
text-align: left;
}
.ai-menu-item:hover {
background: var(--bg-hover);
}
.ai-menu-sep {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.ai-menu-arrow {
margin-left: auto;
color: var(--text-tertiary);
display: flex;
align-items: center;
}
.ai-result-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
}
.ai-result-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.ai-spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ai-result-close {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.ai-result-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.ai-result-body {
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.6;
max-height: 50vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.ai-error {
padding: 8px 12px;
font-size: 12px;
color: var(--danger);
line-height: 1.5;
}
.ai-result-actions {
display: flex;
gap: 6px;
padding: 6px 10px 8px;
border-top: 1px solid var(--border-light);
}
.ai-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.ai-action-btn.apply {
background: var(--accent);
color: white;
}
.ai-action-btn.apply:hover {
opacity: 0.9;
}
.ai-action-btn.discard {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.ai-action-btn.discard:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.ai-custom-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.ai-back-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
}
.ai-back-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.ai-custom-body {
padding: 6px 10px 8px;
}
.ai-custom-input {
width: 100%;
min-height: 60px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.ai-custom-input:focus {
border-color: var(--accent);
}
.ai-custom-input::placeholder {
color: var(--text-tertiary);
}
.ai-custom-submit {
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
padding: 7px 12px;
border: none;
border-radius: 6px;
background: var(--accent);
color: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.ai-custom-submit:hover:not(:disabled) {
opacity: 0.9;
}
.ai-custom-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Wiki-link styles */
:global(.tiptap-wrapper .tiptap .wiki-link) {
color: var(--accent);
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
border-radius: 2px;
padding: 0 1px;
}
:global(.tiptap-wrapper .tiptap .wiki-link:hover) {
background: var(--accent-light);
text-decoration: underline solid;
}
:global(.tiptap-wrapper .tiptap .wiki-link[data-path=""]) {
color: var(--text-tertiary);
text-decoration: underline dashed;
}
.wiki-link-overlay {
position: fixed;
inset: 0;
z-index: 1600;
}
.wiki-link-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-lg);
width: 280px;
max-height: 360px;
overflow-y: auto;
padding: 4px;
z-index: 1601;
}
.wiki-link-empty {
padding: 12px 14px;
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
}
.wiki-link-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.wiki-link-item:hover,
.wiki-link-item.selected {
background: var(--accent-light);
color: var(--accent);
}
.wiki-link-item svg {
flex-shrink: 0;
color: var(--text-tertiary);
}
.wiki-link-item:hover svg,
.wiki-link-item.selected svg {
color: var(--accent);
}
.wiki-link-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>