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:
@@ -10,7 +10,18 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
||||
<title>CalcPad</title>
|
||||
<title>CalcText</title>
|
||||
<script>
|
||||
// Apply theme before React mounts to prevent FOUC
|
||||
(function() {
|
||||
var t = 'system';
|
||||
try { t = localStorage.getItem('calctext-theme') || 'system'; } catch(e) {}
|
||||
if (t === 'system') {
|
||||
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,80 +1,338 @@
|
||||
/**
|
||||
* CalcPad main application component.
|
||||
* CalcText main application component.
|
||||
*
|
||||
* Two-column layout:
|
||||
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
|
||||
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
|
||||
*
|
||||
* The WASM engine runs in a Web Worker. On each document change (debounced),
|
||||
* the editor sends lines to the worker, which evaluates them and posts back
|
||||
* results. Results are fed into the CodeMirror answer gutter extension.
|
||||
* Workspace layout: header → tab bar → editor + results panel.
|
||||
* Multi-document support via document store with localStorage persistence.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState, useRef, useEffect } from 'react'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { CalcEditor } from './editor/CalcEditor.tsx'
|
||||
import { useEngine } from './engine/useEngine.ts'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
|
||||
import { useTheme } from './hooks/useTheme.ts'
|
||||
import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
|
||||
import { OfflineBanner } from './components/OfflineBanner.tsx'
|
||||
import { InstallPrompt } from './components/InstallPrompt.tsx'
|
||||
import { ResultsPanel } from './components/ResultsPanel.tsx'
|
||||
import { ThemePicker } from './components/ThemePicker.tsx'
|
||||
import { TabBar } from './components/TabBar.tsx'
|
||||
import { Sidebar } from './components/Sidebar.tsx'
|
||||
import { StatusBar } from './components/StatusBar.tsx'
|
||||
import { AlignToolbar } from './components/AlignToolbar.tsx'
|
||||
import type { Alignment } from './components/AlignToolbar.tsx'
|
||||
import { FormatToolbar } from './components/FormatToolbar.tsx'
|
||||
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
|
||||
import './styles/app.css'
|
||||
|
||||
const INITIAL_DOC = `# CalcPad
|
||||
|
||||
// Basic arithmetic
|
||||
2 + 3
|
||||
10 * 4.5
|
||||
100 / 7
|
||||
|
||||
// Variables
|
||||
price = 49.99
|
||||
quantity = 3
|
||||
subtotal = price * quantity
|
||||
|
||||
// Percentages
|
||||
tax = subtotal * 8%
|
||||
total = subtotal + tax
|
||||
|
||||
// Functions
|
||||
sqrt(144)
|
||||
2 ^ 10
|
||||
`
|
||||
|
||||
function App() {
|
||||
const engine = useEngine()
|
||||
const isOnline = useOnlineStatus()
|
||||
const installPrompt = useInstallPrompt()
|
||||
const themeCtx = useTheme()
|
||||
const store = useDocumentStore()
|
||||
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
||||
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
||||
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
|
||||
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
|
||||
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
|
||||
const [formatPreview, setFormatPreview] = useState(true)
|
||||
|
||||
// Sidebar state
|
||||
const [sidebarState, setSidebarState] = useState(loadSidebarState)
|
||||
const setSidebarVisible = useCallback((v: boolean) => {
|
||||
const next = { ...sidebarState, visible: v }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
const setSidebarWidth = useCallback((w: number) => {
|
||||
const next = { ...sidebarState, width: w }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
|
||||
// Track a key to force CalcEditor remount on tab switch
|
||||
const [editorKey, setEditorKey] = useState(store.activeTabId)
|
||||
|
||||
// Draggable divider state
|
||||
const [dividerX, setDividerX] = useState<number | null>(null)
|
||||
const isDragging = useRef(false)
|
||||
|
||||
const handleDocChange = useCallback(
|
||||
(lines: string[]) => {
|
||||
engine.evalSheet(lines)
|
||||
// Persist content
|
||||
const content = lines.join('\n')
|
||||
if (store.activeDoc && content !== store.activeDoc.content) {
|
||||
store.updateContent(store.activeTabId, content)
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
// Clear modified dot after save debounce
|
||||
setTimeout(() => {
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[engine.evalSheet],
|
||||
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
|
||||
)
|
||||
|
||||
// Switch tabs
|
||||
const handleTabClick = useCallback((id: string) => {
|
||||
if (id === store.activeTabId) return
|
||||
store.setActiveTab(id)
|
||||
setEditorKey(id)
|
||||
}, [store.activeTabId, store.setActiveTab])
|
||||
|
||||
// New document
|
||||
const handleNewTab = useCallback(() => {
|
||||
const doc = store.createDocument()
|
||||
setEditorKey(doc.id)
|
||||
}, [store.createDocument])
|
||||
|
||||
// Close tab
|
||||
const handleTabClose = useCallback((id: string) => {
|
||||
store.closeTab(id)
|
||||
// If we closed the active tab, editorKey needs updating
|
||||
if (id === store.activeTabId) {
|
||||
// State will update, trigger effect below
|
||||
}
|
||||
}, [store.closeTab, store.activeTabId])
|
||||
|
||||
// Sync editorKey when activeTabId changes externally (e.g. after close)
|
||||
useEffect(() => {
|
||||
if (editorKey !== store.activeTabId) {
|
||||
setEditorKey(store.activeTabId)
|
||||
}
|
||||
}, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Apply editor text alignment via CodeMirror
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
editorView.dom.style.setProperty('--cm-text-align', editorAlign)
|
||||
}, [editorView, editorAlign])
|
||||
|
||||
// Scroll sync: mirror editor scroll position to results panel
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
const scroller = editorView.scrollDOM
|
||||
const onScroll = () => {
|
||||
if (resultsPanelRef.current) {
|
||||
resultsPanelRef.current.scrollTop = scroller.scrollTop
|
||||
}
|
||||
}
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => scroller.removeEventListener('scroll', onScroll)
|
||||
}, [editorView])
|
||||
|
||||
// Draggable divider handlers
|
||||
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.current) return
|
||||
setDividerX(e.clientX)
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (mod && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
setSidebarVisible(!sidebarState.visible)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+N — new document
|
||||
if (mod && e.key === 'n') {
|
||||
e.preventDefault()
|
||||
handleNewTab()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+W — close tab
|
||||
if (mod && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleTabClose(store.activeTabId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
|
||||
if (mod && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const idx = store.openTabIds.indexOf(store.activeTabId)
|
||||
const len = store.openTabIds.length
|
||||
const next = e.shiftKey
|
||||
? store.openTabIds[(idx - 1 + len) % len]
|
||||
: store.openTabIds[(idx + 1) % len]
|
||||
handleTabClick(next)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+1-9 — jump to tab
|
||||
if (mod && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault()
|
||||
const tabIdx = parseInt(e.key) - 1
|
||||
if (tabIdx < store.openTabIds.length) {
|
||||
handleTabClick(store.openTabIds[tabIdx])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible])
|
||||
|
||||
// Compute flex styles from divider position
|
||||
const editorStyle: React.CSSProperties = dividerX !== null
|
||||
? { width: dividerX, flex: 'none' }
|
||||
: {}
|
||||
const resultsStyle: React.CSSProperties = dividerX !== null
|
||||
? { flex: 1 }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div className="calcpad-app">
|
||||
<OfflineBanner isOnline={isOnline} />
|
||||
|
||||
<header className="calcpad-header">
|
||||
<h1>CalcPad</h1>
|
||||
<p className="subtitle">Notepad Calculator</p>
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
|
||||
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
|
||||
<button
|
||||
className="header-sidebar-toggle"
|
||||
onClick={() => setSidebarVisible(!sidebarState.visible)}
|
||||
title="Toggle sidebar (Ctrl+B)"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<h1>CalcText</h1>
|
||||
<div className="header-spacer" />
|
||||
<div className="header-actions">
|
||||
<FormatToolbar
|
||||
editorView={editorView}
|
||||
previewMode={formatPreview}
|
||||
onPreviewToggle={() => setFormatPreview(p => !p)}
|
||||
/>
|
||||
<div className="header-divider" />
|
||||
<AlignToolbar
|
||||
editorAlign={editorAlign}
|
||||
resultsAlign={resultsAlign}
|
||||
onEditorAlignChange={setEditorAlign}
|
||||
onResultsAlignChange={setResultsAlign}
|
||||
/>
|
||||
<ThemePicker
|
||||
theme={themeCtx.theme}
|
||||
accentColor={themeCtx.accentColor}
|
||||
onThemeChange={themeCtx.setTheme}
|
||||
onAccentChange={themeCtx.setAccent}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane">
|
||||
<CalcEditor
|
||||
initialDoc={INITIAL_DOC}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
<TabBar
|
||||
tabs={store.openDocs}
|
||||
activeTabId={store.activeTabId}
|
||||
onTabClick={handleTabClick}
|
||||
onTabClose={handleTabClose}
|
||||
onTabRename={store.renameDocument}
|
||||
onNewTab={handleNewTab}
|
||||
modifiedIds={modifiedIds}
|
||||
/>
|
||||
|
||||
<div className="calcpad-workspace">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarState.visible && (
|
||||
<div
|
||||
className="sidebar-backdrop"
|
||||
onClick={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
<Sidebar
|
||||
visible={sidebarState.visible}
|
||||
width={sidebarState.width}
|
||||
documents={store.documents}
|
||||
folders={store.folders}
|
||||
activeTabId={store.activeTabId}
|
||||
openTabIds={store.openTabIds}
|
||||
onFileClick={(id) => { store.openDocument(id); setEditorKey(id) }}
|
||||
onNewDocument={(title, content) => {
|
||||
const doc = store.createDocument(title, content)
|
||||
setEditorKey(doc.id)
|
||||
}}
|
||||
onNewFolder={() => store.createFolder()}
|
||||
onRenameDocument={store.renameDocument}
|
||||
onDeleteDocument={store.deleteDocument}
|
||||
onToggleFavorite={store.toggleFavorite}
|
||||
onMoveToFolder={store.moveToFolder}
|
||||
onRenameFolder={store.renameFolder}
|
||||
onDeleteFolder={store.deleteFolder}
|
||||
onWidthChange={setSidebarWidth}
|
||||
/>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane" style={editorStyle}>
|
||||
<CalcEditor
|
||||
key={editorKey}
|
||||
initialDoc={store.activeDoc?.content ?? ''}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
onViewReady={setEditorView}
|
||||
formatPreview={formatPreview}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pane-divider"
|
||||
onMouseDown={onDividerMouseDown}
|
||||
/>
|
||||
<ResultsPanel
|
||||
ref={resultsPanelRef}
|
||||
results={engine.results}
|
||||
align={resultsAlign}
|
||||
style={resultsStyle}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MobileResultsTray
|
||||
results={engine.results}
|
||||
docLines={store.activeDoc?.content.split('\n') ?? []}
|
||||
/>
|
||||
|
||||
<StatusBar
|
||||
editorView={editorView}
|
||||
engineReady={engine.ready}
|
||||
lineCount={store.activeDoc?.content.split('\n').length ?? 0}
|
||||
/>
|
||||
|
||||
<InstallPrompt
|
||||
promptEvent={installPrompt.promptEvent}
|
||||
|
||||
84
calcpad-web/src/components/AlignToolbar.tsx
Normal file
84
calcpad-web/src/components/AlignToolbar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Toolbar with justification buttons for editor and results panes.
|
||||
* Matches the macOS app: left / center / right for each pane.
|
||||
*/
|
||||
|
||||
import '../styles/align-toolbar.css'
|
||||
|
||||
export type Alignment = 'left' | 'center' | 'right'
|
||||
|
||||
export interface AlignToolbarProps {
|
||||
editorAlign: Alignment
|
||||
resultsAlign: Alignment
|
||||
onEditorAlignChange: (align: Alignment) => void
|
||||
onResultsAlignChange: (align: Alignment) => void
|
||||
}
|
||||
|
||||
function AlignIcon({ type }: { type: 'left' | 'center' | 'right' }) {
|
||||
if (type === 'left') {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="1" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="1" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (type === 'center') {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="2" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="2" width="12" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="5" y="6" width="8" height="1.5" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="10" width="10" height="1.5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const alignments: Alignment[] = ['left', 'center', 'right']
|
||||
|
||||
export function AlignToolbar({
|
||||
editorAlign,
|
||||
resultsAlign,
|
||||
onEditorAlignChange,
|
||||
onResultsAlignChange,
|
||||
}: AlignToolbarProps) {
|
||||
return (
|
||||
<div className="align-toolbar">
|
||||
<div className="align-group">
|
||||
<span className="align-label">Editor</span>
|
||||
{alignments.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`align-btn${editorAlign === a ? ' active' : ''}`}
|
||||
onClick={() => onEditorAlignChange(a)}
|
||||
title={`Align editor ${a}`}
|
||||
>
|
||||
<AlignIcon type={a} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="align-group">
|
||||
<span className="align-label">Results</span>
|
||||
{alignments.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`align-btn${resultsAlign === a ? ' active' : ''}`}
|
||||
onClick={() => onResultsAlignChange(a)}
|
||||
title={`Align results ${a}`}
|
||||
>
|
||||
<AlignIcon type={a} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Right-side answer column that displays evaluation results
|
||||
* as a standalone panel (alternative to the gutter-based display).
|
||||
*
|
||||
* This component renders results in a scrollable column synced
|
||||
* to the editor's line height. Each line shows the display value
|
||||
* from the engine, color-coded by result type.
|
||||
*/
|
||||
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/answer-column.css'
|
||||
|
||||
export interface AnswerColumnProps {
|
||||
results: EngineLineResult[]
|
||||
}
|
||||
|
||||
function resultClassName(type: string): string {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'unitValue':
|
||||
case 'currencyValue':
|
||||
case 'dateTime':
|
||||
case 'timeDelta':
|
||||
case 'boolean':
|
||||
return 'answer-value'
|
||||
case 'error':
|
||||
return 'answer-error'
|
||||
default:
|
||||
return 'answer-empty'
|
||||
}
|
||||
}
|
||||
|
||||
export function AnswerColumn({ results }: AnswerColumnProps) {
|
||||
return (
|
||||
<div className="answer-column" aria-label="Calculation results" role="complementary">
|
||||
{results.map((result, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`answer-line ${resultClassName(result.type)}`}
|
||||
title={result.error ?? result.display}
|
||||
>
|
||||
{result.type === 'error'
|
||||
? 'Error'
|
||||
: result.display || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
105
calcpad-web/src/components/MobileResultsTray.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mobile results tray — replaces the side panel on small screens.
|
||||
* Collapsed: shows last result + drag handle (48px).
|
||||
* Expanded: scrollable list of all results (40vh).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/mobile-results-tray.css'
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set([
|
||||
'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
|
||||
])
|
||||
|
||||
interface MobileResultsTrayProps {
|
||||
results: EngineLineResult[]
|
||||
docLines: string[]
|
||||
}
|
||||
|
||||
export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
const startY = useRef<number | null>(null)
|
||||
|
||||
// Find last displayable result
|
||||
let lastResult = ''
|
||||
for (let i = results.length - 1; i >= 0; i--) {
|
||||
if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
|
||||
lastResult = results[i].display
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
|
||||
const text = rawValue != null ? String(rawValue) : display
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx)
|
||||
setTimeout(() => setCopiedIdx(null), 1200)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Touch swipe handling
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY
|
||||
}, [])
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (startY.current === null) return
|
||||
const deltaY = startY.current - e.changedTouches[0].clientY
|
||||
startY.current = null
|
||||
if (deltaY > 40) setExpanded(true) // swipe up
|
||||
if (deltaY < -40) setExpanded(false) // swipe down
|
||||
}, [])
|
||||
|
||||
// Build result items for expanded view
|
||||
const resultItems = results.map((r, i) => {
|
||||
if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
|
||||
const expr = docLines[i]?.trim() ?? ''
|
||||
const isCopied = copiedIdx === i
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
|
||||
onClick={() => handleCopy(i, r.rawValue, r.display)}
|
||||
>
|
||||
<span className="tray-result-line">Ln {i + 1}</span>
|
||||
<span className="tray-result-expr">{expr}</span>
|
||||
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
|
||||
</div>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Drag handle + collapsed view */}
|
||||
<div
|
||||
className="tray-header"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<div className="tray-drag-handle" />
|
||||
{!expanded && (
|
||||
<span className="tray-last-result">
|
||||
{lastResult ? `Last: ${lastResult}` : 'No results'}
|
||||
</span>
|
||||
)}
|
||||
{expanded && (
|
||||
<span className="tray-last-result">{resultItems.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="tray-content">
|
||||
{resultItems.length > 0 ? resultItems : (
|
||||
<div className="tray-empty">No results yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
calcpad-web/src/components/ResultsPanel.tsx
Normal file
109
calcpad-web/src/components/ResultsPanel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Right-side results panel that displays one result per editor line.
|
||||
*
|
||||
* Each result line matches CodeMirror's 24px line height (15px * 1.6)
|
||||
* so that results visually align with their corresponding expressions.
|
||||
* Scroll position is driven externally via a forwarded ref.
|
||||
*/
|
||||
|
||||
import { forwardRef, useState, useCallback } from 'react'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
import '../styles/results-panel.css'
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set([
|
||||
'number',
|
||||
'unitValue',
|
||||
'currencyValue',
|
||||
'dateTime',
|
||||
'timeDelta',
|
||||
'boolean',
|
||||
])
|
||||
|
||||
const NON_RESULT_TYPES = new Set(['comment', 'text', 'empty'])
|
||||
|
||||
/** Map result type to CSS class for type-specific coloring */
|
||||
function resultColorClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'currencyValue': return 'result-currency'
|
||||
case 'unitValue': return 'result-unit'
|
||||
case 'dateTime':
|
||||
case 'timeDelta': return 'result-datetime'
|
||||
case 'boolean': return 'result-boolean'
|
||||
default: return 'result-number'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResultsPanelProps {
|
||||
results: EngineLineResult[]
|
||||
align: 'left' | 'center' | 'right'
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const ResultsPanel = forwardRef<HTMLDivElement, ResultsPanelProps>(
|
||||
function ResultsPanel({ results, align, style }, ref) {
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
|
||||
const handleClick = useCallback((idx: number, rawValue: number | null, display: string) => {
|
||||
const text = rawValue != null ? String(rawValue) : display
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx)
|
||||
setTimeout(() => setCopiedIdx(null), 1200)
|
||||
}).catch(() => { /* clipboard unavailable */ })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="results-panel"
|
||||
style={{ textAlign: align, ...style }}
|
||||
role="complementary"
|
||||
aria-label="Calculation results"
|
||||
>
|
||||
{results.map((result, i) => {
|
||||
const isEven = (i + 1) % 2 === 0
|
||||
const stripe = isEven ? ' result-stripe' : ''
|
||||
|
||||
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
|
||||
const colorClass = resultColorClass(result.type)
|
||||
const isCopied = copiedIdx === i
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`result-line result-value ${colorClass}${stripe}${isCopied ? ' copied' : ''}`}
|
||||
onClick={() => handleClick(i, result.rawValue, result.display)}
|
||||
title="Click to copy"
|
||||
>
|
||||
{isCopied ? 'Copied!' : result.display}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error hint
|
||||
if (result.type === 'error' && result.error) {
|
||||
return (
|
||||
<div key={i} className={`result-line result-error-hint${stripe}`}>
|
||||
· error
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Comment/heading marker
|
||||
if (result.type === 'comment' || result.type === 'text') {
|
||||
return (
|
||||
<div key={i} className={`result-line result-marker${stripe}`}>
|
||||
<span className="result-dash">────</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={i} className={`result-line result-empty${stripe}`}>
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
519
calcpad-web/src/components/Sidebar.tsx
Normal file
519
calcpad-web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react'
|
||||
import type { CalcDocument, CalcFolder } from '../hooks/useDocumentStore.ts'
|
||||
import { TEMPLATES } from '../data/templates.ts'
|
||||
import '../styles/sidebar.css'
|
||||
|
||||
interface SidebarProps {
|
||||
visible: boolean
|
||||
width: number
|
||||
documents: CalcDocument[]
|
||||
folders: CalcFolder[]
|
||||
activeTabId: string
|
||||
openTabIds: string[]
|
||||
onFileClick: (id: string) => void
|
||||
onNewDocument: (title?: string, content?: string) => void
|
||||
onNewFolder: () => void
|
||||
onRenameDocument: (id: string, title: string) => void
|
||||
onDeleteDocument: (id: string) => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
onMoveToFolder: (docId: string, folderId: string | null) => void
|
||||
onRenameFolder: (id: string, name: string) => void
|
||||
onDeleteFolder: (id: string) => void
|
||||
onWidthChange: (width: number) => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
visible,
|
||||
width,
|
||||
documents,
|
||||
folders,
|
||||
activeTabId,
|
||||
openTabIds,
|
||||
onFileClick,
|
||||
onNewDocument,
|
||||
onNewFolder,
|
||||
onRenameDocument,
|
||||
onDeleteDocument,
|
||||
onToggleFavorite,
|
||||
onMoveToFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onWidthChange,
|
||||
}: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
() => new Set(['files', 'recent'])
|
||||
)
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set())
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [editType, setEditType] = useState<'file' | 'folder'>('file')
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number; y: number; type: 'file' | 'folder'; id: string
|
||||
} | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
|
||||
// Focus rename input
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editingId])
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const handler = () => setContextMenu(null)
|
||||
document.addEventListener('click', handler)
|
||||
return () => document.removeEventListener('click', handler)
|
||||
}, [contextMenu])
|
||||
|
||||
// Resize handle
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!resizeRef.current) return
|
||||
const newWidth = Math.min(400, Math.max(180, resizeRef.current.startWidth + e.clientX - resizeRef.current.startX))
|
||||
onWidthChange(newWidth)
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!resizeRef.current) return
|
||||
resizeRef.current = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [onWidthChange])
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(section)) next.delete(section)
|
||||
else next.add(section)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleFolder = useCallback((folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(folderId)) next.delete(folderId)
|
||||
else next.add(folderId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Rename — use refs to avoid stale closures in blur/keydown handlers
|
||||
const editRef = useRef<{ id: string; value: string; type: 'file' | 'folder' } | null>(null)
|
||||
|
||||
const startRename = useCallback((id: string, currentName: string, type: 'file' | 'folder') => {
|
||||
editRef.current = { id, value: currentName, type }
|
||||
setEditingId(id)
|
||||
setEditValue(currentName)
|
||||
setEditType(type)
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
const edit = editRef.current
|
||||
if (!edit) return
|
||||
if (edit.type === 'file') onRenameDocument(edit.id, edit.value)
|
||||
else onRenameFolder(edit.id, edit.value)
|
||||
editRef.current = null
|
||||
setEditingId(null)
|
||||
}, [onRenameDocument, onRenameFolder])
|
||||
|
||||
// Keep ref in sync when editValue changes
|
||||
const handleEditChange = useCallback((val: string) => {
|
||||
setEditValue(val)
|
||||
if (editRef.current) editRef.current.value = val
|
||||
}, [])
|
||||
|
||||
// Derived data
|
||||
const recentDocs = [...documents]
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, 5)
|
||||
|
||||
const favoriteDocs = documents.filter(d => d.isFavorite)
|
||||
|
||||
const rootDocs = documents.filter(d => !d.folderId)
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parentId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const getDocsInFolder = (folderId: string) =>
|
||||
documents.filter(d => d.folderId === folderId).sort((a, b) => a.title.localeCompare(b.title))
|
||||
|
||||
// Drag and drop — use ref to avoid stale closure
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const dragIdRef = useRef<string | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent, docId: string) => {
|
||||
dragIdRef.current = docId
|
||||
setDragId(docId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', docId)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent, targetId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDropTarget(targetId)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent, folderId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const docId = dragIdRef.current ?? e.dataTransfer.getData('text/plain')
|
||||
if (docId) {
|
||||
onMoveToFolder(docId, folderId)
|
||||
if (folderId) {
|
||||
setExpandedFolders(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(folderId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
dragIdRef.current = null
|
||||
setDragId(null)
|
||||
setDropTarget(null)
|
||||
}, [onMoveToFolder])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragIdRef.current = null
|
||||
setDragId(null)
|
||||
setDropTarget(null)
|
||||
}, [])
|
||||
|
||||
// Search filter
|
||||
const searchResults = search.trim()
|
||||
? documents.filter(d => d.title.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
: null
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const renderFileItem = (doc: CalcDocument, depth = 0) => {
|
||||
const isActive = doc.id === activeTabId
|
||||
const isOpen = openTabIds.includes(doc.id)
|
||||
const isDragged = dragId === doc.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`sidebar-file ${isActive ? 'active' : ''} ${isOpen ? 'open' : ''} ${isDragged ? 'dragging' : ''}`}
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, doc.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onFileClick(doc.id)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'file', id: doc.id })
|
||||
}}
|
||||
>
|
||||
<span className="sidebar-file-icon">📄</span>
|
||||
{editingId === doc.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sidebar-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => handleEditChange(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="sidebar-file-label">{doc.title}</span>
|
||||
)}
|
||||
{isOpen && !isActive && <span className="sidebar-open-dot" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderFolderItem = (folder: CalcFolder, depth = 0) => {
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
const docsInFolder = getDocsInFolder(folder.id)
|
||||
const subFolders = folders.filter(f => f.parentId === folder.id)
|
||||
const isDropTarget = dropTarget === folder.id
|
||||
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<div
|
||||
className={`sidebar-folder ${isDropTarget ? 'drop-target' : ''}`}
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
onClick={() => { if (!editingId) toggleFolder(folder.id) }}
|
||||
onDragOver={e => handleDragOver(e, folder.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, folder.id)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'folder', id: folder.id })
|
||||
}}
|
||||
>
|
||||
<span className="sidebar-folder-chevron">{isExpanded ? '▾' : '▸'}</span>
|
||||
{editingId === folder.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sidebar-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => handleEditChange(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="sidebar-folder-icon">{isExpanded ? '📂' : '📁'}</span>
|
||||
<span className="sidebar-folder-label">{folder.name}</span>
|
||||
<span className="sidebar-folder-count">({docsInFolder.length})</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{subFolders.map(sf => renderFolderItem(sf, depth + 1))}
|
||||
{docsInFolder.map(d => renderFileItem(d, depth + 1))}
|
||||
{docsInFolder.length === 0 && subFolders.length === 0 && (
|
||||
<div className="sidebar-empty" style={{ paddingLeft: 28 + depth * 16 }}>
|
||||
Empty folder
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar" style={{ width }}>
|
||||
{/* Search */}
|
||||
<div className="sidebar-search">
|
||||
<span className="sidebar-search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="sidebar-search-input"
|
||||
/>
|
||||
{search && (
|
||||
<button className="sidebar-search-clear" onClick={() => setSearch('')}>×</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{searchResults ? (
|
||||
/* Search results */
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
Results ({searchResults.length})
|
||||
</div>
|
||||
{searchResults.map(d => renderFileItem(d))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className="sidebar-empty">No documents match '{search}'</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Recent */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('recent')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('recent') ? '▾' : '▸'}
|
||||
</span>
|
||||
🕐 Recent
|
||||
</div>
|
||||
{expandedSections.has('recent') && (
|
||||
recentDocs.length > 0
|
||||
? recentDocs.map(d => renderFileItem(d))
|
||||
: <div className="sidebar-empty">No recent documents</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
{favoriteDocs.length > 0 && (
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('favorites')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('favorites') ? '▾' : '▸'}
|
||||
</span>
|
||||
⭐ Favorites
|
||||
</div>
|
||||
{expandedSections.has('favorites') && favoriteDocs.map(d => renderFileItem(d))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className="sidebar-section-header"
|
||||
onClick={() => toggleSection('templates')}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('templates') ? '▾' : '▸'}
|
||||
</span>
|
||||
📋 Templates
|
||||
</div>
|
||||
{expandedSections.has('templates') && (
|
||||
<div className="sidebar-templates">
|
||||
{TEMPLATES.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="sidebar-template"
|
||||
onClick={() => onNewDocument(t.name, t.content)}
|
||||
title={t.description}
|
||||
>
|
||||
<span className="sidebar-template-dot" style={{ backgroundColor: t.color }} />
|
||||
<div className="sidebar-template-text">
|
||||
<span className="sidebar-template-name">{t.name}</span>
|
||||
<span className="sidebar-template-desc">{t.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div className="sidebar-section">
|
||||
<div
|
||||
className={`sidebar-section-header ${dropTarget === 'root' ? 'drop-target' : ''}`}
|
||||
onClick={() => toggleSection('files')}
|
||||
onDragOver={e => handleDragOver(e, 'root')}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, null)}
|
||||
>
|
||||
<span className="sidebar-section-chevron">
|
||||
{expandedSections.has('files') ? '▾' : '▸'}
|
||||
</span>
|
||||
📁 Files
|
||||
</div>
|
||||
{expandedSections.has('files') && (
|
||||
<div
|
||||
className={`sidebar-files-area ${dropTarget === 'root' ? 'drop-target-area' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
|
||||
onDrop={e => handleDrop(e, null)}
|
||||
>
|
||||
{rootFolders.map(f => renderFolderItem(f))}
|
||||
{rootDocs.map(d => renderFileItem(d))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sidebar-footer">
|
||||
<button className="sidebar-footer-btn" onClick={onNewDocument}>+ Document</button>
|
||||
<button className="sidebar-footer-btn" onClick={onNewFolder}>+ Folder</button>
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="sidebar-resize"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
resizeRef.current = { startX: e.clientX, startWidth: width }
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}}
|
||||
onDoubleClick={() => onWidthChange(240)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="sidebar-context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{contextMenu.type === 'file' && (
|
||||
<>
|
||||
<button onClick={() => { onFileClick(contextMenu.id); setContextMenu(null) }}>
|
||||
Open
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const doc = documents.find(d => d.id === contextMenu.id)
|
||||
if (doc) startRename(doc.id, doc.title, 'file')
|
||||
}}>
|
||||
Rename
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
onToggleFavorite(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
{documents.find(d => d.id === contextMenu.id)?.isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
</button>
|
||||
{folders.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-context-separator" />
|
||||
<div className="sidebar-context-label">Move to...</div>
|
||||
<button onClick={() => { onMoveToFolder(contextMenu.id, null); setContextMenu(null) }}>
|
||||
Root
|
||||
</button>
|
||||
{folders.map(f => (
|
||||
<button key={f.id} onClick={() => { onMoveToFolder(contextMenu.id, f.id); setContextMenu(null) }}>
|
||||
📁 {f.name}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="sidebar-context-separator" />
|
||||
<button className="sidebar-context-danger" onClick={() => {
|
||||
onDeleteDocument(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{contextMenu.type === 'folder' && (
|
||||
<>
|
||||
<button onClick={() => {
|
||||
const folder = folders.find(f => f.id === contextMenu.id)
|
||||
if (folder) startRename(folder.id, folder.name, 'folder')
|
||||
}}>
|
||||
Rename
|
||||
</button>
|
||||
<div className="sidebar-context-separator" />
|
||||
<button className="sidebar-context-danger" onClick={() => {
|
||||
onDeleteFolder(contextMenu.id)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
Delete Folder
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
calcpad-web/src/components/StatusBar.tsx
Normal file
89
calcpad-web/src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import '../styles/status-bar.css'
|
||||
|
||||
interface StatusBarProps {
|
||||
editorView: EditorView | null
|
||||
engineReady: boolean
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export function StatusBar({ editorView, engineReady, lineCount }: StatusBarProps) {
|
||||
const [cursor, setCursor] = useState({ line: 1, col: 1 })
|
||||
const [selection, setSelection] = useState(0)
|
||||
const [showDedication, setShowDedication] = useState(false)
|
||||
|
||||
const toggleDedication = useCallback(() => {
|
||||
setShowDedication(prev => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
|
||||
const update = () => {
|
||||
const state = editorView.state
|
||||
const pos = state.selection.main.head
|
||||
const line = state.doc.lineAt(pos)
|
||||
setCursor({ line: line.number, col: pos - line.from + 1 })
|
||||
|
||||
const sel = state.selection.main
|
||||
setSelection(Math.abs(sel.to - sel.from))
|
||||
}
|
||||
|
||||
// Initial
|
||||
update()
|
||||
|
||||
const handler = () => update()
|
||||
editorView.dom.addEventListener('keyup', handler)
|
||||
editorView.dom.addEventListener('mouseup', handler)
|
||||
|
||||
return () => {
|
||||
editorView.dom.removeEventListener('keyup', handler)
|
||||
editorView.dom.removeEventListener('mouseup', handler)
|
||||
}
|
||||
}, [editorView])
|
||||
|
||||
return (
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-left">
|
||||
<span>Ln {cursor.line}, Col {cursor.col}</span>
|
||||
<span>{lineCount} lines</span>
|
||||
{selection > 0 && <span>{selection} selected</span>}
|
||||
</div>
|
||||
<div className="status-bar-right">
|
||||
<span className="status-bar-engine">
|
||||
<span className={`status-bar-dot ${engineReady ? 'ready' : 'loading'}`} />
|
||||
{engineReady ? 'Ready' : 'Loading...'}
|
||||
</span>
|
||||
<span
|
||||
className="status-bar-dedication"
|
||||
onClick={toggleDedication}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
Made with <span className="status-bar-heart">♥</span> for Igor Cassel
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showDedication && (
|
||||
<div className="dedication-overlay" onClick={toggleDedication}>
|
||||
<div className="dedication-card" onClick={e => e.stopPropagation()}>
|
||||
<div className="dedication-heart">♥</div>
|
||||
<h2>For Igor Cassel</h2>
|
||||
<p>
|
||||
CalcText was created in honor of my cousin Igor,
|
||||
who has always had a deep love for text editors
|
||||
and the craft of building beautiful, functional tools.
|
||||
</p>
|
||||
<p className="dedication-tagline">
|
||||
Every keystroke in this editor carries that inspiration.
|
||||
</p>
|
||||
<button className="dedication-close" onClick={toggleDedication}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
calcpad-web/src/components/TabBar.tsx
Normal file
130
calcpad-web/src/components/TabBar.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { CalcDocument } from '../hooks/useDocumentStore.ts'
|
||||
import '../styles/tab-bar.css'
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: CalcDocument[]
|
||||
activeTabId: string
|
||||
onTabClick: (id: string) => void
|
||||
onTabClose: (id: string) => void
|
||||
onTabRename: (id: string, title: string) => void
|
||||
onNewTab: () => void
|
||||
modifiedIds?: Set<string>
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabClick,
|
||||
onTabClose,
|
||||
onTabRename,
|
||||
onNewTab,
|
||||
modifiedIds,
|
||||
}: TabBarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (editingId && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editingId])
|
||||
|
||||
// Scroll active tab into view
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return
|
||||
const activeEl = scrollRef.current.querySelector('.tab-item.active')
|
||||
activeEl?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [activeTabId])
|
||||
|
||||
const startRename = useCallback((id: string, currentTitle: string) => {
|
||||
setEditingId(id)
|
||||
setEditValue(currentTitle)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (editingId) {
|
||||
onTabRename(editingId, editValue)
|
||||
setEditingId(null)
|
||||
}
|
||||
}, [editingId, editValue, onTabRename])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setEditingId(null)
|
||||
}, [])
|
||||
|
||||
const handleMiddleClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
onTabClose(id)
|
||||
}
|
||||
}, [onTabClose])
|
||||
|
||||
// Horizontal scroll with mouse wheel
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (scrollRef.current && e.deltaY !== 0) {
|
||||
scrollRef.current.scrollLeft += e.deltaY
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
<div className="tab-bar-scroll" ref={scrollRef} onWheel={handleWheel}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
onClick={() => onTabClick(tab.id)}
|
||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
||||
onDoubleClick={() => startRename(tab.id, tab.title)}
|
||||
title={tab.title}
|
||||
>
|
||||
{modifiedIds?.has(tab.id) && (
|
||||
<span className="tab-modified-dot" />
|
||||
)}
|
||||
|
||||
{editingId === tab.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="tab-rename-input"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="tab-label">{tab.title}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onTabClose(tab.id)
|
||||
}}
|
||||
aria-label={`Close ${tab.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-new"
|
||||
onClick={onNewTab}
|
||||
aria-label="New document"
|
||||
title="New document (Ctrl+N)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
calcpad-web/src/components/ThemePicker.tsx
Normal file
116
calcpad-web/src/components/ThemePicker.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { ThemeId } from '../hooks/useTheme.ts'
|
||||
import { THEMES, ACCENT_COLORS } from '../hooks/useTheme.ts'
|
||||
import '../styles/theme-picker.css'
|
||||
|
||||
interface ThemePickerProps {
|
||||
theme: ThemeId
|
||||
accentColor: string | null
|
||||
onThemeChange: (id: ThemeId) => void
|
||||
onAccentChange: (color: string | null) => void
|
||||
}
|
||||
|
||||
export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+T
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault()
|
||||
setOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [])
|
||||
|
||||
const currentTheme = THEMES.find(t => t.id === theme)
|
||||
const resolvedTheme = theme === 'system' ? undefined : theme
|
||||
const icon = currentTheme?.icon ?? '⚙️'
|
||||
|
||||
return (
|
||||
<div className="theme-picker-container" ref={ref}>
|
||||
<button
|
||||
className="theme-picker-trigger"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
title="Switch theme (Ctrl+Shift+T)"
|
||||
aria-label="Switch theme"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="theme-picker-icon">{icon}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="theme-picker-dropdown" role="menu">
|
||||
<div className="theme-picker-section-label">Themes</div>
|
||||
|
||||
{THEMES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`theme-picker-item ${resolvedTheme === t.id ? 'active' : ''}`}
|
||||
onClick={() => { onThemeChange(t.id); setOpen(false) }}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="theme-picker-item-icon">{t.icon}</span>
|
||||
<span className="theme-picker-item-label">{t.name}</span>
|
||||
{resolvedTheme === t.id && <span className="theme-picker-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="theme-picker-separator" />
|
||||
|
||||
<div className="theme-picker-section-label">Accent Color</div>
|
||||
<div className="theme-picker-accents">
|
||||
{ACCENT_COLORS.map(c => (
|
||||
<button
|
||||
key={c.name}
|
||||
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c.light }}
|
||||
onClick={() => {
|
||||
const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme ?? '')
|
||||
const color = isDark ? c.dark : c.light
|
||||
onAccentChange(accentColor === color ? null : color)
|
||||
}}
|
||||
title={c.name}
|
||||
aria-label={`Accent color: ${c.name}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="theme-picker-separator" />
|
||||
|
||||
<button
|
||||
className={`theme-picker-item ${theme === 'system' ? 'active' : ''}`}
|
||||
onClick={() => { onThemeChange('system'); setOpen(false) }}
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="theme-picker-item-icon">⚙️</span>
|
||||
<span className="theme-picker-item-label">System (auto)</span>
|
||||
{theme === 'system' && <span className="theme-picker-check">✓</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
calcpad-web/src/data/templates.ts
Normal file
151
calcpad-web/src/data/templates.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
export interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'budget',
|
||||
name: 'Budget',
|
||||
description: 'Monthly income and expenses',
|
||||
color: '#10b981',
|
||||
content: `# Monthly Budget
|
||||
|
||||
// Income
|
||||
salary = 5000
|
||||
freelance = 1200
|
||||
total_income = salary + freelance
|
||||
|
||||
// Housing
|
||||
rent = 1500
|
||||
utilities = 150
|
||||
insurance = 80
|
||||
|
||||
// Living
|
||||
groceries = 400
|
||||
transport = 120
|
||||
subscriptions = 45
|
||||
|
||||
// Summary
|
||||
total_expenses = sum
|
||||
savings = total_income - total_expenses
|
||||
savings_rate = savings / total_income
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
name: 'Invoice',
|
||||
description: 'Service invoice with tax',
|
||||
color: '#6366f1',
|
||||
content: `# Invoice #001
|
||||
|
||||
// Client: [Client Name]
|
||||
// Date: [Date]
|
||||
|
||||
// Services
|
||||
web_design = 2500
|
||||
development = 4000
|
||||
consulting = 150 * 8
|
||||
|
||||
// Expenses
|
||||
hosting = 29.99
|
||||
domain = 12.00
|
||||
|
||||
subtotal = sum
|
||||
|
||||
// Tax
|
||||
tax_rate = 10%
|
||||
tax = subtotal * tax_rate
|
||||
total = subtotal + tax
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'units',
|
||||
name: 'Unit Converter',
|
||||
description: 'Common unit conversions',
|
||||
color: '#0d9488',
|
||||
content: `# Unit Converter
|
||||
|
||||
// Weight
|
||||
75 kg in lb
|
||||
2.5 lb in kg
|
||||
100 g in oz
|
||||
|
||||
// Distance
|
||||
10 km in mi
|
||||
26.2 mi in km
|
||||
5280 ft in m
|
||||
|
||||
// Temperature
|
||||
100 °C in °F
|
||||
72 °F in °C
|
||||
0 °C in K
|
||||
|
||||
// Data
|
||||
1 GB in MB
|
||||
500 MB in GB
|
||||
1 TB in GB
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'trip',
|
||||
name: 'Trip Planner',
|
||||
description: 'Travel budget with currencies',
|
||||
color: '#f59e0b',
|
||||
content: `# Trip Planner
|
||||
|
||||
// Budget
|
||||
budget = 3000
|
||||
|
||||
// Flights
|
||||
flight_out = 450
|
||||
flight_back = 380
|
||||
|
||||
// Hotel
|
||||
nights = 7
|
||||
rate_per_night = 120
|
||||
hotel_total = nights * rate_per_night
|
||||
|
||||
// Daily expenses
|
||||
daily_food = 50
|
||||
daily_transport = 20
|
||||
daily_activities = 35
|
||||
daily_total = daily_food + daily_transport + daily_activities
|
||||
trip_expenses = daily_total * nights
|
||||
|
||||
// Summary
|
||||
total_cost = flight_out + flight_back + hotel_total + trip_expenses
|
||||
remaining = budget - total_cost
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'loan',
|
||||
name: 'Loan Calculator',
|
||||
description: 'Mortgage and loan payments',
|
||||
color: '#7c3aed',
|
||||
content: `# Loan Calculator
|
||||
|
||||
// Loan Details
|
||||
principal = 250000
|
||||
annual_rate = 6.5%
|
||||
years = 30
|
||||
|
||||
// Monthly Calculation
|
||||
monthly_rate = annual_rate / 12
|
||||
num_payments = years * 12
|
||||
|
||||
// Monthly Payment
|
||||
monthly_payment = principal * (monthly_rate * (1 + monthly_rate) ^ num_payments) / ((1 + monthly_rate) ^ num_payments - 1)
|
||||
|
||||
// Total Cost
|
||||
total_paid = monthly_payment * num_payments
|
||||
total_interest = total_paid - principal
|
||||
|
||||
// Summary
|
||||
interest_ratio = total_interest / principal
|
||||
`,
|
||||
},
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
||||
*
|
||||
* Integrates the CalcPad language mode, answer gutter, error display,
|
||||
* Integrates the CalcPad language mode, error display,
|
||||
* and debounced evaluation via the WASM engine Web Worker.
|
||||
*/
|
||||
|
||||
@@ -15,15 +15,17 @@ import {
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
HighlightStyle,
|
||||
} from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { calcpadLanguage } from './calcpad-language.ts'
|
||||
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
|
||||
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
|
||||
import { stripedLinesExtension } from './inline-results.ts'
|
||||
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
export interface CalcEditorProps {
|
||||
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
|
||||
initialDoc?: string
|
||||
/** Called when the document text changes (debounced internally) */
|
||||
onDocChange?: (lines: string[]) => void
|
||||
/** Engine evaluation results to display in the answer gutter */
|
||||
/** Engine evaluation results to display as errors */
|
||||
results?: EngineLineResult[]
|
||||
/** Debounce delay in ms before triggering onDocChange */
|
||||
debounceMs?: number
|
||||
/** Called with the EditorView once created (null on cleanup) */
|
||||
onViewReady?: (view: EditorView | null) => void
|
||||
/** Enable live preview formatting */
|
||||
formatPreview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CalcPad editor component built on CodeMirror 6.
|
||||
* Handles syntax highlighting, line numbers, answer gutter,
|
||||
* and error underlines.
|
||||
* Handles syntax highlighting, line numbers, and error underlines.
|
||||
*/
|
||||
export function CalcEditor({
|
||||
initialDoc = '',
|
||||
onDocChange,
|
||||
results,
|
||||
debounceMs = 50,
|
||||
onViewReady,
|
||||
formatPreview = true,
|
||||
}: CalcEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
@@ -86,10 +93,11 @@ export function CalcEditor({
|
||||
indentOnInput(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
syntaxHighlighting(calcpadHighlight),
|
||||
calcpadLanguage(),
|
||||
answerGutterExtension(),
|
||||
errorDisplayExtension(),
|
||||
stripedLinesExtension(),
|
||||
formatPreviewExtension(formatPreview),
|
||||
updateListener,
|
||||
calcpadEditorTheme,
|
||||
],
|
||||
@@ -101,6 +109,7 @@ export function CalcEditor({
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
onViewReady?.(view)
|
||||
|
||||
// Trigger initial evaluation
|
||||
const doc = view.state.doc.toString()
|
||||
@@ -110,23 +119,33 @@ export function CalcEditor({
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
onViewReady?.(null)
|
||||
}
|
||||
// initialDoc intentionally excluded — we only set it once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheduleEval])
|
||||
|
||||
// Push engine results into the answer gutter + error display
|
||||
// Toggle format preview mode
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
effects: formatPreviewCompartment.reconfigure(
|
||||
formatPreview ? formatPreviewEnabled : [],
|
||||
),
|
||||
})
|
||||
}, [formatPreview])
|
||||
|
||||
// Push engine results into the error display
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !results) return
|
||||
|
||||
const answers: LineAnswer[] = []
|
||||
const errors: LineError[] = []
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const result = results[i]
|
||||
answers.push({ line: lineNum, result })
|
||||
|
||||
if (result.type === 'error' && result.error) {
|
||||
// Map to document positions
|
||||
@@ -143,7 +162,6 @@ export function CalcEditor({
|
||||
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setAnswersEffect.of(answers),
|
||||
setErrorsEffect.of(errors),
|
||||
],
|
||||
})
|
||||
@@ -152,6 +170,24 @@ export function CalcEditor({
|
||||
return <div ref={containerRef} className="calc-editor" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax highlighting using CSS variables for theme integration.
|
||||
*/
|
||||
const calcpadHighlight = HighlightStyle.define([
|
||||
{ tag: tags.number, color: 'var(--syntax-number)' },
|
||||
{ tag: tags.operator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.variableName, color: 'var(--syntax-variable)' },
|
||||
{ tag: tags.function(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.keyword, color: 'var(--syntax-keyword)' },
|
||||
{ tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
|
||||
{ tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' },
|
||||
{ tag: tags.definitionOperator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.special(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' },
|
||||
{ tag: tags.paren, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.punctuation, color: 'var(--syntax-operator)' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Base theme for the CalcPad editor.
|
||||
*/
|
||||
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
||||
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
padding: '8px 0',
|
||||
minHeight: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 16px',
|
||||
padding: '0 12px',
|
||||
lineHeight: '1.6',
|
||||
position: 'relative',
|
||||
textAlign: 'var(--cm-text-align, left)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 8px 0 16px',
|
||||
color: '#9ca3af',
|
||||
padding: '0 6px 0 12px',
|
||||
color: 'var(--text, #9ca3af)',
|
||||
opacity: '0.4',
|
||||
fontSize: '13px',
|
||||
minWidth: '32px',
|
||||
},
|
||||
'.cm-answer-gutter': {
|
||||
minWidth: '140px',
|
||||
textAlign: 'right',
|
||||
paddingRight: '16px',
|
||||
borderLeft: '1px solid #e5e4e7',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'&dark .cm-answer-gutter': {
|
||||
borderLeft: '1px solid #2e303a',
|
||||
backgroundColor: '#1a1b23',
|
||||
},
|
||||
'.cm-answer-value': {
|
||||
color: '#6366f1',
|
||||
'.cm-activeLineGutter .cm-gutterElement': {
|
||||
opacity: '1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'&dark .cm-answer-value': {
|
||||
color: '#818cf8',
|
||||
},
|
||||
'.cm-answer-error': {
|
||||
color: '#e53e3e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'&dark .cm-answer-error': {
|
||||
color: '#fc8181',
|
||||
'.cm-stripe': {
|
||||
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
},
|
||||
'&dark .cm-activeLine': {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Custom gutter for displaying computed results alongside each line.
|
||||
* Adapted from epic/9-2-codemirror-6-editor.
|
||||
*
|
||||
* The answer column is right-aligned and visually distinct from the input.
|
||||
*/
|
||||
|
||||
import { GutterMarker, gutter } from '@codemirror/view'
|
||||
import { StateField, StateEffect, type Extension } from '@codemirror/state'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
// --- State Effects ---
|
||||
|
||||
export interface LineAnswer {
|
||||
line: number // 1-indexed line number
|
||||
result: EngineLineResult | null
|
||||
}
|
||||
|
||||
export const setAnswersEffect = StateEffect.define<LineAnswer[]>()
|
||||
|
||||
// --- Gutter Markers ---
|
||||
|
||||
class AnswerMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly isError: boolean,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value'
|
||||
span.textContent = this.text
|
||||
return span
|
||||
}
|
||||
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return (
|
||||
other instanceof AnswerMarker &&
|
||||
other.text === this.text &&
|
||||
other.isError === this.isError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- State Field ---
|
||||
|
||||
export const answersField = StateField.define<Map<number, EngineLineResult | null>>({
|
||||
create() {
|
||||
return new Map()
|
||||
},
|
||||
|
||||
update(answers, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setAnswersEffect)) {
|
||||
const newAnswers = new Map<number, EngineLineResult | null>()
|
||||
for (const { line, result } of effect.value) {
|
||||
newAnswers.set(line, result)
|
||||
}
|
||||
return newAnswers
|
||||
}
|
||||
}
|
||||
return answers
|
||||
},
|
||||
})
|
||||
|
||||
// --- Gutter Extension ---
|
||||
|
||||
const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean'])
|
||||
|
||||
export const answerGutter = gutter({
|
||||
class: 'cm-answer-gutter',
|
||||
lineMarker(view, line) {
|
||||
const doc = view.state.doc
|
||||
const lineNumber = doc.lineAt(line.from).number
|
||||
const answers = view.state.field(answersField)
|
||||
const result = answers.get(lineNumber)
|
||||
|
||||
if (!result) return null
|
||||
|
||||
if (result.type === 'error') {
|
||||
return new AnswerMarker('Error', true)
|
||||
}
|
||||
|
||||
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
|
||||
return new AnswerMarker(result.display, false)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
lineMarkerChange(update) {
|
||||
return update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(setAnswersEffect)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates the answer gutter extension bundle.
|
||||
*/
|
||||
export function answerGutterExtension(): Extension {
|
||||
return [answersField, answerGutter]
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
GutterMarker,
|
||||
gutter,
|
||||
EditorView,
|
||||
hoverTooltip,
|
||||
type Tooltip,
|
||||
} from '@codemirror/view'
|
||||
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
|
||||
|
||||
@@ -98,6 +100,48 @@ export const errorLinesField = StateField.define<Set<number>>({
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Messages (for tooltips) ---
|
||||
|
||||
export const errorMessagesField = StateField.define<Map<number, string>>({
|
||||
create() {
|
||||
return new Map()
|
||||
},
|
||||
update(msgs, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setErrorsEffect)) {
|
||||
const newMsgs = new Map<number, string>()
|
||||
for (const error of effect.value) {
|
||||
const lineNumber = tr.state.doc.lineAt(error.from).number
|
||||
newMsgs.set(lineNumber, error.message)
|
||||
}
|
||||
return newMsgs
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Tooltip (hover) ---
|
||||
|
||||
const errorTooltip = hoverTooltip((view, pos) => {
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
const errorMessages = view.state.field(errorMessagesField)
|
||||
const msg = errorMessages.get(line.number)
|
||||
if (!msg) return null
|
||||
|
||||
return {
|
||||
pos: line.from,
|
||||
end: line.to,
|
||||
above: false,
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'cm-error-tooltip'
|
||||
dom.textContent = msg
|
||||
return { dom }
|
||||
},
|
||||
} satisfies Tooltip
|
||||
})
|
||||
|
||||
// --- Error Gutter ---
|
||||
|
||||
export const errorGutter = gutter({
|
||||
@@ -118,15 +162,32 @@ export const errorGutter = gutter({
|
||||
|
||||
export const errorBaseTheme = EditorView.baseTheme({
|
||||
'.cm-error-underline': {
|
||||
textDecoration: 'underline wavy red',
|
||||
textDecoration: 'underline wavy var(--error, red)',
|
||||
textDecorationThickness: '1.5px',
|
||||
},
|
||||
'.cm-error-marker': {
|
||||
color: '#e53e3e',
|
||||
color: 'var(--error, #e53e3e)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-error-gutter': {
|
||||
width: '20px',
|
||||
},
|
||||
'.cm-error-tooltip': {
|
||||
backgroundColor: 'var(--bg-secondary, #f8f9fa)',
|
||||
color: 'var(--error, #e53e3e)',
|
||||
border: '1px solid var(--border, #e5e4e7)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--sans, system-ui)',
|
||||
maxWidth: '300px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -136,7 +197,9 @@ export function errorDisplayExtension(): Extension {
|
||||
return [
|
||||
errorDecorationsField,
|
||||
errorLinesField,
|
||||
errorMessagesField,
|
||||
errorGutter,
|
||||
errorTooltip,
|
||||
errorBaseTheme,
|
||||
]
|
||||
}
|
||||
|
||||
202
calcpad-web/src/editor/format-preview.ts
Normal file
202
calcpad-web/src/editor/format-preview.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Live Preview extension for CodeMirror.
|
||||
*
|
||||
* When enabled, hides markdown syntax markers and applies visual formatting:
|
||||
* - `# text` → heading (bold, larger)
|
||||
* - `**text**` → bold
|
||||
* - `*text*` → italic
|
||||
* - `// text` → comment (dimmed, italic)
|
||||
*
|
||||
* The active line always shows raw markdown for editing.
|
||||
* Toggle with the compartment to switch between raw and preview modes.
|
||||
*/
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import { Compartment, type Extension } from '@codemirror/state'
|
||||
|
||||
// Invisible widget to replace hidden syntax markers
|
||||
class HiddenMarker extends WidgetType {
|
||||
toDOM() {
|
||||
const span = document.createElement('span')
|
||||
span.style.display = 'none'
|
||||
return span
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenWidget = Decoration.replace({ widget: new HiddenMarker() })
|
||||
|
||||
const headingMark = Decoration.mark({ class: 'cm-fmt-heading' })
|
||||
const boldMark = Decoration.mark({ class: 'cm-fmt-bold' })
|
||||
const italicMark = Decoration.mark({ class: 'cm-fmt-italic' })
|
||||
const commentMark = Decoration.mark({ class: 'cm-fmt-comment' })
|
||||
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: { from: number; to: number; dec: Decoration }[] = []
|
||||
const doc = view.state.doc
|
||||
const activeLine = doc.lineAt(view.state.selection.main.head).number
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i)
|
||||
const text = line.text
|
||||
const isActive = i === activeLine
|
||||
|
||||
// Headings: # text
|
||||
const headingMatch = text.match(/^(#{1,6})\s/)
|
||||
if (headingMatch) {
|
||||
if (!isActive) {
|
||||
// Hide the # prefix
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
to: line.from + headingMatch[0].length,
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
// Style the rest as heading
|
||||
decorations.push({
|
||||
from: line.from + headingMatch[0].length,
|
||||
to: line.to,
|
||||
dec: headingMark,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Comments: // text
|
||||
if (text.trimStart().startsWith('//')) {
|
||||
const offset = text.indexOf('//')
|
||||
if (!isActive) {
|
||||
// Hide the // prefix
|
||||
decorations.push({
|
||||
from: line.from + offset,
|
||||
to: line.from + offset + 2 + (text[offset + 2] === ' ' ? 1 : 0),
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
dec: commentMark,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Bold: **text** (only on non-active lines)
|
||||
if (!isActive) {
|
||||
const boldRegex = /\*\*(.+?)\*\*/g
|
||||
let match
|
||||
while ((match = boldRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
// Hide opening **
|
||||
decorations.push({ from: start, to: start + 2, dec: hiddenWidget })
|
||||
// Bold the content
|
||||
decorations.push({ from: start + 2, to: start + 2 + match[1].length, dec: boldMark })
|
||||
// Hide closing **
|
||||
decorations.push({ from: start + 2 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* (only on non-active lines, avoid **bold**)
|
||||
if (!isActive) {
|
||||
const italicRegex = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g
|
||||
let match
|
||||
while ((match = italicRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
// Hide opening *
|
||||
decorations.push({ from: start, to: start + 1, dec: hiddenWidget })
|
||||
// Italic the content
|
||||
decorations.push({ from: start + 1, to: start + 1 + match[1].length, dec: italicMark })
|
||||
// Hide closing *
|
||||
decorations.push({ from: start + 1 + match[1].length, to: start + match[0].length, dec: hiddenWidget })
|
||||
}
|
||||
}
|
||||
|
||||
// Color labels: [color:text] (on non-active lines, show colored text)
|
||||
if (!isActive) {
|
||||
const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g
|
||||
let match
|
||||
while ((match = colorRegex.exec(text)) !== null) {
|
||||
const start = line.from + match.index
|
||||
const color = match[1]
|
||||
const content = match[2]
|
||||
// Hide [color:
|
||||
decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget })
|
||||
// Color the content
|
||||
decorations.push({
|
||||
from: start + color.length + 2,
|
||||
to: start + color.length + 2 + content.length,
|
||||
dec: Decoration.mark({ class: `cm-fmt-color-${color}` }),
|
||||
})
|
||||
// Hide ]
|
||||
decorations.push({
|
||||
from: start + match[0].length - 1,
|
||||
to: start + match[0].length,
|
||||
dec: hiddenWidget,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position (required by RangeSet)
|
||||
decorations.sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
|
||||
return Decoration.set(decorations.map(d => d.dec.range(d.from, d.to)))
|
||||
}
|
||||
|
||||
const formatPreviewPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view)
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
)
|
||||
|
||||
const formatPreviewTheme = EditorView.baseTheme({
|
||||
'.cm-fmt-heading': {
|
||||
fontWeight: '700',
|
||||
fontSize: '1.15em',
|
||||
color: 'var(--text-h)',
|
||||
},
|
||||
'.cm-fmt-bold': {
|
||||
fontWeight: '700',
|
||||
},
|
||||
'.cm-fmt-italic': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-fmt-comment': {
|
||||
fontStyle: 'italic',
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-fmt-color-red': { color: '#ef4444' },
|
||||
'.cm-fmt-color-orange': { color: '#f97316' },
|
||||
'.cm-fmt-color-yellow': { color: '#eab308' },
|
||||
'.cm-fmt-color-green': { color: '#22c55e' },
|
||||
'.cm-fmt-color-blue': { color: '#3b82f6' },
|
||||
'.cm-fmt-color-purple': { color: '#a855f7' },
|
||||
})
|
||||
|
||||
// Empty extension for "raw" mode
|
||||
const noopExtension: Extension = []
|
||||
|
||||
export const formatPreviewCompartment = new Compartment()
|
||||
|
||||
/** The extensions to use when preview is enabled */
|
||||
export const formatPreviewEnabled = [formatPreviewPlugin, formatPreviewTheme]
|
||||
|
||||
export function formatPreviewExtension(enabled: boolean): Extension {
|
||||
return formatPreviewCompartment.of(
|
||||
enabled ? formatPreviewEnabled : noopExtension,
|
||||
)
|
||||
}
|
||||
54
calcpad-web/src/editor/inline-results.ts
Normal file
54
calcpad-web/src/editor/inline-results.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CodeMirror 6 extension for zebra-striped editor lines.
|
||||
*
|
||||
* Previously also contained inline result widgets, but results
|
||||
* are now rendered in a separate ResultsPanel component.
|
||||
*/
|
||||
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
/**
|
||||
* ViewPlugin that applies alternating background colors (zebra striping)
|
||||
* to even-numbered editor lines, helping users visually connect
|
||||
* expressions on the left to their inline results on the right.
|
||||
*/
|
||||
export const stripedLines = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildStripeDecorations(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildStripeDecorations(update.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
)
|
||||
|
||||
const stripeDeco = Decoration.line({ class: 'cm-stripe' })
|
||||
|
||||
function buildStripeDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Array<ReturnType<typeof stripeDeco.range>> = []
|
||||
for (let i = 1; i <= view.state.doc.lines; i++) {
|
||||
if (i % 2 === 0) {
|
||||
const line = view.state.doc.line(i)
|
||||
decorations.push(stripeDeco.range(line.from))
|
||||
}
|
||||
}
|
||||
return Decoration.set(decorations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the striped-lines extension for the editor.
|
||||
*/
|
||||
export function stripedLinesExtension(): Extension {
|
||||
return [stripedLines]
|
||||
}
|
||||
@@ -78,7 +78,8 @@ function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
|
||||
async function initWasm(): Promise<boolean> {
|
||||
try {
|
||||
// Try to load the wasm-pack output
|
||||
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
|
||||
const wasmPath = '/wasm/calcpad_wasm.js'
|
||||
const wasmModule = await import(/* @vite-ignore */ wasmPath) as {
|
||||
default: () => Promise<void>
|
||||
evalSheet: (lines: string[]) => EngineLineResult[]
|
||||
}
|
||||
|
||||
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal file
357
calcpad-web/src/hooks/useDocumentStore.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'calctext-documents'
|
||||
const FOLDERS_KEY = 'calctext-folders'
|
||||
const TABS_KEY = 'calctext-tabs'
|
||||
const ACTIVE_KEY = 'calctext-active-tab'
|
||||
const SIDEBAR_KEY = 'calctext-sidebar'
|
||||
|
||||
export interface CalcDocument {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
folderId: string | null
|
||||
isFavorite: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CalcFolder {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
documents: CalcDocument[]
|
||||
folders: CalcFolder[]
|
||||
openTabIds: string[]
|
||||
activeTabId: string
|
||||
}
|
||||
|
||||
const WELCOME_DOC: CalcDocument = {
|
||||
id: 'welcome',
|
||||
title: 'Welcome',
|
||||
content: `# CalcText
|
||||
|
||||
// Basic arithmetic
|
||||
2 + 3
|
||||
10 * 4.5
|
||||
100 / 7
|
||||
|
||||
// Variables
|
||||
price = 49.99
|
||||
quantity = 3
|
||||
subtotal = price * quantity
|
||||
|
||||
// Percentages
|
||||
tax = subtotal * 8%
|
||||
total = subtotal + tax
|
||||
|
||||
// Functions
|
||||
sqrt(144)
|
||||
2 ^ 10
|
||||
`,
|
||||
folderId: null,
|
||||
isFavorite: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function loadState(): StoreState {
|
||||
try {
|
||||
const docsJson = localStorage.getItem(STORAGE_KEY)
|
||||
const foldersJson = localStorage.getItem(FOLDERS_KEY)
|
||||
const tabsJson = localStorage.getItem(TABS_KEY)
|
||||
const activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
|
||||
const documents: CalcDocument[] = docsJson ? JSON.parse(docsJson) : [WELCOME_DOC]
|
||||
const folders: CalcFolder[] = foldersJson ? JSON.parse(foldersJson) : []
|
||||
const openTabIds: string[] = tabsJson ? JSON.parse(tabsJson) : [documents[0]?.id ?? 'welcome']
|
||||
const activeTabId = activeId && openTabIds.includes(activeId) ? activeId : openTabIds[0] ?? ''
|
||||
|
||||
if (documents.length === 0) documents.push(WELCOME_DOC)
|
||||
if (openTabIds.length === 0) openTabIds.push(documents[0].id)
|
||||
|
||||
return { documents, folders, openTabIds, activeTabId }
|
||||
} catch {
|
||||
return { documents: [WELCOME_DOC], folders: [], openTabIds: ['welcome'], activeTabId: 'welcome' }
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: StoreState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.documents))
|
||||
localStorage.setItem(FOLDERS_KEY, JSON.stringify(state.folders))
|
||||
localStorage.setItem(TABS_KEY, JSON.stringify(state.openTabIds))
|
||||
localStorage.setItem(ACTIVE_KEY, state.activeTabId)
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
export function loadSidebarState(): { visible: boolean; width: number } {
|
||||
try {
|
||||
const json = localStorage.getItem(SIDEBAR_KEY)
|
||||
if (json) return JSON.parse(json)
|
||||
} catch { /* */ }
|
||||
return { visible: true, width: 240 }
|
||||
}
|
||||
|
||||
export function saveSidebarState(s: { visible: boolean; width: number }) {
|
||||
try { localStorage.setItem(SIDEBAR_KEY, JSON.stringify(s)) } catch { /* */ }
|
||||
}
|
||||
|
||||
export function useDocumentStore() {
|
||||
const [state, setState] = useState<StoreState>(loadState)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Use a ref to always have latest state for return values
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
const persist = useCallback((next: StoreState) => {
|
||||
setState(next)
|
||||
saveState(next)
|
||||
}, [])
|
||||
|
||||
const persistDebounced = useCallback((next: StoreState) => {
|
||||
setState(next)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => saveState(next), 300)
|
||||
}, [])
|
||||
|
||||
// Use functional updates to avoid stale closures
|
||||
|
||||
const setActiveTab = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next = { ...prev, activeTabId: id }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createDocument = useCallback((title?: string, content?: string): CalcDocument => {
|
||||
const id = generateId()
|
||||
const now = new Date().toISOString()
|
||||
// Read current state to count untitled
|
||||
const cur = stateRef.current
|
||||
const existingUntitled = cur.documents.filter(d => d.title.startsWith('Untitled')).length
|
||||
const newDoc: CalcDocument = {
|
||||
id,
|
||||
title: title ?? (existingUntitled === 0 ? 'Untitled' : `Untitled ${existingUntitled + 1}`),
|
||||
content: content ?? '',
|
||||
folderId: null,
|
||||
isFavorite: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: [...prev.documents, newDoc],
|
||||
openTabIds: [...prev.openTabIds, id],
|
||||
activeTabId: id,
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
return newDoc
|
||||
}, [])
|
||||
|
||||
const updateContent = useCallback((id: string, content: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, content, updatedAt: new Date().toISOString() } : d
|
||||
),
|
||||
}
|
||||
// debounced save
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => saveState(next), 300)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const renameDocument = useCallback((id: string, title: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, title: title.trim() || 'Untitled', updatedAt: new Date().toISOString() } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeTab = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
|
||||
let nextActive = prev.activeTabId
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newId = generateId()
|
||||
const newDoc: CalcDocument = {
|
||||
id: newId, title: 'Untitled', content: '',
|
||||
folderId: null, isFavorite: false,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
}
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: [...prev.documents, newDoc],
|
||||
openTabIds: [newId],
|
||||
activeTabId: newId,
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
}
|
||||
|
||||
if (nextActive === id) {
|
||||
const closedIdx = prev.openTabIds.indexOf(id)
|
||||
nextActive = nextTabs[Math.min(closedIdx, nextTabs.length - 1)]
|
||||
}
|
||||
|
||||
const next = { ...prev, openTabIds: nextTabs, activeTabId: nextActive }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteDocument = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const nextDocs = prev.documents.filter(d => d.id !== id)
|
||||
let nextTabs = prev.openTabIds.filter(tid => tid !== id)
|
||||
let nextActive = prev.activeTabId
|
||||
|
||||
if (nextDocs.length === 0) {
|
||||
const wd = { ...WELCOME_DOC, id: generateId() }
|
||||
nextDocs.push(wd)
|
||||
}
|
||||
if (nextTabs.length === 0) nextTabs = [nextDocs[0].id]
|
||||
if (!nextTabs.includes(nextActive)) nextActive = nextTabs[0]
|
||||
|
||||
const next: StoreState = { ...prev, documents: nextDocs, openTabIds: nextTabs, activeTabId: nextActive }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reorderTabs = useCallback((fromIndex: number, toIndex: number) => {
|
||||
setState(prev => {
|
||||
const tabs = [...prev.openTabIds]
|
||||
const [moved] = tabs.splice(fromIndex, 1)
|
||||
tabs.splice(toIndex, 0, moved)
|
||||
const next = { ...prev, openTabIds: tabs }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const openDocument = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next = prev.openTabIds.includes(id)
|
||||
? { ...prev, activeTabId: id }
|
||||
: { ...prev, openTabIds: [...prev.openTabIds, id], activeTabId: id }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleFavorite = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === id ? { ...d, isFavorite: !d.isFavorite } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const moveToFolder = useCallback((docId: string, folderId: string | null) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d =>
|
||||
d.id === docId ? { ...d, folderId } : d
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createFolder = useCallback((name?: string, parentId?: string | null): CalcFolder => {
|
||||
const folder: CalcFolder = {
|
||||
id: generateId(),
|
||||
name: name ?? 'New Folder',
|
||||
parentId: parentId ?? null,
|
||||
order: stateRef.current.folders.length,
|
||||
}
|
||||
setState(prev => {
|
||||
const next = { ...prev, folders: [...prev.folders, folder] }
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
return folder
|
||||
}, [])
|
||||
|
||||
const renameFolder = useCallback((id: string, name: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
folders: prev.folders.map(f =>
|
||||
f.id === id ? { ...f, name: name.trim() || 'Folder' } : f
|
||||
),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteFolder = useCallback((id: string) => {
|
||||
setState(prev => {
|
||||
const next: StoreState = {
|
||||
...prev,
|
||||
documents: prev.documents.map(d => d.folderId === id ? { ...d, folderId: null } : d),
|
||||
folders: prev.folders.filter(f => f.id !== id && f.parentId !== id),
|
||||
}
|
||||
saveState(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activeDoc = state.documents.find(d => d.id === state.activeTabId) ?? state.documents[0]
|
||||
const openDocs = state.openTabIds
|
||||
.map(id => state.documents.find(d => d.id === id))
|
||||
.filter((d): d is CalcDocument => d != null)
|
||||
|
||||
return {
|
||||
documents: state.documents,
|
||||
folders: state.folders,
|
||||
openDocs,
|
||||
openTabIds: state.openTabIds,
|
||||
activeTabId: state.activeTabId,
|
||||
activeDoc,
|
||||
setActiveTab,
|
||||
createDocument,
|
||||
updateContent,
|
||||
renameDocument,
|
||||
closeTab,
|
||||
deleteDocument,
|
||||
reorderTabs,
|
||||
openDocument,
|
||||
toggleFavorite,
|
||||
moveToFolder,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
}
|
||||
}
|
||||
135
calcpad-web/src/hooks/useTheme.ts
Normal file
135
calcpad-web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
const STORAGE_KEY = 'calctext-theme'
|
||||
const ACCENT_KEY = 'calctext-accent'
|
||||
|
||||
export type ThemeId = 'system' | 'light' | 'dark' | 'matrix' | 'midnight' | 'warm'
|
||||
|
||||
export const THEMES: { id: ThemeId; name: string; icon: string }[] = [
|
||||
{ id: 'light', name: 'Light', icon: '☀️' },
|
||||
{ id: 'dark', name: 'Dark', icon: '🌙' },
|
||||
{ id: 'matrix', name: 'Matrix', icon: '💻' },
|
||||
{ id: 'midnight', name: 'Midnight', icon: '🌊' },
|
||||
{ id: 'warm', name: 'Warm', icon: '📜' },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ name: 'Indigo', light: '#6366f1', dark: '#818cf8' },
|
||||
{ name: 'Teal', light: '#14b8a6', dark: '#2dd4bf' },
|
||||
{ name: 'Rose', light: '#f43f5e', dark: '#fb7185' },
|
||||
{ name: 'Amber', light: '#f59e0b', dark: '#fbbf24' },
|
||||
{ name: 'Emerald', light: '#10b981', dark: '#34d399' },
|
||||
{ name: 'Sky', light: '#0ea5e9', dark: '#38bdf8' },
|
||||
]
|
||||
|
||||
function getStoredTheme(): ThemeId {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && ['system', 'light', 'dark', 'matrix', 'midnight', 'warm'].includes(stored)) {
|
||||
return stored as ThemeId
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function getStoredAccent(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCENT_KEY)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function resolveSystemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function isDarkTheme(theme: ThemeId): boolean {
|
||||
if (theme === 'system') return resolveSystemTheme() === 'dark'
|
||||
return theme === 'dark' || theme === 'matrix' || theme === 'midnight'
|
||||
}
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<ThemeId>(getStoredTheme)
|
||||
const [accentColor, setAccentState] = useState<string | null>(getStoredAccent)
|
||||
|
||||
const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme
|
||||
|
||||
const setTheme = useCallback((id: ThemeId) => {
|
||||
setThemeState(id)
|
||||
try { localStorage.setItem(STORAGE_KEY, id) } catch { /* */ }
|
||||
applyTheme(id, accentColor)
|
||||
}, [accentColor])
|
||||
|
||||
const setAccent = useCallback((color: string | null) => {
|
||||
setAccentState(color)
|
||||
try {
|
||||
if (color) localStorage.setItem(ACCENT_KEY, color)
|
||||
else localStorage.removeItem(ACCENT_KEY)
|
||||
} catch { /* */ }
|
||||
applyTheme(theme, color)
|
||||
}, [theme])
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => applyTheme('system', accentColor)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [theme, accentColor])
|
||||
|
||||
// Apply on mount
|
||||
useEffect(() => {
|
||||
applyTheme(theme, accentColor)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
accentColor,
|
||||
setAccent,
|
||||
isDark: isDarkTheme(theme),
|
||||
themes: THEMES,
|
||||
accentColors: ACCENT_COLORS,
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeId, accent: string | null) {
|
||||
const resolved = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme
|
||||
|
||||
document.documentElement.setAttribute('data-theme', resolved)
|
||||
|
||||
// Apply custom accent if set
|
||||
if (accent) {
|
||||
const dark = isDarkTheme(theme)
|
||||
document.documentElement.style.setProperty('--accent', accent)
|
||||
document.documentElement.style.setProperty('--accent-bg', hexToRgba(accent, dark ? 0.15 : 0.1))
|
||||
document.documentElement.style.setProperty('--accent-border', hexToRgba(accent, 0.5))
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--accent')
|
||||
document.documentElement.style.removeProperty('--accent-bg')
|
||||
document.documentElement.style.removeProperty('--accent-border')
|
||||
}
|
||||
|
||||
// Update PWA theme-color
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) {
|
||||
const bgColors: Record<string, string> = {
|
||||
light: '#ffffff',
|
||||
dark: '#16171d',
|
||||
matrix: '#0a0a0a',
|
||||
midnight: '#0f172a',
|
||||
warm: '#fffbf5',
|
||||
}
|
||||
meta.setAttribute('content', bgColors[resolved] ?? '#ffffff')
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
53
calcpad-web/src/styles/align-toolbar.css
Normal file
53
calcpad-web/src/styles/align-toolbar.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* ---------- Align Toolbar (inline in header) ---------- */
|
||||
|
||||
.align-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.align-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.align-label {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
margin-right: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.align-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.align-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.align-btn.active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.align-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* ---------- Answer column (standalone panel mode) ---------- */
|
||||
|
||||
.answer-column {
|
||||
width: 220px;
|
||||
padding: 12px 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.answer-line {
|
||||
padding: 0 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Match CodeMirror's line height for alignment */
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.answer-value {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.answer-error {
|
||||
color: var(--error);
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.answer-empty {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.answer-column {
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
@@ -13,50 +13,67 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.calcpad-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.calcpad-header .subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Sidebar toggle ---------- */
|
||||
|
||||
.header-sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
.header-sidebar-toggle:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.status-dot.loading {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
/* ---------- Workspace (sidebar + editor area) ---------- */
|
||||
|
||||
.calcpad-workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ---------- Editor area ---------- */
|
||||
/* ---------- Editor area (two-column layout) ---------- */
|
||||
|
||||
.calcpad-editor {
|
||||
flex: 1;
|
||||
@@ -65,7 +82,7 @@
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
flex: 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -79,14 +96,87 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
.pane-divider {
|
||||
width: 5px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.calcpad-header {
|
||||
padding: 10px 16px;
|
||||
.pane-divider:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Wider invisible hit area */
|
||||
.pane-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* ---------- Responsive: Mobile (< 768px) ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.calcpad-app {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.calcpad-header .subtitle {
|
||||
.calcpad-header {
|
||||
height: 44px;
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-sidebar-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pane-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Editor goes full width, results panel hidden (tray replaces it) */
|
||||
.calcpad-editor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe areas for notched devices */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
.calcpad-app {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh in PWA */
|
||||
.calcpad-app {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
86
calcpad-web/src/styles/format-toolbar.css
Normal file
86
calcpad-web/src/styles/format-toolbar.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* ---------- Format Toolbar ---------- */
|
||||
|
||||
.format-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.format-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.format-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.format-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--border);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.format-btn:active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.format-italic {
|
||||
font-style: italic;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.format-preview-toggle.active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Color Buttons ---------- */
|
||||
|
||||
.format-colors {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.format-color-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.format-color-btn:hover {
|
||||
transform: scale(1.25);
|
||||
border-color: var(--text-h);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.format-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
/* ---------- Base & Font Setup ---------- */
|
||||
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #6366f1;
|
||||
--accent-bg: rgba(99, 102, 241, 0.1);
|
||||
--accent-border: rgba(99, 102, 241, 0.5);
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, 'Courier New', monospace;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||
--success: #10b981;
|
||||
--success-bg: rgba(16, 185, 129, 0.1);
|
||||
--error: #e53e3e;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, 'Courier New', monospace;
|
||||
|
||||
font: 16px/1.5 var(--sans);
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
@@ -28,18 +21,180 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--bg-secondary: #1a1b23;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #818cf8;
|
||||
--accent-bg: rgba(129, 140, 248, 0.15);
|
||||
--accent-border: rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
/* ---------- Theme: Light (default) ---------- */
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #6366f1;
|
||||
--accent-bg: rgba(99, 102, 241, 0.1);
|
||||
--accent-border: rgba(99, 102, 241, 0.5);
|
||||
--stripe: rgba(0, 0, 0, 0.02);
|
||||
|
||||
--syntax-variable: #4f46e5;
|
||||
--syntax-number: #0d9488;
|
||||
--syntax-operator: #6b6375;
|
||||
--syntax-keyword: #7c3aed;
|
||||
--syntax-function: #2563eb;
|
||||
--syntax-currency: #d97706;
|
||||
--syntax-comment: rgba(107, 99, 117, 0.5);
|
||||
--syntax-heading: #08060d;
|
||||
|
||||
--result-number: #374151;
|
||||
--result-unit: #0d9488;
|
||||
--result-currency: #d97706;
|
||||
--result-datetime: #7c3aed;
|
||||
--result-boolean: #6366f1;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Dark ---------- */
|
||||
|
||||
[data-theme="dark"] {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--bg-secondary: #1a1b23;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #818cf8;
|
||||
--accent-bg: rgba(129, 140, 248, 0.15);
|
||||
--accent-border: rgba(129, 140, 248, 0.5);
|
||||
--stripe: rgba(255, 255, 255, 0.025);
|
||||
|
||||
--syntax-variable: #a5b4fc;
|
||||
--syntax-number: #5eead4;
|
||||
--syntax-operator: #9ca3af;
|
||||
--syntax-keyword: #c4b5fd;
|
||||
--syntax-function: #93c5fd;
|
||||
--syntax-currency: #fcd34d;
|
||||
--syntax-comment: rgba(156, 163, 175, 0.5);
|
||||
--syntax-heading: #f3f4f6;
|
||||
|
||||
--result-number: #d1d5db;
|
||||
--result-unit: #5eead4;
|
||||
--result-currency: #fcd34d;
|
||||
--result-datetime: #c4b5fd;
|
||||
--result-boolean: #818cf8;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Matrix ---------- */
|
||||
|
||||
[data-theme="matrix"] {
|
||||
--text: #00ff41;
|
||||
--text-h: #33ff66;
|
||||
--bg: #0a0a0a;
|
||||
--bg-secondary: #0f1a0f;
|
||||
--border: #003300;
|
||||
--code-bg: #0a0f0a;
|
||||
--accent: #00ff41;
|
||||
--accent-bg: rgba(0, 255, 65, 0.1);
|
||||
--accent-border: rgba(0, 255, 65, 0.4);
|
||||
--stripe: rgba(0, 255, 65, 0.03);
|
||||
|
||||
--syntax-variable: #00ff41;
|
||||
--syntax-number: #00cc33;
|
||||
--syntax-operator: #00ff41;
|
||||
--syntax-keyword: #39ff14;
|
||||
--syntax-function: #00ff41;
|
||||
--syntax-currency: #ffff00;
|
||||
--syntax-comment: rgba(0, 255, 65, 0.4);
|
||||
--syntax-heading: #33ff66;
|
||||
|
||||
--result-number: #00ff41;
|
||||
--result-unit: #00cc33;
|
||||
--result-currency: #ffff00;
|
||||
--result-datetime: #39ff14;
|
||||
--result-boolean: #00ff41;
|
||||
|
||||
--mono: 'Courier New', 'Fira Code', monospace;
|
||||
--success: #00ff41;
|
||||
--error: #ff0000;
|
||||
}
|
||||
|
||||
/* Matrix special effects */
|
||||
[data-theme="matrix"] .calcpad-app::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.06) 2px,
|
||||
rgba(0, 0, 0, 0.06) 4px
|
||||
);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
[data-theme="matrix"] .cm-cursor {
|
||||
border-color: #00ff41 !important;
|
||||
box-shadow: 0 0 4px #00ff41, 0 0 8px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
/* ---------- Theme: Midnight ---------- */
|
||||
|
||||
[data-theme="midnight"] {
|
||||
--text: #94a3b8;
|
||||
--text-h: #e2e8f0;
|
||||
--bg: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--border: #334155;
|
||||
--code-bg: #1e293b;
|
||||
--accent: #38bdf8;
|
||||
--accent-bg: rgba(56, 189, 248, 0.12);
|
||||
--accent-border: rgba(56, 189, 248, 0.5);
|
||||
--stripe: rgba(56, 189, 248, 0.03);
|
||||
|
||||
--syntax-variable: #7dd3fc;
|
||||
--syntax-number: #5eead4;
|
||||
--syntax-operator: #94a3b8;
|
||||
--syntax-keyword: #c4b5fd;
|
||||
--syntax-function: #7dd3fc;
|
||||
--syntax-currency: #fcd34d;
|
||||
--syntax-comment: rgba(148, 163, 184, 0.5);
|
||||
--syntax-heading: #e2e8f0;
|
||||
|
||||
--result-number: #cbd5e1;
|
||||
--result-unit: #5eead4;
|
||||
--result-currency: #fcd34d;
|
||||
--result-datetime: #c4b5fd;
|
||||
--result-boolean: #38bdf8;
|
||||
}
|
||||
|
||||
/* ---------- Theme: Warm ---------- */
|
||||
|
||||
[data-theme="warm"] {
|
||||
--text: #78716c;
|
||||
--text-h: #1c1917;
|
||||
--bg: #fffbf5;
|
||||
--bg-secondary: #fef3e2;
|
||||
--border: #e7e5e4;
|
||||
--code-bg: #fef3e2;
|
||||
--accent: #f97316;
|
||||
--accent-bg: rgba(249, 115, 22, 0.1);
|
||||
--accent-border: rgba(249, 115, 22, 0.5);
|
||||
--stripe: rgba(249, 115, 22, 0.03);
|
||||
|
||||
--syntax-variable: #c2410c;
|
||||
--syntax-number: #0d9488;
|
||||
--syntax-operator: #78716c;
|
||||
--syntax-keyword: #7c3aed;
|
||||
--syntax-function: #2563eb;
|
||||
--syntax-currency: #d97706;
|
||||
--syntax-comment: rgba(120, 113, 108, 0.5);
|
||||
--syntax-heading: #1c1917;
|
||||
|
||||
--result-number: #44403c;
|
||||
--result-unit: #0d9488;
|
||||
--result-currency: #d97706;
|
||||
--result-datetime: #7c3aed;
|
||||
--result-boolean: #f97316;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
117
calcpad-web/src/styles/mobile-results-tray.css
Normal file
117
calcpad-web/src/styles/mobile-results-tray.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* ---------- Mobile Results Tray ---------- */
|
||||
/* Only visible on mobile (< 768px) */
|
||||
|
||||
.mobile-results-tray {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mobile-results-tray {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: max-height 0.2s ease-out;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-results-tray.expanded {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
/* ---------- Header / Collapsed ---------- */
|
||||
|
||||
.tray-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tray-drag-handle {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tray-last-result {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- Expanded Content ---------- */
|
||||
|
||||
.tray-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tray-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tray-result-item:active {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.tray-result-item.copied {
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.tray-result-line {
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tray-result-expr {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tray-result-value {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--result-number, var(--text-h));
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tray-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
104
calcpad-web/src/styles/results-panel.css
Normal file
104
calcpad-web/src/styles/results-panel.css
Normal file
@@ -0,0 +1,104 @@
|
||||
/* ---------- Results Panel ---------- */
|
||||
|
||||
.results-panel {
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.result-line {
|
||||
padding: 0 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- Type-Specific Colors ---------- */
|
||||
|
||||
.result-value {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.result-value:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.result-number {
|
||||
color: var(--result-number, var(--text));
|
||||
}
|
||||
|
||||
.result-unit {
|
||||
color: var(--result-unit, #0d9488);
|
||||
}
|
||||
|
||||
.result-currency {
|
||||
color: var(--result-currency, #d97706);
|
||||
}
|
||||
|
||||
.result-datetime {
|
||||
color: var(--result-datetime, #7c3aed);
|
||||
}
|
||||
|
||||
.result-boolean {
|
||||
color: var(--result-boolean, var(--accent));
|
||||
}
|
||||
|
||||
/* ---------- Copy Feedback ---------- */
|
||||
|
||||
.result-value.copied {
|
||||
color: var(--success) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---------- Error Hint ---------- */
|
||||
|
||||
.result-error-hint {
|
||||
color: var(--text);
|
||||
opacity: 0.25;
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
/* ---------- Comment/Heading Marker ---------- */
|
||||
|
||||
.result-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.result-dash {
|
||||
display: inline-block;
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
opacity: 0.4;
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Empty ---------- */
|
||||
|
||||
.result-empty {
|
||||
/* intentionally blank */
|
||||
}
|
||||
|
||||
/* ---------- Stripes ---------- */
|
||||
|
||||
.result-stripe {
|
||||
background: var(--stripe, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.results-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
460
calcpad-web/src/styles/sidebar.css
Normal file
460
calcpad-web/src/styles/sidebar.css
Normal file
@@ -0,0 +1,460 @@
|
||||
/* ---------- Sidebar ---------- */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Search ---------- */
|
||||
|
||||
.sidebar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 24px 4px 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-search-input:focus {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.sidebar-search-input::placeholder {
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-search-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-search-clear:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- Content ---------- */
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.sidebar-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sidebar-section-header:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-section-chevron {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- File Item ---------- */
|
||||
|
||||
.sidebar-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-file:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-file.active {
|
||||
background: var(--accent-bg);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-file-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-file.active .sidebar-file-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sidebar-open-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---------- Folder Item ---------- */
|
||||
|
||||
.sidebar-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-folder:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-folder-chevron {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-folder-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-folder-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-folder-count {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Drag & Drop ---------- */
|
||||
|
||||
.sidebar-file.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sidebar-folder.drop-target {
|
||||
background: var(--accent-bg);
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section-header.drop-target {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-files-area {
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Templates ---------- */
|
||||
|
||||
.sidebar-template {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-template:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.sidebar-template-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-template-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-template-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-template-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ---------- Empty State ---------- */
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Rename Input ---------- */
|
||||
|
||||
.sidebar-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 2px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer-btn {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-footer-btn:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
/* ---------- Resize Handle ---------- */
|
||||
|
||||
.sidebar-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-resize:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---------- Context Menu ---------- */
|
||||
|
||||
.sidebar-context-menu {
|
||||
position: fixed;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
z-index: 300;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.sidebar-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-context-menu button:hover {
|
||||
background: var(--accent-bg);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sidebar-context-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-context-label {
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-context-danger {
|
||||
color: var(--error) !important;
|
||||
}
|
||||
|
||||
/* ---------- Responsive: Mobile Drawer ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 85vw !important;
|
||||
max-width: 320px;
|
||||
z-index: 400;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.25);
|
||||
animation: sidebar-slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes sidebar-slide-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.sidebar-file {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-folder {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-template {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile sidebar backdrop — rendered from App.tsx */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 399;
|
||||
animation: sidebar-backdrop-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes sidebar-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
159
calcpad-web/src/styles/status-bar.css
Normal file
159
calcpad-web/src/styles/status-bar.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* ---------- Status Bar ---------- */
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
opacity: 0.8;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-bar-left,
|
||||
.status-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-bar-engine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-bar-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-bar-dot.ready {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-bar-dot.loading {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-bar-dedication {
|
||||
opacity: 0.6;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.status-bar-dedication:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-bar-heart {
|
||||
color: #e53e3e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- Dedication Overlay ---------- */
|
||||
|
||||
.dedication-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
animation: dedication-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dedication-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.dedication-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: dedication-card-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dedication-card-in {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.dedication-heart {
|
||||
font-size: 48px;
|
||||
color: #e53e3e;
|
||||
margin-bottom: 16px;
|
||||
animation: dedication-beat 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dedication-beat {
|
||||
0%, 100% { transform: scale(1); }
|
||||
14% { transform: scale(1.15); }
|
||||
28% { transform: scale(1); }
|
||||
42% { transform: scale(1.1); }
|
||||
56% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.dedication-card h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.dedication-card p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.dedication-tagline {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.dedication-close {
|
||||
margin-top: 20px;
|
||||
padding: 8px 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dedication-close:hover {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.status-bar-left span:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
205
calcpad-web/src/styles/tab-bar.css
Normal file
205
calcpad-web/src/styles/tab-bar.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* ---------- Tab Bar ---------- */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-bar-scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.tab-bar-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
/* Fade indicators for scroll overflow */
|
||||
.tab-bar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar::before,
|
||||
.tab-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tab-bar::before {
|
||||
left: 0;
|
||||
background: linear-gradient(to right, var(--bg-secondary), transparent);
|
||||
}
|
||||
|
||||
.tab-bar::after {
|
||||
right: 36px; /* before new tab button */
|
||||
background: linear-gradient(to left, var(--bg-secondary), transparent);
|
||||
}
|
||||
|
||||
/* ---------- Tab Item ---------- */
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
min-width: 100px;
|
||||
max-width: 200px;
|
||||
height: 36px;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--bg);
|
||||
border-bottom-color: transparent;
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.tab-item:not(.active) {
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* ---------- Tab Label ---------- */
|
||||
|
||||
.tab-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
/* ---------- Modified Dot ---------- */
|
||||
|
||||
.tab-modified-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
animation: tab-dot-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-dot-in {
|
||||
from { opacity: 0; transform: scale(0); }
|
||||
to { opacity: 0.6; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tab-bar {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
height: 40px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-new {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Close Button ---------- */
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
/* ---------- Rename Input ---------- */
|
||||
|
||||
.tab-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 2px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---------- New Tab Button ---------- */
|
||||
|
||||
.tab-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.tab-new:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
130
calcpad-web/src/styles/theme-picker.css
Normal file
130
calcpad-web/src/styles/theme-picker.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* ---------- Theme Picker ---------- */
|
||||
|
||||
.theme-picker-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.theme-picker-trigger:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.theme-picker-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 6px;
|
||||
z-index: 200;
|
||||
animation: theme-picker-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes theme-picker-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-picker-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
padding: 6px 8px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.theme-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.theme-picker-item:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.theme-picker-item.active {
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.theme-picker-item-icon {
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theme-picker-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-picker-check {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-picker-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.theme-picker-accents {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 8px 6px;
|
||||
}
|
||||
|
||||
.theme-picker-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.theme-picker-swatch:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.theme-picker-swatch.active {
|
||||
border-color: var(--text-h);
|
||||
}
|
||||
5
calcpad-web/src/vite-env.d.ts
vendored
5
calcpad-web/src/vite-env.d.ts
vendored
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '/wasm/calcpad_wasm.js' {
|
||||
export default function init(): Promise<void>
|
||||
export function evalSheet(lines: string[]): import('./engine/types.ts').EngineLineResult[]
|
||||
}
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons/*.svg'],
|
||||
manifest: {
|
||||
name: 'CalcPad',
|
||||
|
||||
Reference in New Issue
Block a user