implement Storage Provider

This commit is contained in:
Fedor Katurov 2024-10-07 22:48:09 +07:00
parent db911e51e4
commit 2e15044c12
15 changed files with 385 additions and 93 deletions

View file

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "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", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
@ -21,6 +21,7 @@
"i18next": "^22.4.15", "i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.2.0", "i18next-http-backend": "^2.2.0",
"lodash.debounce": "^4.0.8",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.2.2", "react-i18next": "^12.2.2",
@ -38,6 +39,7 @@
"@types/classnames": "^2.3.1", "@types/classnames": "^2.3.1",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/firefox-webext-browser": "^120.0.4", "@types/firefox-webext-browser": "^120.0.4",
"@types/lodash.debounce": "^4.0.9",
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",

View file

@ -1,7 +1,7 @@
{ {
"name": "Markdown Home Tab", "name": "Markdown Home Tab",
"short_name": "Markdown New 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.", "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, "manifest_version": 2,
"permissions": ["storage"], "permissions": ["storage"],

View file

@ -4,16 +4,20 @@ import { Editor } from "~/pages/editor";
import { ThemeProvider } from "./modules/theme/containers/ThemeProvider"; import { ThemeProvider } from "./modules/theme/containers/ThemeProvider";
import { SettingsProvider } from "./modules/settings/providers/SettingsProvider"; import { SettingsProvider } from "./modules/settings/providers/SettingsProvider";
import { StorageProvider } from "~/modules/storage/StorageProvider";
import "./i18n"; import "./i18n";
import "./styles/main.scss"; import "./styles/main.scss";
import "./utils/seed";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<StorageProvider>
<SettingsProvider> <SettingsProvider>
<ThemeProvider> <ThemeProvider>
<Editor /> <Editor />
</ThemeProvider> </ThemeProvider>
</SettingsProvider> </SettingsProvider>
</StorageProvider>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -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<string>(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 };
};

View file

@ -1,11 +1,10 @@
import { FC, Suspense, lazy } from "react"; import { FC, Suspense, useCallback } 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 { EditorWrapper } from "../../components/EditorWrapper"; 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 { interface MarkdownEditorContainerProps {
id: string; id: string;
@ -14,29 +13,22 @@ interface MarkdownEditorContainerProps {
remove: VoidCallback; remove: VoidCallback;
} }
const RichEditor = lazy(() =>
import("../../components/RemirrorEditor").then((module) => ({
default: module.RemirrorEditor,
}))
);
export const MarkdownEditorContainer: FC<MarkdownEditorContainerProps> = ({ export const MarkdownEditorContainer: FC<MarkdownEditorContainerProps> = ({
id, id,
locked, locked,
startEditing, startEditing,
remove, remove,
}) => { }) => {
const { const { panels, setPanel, hydrated } = useStorage();
settings: { richEditorEnabled },
} = useSettings();
const { value, setValue, hydrated } = usePersistedValue(
id,
"MarkdownEditorContainer"
);
const value = panels[id] ?? "";
const empty = !value.trim(); const empty = !value.trim();
const onChange = useCallback(
(val: string) => setPanel(id, val),
[id, setPanel]
);
const viewer = empty ? ( const viewer = empty ? (
<EmptyViewer startEditing={startEditing} /> <EmptyViewer startEditing={startEditing} />
) : ( ) : (
@ -45,15 +37,11 @@ export const MarkdownEditorContainer: FC<MarkdownEditorContainerProps> = ({
const editor = ( const editor = (
<EditorWrapper save={startEditing} remove={remove}> <EditorWrapper save={startEditing} remove={remove}>
{richEditorEnabled ? (
<RichEditor value={value} onChange={setValue} locked={locked} />
) : (
<SimpleTextareaEditor <SimpleTextareaEditor
value={value} value={value}
onChange={setValue} onChange={onChange}
save={startEditing} save={startEditing}
/> />
)}
</EditorWrapper> </EditorWrapper>
); );

View file

@ -1,30 +1,27 @@
import { DockviewApi, DockviewReadyEvent, SerializedDockview } from "dockview"; import { DockviewApi, DockviewReadyEvent } from "dockview";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useStorage } from "../../../../../modules/storage/StorageContext";
import { createDefaultLayout } from "../utils/createDefaultLayout"; import { createDefaultLayout } from "../utils/createDefaultLayout";
import { BrowserSyncStorage } from "~/utils";
const storage = new BrowserSyncStorage();
const key = 'dockview_persistance_layout';
export const useGridLayoutPersistance = () => { export const useGridLayoutPersistance = () => {
const api = useRef<DockviewApi>(); const api = useRef<DockviewApi>();
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const { layout, setLayout } = useStorage();
const onReady = (event: DockviewReadyEvent) => { const onReady = (event: DockviewReadyEvent) => {
if (hydrated) {
return;
}
api.current = event.api; api.current = event.api;
storage.get<SerializedDockview>(key).then(layout => {
if (!layout) { if (!layout) {
throw new Error("No layout saved, its okay"); createDefaultLayout(event.api);
return;
} }
event.api.fromJSON(layout); event.api.fromJSON(layout);
}).catch(() => {
createDefaultLayout(event.api);
}).finally(() => {
setHydrated(true); setHydrated(true);
});
}; };
const persistLayout = useCallback(() => { const persistLayout = useCallback(() => {
@ -32,8 +29,8 @@ export const useGridLayoutPersistance = () => {
return; return;
} }
storage.set(key, api.current.toJSON()); setLayout(api.current.toJSON());
}, []); }, [setLayout]);
useEffect(() => { useEffect(() => {
const onLayoutChange = api.current?.onDidLayoutChange(() => { const onLayoutChange = api.current?.onDidLayoutChange(() => {

View file

@ -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<string, string>,
hydrated: false,
setPanel: noop as (uuid: string, content: string) => void,
setLayout: noop as (layout: SerializedDockview) => void,
});
export const useStorage = () => useContext(StorageContext);

View file

@ -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<SerializedDockview | null>(null);
const [panels, setPanelsValue] = useState<Record<string, string>>({});
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 (
<StorageContext.Provider
value={{ hydrated, layout, panels, setLayout, setPanel }}
>
{hydrated ? children : null}
</StorageContext.Provider>
);
};

View file

@ -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<DebouncedFunc<typeof storeLayoutInSync>>();
const panelTimers = useRef<
Record<string, DebouncedFunc<typeof storePanelInSync>>
>({});
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 };
};

127
src/utils/hydrate.ts Normal file
View file

@ -0,0 +1,127 @@
import { SerializedDockview } from "dockview";
import { hasBrowserStorage, hasChromeStorage } from "~/utils/storage";
interface Result {
layout: SerializedDockview;
panels: Record<string, string>;
}
const layoutKey = "dockview_persistance_layout";
const panelPrefix = "MarkdownEditorContainerMarkdownEditorContainer";
const makePanelKey = (uuid: string) => `${panelPrefix}${uuid}`;
const getFromBrowserStorage = async (): Promise<Result | null> => {
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<string, string>
);
return {
layout,
panels,
};
};
const getFromChromeStorage = async (): Promise<Result | null> => {
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<string, string>
);
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<string, string>
);
return {
layout,
panels,
};
};
export const hydrateLayout = async (): Promise<Result | null> => {
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 });
}
};

View file

@ -1,11 +1,13 @@
import { hasBrowserStorage, hasChromeStorage } from "./storage";
export class BrowserSyncStorage { export class BrowserSyncStorage {
constructor(private globalPrefix = "") {} constructor(private globalPrefix = "") {}
get engine() { get engine() {
if (typeof browser !== 'undefined' && browser?.storage) { if (hasBrowserStorage()) {
return "browser" return "browser";
} else if (typeof chrome !== 'undefined' && chrome?.storage) { } else if (hasChromeStorage()) {
return "chrome" return "chrome";
} }
return "local"; return "local";
@ -15,20 +17,20 @@ export class BrowserSyncStorage {
set = async <T>(key: string, value: T) => { set = async <T>(key: string, value: T) => {
switch (this.engine) { switch (this.engine) {
case 'browser': case "browser":
await browser.storage.sync.set({ [this.makeKey(key)]: value }); await browser.storage.sync.set({ [this.makeKey(key)]: value });
return; return;
case 'chrome': case "chrome":
await chrome.storage.sync.set({ [this.makeKey(key)]: value }); await chrome.storage.sync.set({ [this.makeKey(key)]: value });
return; return;
default: default:
localStorage.setItem(this.makeKey(key), JSON.stringify(value)); localStorage.setItem(this.makeKey(key), JSON.stringify(value));
return return;
} }
}; };
get = async <T>(key: string): Promise<T | undefined> => { get = async <T>(key: string): Promise<T | undefined> => {
if (this.engine === 'browser') { if (this.engine === "browser") {
const value = await browser.storage.sync const value = await browser.storage.sync
.get([this.makeKey(key)]) .get([this.makeKey(key)])
.then((result) => result[this.makeKey(key)] as T | undefined); .then((result) => result[this.makeKey(key)] as T | undefined);
@ -36,10 +38,10 @@ export class BrowserSyncStorage {
if (value) { if (value) {
return value; return value;
} }
} else if (this.engine === 'chrome') { } else if (this.engine === "chrome") {
const value = await chrome.storage.sync.get(this.makeKey(key)).then( const value = await chrome.storage.sync
(result) => result[this.makeKey(key)] as T | undefined .get(this.makeKey(key))
); .then((result) => result[this.makeKey(key)] as T | undefined);
if (value) { if (value) {
return value; return value;

1
src/utils/noop.ts Normal file
View file

@ -0,0 +1 @@
export const noop = () => {};

57
src/utils/seed.ts Normal file
View file

@ -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",
},
});
};

5
src/utils/storage.ts Normal file
View file

@ -0,0 +1,5 @@
export const hasBrowserStorage = () =>
typeof browser !== "undefined" && browser?.storage;
export const hasChromeStorage = () =>
typeof chrome !== "undefined" && chrome?.storage;

View file

@ -1961,6 +1961,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== 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": "@types/marked@^4.0.2":
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.8.tgz#b316887ab3499d0a8f4c70b7bd8508f92d477955" 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" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== 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: lodash.merge@^4.6.2:
version "4.6.2" version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"