From 2e15044c120c3c3924b64be7805144ee749d3c3b Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Mon, 7 Oct 2024 22:48:09 +0700 Subject: [PATCH] implement Storage Provider --- package.json | 4 +- public/manifest.json | 2 +- src/main.tsx | 14 +- .../hooks/usePersistedValue.ts | 26 ---- .../MarkdownEditorContainer/index.tsx | 48 +++---- .../hooks/useGridLayoutPersistance.ts | 33 +++-- src/modules/storage/StorageContext.ts | 13 ++ src/modules/storage/StorageProvider.tsx | 67 +++++++++ src/modules/storage/hooks/useDelayedSync.ts | 38 ++++++ src/utils/hydrate.ts | 127 ++++++++++++++++++ src/utils/index.ts | 26 ++-- src/utils/noop.ts | 1 + src/utils/seed.ts | 57 ++++++++ src/utils/storage.ts | 5 + yarn.lock | 17 +++ 15 files changed, 385 insertions(+), 93 deletions(-) delete mode 100644 src/modules/editor/containers/MarkdownEditorContainer/hooks/usePersistedValue.ts create mode 100644 src/modules/storage/StorageContext.ts create mode 100644 src/modules/storage/StorageProvider.tsx create mode 100644 src/modules/storage/hooks/useDelayedSync.ts create mode 100644 src/utils/hydrate.ts create mode 100644 src/utils/noop.ts create mode 100644 src/utils/seed.ts create mode 100644 src/utils/storage.ts diff --git a/package.json b/package.json index 5ab2ab5..32fd0f4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "package": "yarn build && web-ext build -s ./dist -a ./output", + "package": "yarn build && web-ext build -s ./dist -a ./output --overwrite-dest", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, @@ -21,6 +21,7 @@ "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "i18next-http-backend": "^2.2.0", + "lodash.debounce": "^4.0.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^12.2.2", @@ -38,6 +39,7 @@ "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", "@types/firefox-webext-browser": "^120.0.4", + "@types/lodash.debounce": "^4.0.9", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/uuid": "^9.0.1", diff --git a/public/manifest.json b/public/manifest.json index 9d5923f..dc39c21 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "name": "Markdown Home Tab", "short_name": "Markdown New Tab", - "version": "0.0.5", + "version": "0.0.6", "description": "Markdown right in your home tab! Paste links, pictures, lists and more. You can also customize colors to match your needs.", "manifest_version": 2, "permissions": ["storage"], diff --git a/src/main.tsx b/src/main.tsx index dcadee9..31186e7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,16 +4,20 @@ import { Editor } from "~/pages/editor"; import { ThemeProvider } from "./modules/theme/containers/ThemeProvider"; import { SettingsProvider } from "./modules/settings/providers/SettingsProvider"; +import { StorageProvider } from "~/modules/storage/StorageProvider"; import "./i18n"; import "./styles/main.scss"; +import "./utils/seed"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - + + + + + + + ); diff --git a/src/modules/editor/containers/MarkdownEditorContainer/hooks/usePersistedValue.ts b/src/modules/editor/containers/MarkdownEditorContainer/hooks/usePersistedValue.ts deleted file mode 100644 index 326feee..0000000 --- a/src/modules/editor/containers/MarkdownEditorContainer/hooks/usePersistedValue.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { BrowserSyncStorage } from '~/utils/index'; - -export const usePersistedValue = ( - id: string, - prefix: string -) => { - const [hydrated, setHydrated] = useState(false); - const storage = useMemo(() => new BrowserSyncStorage(prefix), [prefix]); - const key = `${prefix}${id}`; - const [value, setValue] = useState(''); - - useEffect(() => { - storage.get(key).then(val => setValue(val ?? '')).finally(() => setHydrated(true)); - }, [key, storage]); - - useEffect(() => { - if (!hydrated) { - return; - } - - storage.set(key, value); - }, [key, value, storage, hydrated]); - - return { value, setValue, hydrated }; -}; diff --git a/src/modules/editor/containers/MarkdownEditorContainer/index.tsx b/src/modules/editor/containers/MarkdownEditorContainer/index.tsx index 9a132e3..7dc50e0 100644 --- a/src/modules/editor/containers/MarkdownEditorContainer/index.tsx +++ b/src/modules/editor/containers/MarkdownEditorContainer/index.tsx @@ -1,11 +1,10 @@ -import { FC, Suspense, lazy } from "react"; -import { SimpleTextareaEditor } from "../../components/SimpleTextareaEditor"; -import { MarkdownViewer } from "../../components/MarkdownViewer"; -import { usePersistedValue } from "./hooks/usePersistedValue"; -import styles from "./styles.module.scss"; -import { useSettings } from "~/modules/settings/context/SettingsContext"; -import { EmptyViewer } from "../../components/EmptyViewer"; +import { FC, Suspense, useCallback } from "react"; import { EditorWrapper } from "../../components/EditorWrapper"; +import { EmptyViewer } from "../../components/EmptyViewer"; +import { MarkdownViewer } from "../../components/MarkdownViewer"; +import { SimpleTextareaEditor } from "../../components/SimpleTextareaEditor"; +import styles from "./styles.module.scss"; +import { useStorage } from "../../../../modules/storage/StorageContext"; interface MarkdownEditorContainerProps { id: string; @@ -14,29 +13,22 @@ interface MarkdownEditorContainerProps { remove: VoidCallback; } -const RichEditor = lazy(() => - import("../../components/RemirrorEditor").then((module) => ({ - default: module.RemirrorEditor, - })) -); - export const MarkdownEditorContainer: FC = ({ id, locked, startEditing, remove, }) => { - const { - settings: { richEditorEnabled }, - } = useSettings(); - - const { value, setValue, hydrated } = usePersistedValue( - id, - "MarkdownEditorContainer" - ); + const { panels, setPanel, hydrated } = useStorage(); + const value = panels[id] ?? ""; const empty = !value.trim(); + const onChange = useCallback( + (val: string) => setPanel(id, val), + [id, setPanel] + ); + const viewer = empty ? ( ) : ( @@ -45,15 +37,11 @@ export const MarkdownEditorContainer: FC = ({ const editor = ( - {richEditorEnabled ? ( - - ) : ( - - )} + ); diff --git a/src/modules/layout/components/GridLayout/hooks/useGridLayoutPersistance.ts b/src/modules/layout/components/GridLayout/hooks/useGridLayoutPersistance.ts index 976534c..50f0fe3 100644 --- a/src/modules/layout/components/GridLayout/hooks/useGridLayoutPersistance.ts +++ b/src/modules/layout/components/GridLayout/hooks/useGridLayoutPersistance.ts @@ -1,30 +1,27 @@ -import { DockviewApi, DockviewReadyEvent, SerializedDockview } from "dockview"; +import { DockviewApi, DockviewReadyEvent } from "dockview"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useStorage } from "../../../../../modules/storage/StorageContext"; import { createDefaultLayout } from "../utils/createDefaultLayout"; -import { BrowserSyncStorage } from "~/utils"; - -const storage = new BrowserSyncStorage(); -const key = 'dockview_persistance_layout'; export const useGridLayoutPersistance = () => { const api = useRef(); const [hydrated, setHydrated] = useState(false); + const { layout, setLayout } = useStorage(); const onReady = (event: DockviewReadyEvent) => { + if (hydrated) { + return; + } + api.current = event.api; - storage.get(key).then(layout => { - if (!layout) { - throw new Error("No layout saved, its okay"); - } - - event.api.fromJSON(layout); - }).catch(() => { + if (!layout) { createDefaultLayout(event.api); - - }).finally(() => { - setHydrated(true); - }); + return; + } + + event.api.fromJSON(layout); + setHydrated(true); }; const persistLayout = useCallback(() => { @@ -32,8 +29,8 @@ export const useGridLayoutPersistance = () => { return; } - storage.set(key, api.current.toJSON()); - }, []); + setLayout(api.current.toJSON()); + }, [setLayout]); useEffect(() => { const onLayoutChange = api.current?.onDidLayoutChange(() => { diff --git a/src/modules/storage/StorageContext.ts b/src/modules/storage/StorageContext.ts new file mode 100644 index 0000000..b86c79b --- /dev/null +++ b/src/modules/storage/StorageContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; +import { SerializedDockview } from "dockview"; +import { noop } from "~/utils/noop"; + +export const StorageContext = createContext({ + layout: null as SerializedDockview | null, + panels: {} as Record, + hydrated: false, + setPanel: noop as (uuid: string, content: string) => void, + setLayout: noop as (layout: SerializedDockview) => void, +}); + +export const useStorage = () => useContext(StorageContext); diff --git a/src/modules/storage/StorageProvider.tsx b/src/modules/storage/StorageProvider.tsx new file mode 100644 index 0000000..a2d882d --- /dev/null +++ b/src/modules/storage/StorageProvider.tsx @@ -0,0 +1,67 @@ +import { SerializedDockview } from "dockview"; +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { + hydrateLayout, + storeLayoutLocally, + storePanelLocally, +} from "~/utils/hydrate"; +import { useDelayedSync } from "./hooks/useDelayedSync"; +import { StorageContext } from "./StorageContext"; + +const debounceDelay = 500; + +export const StorageProvider = ({ children }: { children: ReactNode }) => { + const [hydrated, setHydrated] = useState(false); + const [layout, setLayoutValue] = useState(null); + const [panels, setPanelsValue] = useState>({}); + + const { storeLayout, storePanel } = useDelayedSync(debounceDelay); + + const setPanel = useCallback( + (uuid: string, value: string) => { + setPanelsValue((prev) => ({ ...prev, [uuid]: value })); + storePanelLocally(uuid, value); + storePanel(uuid, value); + }, + [storePanel] + ); + + const setLayout = useCallback( + (value: SerializedDockview) => { + setLayoutValue(value); + storeLayoutLocally(value); + storeLayout(value); + }, + [storeLayout] + ); + + useEffect(() => { + if (hydrated) { + return; + } + + hydrateLayout() + .then((result) => { + if (!result) { + return; + } + + setLayout(result.layout); + + Object.entries(result.panels).forEach(([uuid, value]) => { + setPanel(uuid, value); + }); + }) + .finally(() => { + setHydrated(true); + }); + }, [hydrated, setLayout, setPanel]); + + return ( + + {hydrated ? children : null} + + ); +}; diff --git a/src/modules/storage/hooks/useDelayedSync.ts b/src/modules/storage/hooks/useDelayedSync.ts new file mode 100644 index 0000000..c9b8600 --- /dev/null +++ b/src/modules/storage/hooks/useDelayedSync.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef } from "react"; +import { storeLayoutInSync, storePanelInSync } from "~/utils/hydrate"; +import { DebouncedFunc } from "lodash"; +import { SerializedDockview } from "dockview"; +import debounce from "lodash.debounce"; + +export const useDelayedSync = (debounceDelay: number) => { + const layoutTimer = useRef>(); + const panelTimers = useRef< + Record> + >({}); + + const storeLayout = useCallback( + (layout: SerializedDockview) => { + if (layoutTimer.current) { + layoutTimer.current.cancel(); + } + + layoutTimer.current = debounce(storeLayoutInSync, debounceDelay); + layoutTimer.current(layout); + }, + [debounceDelay] + ); + + const storePanel = useCallback( + (uuid: string, value: string) => { + if (panelTimers.current[uuid]) { + panelTimers.current[uuid].cancel(); + } + + panelTimers.current[uuid] = debounce(storePanelInSync, debounceDelay); + panelTimers.current[uuid](uuid, value); + }, + [debounceDelay] + ); + + return { storeLayout, storePanel }; +}; diff --git a/src/utils/hydrate.ts b/src/utils/hydrate.ts new file mode 100644 index 0000000..1b87090 --- /dev/null +++ b/src/utils/hydrate.ts @@ -0,0 +1,127 @@ +import { SerializedDockview } from "dockview"; +import { hasBrowserStorage, hasChromeStorage } from "~/utils/storage"; + +interface Result { + layout: SerializedDockview; + panels: Record; +} + +const layoutKey = "dockview_persistance_layout"; +const panelPrefix = "MarkdownEditorContainerMarkdownEditorContainer"; + +const makePanelKey = (uuid: string) => `${panelPrefix}${uuid}`; + +const getFromBrowserStorage = async (): Promise => { + const result = await browser.storage.sync.get(); + const layout = result[layoutKey] as SerializedDockview | undefined; + + if (!layout) { + return null; + } + + const panels = Object.keys(layout.panels).reduce( + (acc, uuid) => ({ + ...acc, + [uuid]: (result[makePanelKey(uuid)] as string) ?? "", + }), + {} as Record + ); + + return { + layout, + panels, + }; +}; + +const getFromChromeStorage = async (): Promise => { + const result = await chrome.storage.sync.get(); + const layout = result[layoutKey] as SerializedDockview | undefined; + + if (!layout) { + return null; + } + + const panels = Object.keys(layout.panels).reduce( + (acc, uuid) => ({ + ...acc, + [uuid]: (result[makePanelKey(uuid)] as string) ?? "", + }), + {} as Record + ); + + return { + layout, + panels, + }; +}; + +const getFromLocalStorage = () => { + const rawLayout = localStorage.getItem(layoutKey); + + if (!rawLayout) { + return null; + } + + const layout = JSON.parse(rawLayout) as SerializedDockview; + + if (!layout.panels) { + return null; + } + + const panels = Object.keys(layout.panels).reduce( + (acc, uuid) => ({ + ...acc, + [uuid]: localStorage.getItem(makePanelKey(uuid)) ?? "", + }), + {} as Record + ); + + return { + layout, + panels, + }; +}; + +export const hydrateLayout = async (): Promise => { + const local = getFromLocalStorage(); + + if (local) { + return local; + } + + if (hasBrowserStorage()) { + return getFromBrowserStorage(); + } + + if (hasChromeStorage()) { + return getFromChromeStorage(); + } + + return null; +}; + +export const storeLayoutLocally = (layout: SerializedDockview) => + localStorage.setItem(layoutKey, JSON.stringify(layout)); + +export const storeLayoutInSync = (layout: SerializedDockview) => { + if (hasBrowserStorage()) { + return browser.storage.sync.set({ [layoutKey]: layout }); + } + + if (hasChromeStorage()) { + return chrome.storage.sync.set({ [layoutKey]: layout }); + } +}; + +export const storePanelLocally = (uuid: string, value: string) => + localStorage.setItem(`${panelPrefix}${uuid}`, value); + +export const storePanelInSync = (uuid: string, value: string) => { + if (hasBrowserStorage()) { + return browser.storage.sync.set({ [`${panelPrefix}${uuid}`]: value }); + } + + if (hasChromeStorage()) { + return chrome.storage.sync.set({ [`${panelPrefix}${uuid}`]: value }); + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4f44272..8077ba2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,11 +1,13 @@ +import { hasBrowserStorage, hasChromeStorage } from "./storage"; + export class BrowserSyncStorage { constructor(private globalPrefix = "") {} get engine() { - if (typeof browser !== 'undefined' && browser?.storage) { - return "browser" - } else if (typeof chrome !== 'undefined' && chrome?.storage) { - return "chrome" + if (hasBrowserStorage()) { + return "browser"; + } else if (hasChromeStorage()) { + return "chrome"; } return "local"; @@ -15,20 +17,20 @@ export class BrowserSyncStorage { set = async (key: string, value: T) => { switch (this.engine) { - case 'browser': + case "browser": await browser.storage.sync.set({ [this.makeKey(key)]: value }); return; - case 'chrome': + case "chrome": await chrome.storage.sync.set({ [this.makeKey(key)]: value }); return; default: localStorage.setItem(this.makeKey(key), JSON.stringify(value)); - return + return; } }; get = async (key: string): Promise => { - if (this.engine === 'browser') { + if (this.engine === "browser") { const value = await browser.storage.sync .get([this.makeKey(key)]) .then((result) => result[this.makeKey(key)] as T | undefined); @@ -36,10 +38,10 @@ export class BrowserSyncStorage { if (value) { return value; } - } else if (this.engine === 'chrome') { - const value = await chrome.storage.sync.get(this.makeKey(key)).then( - (result) => result[this.makeKey(key)] as T | undefined - ); + } else if (this.engine === "chrome") { + const value = await chrome.storage.sync + .get(this.makeKey(key)) + .then((result) => result[this.makeKey(key)] as T | undefined); if (value) { return value; diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 0000000..3629392 --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1 @@ +export const noop = () => {}; \ No newline at end of file diff --git a/src/utils/seed.ts b/src/utils/seed.ts new file mode 100644 index 0000000..e10e016 --- /dev/null +++ b/src/utils/seed.ts @@ -0,0 +1,57 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.seedBrowserData = async () => { + await browser.storage.sync.clear(); + await browser.storage.sync.set({ + "MarkdownEditorContainerMarkdownEditorContainerd87f6e7b-d21e-462e-9f15-a61da9178282": + "d123", + "MarkdownEditorContainerMarkdownEditorContainer8ea3f663-9c16-4d53-b0cc-25f47adae2b2": + "d456", + dockview_persistance_layout: { + grid: { + root: { + type: "branch", + data: [ + { + type: "leaf", + data: { + views: ["d87f6e7b-d21e-462e-9f15-a61da9178282"], + activeView: "d87f6e7b-d21e-462e-9f15-a61da9178282", + id: "1", + hideHeader: true, + }, + size: 960, + }, + { + type: "leaf", + data: { + views: ["8ea3f663-9c16-4d53-b0cc-25f47adae2b2"], + activeView: "8ea3f663-9c16-4d53-b0cc-25f47adae2b2", + id: "2", + hideHeader: true, + }, + size: 960, + }, + ], + size: 437, + }, + width: 1920, + height: 437, + orientation: "HORIZONTAL", + }, + panels: { + "d87f6e7b-d21e-462e-9f15-a61da9178282": { + id: "d87f6e7b-d21e-462e-9f15-a61da9178282", + contentComponent: "default", + params: { title: "", locked: true }, + }, + "8ea3f663-9c16-4d53-b0cc-25f47adae2b2": { + id: "8ea3f663-9c16-4d53-b0cc-25f47adae2b2", + contentComponent: "default", + params: { title: "", locked: true }, + }, + }, + activeGroup: "2", + }, + }); +}; diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..fea534d --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,5 @@ +export const hasBrowserStorage = () => + typeof browser !== "undefined" && browser?.storage; + +export const hasChromeStorage = () => + typeof chrome !== "undefined" && chrome?.storage; diff --git a/yarn.lock b/yarn.lock index 5c427d7..5715361 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1961,6 +1961,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== +"@types/lodash.debounce@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz#0f5f21c507bce7521b5e30e7a24440975ac860a5" + integrity sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.10" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.10.tgz#64f3edf656af2fe59e7278b73d3e62404144a6e6" + integrity sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ== + "@types/marked@^4.0.2": version "4.0.8" resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.8.tgz#b316887ab3499d0a8f4c70b7bd8508f92d477955" @@ -4965,6 +4977,11 @@ lodash-es@^4.17.15, lodash-es@^4.17.21: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"