/** * — notebook and note management. * * Browse notebooks, create/edit notes with rich text (Tiptap), * search, tag management. * * Notebook list: REST (GET /api/notebooks) * Notebook detail + notes: Automerge sync via WebSocket * Search: REST (GET /api/notes?q=...) */ import * as Automerge from '@automerge/automerge'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; import TaskList from '@tiptap/extension-task-list'; import TaskItem from '@tiptap/extension-task-item'; import Placeholder from '@tiptap/extension-placeholder'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import Typography from '@tiptap/extension-typography'; import Underline from '@tiptap/extension-underline'; import { common, createLowlight } from 'lowlight'; import { createSlashCommandPlugin } from './slash-command'; const lowlight = createLowlight(common); interface Notebook { id: string; title: string; description: string; cover_color: string; note_count: string; updated_at: string; } interface Note { id: string; title: string; content: string; content_plain: string; content_format?: 'html' | 'tiptap-json'; type: string; tags: string[] | null; is_pinned: boolean; created_at: string; updated_at: string; } /** Shape of Automerge notebook doc (matches PG→Automerge migration) */ interface NotebookDoc { meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; notebook: { id: string; title: string; slug: string; description: string; coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number; }; items: Record; } class FolkNotesApp extends HTMLElement { private shadow!: ShadowRoot; private space = ""; private view: "notebooks" | "notebook" | "note" = "notebooks"; private notebooks: Notebook[] = []; private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; private selectedNote: Note | null = null; private searchQuery = ""; private searchResults: Note[] = []; private loading = false; private error = ""; // Zone-based rendering private navZone!: HTMLDivElement; private contentZone!: HTMLDivElement; private metaZone!: HTMLDivElement; // Tiptap editor private editor: Editor | null = null; private editorNoteId: string | null = null; private isRemoteUpdate = false; private editorUpdateTimer: ReturnType | null = null; // Automerge sync state private ws: WebSocket | null = null; private doc: Automerge.Doc | null = null; private syncState: Automerge.SyncState = Automerge.initSyncState(); private subscribedDocId: string | null = null; private syncConnected = false; private pingInterval: ReturnType | null = null; // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; constructor() { super(); this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.setupShadow(); if (this.space === "demo") { this.loadDemoData(); return; } this.connectSync(); this.loadNotebooks(); } private setupShadow() { const style = document.createElement('style'); style.textContent = this.getStyles(); this.navZone = document.createElement('div'); this.navZone.id = 'nav-zone'; this.contentZone = document.createElement('div'); this.contentZone.id = 'content-zone'; this.metaZone = document.createElement('div'); this.metaZone.id = 'meta-zone'; this.shadow.appendChild(style); this.shadow.appendChild(this.navZone); this.shadow.appendChild(this.contentZone); this.shadow.appendChild(this.metaZone); } // ── Demo data ── private loadDemoData() { const now = Date.now(); const hour = 3600000; const day = 86400000; const tripPlanningNotes: Note[] = [ { id: "demo-note-1", title: "Pre-trip Preparation", content: `

Pre-trip Preparation

Flights & Transfers

  • Jul 6: Fly Geneva, shuttle to Chamonix (~1.5h)
  • Jul 14: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)
  • Jul 20: Fly home from Innsbruck

Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.

Travel Documents

  1. Passports (valid 6+ months)
  2. EU health insurance cards (EHIC)
  3. Travel insurance policy (ref: WA-2026-7891)
  4. Hut reservation confirmations (printed copies)
  5. Drone registration for Italy

Budget Overview

Total budget: EUR 4,000 across 4 travelers.

Transport:     EUR  800 (20%)
Accommodation: EUR 1200 (30%)
Activities:    EUR 1000 (25%)
Food:          EUR  600 (15%)
Gear:          EUR  400 (10%)

Maya is tracking expenses in rFunds. Current spend: EUR 1,203.

`, content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.", content_format: 'html', type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true, created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), }, { id: "demo-note-2", title: "Accommodation Research", content: `

Accommodation Research

Chamonix (Jul 6-10)

  • Refuge du Lac Blanc -- Jul 7, 4 beds, conf #LB2026-234
  • Airbnb in town for other nights (~EUR 120/night for 4 pax)
  • Consider Hotel Le Morgane if Airbnb falls through

Zermatt (Jul 10-14)

  • Hornlihutte (Matterhorn base) -- Waitlisted for Jul 12
  • Main accommodation: Apartment near Bahnhofstrasse
  • Car-free village, arrive by Glacier Express

Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.

Dolomites (Jul 14-20)

  • Rifugio Locatelli -- Jul 15, 4 beds, conf #TRE2026-089
  • Val Gardena base: Ortisei area
  • Look for agriturismo options for authentic experience
`, content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.", content_format: 'html', type: "NOTE", tags: ["accommodation", "budget"], is_pinned: false, created_at: new Date(now - 12 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), }, { id: "demo-note-3", title: "Activity Planning", content: `

Activity Planning

