Editor overhaul: Bear/Obsidian-style live preview
- Headers keep enlarged font size on active line (# marker shown in subtle gray) - Unordered list "-" rendered as "•" bullet widget - Clickable checkbox widgets for "- [ ]" / "- [x]" task lists - Nested list vertical guide lines for indented items - Improved typography: 15px base, 1.7 line-height, spacious 40px padding - Softer active line highlight, thicker cursor - Mock data enriched with checkboxes and nested lists for testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<Decoration>[] = []
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<string, (args: any) => 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<T>(cmd: string, args?: any): Promise<T> {
|
||||
|
||||
Reference in New Issue
Block a user