Skip to content

Commit f39c843

Browse files
committed
feat: persist editor markdown locally
1 parent a0e0ff6 commit f39c843

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
"tailwindcss": "^4.3.0",
6060
"tw-animate-css": "^1.4.0",
6161
"vaul": "^1.1.2",
62-
"wrangler": "^4.90.0"
62+
"wrangler": "^4.90.0",
63+
"zustand": "^5.0.13"
6364
},
6465
"devDependencies": {
6566
"@tanstack/devtools-vite": "^0.6.0",

pnpm-lock.yaml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/editor/EditorLayout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { type EditorView } from '@codemirror/view'
66
import { EditorHeader, type ViewMode } from './EditorHeader'
77
import { MarkdownPane } from './MarkdownPane'
88
import { PreviewPane } from './PreviewPane'
9-
import { defaultContent } from './markdown/default-content'
109
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
10+
import { useEditorContentStore } from '@/stores/editor-content-store'
1111

1212
const editorPaneVariants = cva(
1313
'h-full min-h-0 min-w-0 transition-[width] duration-200 border-border',
@@ -34,7 +34,8 @@ const previewPaneVariants = cva('h-full min-h-0 min-w-0 transition-[width] durat
3434

3535
export function EditorLayout() {
3636
const [viewMode, setViewMode] = useState<ViewMode>('split')
37-
const [markdown, setMarkdown] = useState(defaultContent)
37+
const markdown = useEditorContentStore((state) => state.markdown)
38+
const setMarkdown = useEditorContentStore((state) => state.setMarkdown)
3839

3940
const deferredMarkdown = useDeferredValue(markdown)
4041

src/stores/editor-content-store.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { create } from 'zustand'
2+
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
3+
4+
import { defaultContent } from '@/components/editor/markdown/default-content'
5+
6+
const MARKDOWN_STORAGE_KEY = 'render-md:editor-content'
7+
const MARKDOWN_STORAGE_TTL_MS = 30 * 24 * 60 * 60 * 1000
8+
9+
type PersistedEditorContent = {
10+
markdown: string
11+
expiresAt: number | null
12+
}
13+
14+
interface EditorContentStore extends PersistedEditorContent {
15+
setMarkdown: (markdown: string) => void
16+
clearMarkdown: () => void
17+
}
18+
19+
function getLocalStorage() {
20+
if (typeof window === 'undefined') return null
21+
22+
try {
23+
return window.localStorage
24+
} catch {
25+
return null
26+
}
27+
}
28+
29+
function isRecord(value: unknown): value is Record<string, unknown> {
30+
return typeof value === 'object' && value !== null
31+
}
32+
33+
function getStoredMarkdownExpiresAt(storedValue: string) {
34+
try {
35+
const parsed: unknown = JSON.parse(storedValue)
36+
37+
if (!isRecord(parsed) || !isRecord(parsed.state)) return null
38+
if (typeof parsed.state.markdown !== 'string') return null
39+
if (typeof parsed.state.expiresAt !== 'number') return null
40+
41+
return parsed.state.expiresAt
42+
} catch {
43+
return null
44+
}
45+
}
46+
47+
function shouldDiscardStoredMarkdown(storedValue: string) {
48+
const expiresAt = getStoredMarkdownExpiresAt(storedValue)
49+
50+
return expiresAt === null || expiresAt <= Date.now()
51+
}
52+
53+
const expiringLocalStorage: StateStorage = {
54+
getItem: (name) => {
55+
const storage = getLocalStorage()
56+
if (!storage) return null
57+
58+
const storedValue = storage.getItem(name)
59+
if (!storedValue) return null
60+
61+
if (shouldDiscardStoredMarkdown(storedValue)) {
62+
storage.removeItem(name)
63+
return null
64+
}
65+
66+
return storedValue
67+
},
68+
setItem: (name, value) => {
69+
const storage = getLocalStorage()
70+
if (!storage) return
71+
72+
storage.setItem(name, value)
73+
},
74+
removeItem: (name) => {
75+
const storage = getLocalStorage()
76+
if (!storage) return
77+
78+
storage.removeItem(name)
79+
},
80+
}
81+
82+
export const useEditorContentStore = create<EditorContentStore>()(
83+
persist(
84+
(set) => ({
85+
markdown: defaultContent,
86+
expiresAt: null,
87+
setMarkdown: (markdown) =>
88+
set({
89+
markdown,
90+
expiresAt: Date.now() + MARKDOWN_STORAGE_TTL_MS,
91+
}),
92+
clearMarkdown: () => {
93+
set({
94+
markdown: defaultContent,
95+
expiresAt: null,
96+
})
97+
useEditorContentStore.persist.clearStorage()
98+
},
99+
}),
100+
{
101+
name: MARKDOWN_STORAGE_KEY,
102+
storage: createJSONStorage(() => expiringLocalStorage),
103+
partialize: (state): PersistedEditorContent => ({
104+
markdown: state.markdown,
105+
expiresAt: state.expiresAt,
106+
}),
107+
},
108+
),
109+
)

0 commit comments

Comments
 (0)