feat(web): implement complete workspace with themes, tabs, sidebar, and mobile
Transform CalcText from a single-document calculator into a full workspace application with multi-document support, theming, and responsive mobile experience. - Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors - Document model with localStorage persistence and auto-save - Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close - Sidebar with search, recent, favorites, folders, templates, drag-and-drop - 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator - Status bar with cursor position, engine status, dedication to Igor Cassel - Results panel: type-specific colors, click-to-copy, error hints - Format toolbar: H, B, I, //, color labels with live preview toggle - Syntax highlighting using theme CSS variables - Error hover tooltips - Mobile: bottom results tray, sidebar drawer, touch targets, safe areas - Docker multi-stage build (Rust WASM + Vite + Nginx) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
calcpad-web/src/components/FormatToolbar.tsx
Normal file
148
calcpad-web/src/components/FormatToolbar.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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'}
|
||||
>
|
||||
👁
|
||||
</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 (toggle // prefix)"
|
||||
>
|
||||
//
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user