feat: add platform shells, CLI, formatting, plugins, tests, and benchmarks
Phase 4 — Platform shells: - calcpad-macos/: SwiftUI two-column editor with Rust FFI bridge (16 files) - calcpad-windows/: iced GUI with Windows 11 Fluent theme (7 files, 13 tests) - calcpad-web/: React 18 + CodeMirror 6 + WASM Worker + PWA (20 files) - calcpad-cli/: clap-based CLI with expression eval, pipe/stdin, JSON/CSV output, and interactive REPL with rustyline history Phase 5 — Engine modules: - formatting/: answer formatting (decimal/scientific/SI notation, thousands separators, currency), line type classification, clipboard values (93 tests) - plugins/: CalcPadPlugin trait, PluginRegistry, Rhai scripting stub (43 tests) - benches/: criterion benchmarks (single-line, 100/500-line sheets, DAG, incremental) - tests/sheet_scenarios.rs: 20 real-world integration tests - tests/proptest_fuzz.rs: 12 property-based fuzz tests 771 tests passing across workspace, 0 failures.
This commit is contained in:
24
calcpad-web/.gitignore
vendored
Normal file
24
calcpad-web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
19
calcpad-web/index.html
Normal file
19
calcpad-web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="CalcPad - A modern notepad calculator powered by WebAssembly" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
calcpad-web/package.json
Normal file
33
calcpad-web/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "calcpad-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "CalcPad web app — React + CodeMirror 6 + WASM engine",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.5",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
4
calcpad-web/public/favicon.svg
Normal file
4
calcpad-web/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#6366f1"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="white">=</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
4
calcpad-web/public/icons/icon-192.svg
Normal file
4
calcpad-web/public/icons/icon-192.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="24" fill="#6366f1"/>
|
||||
<text x="96" y="128" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="100" fill="white">=</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 264 B |
4
calcpad-web/public/icons/icon-512.svg
Normal file
4
calcpad-web/public/icons/icon-512.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#6366f1"/>
|
||||
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
4
calcpad-web/public/icons/icon-maskable-512.svg
Normal file
4
calcpad-web/public/icons/icon-maskable-512.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#6366f1"/>
|
||||
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
89
calcpad-web/src/App.tsx
Normal file
89
calcpad-web/src/App.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* CalcPad 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.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
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 { OfflineBanner } from './components/OfflineBanner.tsx'
|
||||
import { InstallPrompt } from './components/InstallPrompt.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 handleDocChange = useCallback(
|
||||
(lines: string[]) => {
|
||||
engine.evalSheet(lines)
|
||||
},
|
||||
[engine.evalSheet],
|
||||
)
|
||||
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane">
|
||||
<CalcEditor
|
||||
initialDoc={INITIAL_DOC}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<InstallPrompt
|
||||
promptEvent={installPrompt.promptEvent}
|
||||
isInstalled={installPrompt.isInstalled}
|
||||
onInstall={installPrompt.handleInstall}
|
||||
onDismiss={installPrompt.handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
49
calcpad-web/src/components/AnswerColumn.tsx
Normal file
49
calcpad-web/src/components/AnswerColumn.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
45
calcpad-web/src/components/InstallPrompt.tsx
Normal file
45
calcpad-web/src/components/InstallPrompt.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* PWA install prompt shown when the browser supports app installation.
|
||||
* Adapted from epic/9-3-pwa-support.
|
||||
*/
|
||||
|
||||
import '../styles/install-prompt.css'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
interface InstallPromptProps {
|
||||
promptEvent: BeforeInstallPromptEvent | null
|
||||
isInstalled: boolean
|
||||
onInstall: () => Promise<void>
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function InstallPrompt({
|
||||
promptEvent,
|
||||
isInstalled,
|
||||
onInstall,
|
||||
onDismiss,
|
||||
}: InstallPromptProps) {
|
||||
if (isInstalled || !promptEvent) return null
|
||||
|
||||
return (
|
||||
<div className="install-prompt" role="complementary" aria-label="Install CalcPad">
|
||||
<div className="install-content">
|
||||
<p className="install-text">
|
||||
Install CalcPad for offline access and a native app experience.
|
||||
</p>
|
||||
<div className="install-actions">
|
||||
<button className="install-btn" onClick={onInstall}>
|
||||
Install
|
||||
</button>
|
||||
<button className="install-dismiss" onClick={onDismiss}>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
calcpad-web/src/components/OfflineBanner.tsx
Normal file
23
calcpad-web/src/components/OfflineBanner.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Banner shown when the user is offline.
|
||||
* Adapted from epic/9-3-pwa-support.
|
||||
*/
|
||||
|
||||
import '../styles/offline-banner.css'
|
||||
|
||||
interface OfflineBannerProps {
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
export function OfflineBanner({ isOnline }: OfflineBannerProps) {
|
||||
if (isOnline) return null
|
||||
|
||||
return (
|
||||
<div className="offline-banner" role="status" aria-live="polite">
|
||||
<span className="offline-icon" aria-hidden="true">
|
||||
●
|
||||
</span>
|
||||
You are offline. Changes are saved locally.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
calcpad-web/src/editor/CalcEditor.tsx
Normal file
224
calcpad-web/src/editor/CalcEditor.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
||||
*
|
||||
* Integrates the CalcPad language mode, answer gutter, error display,
|
||||
* and debounced evaluation via the WASM engine Web Worker.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
lineNumbers,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
} from '@codemirror/language'
|
||||
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 type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
export interface CalcEditorProps {
|
||||
/** Initial document content */
|
||||
initialDoc?: string
|
||||
/** Called when the document text changes (debounced internally) */
|
||||
onDocChange?: (lines: string[]) => void
|
||||
/** Engine evaluation results to display in the answer gutter */
|
||||
results?: EngineLineResult[]
|
||||
/** Debounce delay in ms before triggering onDocChange */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* CalcPad editor component built on CodeMirror 6.
|
||||
* Handles syntax highlighting, line numbers, answer gutter,
|
||||
* and error underlines.
|
||||
*/
|
||||
export function CalcEditor({
|
||||
initialDoc = '',
|
||||
onDocChange,
|
||||
results,
|
||||
debounceMs = 50,
|
||||
}: CalcEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Stable callback ref for doc changes
|
||||
const onDocChangeRef = useRef(onDocChange)
|
||||
onDocChangeRef.current = onDocChange
|
||||
|
||||
const scheduleEval = useCallback((view: EditorView) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const doc = view.state.doc.toString()
|
||||
const lines = doc.split('\n')
|
||||
onDocChangeRef.current?.(lines)
|
||||
}, debounceMs)
|
||||
}, [debounceMs])
|
||||
|
||||
// Create editor on mount
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && viewRef.current) {
|
||||
scheduleEval(viewRef.current)
|
||||
}
|
||||
})
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialDoc,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
calcpadLanguage(),
|
||||
answerGutterExtension(),
|
||||
errorDisplayExtension(),
|
||||
updateListener,
|
||||
calcpadEditorTheme,
|
||||
],
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
|
||||
// Trigger initial evaluation
|
||||
const doc = view.state.doc.toString()
|
||||
onDocChangeRef.current?.(doc.split('\n'))
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
view.destroy()
|
||||
viewRef.current = 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
|
||||
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
|
||||
if (lineNum <= view.state.doc.lines) {
|
||||
const docLine = view.state.doc.line(lineNum)
|
||||
errors.push({
|
||||
from: docLine.from,
|
||||
to: docLine.to,
|
||||
message: result.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setAnswersEffect.of(answers),
|
||||
setErrorsEffect.of(errors),
|
||||
],
|
||||
})
|
||||
}, [results])
|
||||
|
||||
return <div ref={containerRef} className="calc-editor" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Base theme for the CalcPad editor.
|
||||
*/
|
||||
const calcpadEditorTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
minHeight: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 16px',
|
||||
lineHeight: '1.6',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 8px 0 16px',
|
||||
color: '#9ca3af',
|
||||
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',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'&dark .cm-answer-value': {
|
||||
color: '#818cf8',
|
||||
},
|
||||
'.cm-answer-error': {
|
||||
color: '#e53e3e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'&dark .cm-answer-error': {
|
||||
color: '#fc8181',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
},
|
||||
'&dark .cm-activeLine': {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
})
|
||||
104
calcpad-web/src/editor/answer-gutter.ts
Normal file
104
calcpad-web/src/editor/answer-gutter.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
145
calcpad-web/src/editor/calcpad-language.ts
Normal file
145
calcpad-web/src/editor/calcpad-language.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* CodeMirror 6 language extension for CalcPad syntax highlighting.
|
||||
* Adapted from epic/9-2-codemirror-6-editor.
|
||||
*
|
||||
* Uses StreamLanguage to tokenize CalcPad input and map token types
|
||||
* to CodeMirror highlight tags.
|
||||
*/
|
||||
|
||||
import { StreamLanguage, type StringStream, LanguageSupport } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
'if', 'then', 'else',
|
||||
'in', 'to', 'as', 'of', 'per',
|
||||
'sum', 'total', 'average', 'avg', 'min', 'max', 'count',
|
||||
])
|
||||
|
||||
const BUILTIN_FUNCTIONS = new Set([
|
||||
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
|
||||
'log', 'ln', 'exp', 'sqrt', 'abs',
|
||||
'floor', 'ceil', 'round',
|
||||
])
|
||||
|
||||
const CONSTANTS = new Set(['pi', 'e'])
|
||||
|
||||
export interface CalcPadState {
|
||||
inComment: boolean
|
||||
}
|
||||
|
||||
export const calcpadStreamParser = {
|
||||
startState(): CalcPadState {
|
||||
return { inComment: false }
|
||||
},
|
||||
|
||||
token(stream: StringStream, _state: CalcPadState): string | null {
|
||||
// Start of line: check for heading
|
||||
if (stream.sol() && stream.match(/^#{1,6}\s/)) {
|
||||
stream.skipToEnd()
|
||||
return 'heading'
|
||||
}
|
||||
|
||||
// Comment
|
||||
if (stream.match('//')) {
|
||||
stream.skipToEnd()
|
||||
return 'lineComment'
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null
|
||||
|
||||
// Line reference: #N
|
||||
if (stream.match(/^#\d+/)) {
|
||||
return 'special'
|
||||
}
|
||||
|
||||
// Line reference: lineN
|
||||
if (stream.match(/^line\d+/i)) {
|
||||
return 'special'
|
||||
}
|
||||
|
||||
// Number (integer or decimal, with optional scientific notation)
|
||||
if (stream.match(/^\d+(\.\d+)?([eE][+-]?\d+)?/)) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
// Assignment operator
|
||||
if (stream.eat('=')) {
|
||||
return 'definitionOperator'
|
||||
}
|
||||
|
||||
// Operators
|
||||
if (stream.match(/^[+\-*\/^%]/)) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
// Comparison operators
|
||||
if (stream.match(/^[<>]=?/) || stream.match(/^[!=]=/)) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
// Parentheses
|
||||
if (stream.eat('(') || stream.eat(')')) {
|
||||
return 'paren'
|
||||
}
|
||||
|
||||
// Comma
|
||||
if (stream.eat(',')) {
|
||||
return 'punctuation'
|
||||
}
|
||||
|
||||
// Currency symbols
|
||||
if (stream.eat('$') || stream.eat('\u20AC') || stream.eat('\u00A3') || stream.eat('\u00A5')) {
|
||||
return 'keyword'
|
||||
}
|
||||
|
||||
// Identifier, keyword, or function
|
||||
const identMatch = stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)
|
||||
if (identMatch) {
|
||||
const word = typeof identMatch === 'string' ? identMatch : (identMatch as RegExpMatchArray)[0]
|
||||
const lower = word.toLowerCase()
|
||||
|
||||
if (BUILTIN_FUNCTIONS.has(lower)) {
|
||||
return 'function'
|
||||
}
|
||||
if (CONSTANTS.has(lower)) {
|
||||
return 'constant'
|
||||
}
|
||||
if (KEYWORDS.has(lower)) {
|
||||
return 'keyword'
|
||||
}
|
||||
return 'variableName'
|
||||
}
|
||||
|
||||
// Skip unknown character
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
export const calcpadStreamLanguage = StreamLanguage.define<CalcPadState>(calcpadStreamParser)
|
||||
|
||||
/**
|
||||
* Tag mapping for CalcPad token types to CodeMirror highlight tags.
|
||||
*/
|
||||
export const calcpadHighlightTags: Record<string, Tag> = {
|
||||
number: tags.number,
|
||||
operator: tags.operator,
|
||||
variableName: tags.variableName,
|
||||
function: tags.function(tags.variableName),
|
||||
keyword: tags.keyword,
|
||||
lineComment: tags.lineComment,
|
||||
heading: tags.heading,
|
||||
definitionOperator: tags.definitionOperator,
|
||||
special: tags.special(tags.variableName),
|
||||
constant: tags.constant(tags.variableName),
|
||||
paren: tags.paren,
|
||||
punctuation: tags.punctuation,
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CalcPad language extension for CodeMirror 6.
|
||||
*/
|
||||
export function calcpadLanguage(): LanguageSupport {
|
||||
return new LanguageSupport(calcpadStreamLanguage)
|
||||
}
|
||||
142
calcpad-web/src/editor/error-display.ts
Normal file
142
calcpad-web/src/editor/error-display.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Inline error display for CalcPad.
|
||||
* Adapted from epic/9-2-codemirror-6-editor.
|
||||
*
|
||||
* Shows red underline decorations and gutter markers for lines with errors.
|
||||
*/
|
||||
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
GutterMarker,
|
||||
gutter,
|
||||
EditorView,
|
||||
} from '@codemirror/view'
|
||||
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
|
||||
|
||||
// --- State Effects ---
|
||||
|
||||
export interface LineError {
|
||||
from: number // absolute start position in document
|
||||
to: number // absolute end position in document
|
||||
message: string
|
||||
}
|
||||
|
||||
export const setErrorsEffect = StateEffect.define<LineError[]>()
|
||||
|
||||
// --- Error Gutter Marker ---
|
||||
|
||||
class ErrorGutterMarker extends GutterMarker {
|
||||
override toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-error-marker'
|
||||
span.textContent = '\u26A0' // Warning sign
|
||||
return span
|
||||
}
|
||||
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return other instanceof ErrorGutterMarker
|
||||
}
|
||||
}
|
||||
|
||||
const errorMarkerInstance = new ErrorGutterMarker()
|
||||
|
||||
// --- Error Underline Decorations (StateField) ---
|
||||
|
||||
const errorUnderlineMark = Decoration.mark({ class: 'cm-error-underline' })
|
||||
|
||||
export const errorDecorationsField = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none
|
||||
},
|
||||
|
||||
update(decos, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setErrorsEffect)) {
|
||||
if (effect.value.length === 0) {
|
||||
return Decoration.none
|
||||
}
|
||||
const ranges = effect.value
|
||||
.filter((e) => e.from < e.to)
|
||||
.sort((a, b) => a.from - b.from)
|
||||
.map((e) => errorUnderlineMark.range(e.from, e.to))
|
||||
return RangeSet.of(ranges)
|
||||
}
|
||||
}
|
||||
|
||||
if (tr.docChanged) {
|
||||
return decos.map(tr.changes)
|
||||
}
|
||||
|
||||
return decos
|
||||
},
|
||||
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field)
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Lines Set (for gutter) ---
|
||||
|
||||
export const errorLinesField = StateField.define<Set<number>>({
|
||||
create() {
|
||||
return new Set()
|
||||
},
|
||||
|
||||
update(lines, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setErrorsEffect)) {
|
||||
const newLines = new Set<number>()
|
||||
for (const error of effect.value) {
|
||||
const lineNumber = tr.state.doc.lineAt(error.from).number
|
||||
newLines.add(lineNumber)
|
||||
}
|
||||
return newLines
|
||||
}
|
||||
}
|
||||
return lines
|
||||
},
|
||||
})
|
||||
|
||||
// --- Error Gutter ---
|
||||
|
||||
export const errorGutter = gutter({
|
||||
class: 'cm-error-gutter',
|
||||
lineMarker(view, line) {
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number
|
||||
const errorLines = view.state.field(errorLinesField)
|
||||
return errorLines.has(lineNumber) ? errorMarkerInstance : null
|
||||
},
|
||||
lineMarkerChange(update) {
|
||||
return update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(setErrorsEffect)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// --- Base Theme ---
|
||||
|
||||
export const errorBaseTheme = EditorView.baseTheme({
|
||||
'.cm-error-underline': {
|
||||
textDecoration: 'underline wavy red',
|
||||
},
|
||||
'.cm-error-marker': {
|
||||
color: '#e53e3e',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'.cm-error-gutter': {
|
||||
width: '20px',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates the error display extension bundle.
|
||||
*/
|
||||
export function errorDisplayExtension(): Extension {
|
||||
return [
|
||||
errorDecorationsField,
|
||||
errorLinesField,
|
||||
errorGutter,
|
||||
errorBaseTheme,
|
||||
]
|
||||
}
|
||||
28
calcpad-web/src/engine/types.ts
Normal file
28
calcpad-web/src/engine/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Types shared between the main thread and the WASM engine worker.
|
||||
* These mirror the JsResult struct from calcpad-wasm/src/types.rs.
|
||||
*/
|
||||
|
||||
/** Result from evaluating a single line via the WASM engine. */
|
||||
export interface EngineLineResult {
|
||||
/** Result type: number, unitValue, currencyValue, dateTime, timeDelta, boolean, error, text, comment, empty */
|
||||
type: string
|
||||
/** Display-formatted string of the result */
|
||||
display: string
|
||||
/** Raw numeric value, if applicable */
|
||||
rawValue: number | null
|
||||
/** Error message, if this is an error result */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** Messages sent from the main thread to the worker. */
|
||||
export type WorkerRequest =
|
||||
| { kind: 'init' }
|
||||
| { kind: 'evalSheet'; id: number; lines: string[] }
|
||||
|
||||
/** Messages sent from the worker back to the main thread. */
|
||||
export type WorkerResponse =
|
||||
| { kind: 'ready' }
|
||||
| { kind: 'initError'; error: string }
|
||||
| { kind: 'evalResult'; id: number; results: EngineLineResult[] }
|
||||
| { kind: 'evalError'; id: number; error: string }
|
||||
91
calcpad-web/src/engine/useEngine.ts
Normal file
91
calcpad-web/src/engine/useEngine.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* React hook that manages communication with the calcpad WASM engine
|
||||
* running in a Web Worker. Provides a function to evaluate a sheet
|
||||
* (array of lines) and returns results asynchronously.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import type { WorkerResponse, EngineLineResult } from './types.ts'
|
||||
|
||||
export interface EngineState {
|
||||
/** Whether the worker is initialized and ready */
|
||||
ready: boolean
|
||||
/** Evaluate a full sheet; returns results per line */
|
||||
evalSheet: (lines: string[]) => void
|
||||
/** The most recent evaluation results, one per input line */
|
||||
results: EngineLineResult[]
|
||||
/** Error from the last evaluation, if any */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useEngine(): EngineState {
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const latestIdRef = useRef(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [results, setResults] = useState<EngineLineResult[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const worker = new Worker(
|
||||
new URL('./worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
)
|
||||
workerRef.current = worker
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const msg = event.data
|
||||
|
||||
switch (msg.kind) {
|
||||
case 'ready':
|
||||
setReady(true)
|
||||
break
|
||||
|
||||
case 'initError':
|
||||
setError(msg.error)
|
||||
break
|
||||
|
||||
case 'evalResult':
|
||||
// Only apply if this is the most recent request
|
||||
if (msg.id === latestIdRef.current) {
|
||||
setResults(msg.results)
|
||||
setError(null)
|
||||
}
|
||||
break
|
||||
|
||||
case 'evalError':
|
||||
if (msg.id === latestIdRef.current) {
|
||||
setError(msg.error)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = (err) => {
|
||||
setError(`Worker error: ${err.message}`)
|
||||
}
|
||||
|
||||
// Initialize the worker (trigger WASM loading)
|
||||
worker.postMessage({ kind: 'init' })
|
||||
|
||||
return () => {
|
||||
worker.terminate()
|
||||
workerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const evalSheet = useCallback((lines: string[]) => {
|
||||
if (!workerRef.current) return
|
||||
|
||||
const id = ++requestIdRef.current
|
||||
latestIdRef.current = id
|
||||
|
||||
workerRef.current.postMessage({
|
||||
kind: 'evalSheet',
|
||||
id,
|
||||
lines,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { ready, evalSheet, results, error }
|
||||
}
|
||||
133
calcpad-web/src/engine/worker.ts
Normal file
133
calcpad-web/src/engine/worker.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Web Worker that loads the calcpad-wasm module and evaluates sheets
|
||||
* off the main thread. Communication is via structured postMessage.
|
||||
*
|
||||
* The WASM module is expected at `/wasm/calcpad_wasm_bg.wasm` with
|
||||
* its JS glue at `/wasm/calcpad_wasm.js` (output of wasm-pack).
|
||||
*
|
||||
* If the WASM module is not available (e.g., during development without
|
||||
* a WASM build), the worker falls back to a lightweight JS evaluator.
|
||||
*/
|
||||
|
||||
import type { WorkerRequest, WorkerResponse, EngineLineResult } from './types.ts'
|
||||
|
||||
// ---------- WASM engine interface ----------
|
||||
|
||||
interface CalcpadWasm {
|
||||
evalSheet(lines: string[]): EngineLineResult[]
|
||||
}
|
||||
|
||||
let engine: CalcpadWasm | null = null
|
||||
|
||||
// ---------- Fallback JS evaluator ----------
|
||||
|
||||
function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
|
||||
return lines.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed === '') {
|
||||
return { type: 'empty', display: '', rawValue: null, error: null }
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('//')) {
|
||||
return { type: 'comment', display: trimmed.slice(2).trim(), rawValue: null, error: null }
|
||||
}
|
||||
|
||||
if (/^#{1,6}\s/.test(trimmed)) {
|
||||
return { type: 'text', display: trimmed, rawValue: null, error: null }
|
||||
}
|
||||
|
||||
// Try to evaluate as a simple math expression
|
||||
try {
|
||||
// Strip inline comments
|
||||
const exprPart = trimmed.replace(/\/\/.*$/, '').trim()
|
||||
if (exprPart === '') {
|
||||
return { type: 'empty', display: '', rawValue: null, error: null }
|
||||
}
|
||||
|
||||
// Handle variable assignments: "name = expr"
|
||||
const assignMatch = exprPart.match(/^([a-zA-Z_]\w*)\s*=\s*(.+)$/)
|
||||
const expr = assignMatch ? assignMatch[2] : exprPart
|
||||
|
||||
// Very basic: try Function-based evaluation for simple arithmetic
|
||||
// This is a development fallback only; production uses the WASM engine.
|
||||
const sanitized = expr
|
||||
.replace(/\^/g, '**')
|
||||
.replace(/\bpi\b/gi, String(Math.PI))
|
||||
.replace(/\be\b/gi, String(Math.E))
|
||||
|
||||
// Only allow safe characters for eval
|
||||
if (/^[\d\s+\-*/.()%,eE]+$/.test(sanitized)) {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`"use strict"; return (${sanitized})`)() as number
|
||||
if (typeof result === 'number' && isFinite(result)) {
|
||||
const display = Number.isInteger(result) ? result.toString() : parseFloat(result.toPrecision(10)).toString()
|
||||
return { type: 'number', display, rawValue: result, error: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'text', display: '', rawValue: null, error: null }
|
||||
} catch {
|
||||
return { type: 'text', display: '', rawValue: null, error: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- WASM loading ----------
|
||||
|
||||
async function initWasm(): Promise<boolean> {
|
||||
try {
|
||||
// Try to load the wasm-pack output
|
||||
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
|
||||
default: () => Promise<void>
|
||||
evalSheet: (lines: string[]) => EngineLineResult[]
|
||||
}
|
||||
await wasmModule.default()
|
||||
|
||||
engine = {
|
||||
evalSheet: (lines: string[]) => wasmModule.evalSheet(lines),
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// WASM not available; fallback mode
|
||||
console.warn('[calcpad-worker] WASM engine not found, using JS fallback')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Message handler ----------
|
||||
|
||||
function sendResponse(msg: WorkerResponse) {
|
||||
self.postMessage(msg)
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const msg = event.data
|
||||
|
||||
switch (msg.kind) {
|
||||
case 'init': {
|
||||
const loaded = await initWasm()
|
||||
if (loaded) {
|
||||
sendResponse({ kind: 'ready' })
|
||||
} else {
|
||||
// Still report ready — we have the fallback
|
||||
sendResponse({ kind: 'ready' })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'evalSheet': {
|
||||
try {
|
||||
const results = engine
|
||||
? engine.evalSheet(msg.lines)
|
||||
: fallbackEvalSheet(msg.lines)
|
||||
sendResponse({ kind: 'evalResult', id: msg.id, results })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
sendResponse({ kind: 'evalError', id: msg.id, error: message })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
69
calcpad-web/src/hooks/useInstallPrompt.ts
Normal file
69
calcpad-web/src/hooks/useInstallPrompt.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* React hook that captures the beforeinstallprompt event for PWA install.
|
||||
* Adapted from epic/9-3-pwa-support.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
export interface InstallPromptState {
|
||||
promptEvent: BeforeInstallPromptEvent | null
|
||||
isInstalled: boolean
|
||||
handleInstall: () => Promise<void>
|
||||
handleDismiss: () => void
|
||||
}
|
||||
|
||||
export function useInstallPrompt(): InstallPromptState {
|
||||
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already installed (standalone mode)
|
||||
const isStandalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
('standalone' in navigator && (navigator as unknown as { standalone: boolean }).standalone)
|
||||
|
||||
if (isStandalone) {
|
||||
setIsInstalled(true)
|
||||
return
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setPromptEvent(e as BeforeInstallPromptEvent)
|
||||
}
|
||||
|
||||
const installedHandler = () => {
|
||||
setIsInstalled(true)
|
||||
setPromptEvent(null)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler)
|
||||
window.addEventListener('appinstalled', installedHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler)
|
||||
window.removeEventListener('appinstalled', installedHandler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstall = useCallback(async () => {
|
||||
if (!promptEvent) return
|
||||
await promptEvent.prompt()
|
||||
const result = await promptEvent.userChoice
|
||||
if (result.outcome === 'accepted') {
|
||||
setIsInstalled(true)
|
||||
}
|
||||
setPromptEvent(null)
|
||||
}, [promptEvent])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setPromptEvent(null)
|
||||
}, [])
|
||||
|
||||
return { promptEvent, isInstalled, handleInstall, handleDismiss }
|
||||
}
|
||||
27
calcpad-web/src/hooks/useOnlineStatus.ts
Normal file
27
calcpad-web/src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* React hook that tracks whether the browser is online or offline.
|
||||
* Adapted from epic/9-3-pwa-support.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(
|
||||
typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isOnline
|
||||
}
|
||||
31
calcpad-web/src/main.tsx
Normal file
31
calcpad-web/src/main.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Register service worker for PWA support
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const { registerSW } = await import('virtual:pwa-register')
|
||||
registerSW({
|
||||
onNeedRefresh() {
|
||||
if (confirm('New version available. Reload to update?')) {
|
||||
window.location.reload()
|
||||
}
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('CalcPad is ready to work offline')
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// PWA registration not available in dev mode
|
||||
}
|
||||
})
|
||||
}
|
||||
47
calcpad-web/src/styles/answer-column.css
Normal file
47
calcpad-web/src/styles/answer-column.css
Normal file
@@ -0,0 +1,47 @@
|
||||
/* ---------- 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);
|
||||
}
|
||||
}
|
||||
92
calcpad-web/src/styles/app.css
Normal file
92
calcpad-web/src/styles/app.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* ---------- App layout ---------- */
|
||||
|
||||
.calcpad-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100svh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
|
||||
.calcpad-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calcpad-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.calcpad-header .subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-status {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-dot.loading {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ---------- Editor area ---------- */
|
||||
|
||||
.calcpad-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-pane .calc-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Ensure CodeMirror fills its container */
|
||||
.editor-pane .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.calcpad-header {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.calcpad-header .subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
60
calcpad-web/src/styles/index.css
Normal file
60
calcpad-web/src/styles/index.css
Normal file
@@ -0,0 +1,60 @@
|
||||
: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);
|
||||
--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);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
}
|
||||
69
calcpad-web/src/styles/install-prompt.css
Normal file
69
calcpad-web/src/styles/install-prompt.css
Normal file
@@ -0,0 +1,69 @@
|
||||
.install-prompt {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.install-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.install-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.install-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.install-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.install-dismiss {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.install-dismiss:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.install-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
23
calcpad-web/src/styles/offline-banner.css
Normal file
23
calcpad-web/src/styles/offline-banner.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-icon {
|
||||
font-size: 8px;
|
||||
animation: offline-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes offline-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
14
calcpad-web/src/vite-env.d.ts
vendored
Normal file
14
calcpad-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean
|
||||
onNeedRefresh?: () => void
|
||||
onOfflineReady?: () => void
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
|
||||
onRegisteredSW?: (swScriptUrl: string, registration: ServiceWorkerRegistration | undefined) => void
|
||||
onRegisterError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
26
calcpad-web/tsconfig.app.json
Normal file
26
calcpad-web/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
calcpad-web/tsconfig.json
Normal file
7
calcpad-web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
calcpad-web/tsconfig.node.json
Normal file
24
calcpad-web/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
95
calcpad-web/vite.config.ts
Normal file
95
calcpad-web/vite.config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['favicon.svg', 'icons/*.svg'],
|
||||
manifest: {
|
||||
name: 'CalcPad',
|
||||
short_name: 'CalcPad',
|
||||
description: 'A modern notepad calculator powered by WebAssembly',
|
||||
theme_color: '#6366f1',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-maskable-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
categories: ['productivity', 'utilities'],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,wasm}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\./,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
networkTimeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'image-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'es2022',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
react: ['react', 'react-dom'],
|
||||
codemirror: [
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@codemirror/language',
|
||||
'@lezer/highlight',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user