Files
calctext/calcpad-web/src/components/FormatToolbar.tsx
C. Cassel ef302ebda9 feat: add auth, real-time collaboration, sharing, font control, and UI fixes
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>
2026-03-19 16:21:04 -04:00

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>
)
}