Hiking Routes

  • Lac Blanc (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.
  • Gornergrat Sunrise (Jul 11) -- Take the first train up at 7am, hike down.
  • Matterhorn Base Camp (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.
  • Tre Cime di Lavaredo (Jul 15) -- Classic loop, ~4h.
  • Seceda Ridgeline (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.

Adventure Activities

  1. Via Ferrata at Aiguille du Midi (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day
  2. Paragliding over Zermatt (Jul 13) -- Tandem flights ~EUR 180pp
  3. Kayaking at Lago di Braies (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour

Rest Days

  • Jul 9: Explore Chamonix town, gear shopping
  • Jul 19: Free day before flying home, packing
`, content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.", content_format: 'html', type: "NOTE", tags: ["hiking", "activities", "adventure"], is_pinned: false, created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(), }, { id: "demo-note-4", title: "Gear Research", content: `

Gear Research

Via Ferrata Kit

Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.

Camera & Drone

  • Bring the DJI Mini 4 Pro for Tre Cime and Seceda
  • Check Italian drone regulations! Need ENAC registration for flights over 250g
  • ND filters for long exposure water shots at Lago di Braies
  • Extra batteries (3x) -- cold altitude drains them fast

Personal Gear Checklist

  • Hiking boots (broken in!)
  • Rain jacket (waterproof, not just resistant)
  • Headlamp + spare batteries
  • Trekking poles (collapsible for flights)
  • Sunscreen SPF 50 + lip balm
  • Wool base layers for hut nights
`, content_plain: "Gear research including Via Ferrata rental, camera and drone regulations, shared group gear status, and personal gear checklist.", content_format: 'html', type: "NOTE", tags: ["gear", "equipment", "budget"], is_pinned: false, created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), }, { id: "demo-note-5", title: "Emergency Contacts & Safety", content: `

Emergency Contacts & Safety

Emergency Numbers

  • France: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89
  • Switzerland: 1414 (REGA air rescue), 144 (ambulance)
  • Italy: 118 (medical), 112 (general emergency)

Insurance

  • Policy #: WA-2026-7891
  • Emergency line: +1-800-555-0199
  • Covers: mountain rescue, helicopter evacuation, medical repatriation

Altitude Sickness Protocol

  1. Acclimatize in Chamonix (1,035m) for 2 days before going high
  2. Stay hydrated -- minimum 3L water per day above 2,500m
  3. Watch for symptoms: headache, nausea, dizziness
  4. Descend immediately if symptoms worsen
`, content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans.", content_format: 'html', type: "NOTE", tags: ["safety", "emergency", "contacts"], is_pinned: false, created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(), }, { id: "demo-note-6", title: "Photo Spots & Creative Plan", content: `

Photo Spots & Creative Plan

Must-Capture Locations

  1. Lac Blanc -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.
  2. Gornergrat Panorama -- 360-degree view with Matterhorn. Golden hour is best.
  3. Tre Cime from Rifugio Locatelli -- The iconic three peaks at golden hour. Drone shots here.
  4. Seceda Ridgeline -- Dramatic Dolomite spires. Best drone footage location.
  5. Lago di Braies -- Turquoise water, use ND filters for long exposure reflections.

Zine Plan (Maya)

We are making an Alpine Explorer Zine after the trip:

  • Format: A5 risograph, 50 copies
  • Print at Chamonix Print Collective
  • Content: best photos, trail notes, hand-drawn maps
  • Price: EUR 12 per copy on rCart
`, content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.", content_format: 'html', type: "NOTE", tags: ["photography", "creative", "planning"], is_pinned: false, created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(), }, ]; const packingNotes: Note[] = [ { id: "demo-note-7", title: "Packing Checklist", content: `

Packing Checklist

Footwear

  • Hiking boots (broken in!)
  • Camp sandals / flip-flops
  • Extra laces

Clothing

  • Rain jacket (Gore-Tex)
  • Down jacket for hut nights
  • 3x wool base layers
  • 2x hiking pants
  • Sun hat + warm beanie

Gear

  • Headlamp + spare batteries
  • Trekking poles (collapsible)
  • First aid kit
  • Sunscreen SPF 50
  • Water filter (Sawyer Squeeze)
`, content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food.", content_format: 'html', type: "NOTE", tags: ["packing", "gear", "checklist"], is_pinned: true, created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), }, { id: "demo-note-8", title: "Food & Cooking Plan", content: `

Food & Cooking Plan

Hut Meals (Half-Board)

Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.

Self-Catering Days

We have a kitchen in the Chamonix Airbnb and Zermatt apartment.

Trail Lunches

Pack these the night before each hike:

  • Sandwiches (baguette + cheese + ham)
  • Energy bars (2 per person)
  • Nuts and dried fruit
  • Chocolate (the altitude calls for it)
  • 1.5L water minimum
`, content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes.", content_format: 'html', type: "NOTE", tags: ["food", "planning", "budget"], is_pinned: false, created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(), }, { id: "demo-note-9", title: "Transport & Logistics", content: `

Transport & Logistics

Getting There

  • Jul 6: Fly to Geneva (everyone arrives by 14:00)
  • Geneva to Chamonix shuttle: EUR 186 for 4 pax

Between Destinations

  • Jul 10: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)
  • Jul 14: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)

Local Transport

  • Chamonix: Free local bus with guest card
  • Zermatt: Car-free! Electric taxis + Gornergrat railway
  • Dolomites: Need rental car or local bus (limited schedule)
`, content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and timetables.", content_format: 'html', type: "NOTE", tags: ["transport", "logistics"], is_pinned: false, created_at: new Date(now - 9 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(), }, ]; const itineraryNotes: Note[] = [ { id: "demo-note-10", title: "Full Itinerary -- Alpine Explorer 2026", content: `

Full Itinerary -- Alpine Explorer 2026

Jul 6-20 | France, Switzerland, Italy

Week 1: Chamonix, France (Jul 6-10)

  • Jul 6: Fly Geneva, shuttle to Chamonix
  • Jul 7: Acclimatization hike -- Lac Blanc
  • Jul 8: Via Ferrata -- Aiguille du Midi
  • Jul 9: Rest day / Chamonix town
  • Jul 10: Train to Zermatt

Week 2: Zermatt, Switzerland (Jul 10-14)

  • Jul 10: Arrive Zermatt, settle in
  • Jul 11: Gornergrat sunrise hike
  • Jul 12: Matterhorn base camp trek
  • Jul 13: Paragliding over Zermatt
  • Jul 14: Transfer to Dolomites

Week 3: Dolomites, Italy (Jul 14-20)

  • Jul 14: Arrive Val Gardena
  • Jul 15: Tre Cime di Lavaredo loop
  • Jul 16: Lago di Braies kayaking
  • Jul 17: Seceda ridgeline hike
  • Jul 18: Cooking class in Bolzano
  • Jul 19: Free day -- shopping & packing
  • Jul 20: Fly home from Innsbruck
`, content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.", content_format: 'html', type: "NOTE", tags: ["itinerary", "planning"], is_pinned: true, created_at: new Date(now - 15 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), }, { id: "demo-note-11", title: "Mountain Hut Reservations", content: `

Mountain Hut Reservations

Confirmed

  • Refuge du Lac Blanc (Jul 7) -- 4 beds, half-board, conf #LB2026-234
  • Rifugio Locatelli (Jul 15) -- 4 beds, half-board, conf #TRE2026-089

Waitlisted

  • Hornlihutte (Matterhorn base, Jul 12) -- will know by Jul 1

Hut Etiquette Reminders

  1. Arrive before 17:00 if possible
  2. Remove boots at entrance (bring hut shoes or thick socks)
  3. Lights out by 22:00
  4. Pack out all trash
  5. Tip is appreciated but not required
`, content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders.", content_format: 'html', type: "NOTE", tags: ["accommodation", "hiking"], is_pinned: false, created_at: new Date(now - 11 * day).toISOString(), updated_at: new Date(now - day).toISOString(), }, { id: "demo-note-12", title: "Group Decisions & Votes", content: `

Group Decisions & Votes

Decided

  • Camera Gear: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)
  • First Night Dinner in Zermatt: Fondue at Chez Vrony (won 5-4 over pizza)
  • Day 5 Activity: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)

Active Votes (in rVote)

  • Zermatt to Dolomites transfer: Train vs rental car -- Train leading 3-2

Pending Decisions

  • Val Gardena accommodation (agriturismo vs apartment)
  • Whether to rent the Starlink Mini (EUR 200, needs funding)
  • Trip zine print run size (50 vs 100 copies)
`, content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions.", content_format: 'html', type: "NOTE", tags: ["decisions", "planning"], is_pinned: false, created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), }, ]; this.demoNotebooks = [ { id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy", cover_color: "#f59e0b", note_count: "6", updated_at: new Date(now - hour).toISOString(), notes: tripPlanningNotes, } as any, { id: "demo-nb-2", title: "Packing & Logistics", description: "Checklists, food plans, and transport details", cover_color: "#22c55e", note_count: "3", updated_at: new Date(now - hour).toISOString(), notes: packingNotes, } as any, { id: "demo-nb-3", title: "Itinerary & Decisions", description: "Day-by-day schedule, hut reservations, and group votes", cover_color: "#6366f1", note_count: "3", updated_at: new Date(now - 2 * hour).toISOString(), notes: itineraryNotes, } as any, ]; this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook); this.loading = false; this.render(); } private demoSearchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } const q = query.toLowerCase(); const all = this.demoNotebooks.flatMap(nb => nb.notes); this.searchResults = all.filter(n => n.title.toLowerCase().includes(q) || n.content_plain.toLowerCase().includes(q) || (n.tags && n.tags.some(t => t.toLowerCase().includes(q))) ); this.render(); } private demoLoadNotebook(id: string) { const nb = this.demoNotebooks.find(n => n.id === id); if (nb) { this.selectedNotebook = { ...nb }; } else { this.error = "Notebook not found"; } this.loading = false; this.render(); } private demoLoadNote(id: string) { const allNotes = this.demoNotebooks.flatMap(nb => nb.notes); this.selectedNote = allNotes.find(n => n.id === id) || null; if (this.selectedNote) { this.view = "note"; this.renderNav(); this.renderMeta(); this.mountEditor(this.selectedNote); } } private demoCreateNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; const now = Date.now(); const nb = { id: `demo-nb-${now}`, title, description: "", cover_color: "#8b5cf6", note_count: "0", updated_at: new Date(now).toISOString(), notes: [] as Note[], } as any; this.demoNotebooks.push(nb); this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook); this.render(); } private demoCreateNote() { if (!this.selectedNotebook) return; const now = Date.now(); const noteId = `demo-note-${now}`; const newNote: Note = { id: noteId, title: "Untitled Note", content: "", content_plain: "", content_format: 'tiptap-json', type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id); if (demoNb) { demoNb.notes.push(newNote); demoNb.note_count = String(demoNb.notes.length); } this.selectedNotebook.notes.push(newNote); this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length); this.selectedNote = newNote; this.view = "note"; this.renderNav(); this.renderMeta(); this.mountEditor(newNote); } disconnectedCallback() { this.destroyEditor(); this.disconnectSync(); } // ── WebSocket Sync ── private connectSync() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${proto}//${location.host}/ws/${this.space}`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.syncConnected = true; this.pingInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); } }, 30000); if (this.subscribedDocId && this.doc) { this.subscribeNotebook(this.subscribedDocId.split(":").pop()!); } }; this.ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === "sync" && msg.docId === this.subscribedDocId) { this.handleSyncMessage(new Uint8Array(msg.data)); } } catch { // ignore parse errors } }; this.ws.onclose = () => { this.syncConnected = false; if (this.pingInterval) clearInterval(this.pingInterval); setTimeout(() => { if (this.isConnected) this.connectSync(); }, 3000); }; this.ws.onerror = () => {}; } private disconnectSync() { if (this.pingInterval) clearInterval(this.pingInterval); if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } this.syncConnected = false; } private handleSyncMessage(syncMsg: Uint8Array) { if (!this.doc) return; const [newDoc, newSyncState] = Automerge.receiveSyncMessage( this.doc, this.syncState, syncMsg ); this.doc = newDoc; this.syncState = newSyncState; const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = nextState; if (reply && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(reply), })); } this.renderFromDoc(); } private subscribeNotebook(notebookId: string) { this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; this.doc = Automerge.init(); this.syncState = Automerge.initSyncState(); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = s; if (m) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(m), })); } } private unsubscribeNotebook() { if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); } this.subscribedDocId = null; this.doc = null; this.syncState = Automerge.initSyncState(); } /** Extract notebook + notes from Automerge doc into component state */ private renderFromDoc() { if (!this.doc) return; const nb = this.doc.notebook; const items = this.doc.items; if (!nb) return; // Build notebook data from doc const notes: Note[] = []; if (items) { for (const [, item] of Object.entries(items)) { notes.push({ id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", content_format: (item.contentFormat as Note['content_format']) || undefined, type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }); } } notes.sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); this.selectedNotebook = { id: nb.id, title: nb.title, description: nb.description || "", cover_color: nb.coverColor || "#3b82f6", note_count: String(notes.length), updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(), notes, }; // If viewing a note and editor is mounted, update editor content from remote if (this.view === "note" && this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) { const noteItem = items?.[this.selectedNote.id]; if (noteItem) { this.selectedNote = { id: noteItem.id, title: noteItem.title || "Untitled", content: noteItem.content || "", content_plain: noteItem.contentPlain || "", content_format: (noteItem.contentFormat as Note['content_format']) || undefined, type: noteItem.type || "NOTE", tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, is_pinned: noteItem.isPinned || false, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; // Update editor content if different (remote change) const remoteContent = noteItem.content || ""; const currentContent = noteItem.contentFormat === 'tiptap-json' ? JSON.stringify(this.editor.getJSON()) : this.editor.getHTML(); if (remoteContent !== currentContent) { this.isRemoteUpdate = true; try { if (noteItem.contentFormat === 'tiptap-json') { try { this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false }); } catch { this.editor.commands.setContent(remoteContent, { emitUpdate: false }); } } else { this.editor.commands.setContent(remoteContent, { emitUpdate: false }); } } finally { this.isRemoteUpdate = false; } } // Update title input if it exists const titleInput = this.shadow.querySelector('#note-title-input') as HTMLInputElement; if (titleInput && document.activeElement !== titleInput && titleInput !== this.shadow.activeElement) { titleInput.value = noteItem.title || "Untitled"; } // Only update nav/meta, skip contentZone this.renderNav(); this.renderMeta(); this.loading = false; return; } } // If viewing a specific note without editor mounted, update selectedNote if (this.view === "note" && this.selectedNote) { const noteItem = items?.[this.selectedNote.id]; if (noteItem) { this.selectedNote = { id: noteItem.id, title: noteItem.title || "Untitled", content: noteItem.content || "", content_plain: noteItem.contentPlain || "", content_format: (noteItem.contentFormat as Note['content_format']) || undefined, type: noteItem.type || "NOTE", tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, is_pinned: noteItem.isPinned || false, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; } } this.loading = false; this.render(); } // ── Automerge mutations ── private createNoteViaSync() { if (!this.doc || !this.selectedNotebook) return; const noteId = crypto.randomUUID(); const now = Date.now(); this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { if (!d.items) (d as any).items = {}; d.items[noteId] = { id: noteId, notebookId: this.selectedNotebook!.id, title: "Untitled Note", content: "", contentPlain: "", contentFormat: "tiptap-json", type: "NOTE", tags: [], isPinned: false, sortOrder: 0, createdAt: now, updatedAt: now, }; }); this.sendSyncAfterChange(); this.renderFromDoc(); // Open the new note this.selectedNote = { id: noteId, title: "Untitled Note", content: "", content_plain: "", content_format: 'tiptap-json', type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; this.view = "note"; this.renderNav(); this.renderMeta(); this.mountEditor(this.selectedNote); } private updateNoteField(noteId: string, field: string, value: string) { if (!this.doc || !this.doc.items?.[noteId]) return; this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { (d.items[noteId] as any)[field] = value; d.items[noteId].updatedAt = Date.now(); }); this.sendSyncAfterChange(); } private sendSyncAfterChange() { if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = newState; if (msg) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(msg), })); } } // ── REST (notebook list + search) ── private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnotes/); return match ? match[0] : ""; } private async loadNotebooks() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks`); const data = await res.json(); this.notebooks = data.notebooks || []; } catch { this.error = "Failed to load notebooks"; } this.loading = false; this.render(); } private async loadNotebook(id: string) { this.loading = true; this.render(); this.unsubscribeNotebook(); this.subscribeNotebook(id); setTimeout(() => { if (this.loading && this.view === "notebook") { this.loadNotebookREST(id); } }, 5000); } private async loadNotebookREST(id: string) { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks/${id}`); this.selectedNotebook = await res.json(); } catch { this.error = "Failed to load notebook"; } this.loading = false; this.render(); } private loadNote(id: string) { // Note is already in the Automerge doc if (this.doc?.items?.[id]) { const item = this.doc.items[id]; this.selectedNote = { id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", content_format: (item.contentFormat as Note['content_format']) || undefined, type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }; } else if (this.selectedNotebook?.notes) { this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null; } if (this.selectedNote) { this.renderNav(); this.renderMeta(); this.mountEditor(this.selectedNote); } } private async searchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`); const data = await res.json(); this.searchResults = data.notes || []; } catch { this.searchResults = []; } this.render(); } private async createNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; try { const base = this.getApiBase(); await fetch(`${base}/api/notebooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); await this.loadNotebooks(); } catch { this.error = "Failed to create notebook"; this.render(); } } // ── Tiptap Editor ── private mountEditor(note: Note) { this.destroyEditor(); // Build content zone const isDemo = this.space === "demo"; const isAutomerge = !!(this.doc?.items?.[note.id]); const isEditable = isAutomerge || isDemo; this.contentZone.innerHTML = `
${isEditable ? this.renderToolbar() : ''}
`; const container = this.shadow.getElementById('tiptap-container'); if (!container) return; // Determine content to load let content: any = ''; if (note.content) { if (note.content_format === 'tiptap-json') { try { content = JSON.parse(note.content); } catch { content = note.content; } } else { // HTML content (legacy or explicit) content = note.content; } } const slashPlugin = createSlashCommandPlugin( null as any, // Will be set after editor creation this.shadow ); this.editor = new Editor({ element: container, editable: isEditable, extensions: [ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, }), Link.configure({ openOnClick: false }), Image, TaskList, TaskItem.configure({ nested: true }), Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), CodeBlockLowlight.configure({ lowlight }), Typography, Underline, ], content, onUpdate: ({ editor }) => { if (this.isRemoteUpdate) return; if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); this.editorUpdateTimer = setTimeout(() => { const json = JSON.stringify(editor.getJSON()); const plain = editor.getText(); const noteId = this.editorNoteId; if (!noteId) return; if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json'); } else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); this.updateNoteField(noteId, "contentFormat", 'tiptap-json'); } }, 800); }, onSelectionUpdate: () => { this.updateToolbarState(); }, }); // Now register the slash command plugin with the actual editor this.editor.registerPlugin( createSlashCommandPlugin(this.editor, this.shadow) ); this.editorNoteId = note.id; // Wire up title input const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; if (titleInput) { let titleTimeout: any; titleInput.addEventListener("input", () => { clearTimeout(titleTimeout); titleTimeout = setTimeout(() => { if (isDemo) { this.demoUpdateNoteField(note.id, "title", titleInput.value); } else { this.updateNoteField(note.id, "title", titleInput.value); } }, 500); }); } // Wire up toolbar this.attachToolbarListeners(); } private destroyEditor() { if (this.editorUpdateTimer) { clearTimeout(this.editorUpdateTimer); this.editorUpdateTimer = null; } if (this.editor) { this.editor.destroy(); this.editor = null; } this.editorNoteId = null; } private renderToolbar(): string { return `
`; } private attachToolbarListeners() { const toolbar = this.shadow.getElementById('editor-toolbar'); if (!toolbar || !this.editor) return; // Button clicks via event delegation toolbar.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement; if (!btn || btn.tagName === 'SELECT') return; e.preventDefault(); const cmd = btn.dataset.cmd; if (!this.editor) return; switch (cmd) { case 'bold': this.editor.chain().focus().toggleBold().run(); break; case 'italic': this.editor.chain().focus().toggleItalic().run(); break; case 'underline': this.editor.chain().focus().toggleUnderline().run(); break; case 'strike': this.editor.chain().focus().toggleStrike().run(); break; case 'code': this.editor.chain().focus().toggleCode().run(); break; case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break; case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break; case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break; case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break; case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break; case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break; case 'link': { const url = prompt('Link URL:'); if (url) this.editor.chain().focus().setLink({ href: url }).run(); break; } case 'image': { const url = prompt('Image URL:'); if (url) this.editor.chain().focus().setImage({ src: url }).run(); break; } case 'undo': this.editor.chain().focus().undo().run(); break; case 'redo': this.editor.chain().focus().redo().run(); break; } }); // Heading select const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; if (headingSelect) { headingSelect.addEventListener('change', () => { if (!this.editor) return; const val = headingSelect.value; if (val === 'paragraph') { this.editor.chain().focus().setParagraph().run(); } else { this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run(); } }); } } private updateToolbarState() { if (!this.editor) return; const toolbar = this.shadow.getElementById('editor-toolbar'); if (!toolbar) return; // Toggle active class on buttons toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => { const cmd = (btn as HTMLElement).dataset.cmd!; let isActive = false; switch (cmd) { case 'bold': isActive = this.editor!.isActive('bold'); break; case 'italic': isActive = this.editor!.isActive('italic'); break; case 'underline': isActive = this.editor!.isActive('underline'); break; case 'strike': isActive = this.editor!.isActive('strike'); break; case 'code': isActive = this.editor!.isActive('code'); break; case 'bulletList': isActive = this.editor!.isActive('bulletList'); break; case 'orderedList': isActive = this.editor!.isActive('orderedList'); break; case 'taskList': isActive = this.editor!.isActive('taskList'); break; case 'blockquote': isActive = this.editor!.isActive('blockquote'); break; case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break; } btn.classList.toggle('active', isActive); }); // Update heading select const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; if (headingSelect) { if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1'; else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2'; else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3'; else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4'; else headingSelect.value = 'paragraph'; } } // ── Helpers ── private getNoteIcon(type: string): string { switch (type) { case "NOTE": return "\u{1F4DD}"; case "CODE": return "\u{1F4BB}"; case "BOOKMARK": return "\u{1F517}"; case "IMAGE": return "\u{1F5BC}"; case "AUDIO": return "\u{1F3A4}"; case "FILE": return "\u{1F4CE}"; case "CLIP": return "\u2702\uFE0F"; default: return "\u{1F4C4}"; } } private formatDate(dateStr: string): string { const d = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } // ── Rendering ── private render() { this.renderNav(); if (this.view === 'note' && this.editor && this.editorNoteId) { // Editor already mounted — don't touch contentZone } else { this.renderContent(); } this.renderMeta(); this.attachListeners(); } private renderNav() { const isDemo = this.space === "demo"; if (this.view === "note" && this.selectedNote) { // Nav is handled by mountEditor's content, or we just show back button this.navZone.innerHTML = `
`; // Re-attach back listener this.navZone.querySelectorAll('[data-back]').forEach((el) => { el.addEventListener('click', (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; this.destroyEditor(); if (target === 'notebooks') { this.view = 'notebooks'; if (!isDemo) this.unsubscribeNotebook(); this.selectedNotebook = null; this.selectedNote = null; this.render(); } else if (target === 'notebook') { this.view = 'notebook'; this.selectedNote = null; this.render(); } }); }); return; } if (this.view === "notebook" && this.selectedNotebook) { const nb = this.selectedNotebook; const syncBadge = this.subscribedDocId ? `` : ""; this.navZone.innerHTML = `
${this.esc(nb.title)}${syncBadge}
`; return; } // Notebooks view this.navZone.innerHTML = `
Notebooks
`; } private renderContent() { if (this.error) { this.contentZone.innerHTML = `
${this.esc(this.error)}
`; return; } if (this.loading) { this.contentZone.innerHTML = '
Loading...
'; return; } if (this.view === "notebook" && this.selectedNotebook) { const nb = this.selectedNotebook; this.contentZone.innerHTML = nb.notes && nb.notes.length > 0 ? nb.notes.map((n) => this.renderNoteItem(n)).join("") : '
No notes in this notebook.
'; return; } // Notebooks view let html = ''; if (this.searchQuery && this.searchResults.length > 0) { html += `
${this.searchResults.length} results for "${this.esc(this.searchQuery)}"
`; html += this.searchResults.map((n) => this.renderNoteItem(n)).join(""); } if (!this.searchQuery) { html += `
${this.notebooks.map((nb) => `
${this.esc(nb.title)}
${this.esc(nb.description || "")}
${nb.note_count} notes · ${this.formatDate(nb.updated_at)}
`).join("")}
`; if (this.notebooks.length === 0) { html += '
No notebooks yet. Create one to get started.
'; } } this.contentZone.innerHTML = html; } private renderMeta() { if (this.view === "note" && this.selectedNote) { const n = this.selectedNote; const isAutomerge = !!(this.doc?.items?.[n.id]); const isDemo = this.space === "demo"; this.metaZone.innerHTML = `
Type: ${n.type} Created: ${this.formatDate(n.created_at)} Updated: ${this.formatDate(n.updated_at)} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} ${isAutomerge ? 'Live' : ""} ${isDemo ? 'Demo' : ""}
`; } else { this.metaZone.innerHTML = ''; } } private renderNoteItem(n: Note): string { return `
${this.getNoteIcon(n.type)}
${n.is_pinned ? '📌 ' : ""}${this.esc(n.title)}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} ${n.type} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
`; } private attachListeners() { const isDemo = this.space === "demo"; // Create notebook this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { isDemo ? this.demoCreateNotebook() : this.createNotebook(); }); // Create note this.shadow.getElementById("create-note")?.addEventListener("click", () => { isDemo ? this.demoCreateNote() : this.createNoteViaSync(); }); // Search const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; let searchTimeout: any; searchInput?.addEventListener("input", () => { clearTimeout(searchTimeout); this.searchQuery = searchInput.value; searchTimeout = setTimeout(() => { isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery); }, 300); }); // Notebook cards this.shadow.querySelectorAll("[data-notebook]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.notebook!; this.view = "notebook"; isDemo ? this.demoLoadNotebook(id) : this.loadNotebook(id); }); }); // Note items this.shadow.querySelectorAll("[data-note]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.note!; this.view = "note"; isDemo ? this.demoLoadNote(id) : this.loadNote(id); }); }); // Back buttons (for notebook view) this.navZone.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; if (target === "notebooks") { this.destroyEditor(); this.view = "notebooks"; if (!isDemo) this.unsubscribeNotebook(); this.selectedNotebook = null; this.selectedNote = null; this.render(); } else if (target === "notebook") { this.destroyEditor(); this.view = "notebook"; this.selectedNote = null; this.render(); } }); }); } private demoUpdateNoteField(noteId: string, field: string, value: string) { if (this.selectedNote && this.selectedNote.id === noteId) { (this.selectedNote as any)[field] = value; this.selectedNote.updated_at = new Date().toISOString(); } for (const nb of this.demoNotebooks) { const note = nb.notes.find(n => n.id === noteId); if (note) { (note as any)[field] = value; note.updated_at = new Date().toISOString(); break; } } if (this.selectedNotebook?.notes) { const note = this.selectedNotebook.notes.find(n => n.id === noteId); if (note) { (note as any)[field] = value; note.updated_at = new Date().toISOString(); } } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } private getStyles(): string { return ` :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; } * { box-sizing: border-box; } .rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; } .rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; } .rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); } .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; } .rapp-nav__btn:hover { background: #6366f1; } .search-bar { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #444; background: #2a2a3e; color: #e0e0e0; font-size: 14px; margin-bottom: 16px; } .search-bar:focus { border-color: #6366f1; outline: none; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } .notebook-card { border-radius: 10px; padding: 16px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s; min-height: 120px; display: flex; flex-direction: column; justify-content: space-between; } .notebook-card:hover { border-color: rgba(255,255,255,0.2); } .notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; } .notebook-meta { font-size: 12px; opacity: 0.7; } .note-item { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; display: flex; gap: 12px; align-items: flex-start; } .note-item:hover { border-color: #555; } .note-icon { font-size: 20px; flex-shrink: 0; } .note-body { flex: 1; min-width: 0; } .note-title { font-size: 14px; font-weight: 600; } .note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; } .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; } .pinned { color: #f59e0b; } .editable-title { background: transparent; border: none; color: #e2e8f0; font-family: inherit; font-size: 22px; font-weight: 700; width: 100%; outline: none; padding: 8px 0; margin-bottom: 4px; } .editable-title:focus { border-bottom: 2px solid #6366f1; } .editable-title::placeholder { color: #555; } .sync-badge { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 8px; vertical-align: middle; } .sync-badge.connected { background: #10b981; } .sync-badge.disconnected { background: #ef4444; } .empty { text-align: center; color: #666; padding: 40px; } .loading { text-align: center; color: #888; padding: 40px; } .error { text-align: center; color: #ef5350; padding: 20px; } .note-meta-bar { margin-top: 12px; font-size: 12px; color: #666; display: flex; gap: 12px; padding: 8px 0; } /* ── Editor Toolbar ── */ .editor-toolbar { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 4px 6px; margin-bottom: 2px; } .toolbar-group { display: flex; gap: 1px; } .toolbar-sep { width: 1px; height: 20px; background: #1e293b; margin: 0 4px; } .toolbar-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 28px; border: none; border-radius: 4px; background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; font-family: inherit; transition: all 0.15s; } .toolbar-btn:hover { background: #1e293b; color: #e2e8f0; } .toolbar-btn.active { background: #312e81; color: #a5b4fc; } .toolbar-select { padding: 2px 4px; border-radius: 4px; border: 1px solid #1e293b; background: #0f172a; color: #94a3b8; font-size: 12px; cursor: pointer; font-family: inherit; } .toolbar-select:focus { outline: none; border-color: #4f46e5; } /* ── Tiptap Editor ── */ .editor-wrapper { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; overflow: hidden; } .editor-wrapper .editable-title { padding: 16px 20px 0; } .editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; } .tiptap-container .tiptap { min-height: 300px; padding: 16px 20px; outline: none; font-size: 15px; line-height: 1.7; color: #e0e0e0; } .tiptap-container .tiptap:focus { outline: none; } /* Prose styles */ .tiptap-container .tiptap h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; color: #f1f5f9; } .tiptap-container .tiptap h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.3em; color: #e2e8f0; } .tiptap-container .tiptap h3 { font-size: 1.15em; font-weight: 600; margin: 0.7em 0 0.25em; color: #cbd5e1; } .tiptap-container .tiptap h4 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; color: #94a3b8; } .tiptap-container .tiptap p { margin: 0.4em 0; } .tiptap-container .tiptap blockquote { border-left: 3px solid #4f46e5; padding-left: 16px; margin: 0.8em 0; color: #94a3b8; font-style: italic; } .tiptap-container .tiptap code { background: #2a2a3e; padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9em; color: #a5b4fc; } .tiptap-container .tiptap pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 12px 16px; margin: 0.8em 0; overflow-x: auto; } .tiptap-container .tiptap pre code { background: none; padding: 0; border-radius: 0; color: #e0e0e0; font-size: 13px; line-height: 1.5; } .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.4em 0; } .tiptap-container .tiptap li { margin: 0.15em 0; } .tiptap-container .tiptap li p { margin: 0.1em 0; } /* Task list */ .tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; } .tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; } .tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; } .tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p { text-decoration: line-through; color: #666; } .tiptap-container .tiptap img { max-width: 100%; border-radius: 8px; margin: 0.5em 0; } .tiptap-container .tiptap a { color: #818cf8; text-decoration: underline; text-underline-offset: 2px; } .tiptap-container .tiptap a:hover { color: #a5b4fc; } .tiptap-container .tiptap hr { border: none; border-top: 1px solid #333; margin: 1.5em 0; } .tiptap-container .tiptap strong { color: #f1f5f9; } .tiptap-container .tiptap em { color: inherit; } .tiptap-container .tiptap s { color: #666; } .tiptap-container .tiptap u { text-underline-offset: 3px; } /* Placeholder */ .tiptap-container .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; color: #555; pointer-events: none; height: 0; } /* ── Slash Menu ── */ .slash-menu { position: absolute; z-index: 100; background: #1e1e2e; border: 1px solid #333; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); max-height: 320px; overflow-y: auto; min-width: 220px; display: none; } .slash-menu-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; transition: background 0.1s; } .slash-menu-item:hover, .slash-menu-item.selected { background: #312e81; } .slash-menu-icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: #2a2a3e; border-radius: 4px; font-size: 13px; font-weight: 600; color: #a5b4fc; flex-shrink: 0; } .slash-menu-text { flex: 1; } .slash-menu-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } .slash-menu-desc { font-size: 11px; color: #666; } /* ── Code highlighting (lowlight) ── */ .tiptap-container .tiptap .hljs-keyword { color: #c792ea; } .tiptap-container .tiptap .hljs-string { color: #c3e88d; } .tiptap-container .tiptap .hljs-number { color: #f78c6c; } .tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; } .tiptap-container .tiptap .hljs-built_in { color: #82aaff; } .tiptap-container .tiptap .hljs-function { color: #82aaff; } .tiptap-container .tiptap .hljs-title { color: #82aaff; } .tiptap-container .tiptap .hljs-attr { color: #ffcb6b; } .tiptap-container .tiptap .hljs-tag { color: #f07178; } .tiptap-container .tiptap .hljs-type { color: #ffcb6b; } `; } } customElements.define("folk-notes-app", FolkNotesApp);