diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a0769ebe..84b06046 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -27,15 +27,20 @@ interface EditorProps { const editorTheme = EditorView.theme({ '&': { height: '100%', - fontSize: '14px', + fontSize: '15px', }, '.cm-scroller': { - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - padding: '16px 0', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', + padding: '20px 0', + lineHeight: '1.7', }, '.cm-content': { - padding: '0 24px', - maxWidth: '800px', + padding: '0 40px', + maxWidth: '760px', + caretColor: '#e0e0e0', + }, + '.cm-line': { + padding: '1px 0', }, '.cm-gutters': { background: '#0f0f1a', @@ -46,10 +51,11 @@ const editorTheme = EditorView.theme({ background: '#1a1a2e', }, '.cm-activeLine': { - background: '#1a1a2e', + background: 'rgba(255, 255, 255, 0.02)', }, '.cm-cursor': { borderLeftColor: '#e0e0e0', + borderLeftWidth: '1.5px', }, '.cm-selectionBackground': { background: '#2a2a5a !important', diff --git a/src/components/livePreview.ts b/src/components/livePreview.ts index 84eac3e3..04d5ef0c 100644 --- a/src/components/livePreview.ts +++ b/src/components/livePreview.ts @@ -8,6 +8,7 @@ import { syntaxTree } from '@codemirror/language' /** * Live preview plugin — Obsidian/Bear-style markdown editing. * Hides syntax markers when cursor is NOT on a line, reveals them when it IS. + * Headers ALWAYS keep their enlarged size, even on the active line. */ function isOnCursorLine(view: EditorView, from: number, to: number): boolean { @@ -28,19 +29,66 @@ class HrWidget extends WidgetType { } } +class BulletWidget extends WidgetType { + toDOM() { + const span = document.createElement('span') + span.className = 'cm-live-bullet' + span.textContent = '•' + return span + } +} + +class CheckboxWidget extends WidgetType { + checked: boolean + pos: number + + constructor(checked: boolean, pos: number) { + super() + this.checked = checked + this.pos = pos + } + + eq(other: CheckboxWidget) { + return this.checked === other.checked && this.pos === other.pos + } + + toDOM(view: EditorView) { + const wrapper = document.createElement('span') + wrapper.className = 'cm-live-checkbox-wrap' + + const cb = document.createElement('span') + cb.className = this.checked ? 'cm-live-checkbox cm-live-checkbox-checked' : 'cm-live-checkbox' + if (this.checked) { + cb.textContent = '✓' + } + cb.addEventListener('mousedown', (e) => { + e.preventDefault() + const newText = this.checked ? '- [ ] ' : '- [x] ' + view.dispatch({ + changes: { from: this.pos, to: this.pos + 6, insert: newText }, + }) + }) + + wrapper.appendChild(cb) + return wrapper + } + + ignoreEvent() { return false } +} + function buildDecorations(view: EditorView): DecorationSet { const decs: Range[] = [] - const tree = syntaxTree(view.state) + const doc = view.state.doc tree.iterate({ enter: (node) => { const { from, to, name } = node + const onCursor = isOnCursorLine(view, from, to) - // Skip nodes on cursor line — reveal raw syntax there - if (isOnCursorLine(view, from, to)) return - - // ATX Headings: hide "## " marker, style content + // ATX Headings: ALWAYS apply size styling, even on cursor line + // On cursor line: show "#" markers in subtle color + // Off cursor line: hide "#" markers entirely if (/^ATXHeading[1-6]$/.test(name)) { const level = parseInt(name.charAt(name.length - 1)) let markerEnd = from @@ -53,17 +101,30 @@ function buildDecorations(view: EditorView): DecorationSet { } while (cursor.nextSibling()) } if (markerEnd > from) { - const afterMarker = view.state.doc.sliceString(markerEnd, Math.min(markerEnd + 1, to)) - const hideEnd = afterMarker === ' ' ? markerEnd + 1 : markerEnd - decs.push(Decoration.replace({}).range(from, hideEnd)) - decs.push(Decoration.mark({ class: `cm-live-heading cm-live-heading-${level}` }).range(hideEnd, to)) + const afterMarker = doc.sliceString(markerEnd, Math.min(markerEnd + 1, to)) + const contentStart = afterMarker === ' ' ? markerEnd + 1 : markerEnd + + if (onCursor) { + // Active line: show markers in subtle color, keep heading size on whole line + decs.push(Decoration.mark({ class: `cm-live-heading-marker cm-live-heading-marker-${level}` }).range(from, markerEnd)) + if (contentStart < to) { + decs.push(Decoration.mark({ class: `cm-live-heading cm-live-heading-${level}` }).range(contentStart, to)) + } + } else { + // Inactive line: hide markers, style content + decs.push(Decoration.replace({}).range(from, contentStart)) + decs.push(Decoration.mark({ class: `cm-live-heading cm-live-heading-${level}` }).range(contentStart, to)) + } } return false } + // Skip remaining nodes on cursor line — reveal raw syntax there + if (onCursor) return + // Bold: **text** or __text__ if (name === 'StrongEmphasis') { - const text = view.state.doc.sliceString(from, to) + const text = doc.sliceString(from, to) const marker = text.startsWith('**') ? '**' : text.startsWith('__') ? '__' : null if (marker && text.endsWith(marker) && to - from > marker.length * 2) { decs.push(Decoration.replace({}).range(from, from + marker.length)) @@ -75,7 +136,7 @@ function buildDecorations(view: EditorView): DecorationSet { // Italic: *text* or _text_ if (name === 'Emphasis') { - const text = view.state.doc.sliceString(from, to) + const text = doc.sliceString(from, to) if ((text.startsWith('*') && text.endsWith('*')) || (text.startsWith('_') && text.endsWith('_'))) { if (to - from > 2) { decs.push(Decoration.replace({}).range(from, from + 1)) @@ -116,7 +177,7 @@ function buildDecorations(view: EditorView): DecorationSet { // Inline code: `code` if (name === 'InlineCode') { - const text = view.state.doc.sliceString(from, to) + const text = doc.sliceString(from, to) if (text.startsWith('`') && text.endsWith('`') && to - from > 2) { decs.push(Decoration.replace({}).range(from, from + 1)) decs.push(Decoration.mark({ class: 'cm-live-code' }).range(from + 1, to - 1)) @@ -133,6 +194,50 @@ function buildDecorations(view: EditorView): DecorationSet { }, }) + // Second pass: line-based decorations for lists and checkboxes + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i) + const text = line.text + const stripped = text.replace(/^\s*/, '') + const indent = text.length - stripped.length + const lineOnCursor = isOnCursorLine(view, line.from, line.to) + + // Checkbox: "- [ ] " or "- [x] " (with optional leading whitespace) + const checkboxMatch = text.match(/^(\s*)- \[([ x])\] /) + if (checkboxMatch && !lineOnCursor) { + const checked = checkboxMatch[2] === 'x' + const markerStart = line.from + checkboxMatch[1].length + const markerEnd = markerStart + 6 // "- [x] " is 6 chars + decs.push( + Decoration.replace({ + widget: new CheckboxWidget(checked, markerStart), + }).range(markerStart, markerEnd) + ) + // Add nested guide line decoration + if (indent > 0) { + decs.push(Decoration.line({ class: 'cm-live-list-nested' }).range(line.from)) + } + continue + } + + // Unordered list bullet: "- " (with optional leading whitespace) + const bulletMatch = text.match(/^(\s*)- /) + if (bulletMatch && !lineOnCursor) { + const markerStart = line.from + bulletMatch[1].length + const markerEnd = markerStart + 2 // "- " is 2 chars + decs.push( + Decoration.replace({ + widget: new BulletWidget(), + }).range(markerStart, markerEnd) + ) + } + + // Add nested guide line for any indented list item (bullet or checkbox) + if (indent > 0 && /^\s+(- |\* |\d+\. )/.test(text)) { + decs.push(Decoration.line({ class: 'cm-live-list-nested' }).range(line.from)) + } + } + return Decoration.set(decs, true) } @@ -156,41 +261,33 @@ const livePreviewPlugin = ViewPlugin.fromClass( ) const livePreviewTheme = EditorView.theme({ + // Headings — content text '.cm-live-heading': { fontWeight: '700', color: '#e0e0e0', }, - '.cm-live-heading-1': { - fontSize: '1.8em', - lineHeight: '1.3', - }, - '.cm-live-heading-2': { - fontSize: '1.4em', - lineHeight: '1.3', - }, - '.cm-live-heading-3': { - fontSize: '1.2em', - lineHeight: '1.3', - }, - '.cm-live-heading-4': { - fontSize: '1.05em', - lineHeight: '1.3', - }, - '.cm-live-heading-5': { - fontSize: '1em', - lineHeight: '1.3', - }, - '.cm-live-heading-6': { - fontSize: '0.9em', - lineHeight: '1.3', - opacity: '0.8', - }, - '.cm-live-strong': { - fontWeight: '700', - }, - '.cm-live-em': { - fontStyle: 'italic', + '.cm-live-heading-1': { fontSize: '1.8em', lineHeight: '1.4' }, + '.cm-live-heading-2': { fontSize: '1.4em', lineHeight: '1.4' }, + '.cm-live-heading-3': { fontSize: '1.2em', lineHeight: '1.35' }, + '.cm-live-heading-4': { fontSize: '1.05em', lineHeight: '1.35' }, + '.cm-live-heading-5': { fontSize: '1em', lineHeight: '1.3' }, + '.cm-live-heading-6': { fontSize: '0.9em', lineHeight: '1.3', opacity: '0.8' }, + + // Heading markers (#) — visible on active line, subtle color + '.cm-live-heading-marker': { + color: '#555', + fontWeight: '300', }, + '.cm-live-heading-marker-1': { fontSize: '1.8em', lineHeight: '1.4' }, + '.cm-live-heading-marker-2': { fontSize: '1.4em', lineHeight: '1.4' }, + '.cm-live-heading-marker-3': { fontSize: '1.2em', lineHeight: '1.35' }, + '.cm-live-heading-marker-4': { fontSize: '1.05em', lineHeight: '1.35' }, + '.cm-live-heading-marker-5': { fontSize: '1em', lineHeight: '1.3' }, + '.cm-live-heading-marker-6': { fontSize: '0.9em', lineHeight: '1.3' }, + + // Inline formatting + '.cm-live-strong': { fontWeight: '700' }, + '.cm-live-em': { fontStyle: 'italic' }, '.cm-live-link': { color: '#4a9eff', textDecoration: 'underline', @@ -203,11 +300,58 @@ const livePreviewTheme = EditorView.theme({ padding: '1px 4px', borderRadius: '3px', }, + + // Horizontal rule '.cm-live-hr': { border: 'none', borderTop: '1px solid #3a3a5a', margin: '8px 0', }, + + // Bullet widget + '.cm-live-bullet': { + color: '#888', + fontSize: '0.85em', + marginRight: '4px', + }, + + // Checkbox widget + '.cm-live-checkbox-wrap': { + display: 'inline-flex', + alignItems: 'center', + marginRight: '4px', + verticalAlign: 'middle', + }, + '.cm-live-checkbox': { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '16px', + height: '16px', + border: '1.5px solid #555', + borderRadius: '3px', + background: 'transparent', + cursor: 'pointer', + verticalAlign: 'middle', + fontSize: '11px', + lineHeight: '1', + color: 'transparent', + userSelect: 'none', + }, + '.cm-live-checkbox:hover': { + borderColor: '#4a9eff', + }, + '.cm-live-checkbox-checked': { + background: '#4a9eff', + borderColor: '#4a9eff', + color: '#fff', + }, + + // Nested list guide line + '.cm-live-list-nested': { + borderLeft: '1.5px solid #2a2a4a', + marginLeft: '8px', + }, }) export function livePreview(): Extension { diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index dd00b489..d07b011d 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -133,6 +133,23 @@ belongs_to: - One clear takeaway - Use *real examples* from personal experience - Include actionable advice, not just theory + +### Checklist +- [ ] Pick a topic from the backlog +- [ ] Write outline with 3-5 sections +- [x] Set up newsletter template +- [x] Configure email scheduling +- [ ] Review analytics from last issue + +### Nested Topics +- Content strategy + - Newsletter growth + - Organic subscribers + - Paid acquisition + - Social media cross-posting +- Technical writing + - Code examples + - Architecture diagrams `, '/Users/luca/Laputa/procedure/run-sponsorships.md': `--- title: Run Sponsorships @@ -332,6 +349,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Luca Rossi', cadence: null, modifiedAt: Date.now() / 1000, + createdAt: null, fileSize: 2048, }, { @@ -346,6 +364,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Luca Rossi', cadence: null, modifiedAt: Date.now() / 1000 - 3600, + createdAt: null, fileSize: 1024, }, { @@ -360,6 +379,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Matteo Cellini', cadence: null, modifiedAt: Date.now() / 1000 - 7200, + createdAt: null, fileSize: 890, }, { @@ -374,6 +394,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Luca Rossi', cadence: 'Weekly', modifiedAt: Date.now() / 1000 - 86400, + createdAt: null, fileSize: 512, }, { @@ -388,6 +409,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Matteo Cellini', cadence: 'Weekly', modifiedAt: Date.now() / 1000 - 86400 * 2, + createdAt: null, fileSize: 640, }, { @@ -402,6 +424,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: 'Luca Rossi', cadence: null, modifiedAt: Date.now() / 1000 - 86400, + createdAt: null, fileSize: 3200, }, { @@ -416,6 +439,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 3600 * 5, + createdAt: null, fileSize: 847, }, { @@ -430,6 +454,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 86400, + createdAt: null, fileSize: 560, }, { @@ -444,6 +469,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 86400 * 7, + createdAt: null, fileSize: 320, }, { @@ -458,6 +484,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 3600 * 2, + createdAt: null, fileSize: 1200, }, { @@ -472,6 +499,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 86400 * 30, + createdAt: null, fileSize: 256, }, { @@ -486,6 +514,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ owner: null, cadence: null, modifiedAt: Date.now() / 1000 - 86400 * 14, + createdAt: null, fileSize: 180, }, ] @@ -529,12 +558,28 @@ const mockHandlers: Record any> = { } export function isTauri(): boolean { - return typeof window !== 'undefined' && '__TAURI__' in window + return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) +} + +// Initialize window.__mockContent for browser testing +if (typeof window !== 'undefined') { + window.__mockContent = MOCK_CONTENT } /** Register content for a new entry in mock mode (for get_note_content calls) */ export function addMockEntry(_entry: VaultEntry, content: string) { MOCK_CONTENT[_entry.path] = content + if (typeof window !== 'undefined') { + window.__mockContent = MOCK_CONTENT + } +} + +/** Update content for an existing entry in mock mode */ +export function updateMockContent(path: string, content: string) { + MOCK_CONTENT[path] = content + if (typeof window !== 'undefined') { + window.__mockContent = MOCK_CONTENT + } } export async function mockInvoke(cmd: string, args?: any): Promise {