snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

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 }