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:
lucaronin
2026-02-15 10:20:05 +01:00
parent ba511e4011
commit 2a841df5ac
3 changed files with 244 additions and 49 deletions

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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> {