Phase 1 - Bug fixes:
- Fix color labels not showing on active line in format preview
- Replace eye emoji with SVG icon showing clear preview/raw state
- Replace // button with comment icon + better tooltip
- Fix ThemePicker accent colors when using system theme
Phase 2 - Font:
- Load JetBrains Mono via Google Fonts with offline fallback
- Add font size control (A-/A+) with keyboard shortcuts
- Persist font size preference in localStorage
Phase 3 - Auth:
- Supabase-based email/password authentication
- Device session management with configurable password renewal TTL
- AuthModal, UserMenu, SecuritySettings components
Phase 4 - Cloud sync:
- Document metadata sync to Supabase PostgreSQL
- Legacy localStorage migration on first login
- IndexedDB persistence via y-indexeddb
Phase 5 - Real-time collaboration:
- Y.js CRDT integration with CodeMirror 6
- Hocuspocus WebSocket server with JWT auth
- Collaborative cursor awareness
- CollabIndicator component
Phase 6 - Sharing:
- Share links with view/edit permissions
- ShareDialog component with copy-to-clipboard
- Minimal client-side router for /s/{token} URLs
Infrastructure:
- Docker Compose with PostgreSQL, GoTrue, PostgREST, Hocuspocus
- Nginx reverse proxy for all backend services
- SQL migrations with RLS policies
- Production-ready Dockerfile with build args
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
/**
|
|
* Formatting toolbar for the editor.
|
|
* Inserts/toggles markdown-like syntax in the CodeMirror editor.
|
|
*/
|
|
|
|
import type { EditorView } from '@codemirror/view'
|
|
import '../styles/format-toolbar.css'
|
|
|
|
interface FormatToolbarProps {
|
|
editorView: EditorView | null
|
|
previewMode: boolean
|
|
onPreviewToggle: () => void
|
|
}
|
|
|
|
function insertPrefix(view: EditorView, prefix: string) {
|
|
const { state } = view
|
|
const line = state.doc.lineAt(state.selection.main.head)
|
|
const lineText = line.text
|
|
|
|
if (lineText.startsWith(prefix)) {
|
|
// Remove prefix
|
|
view.dispatch({
|
|
changes: { from: line.from, to: line.from + prefix.length, insert: '' },
|
|
})
|
|
} else {
|
|
// Add prefix
|
|
view.dispatch({
|
|
changes: { from: line.from, insert: prefix },
|
|
})
|
|
}
|
|
view.focus()
|
|
}
|
|
|
|
function wrapSelection(view: EditorView, wrapper: string) {
|
|
const { state } = view
|
|
const sel = state.selection.main
|
|
const selected = state.sliceDoc(sel.from, sel.to)
|
|
|
|
if (selected.length === 0) {
|
|
// No selection — insert wrapper pair and place cursor inside
|
|
const text = `${wrapper}text${wrapper}`
|
|
view.dispatch({
|
|
changes: { from: sel.from, insert: text },
|
|
selection: { anchor: sel.from + wrapper.length, head: sel.from + wrapper.length + 4 },
|
|
})
|
|
} else if (selected.startsWith(wrapper) && selected.endsWith(wrapper)) {
|
|
// Already wrapped — unwrap
|
|
const inner = selected.slice(wrapper.length, -wrapper.length)
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert: inner },
|
|
selection: { anchor: sel.from, head: sel.from + inner.length },
|
|
})
|
|
} else {
|
|
// Wrap selection
|
|
const text = `${wrapper}${selected}${wrapper}`
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert: text },
|
|
selection: { anchor: sel.from, head: sel.from + text.length },
|
|
})
|
|
}
|
|
view.focus()
|
|
}
|
|
|
|
function insertColor(view: EditorView, color: string) {
|
|
const { state } = view
|
|
const sel = state.selection.main
|
|
const selected = state.sliceDoc(sel.from, sel.to)
|
|
const label = selected.length > 0 ? selected : 'label'
|
|
const text = `[${color}:${label}]`
|
|
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert: text },
|
|
selection: { anchor: sel.from, head: sel.from + text.length },
|
|
})
|
|
view.focus()
|
|
}
|
|
|
|
const COLORS = [
|
|
{ name: 'Red', value: '#ef4444' },
|
|
{ name: 'Orange', value: '#f97316' },
|
|
{ name: 'Yellow', value: '#eab308' },
|
|
{ name: 'Green', value: '#22c55e' },
|
|
{ name: 'Blue', value: '#3b82f6' },
|
|
{ name: 'Purple', value: '#a855f7' },
|
|
]
|
|
|
|
export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: FormatToolbarProps) {
|
|
return (
|
|
<div className="format-toolbar">
|
|
<div className="format-group">
|
|
<button
|
|
className={`format-btn format-preview-toggle ${previewMode ? 'active' : ''}`}
|
|
onClick={onPreviewToggle}
|
|
title={previewMode ? 'Show raw markdown' : 'Show formatted preview'}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
{!previewMode && <line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" />}
|
|
</svg>
|
|
<span className="format-preview-label">{previewMode ? 'Preview' : 'Raw'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="format-separator" />
|
|
|
|
<div className="format-group">
|
|
<button
|
|
className="format-btn"
|
|
onClick={() => editorView && insertPrefix(editorView, '# ')}
|
|
title="Heading (toggle # prefix)"
|
|
>
|
|
<strong>H</strong>
|
|
</button>
|
|
<button
|
|
className="format-btn"
|
|
onClick={() => editorView && wrapSelection(editorView, '**')}
|
|
title="Bold (**text**)"
|
|
>
|
|
<strong>B</strong>
|
|
</button>
|
|
<button
|
|
className="format-btn format-italic"
|
|
onClick={() => editorView && wrapSelection(editorView, '*')}
|
|
title="Italic (*text*)"
|
|
>
|
|
<em>I</em>
|
|
</button>
|
|
<button
|
|
className="format-btn"
|
|
onClick={() => editorView && insertPrefix(editorView, '// ')}
|
|
title="Comment — line won't be calculated"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
<line x1="9" y1="10" x2="15" y2="10" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="format-separator" />
|
|
|
|
<div className="format-group format-colors">
|
|
{COLORS.map(c => (
|
|
<button
|
|
key={c.name}
|
|
className="format-color-btn"
|
|
style={{ backgroundColor: c.value }}
|
|
onClick={() => editorView && insertColor(editorView, c.name.toLowerCase())}
|
|
title={`${c.name} label`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|