LocalEditorPage.jsx (7427B)
1 import { useCallback, useEffect, useRef, useState } from 'react'; 2 import IconButton from '../components/IconButton.jsx'; 3 import ShareModal from '../components/ShareModal.jsx'; 4 import StatusBadge from '../components/StatusBadge.jsx'; 5 import EditorLayout from '../components/EditorLayout.jsx'; 6 import { 7 ClearIcon, 8 DownloadIcon, 9 ShareIcon, 10 UploadIcon, 11 } from '../components/icons/index.js'; 12 import { 13 MODES, 14 isDefaultContent, 15 loadContent, 16 loadMode, 17 persistContent, 18 persistMode, 19 } from '../lib/editorConstants.js'; 20 import { ensureMarkedLoaded } from '../lib/previewHtml.js'; 21 import { STR } from '../lib/strings.js'; 22 23 const STORAGE_DEBOUNCE_MS = 500; 24 const APP_VERSION = '0.0.1'; 25 const CREATOR_NAME = 'Pablo Murad'; 26 const CREATOR_EMAIL = 'pablomurad@pm.me'; 27 28 function getFileExtension(filename) { 29 const parts = filename.toLowerCase().split('.'); 30 if (parts.length < 2) return 'txt'; 31 return parts[parts.length - 1]; 32 } 33 34 function deriveTitle(content, mode) { 35 const line = content.split('\n').find((l) => l.trim()); 36 if (!line) return STR.UNTITLED_DOCUMENT; 37 if (mode === MODES.MARKDOWN) { 38 const m = line.match(/^#+\s+(.+)$/); 39 if (m) return m[1].trim(); 40 } 41 if (mode === MODES.ORG) { 42 const m = line.match(/^\*+\s+(.+)$/); 43 if (m) return m[1].replace(/^(TODO|DONE)\s+/, '').trim(); 44 } 45 return line.trim().slice(0, 80) || STR.UNTITLED_DOCUMENT; 46 } 47 48 export default function LocalEditorPage() { 49 const initialMode = loadMode(); 50 const [mode, setMode] = useState(initialMode); 51 const [content, setContent] = useState(() => loadContent(initialMode)); 52 const [storageWarning, setStorageWarning] = useState(false); 53 const [shareOpen, setShareOpen] = useState(false); 54 const fileInputRef = useRef(null); 55 const editorRef = useRef(null); 56 57 useEffect(() => { 58 const timer = window.setTimeout(() => { 59 const saved = persistContent(mode, content); 60 setStorageWarning(!saved); 61 }, STORAGE_DEBOUNCE_MS); 62 63 return () => window.clearTimeout(timer); 64 }, [content, mode]); 65 66 const saveLabel = mode === MODES.ORG ? 'Save .org' : 'Save .md'; 67 68 const prefetchMarkdown = useCallback(() => { 69 ensureMarkedLoaded(); 70 }, []); 71 72 const handleModeChange = useCallback( 73 (nextMode) => { 74 if (nextMode === mode) return; 75 persistContent(mode, content); 76 persistMode(nextMode); 77 setMode(nextMode); 78 setContent(loadContent(nextMode)); 79 setStorageWarning(false); 80 if (nextMode === MODES.MARKDOWN) prefetchMarkdown(); 81 editorRef.current?.focus(); 82 }, 83 [mode, content, prefetchMarkdown], 84 ); 85 86 const handleSave = useCallback(() => { 87 const isOrg = mode === MODES.ORG; 88 const blob = new Blob([content], { 89 type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', 90 }); 91 const url = URL.createObjectURL(blob); 92 const link = document.createElement('a'); 93 link.href = url; 94 link.download = isOrg ? 'document.org' : 'document.md'; 95 link.click(); 96 URL.revokeObjectURL(url); 97 }, [content, mode]); 98 99 const handleImport = useCallback( 100 (event) => { 101 const file = event.target.files?.[0]; 102 if (!file) return; 103 104 const ext = getFileExtension(file.name); 105 let targetMode = mode; 106 if (ext === 'org') targetMode = MODES.ORG; 107 else if (ext === 'md' || ext === 'markdown') targetMode = MODES.MARKDOWN; 108 109 const reader = new FileReader(); 110 reader.onload = () => { 111 const result = reader.result; 112 if (typeof result !== 'string') return; 113 114 if (targetMode !== mode) { 115 persistContent(mode, content); 116 persistMode(targetMode); 117 setMode(targetMode); 118 if (targetMode === MODES.MARKDOWN) prefetchMarkdown(); 119 } 120 121 setContent(result); 122 persistContent(targetMode, result); 123 setStorageWarning(false); 124 }; 125 reader.readAsText(file); 126 event.target.value = ''; 127 }, 128 [mode, content, prefetchMarkdown], 129 ); 130 131 const handleClear = useCallback(() => { 132 if (!isDefaultContent(mode, content)) { 133 const confirmed = window.confirm( 134 'Clear the editor and start a blank document? Current content will be replaced.', 135 ); 136 if (!confirmed) return; 137 } 138 139 setContent(''); 140 persistContent(mode, ''); 141 setStorageWarning(false); 142 editorRef.current?.focus(); 143 }, [mode, content]); 144 145 return ( 146 <div className="app"> 147 <header className="app-header"> 148 <div className="app-header-text"> 149 <div className="app-header-top"> 150 <h1 className="app-title">Snow Editor</h1> 151 <StatusBadge variant="online">{STR.BADGE_ONLINE}</StatusBadge> 152 </div> 153 <p className="app-subtitle">Write calmly. See the result live.</p> 154 <div className="mode-switch" role="group" aria-label="Editor mode"> 155 <button 156 type="button" 157 className={`mode-switch__btn${mode === MODES.MARKDOWN ? ' is-active' : ''}`} 158 aria-pressed={mode === MODES.MARKDOWN} 159 onClick={() => handleModeChange(MODES.MARKDOWN)} 160 onMouseEnter={prefetchMarkdown} 161 onFocus={prefetchMarkdown} 162 > 163 Markdown 164 </button> 165 <button 166 type="button" 167 className={`mode-switch__btn${mode === MODES.ORG ? ' is-active' : ''}`} 168 aria-pressed={mode === MODES.ORG} 169 onClick={() => handleModeChange(MODES.ORG)} 170 > 171 Org-mode 172 </button> 173 </div> 174 </div> 175 <div className="toolbar" role="toolbar" aria-label="Editor actions"> 176 <IconButton 177 icon={<ShareIcon />} 178 label={STR.SHARE} 179 onClick={() => setShareOpen(true)} 180 /> 181 <IconButton 182 icon={<DownloadIcon />} 183 label={mode === MODES.ORG ? 'Save as Org-mode file' : 'Save as Markdown file'} 184 onClick={handleSave} 185 /> 186 <IconButton 187 icon={<UploadIcon />} 188 label="Import file" 189 onClick={() => fileInputRef.current?.click()} 190 /> 191 <input 192 ref={fileInputRef} 193 id="file-import" 194 type="file" 195 accept=".md,.markdown,.org,.txt,text/markdown,text/plain" 196 className="file-input-hidden" 197 onChange={handleImport} 198 aria-label="Choose file to import" 199 /> 200 <IconButton 201 icon={<ClearIcon />} 202 variant="ghost" 203 label="Clear editor and start blank document" 204 onClick={handleClear} 205 /> 206 </div> 207 </header> 208 209 <EditorLayout 210 mode={mode} 211 content={content} 212 onContentChange={setContent} 213 editorRef={editorRef} 214 /> 215 216 <footer className="app-footer"> 217 {storageWarning && ( 218 <p className="app-storage-warning" role="status"> 219 Draft too large to save in browser storage; export with {saveLabel}. 220 </p> 221 )} 222 <p className="app-meta"> 223 Snow Editor v{APP_VERSION} · {CREATOR_NAME} ·{' '} 224 <a href={`mailto:${CREATOR_EMAIL}`}>{CREATOR_EMAIL}</a> 225 </p> 226 </footer> 227 228 <ShareModal 229 open={shareOpen} 230 onClose={() => setShareOpen(false)} 231 title={deriveTitle(content, mode)} 232 mode={mode} 233 content={content} 234 /> 235 </div> 236 ); 237 }