mobile: add readonly editor

This commit is contained in:
Ammar Ahmed
2024-03-15 07:43:03 +05:00
committed by Abdullah Atta
parent c7142e409b
commit 43cb5d278c
13 changed files with 660 additions and 78 deletions

View File

@@ -31,7 +31,7 @@ import RNFetchBlob from "react-native-blob-util";
import { ShareComponent } from "../../components/sheets/export-notes/share";
import { ToastManager, presentSheet } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { db } from "../database";
import { DatabaseLogger, db } from "../database";
import Storage from "../database/storage";
import { cacheDir, copyFileAsync, releasePermissions } from "./utils";
import { createCacheDir, exists } from "./io";
@@ -192,7 +192,7 @@ export default async function downloadAttachment(
let attachment = await db.attachments.attachment(hash);
if (!attachment) {
console.log("attachment not found");
DatabaseLogger.log("Attachment not found");
return;
}
@@ -207,19 +207,21 @@ export default async function downloadAttachment(
}
try {
console.log(
"starting download attachment",
attachment.hash,
options.groupId
);
await db
.fs()
.downloadFile(options.groupId || attachment.hash, attachment.hash);
if (!(await exists(attachment.hash))) {
DatabaseLogger.log("Attachment does not exist after download.");
return;
}
if (options.base64 || options.text) {
console.log(
"starting decrypt base64 file...",
options.base64,
options.text,
attachment.hash
);
return await db.attachments.read(
attachment.hash,
options.base64 ? "base64" : "text"
@@ -232,6 +234,7 @@ export default async function downloadAttachment(
);
let key = await db.attachments.decryptKey(attachment.key);
let info = {
iv: attachment.iv,
salt: attachment.salt,
@@ -245,7 +248,6 @@ export default async function downloadAttachment(
chunkSize: attachment.chunkSize,
appGroupId: IOS_APPGROUPID
};
let fileUri = await Sodium.decryptFile(
key,
info,

View File

@@ -29,13 +29,14 @@ import { createCacheDir, exists } from "./io";
export async function downloadFile(filename, data, cancelToken) {
if (!data) return false;
console.log("Downloading", filename);
await createCacheDir();
let { url, headers } = data;
let path = `${cacheDir}/${filename}`;
try {
if (await exists(filename)) {
console.log("Exists already", filename);
return true;
}

View File

@@ -26,16 +26,14 @@ import { DDS } from "../../../services/device-detection";
import {
eSendEvent,
hideSheet,
openVault,
presentSheet
} from "../../../services/event-manager";
import { useSelectionStore } from "../../../stores/use-selection-store";
import { eOnLoadNote, eShowMergeDialog } from "../../../utils/events";
import { tabBarRef } from "../../../utils/global-refs";
import { NotebooksWithDateEdited, TagsWithDateEdited } from "@notesnook/common";
import NotePreview from "../../note-history/preview";
import SelectionWrapper from "../selection-wrapper";
import { NotebooksWithDateEdited, TagsWithDateEdited } from "@notesnook/common";
export const openNote = async (
item: Note,
@@ -64,7 +62,7 @@ export const openNote = async (
return;
}
if (note.conflicted) {
if (!note.conflicted) {
eSendEvent(eShowMergeDialog, note);
return;
}
@@ -74,9 +72,7 @@ export const openNote = async (
const content = await db.content.get(note.contentId as string);
presentSheet({
component: (
<NotePreview note={item} content={{ type: "tiptap", data: content }} />
)
component: <NotePreview note={item} content={content} />
});
} else {
eSendEvent(eOnLoadNote, {

View File

@@ -25,7 +25,8 @@ import { SafeAreaView, Text, View } from "react-native";
import Animated from "react-native-reanimated";
import { db } from "../../common/database";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import Editor from "../../screens/editor";
import { ReadonlyEditor } from "../../screens/editor/readonly-editor";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import { DDS } from "../../services/device-detection";
import {
@@ -38,7 +39,6 @@ import Sync from "../../services/sync";
import { useSettingStore } from "../../stores/use-setting-store";
import { eOnLoadNote, eShowMergeDialog } from "../../utils/events";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import BaseDialog from "../dialog/base-dialog";
import DialogButtons from "../dialog/dialog-buttons";
import DialogContainer from "../dialog/dialog-container";
@@ -47,7 +47,6 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Seperator from "../ui/seperator";
import Paragraph from "../ui/typography/paragraph";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
const MergeConflicts = () => {
const { colors } = useThemeColors();
@@ -316,23 +315,14 @@ const MergeConflicts = () => {
borderBottomColor: colors.secondary.background
}}
>
<Editor
noHeader
noToolbar
readonly
editorId=":conflictPrimary"
onLoad={async () => {
<ReadonlyEditor
editorId="conflictPrimary"
onLoad={async (loadContent) => {
const note = await db.notes.note(content.current?.noteId);
if (!note) return;
await sleep(300);
eSendEvent(eOnLoadNote + ":conflictPrimary", {
item: {
...note,
content: {
...content.current,
isPreview: true
}
}
loadContent({
id: note.id,
data: content.current.data
});
}}
/>
@@ -353,20 +343,14 @@ const MergeConflicts = () => {
borderRadius: 10
}}
>
<Editor
noHeader
noToolbar
readonly
editorId=":conflictSecondary"
onLoad={async () => {
<ReadonlyEditor
editorId="conflictSecondary"
onLoad={async (loadContent) => {
const note = await db.notes.note(content.current?.noteId);
if (!note) return;
await sleep(300);
eSendEvent(eOnLoadNote + ":conflictSecondary", {
item: {
...note,
content: { ...content.current.conflicted, isPreview: true }
}
loadContent({
id: note.id,
data: content.current.conflicted.data
});
}}
/>

View File

@@ -34,6 +34,7 @@ import DialogHeader from "../dialog/dialog-header";
import { presentDialog } from "../dialog/functions";
import { Button } from "../ui/button";
import Paragraph from "../ui/typography/paragraph";
import { ReadonlyEditor } from "../../screens/editor/readonly-editor";
/**
*
@@ -42,7 +43,6 @@ import Paragraph from "../ui/typography/paragraph";
*/
export default function NotePreview({ session, content, note }) {
const { colors } = useThemeColors();
const editorId = ":noteHistory";
const [locked, setLocked] = useState(false);
async function restore() {
@@ -118,22 +118,16 @@ export default function NotePreview({ session, content, note }) {
flex: 1
}}
>
<Editor
noHeader
noToolbar
readonly
editorId={editorId}
onLoad={async () => {
const _note = note || (await db.notes.note(session?.noteId));
eSendEvent(eOnLoadNote + editorId, {
item: {
..._note,
content: {
...content,
isPreview: true
}
}
});
<ReadonlyEditor
editorId="historyPreview"
onLoad={async (loadContent) => {
if (content.data) {
const _note = note || (await db.notes.note(session?.noteId));
loadContent({
data: content.data,
id: _note.id
});
}
}}
/>
</View>

View File

@@ -101,17 +101,20 @@ const Editor = React.memo(
noToolbar,
noHeader
});
const renderKey = useRef(`editor-0`);
const renderKey = useRef(`editor-0` + editorId);
useImperativeHandle(ref, () => ({
get: () => editor
}));
const onError = useCallback(() => {
renderKey.current =
renderKey.current === `editor-0` ? `editor-1` : `editor-0`;
renderKey.current === `editor-0`
? `editor-1` + editorId
: `editor-0` + editorId;
editor.state.current.ready = false;
editor.setLoading(true);
}, [editor]);
}, [editor, editorId]);
useEffect(() => {
const sub = [eSubscribeEvent("webview_reset", onError)];

View File

@@ -0,0 +1,287 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useState } from "react";
import { Platform, View, ViewStyle } from "react-native";
import { openLinkInBrowser } from "../../utils/functions";
import {
ShouldStartLoadRequest,
WebViewMessageEvent
} from "react-native-webview/lib/WebViewTypes";
import WebView from "react-native-webview";
import { useRef } from "react";
import { EDITOR_URI } from "./source";
import { EditorMessage } from "./tiptap/types";
import { EventTypes } from "./tiptap/editor-events";
import { Attachment } from "@notesnook/editor";
import downloadAttachment from "../../common/filesystem/download-attachment";
import { EditorEvents } from "./tiptap/utils";
import { useThemeColors } from "@notesnook/theme";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import { db } from "../../common/database";
const onShouldStartLoadWithRequest = (request: ShouldStartLoadRequest) => {
if (request.url.includes("https")) {
if (Platform.OS === "ios" && !request.isTopFrame) return true;
openLinkInBrowser(request.url);
return false;
} else {
return true;
}
};
const style: ViewStyle = {
height: "100%",
maxHeight: "100%",
width: "100%",
alignSelf: "center",
backgroundColor: "transparent"
};
export function ReadonlyEditor(props: {
onLoad: (
loadContent: (content: { data: string; id: string }) => void
) => void;
editorId: string;
}) {
const { colors } = useThemeColors();
const editorRef = useRef<WebView>(null);
const [loading, setLoading] = useState(true);
const insets = useGlobalSafeAreaInsets();
const noteId = useRef<string>();
const onMessage = (event: WebViewMessageEvent) => {
const data = event.nativeEvent.data;
const editorMessage = JSON.parse(data) as EditorMessage<any>;
if (editorMessage.type === EventTypes.logger) {
logger.info("[READONLY EDITOR LOG]", editorMessage.value);
}
if (editorMessage.type === EventTypes.readonlyEditorLoaded) {
console.log("Readonly editor loaded.");
props.onLoad?.((content: { data: string; id: string }) => {
noteId.current = content.id;
editorRef.current?.postMessage(
JSON.stringify({
type: "native:html",
value: content.data
})
);
setLoading(false);
});
} else if (editorMessage.type === EventTypes.getAttachmentData) {
const attachment = (editorMessage.value as any).attachment as Attachment;
console.log("Getting attachment data:", attachment.hash, attachment.type);
downloadAttachment(attachment.hash, true, {
base64: attachment.type === "image",
text: attachment.type === "web-clip",
silent: true,
groupId: noteId.current,
cache: true
} as any)
.then((data: any) => {
console.log(
"Got attachment data:",
!!data,
(editorMessage.value as any).resolverId
);
editorRef.current?.postMessage(
JSON.stringify({
type: EditorEvents.attachmentData,
value: {
resolverId: (editorMessage.value as any).resolverId,
data
}
})
);
})
.catch(() => {
console.log("Error downloading attachment data");
editorRef.current?.postMessage(
JSON.stringify({
type: EditorEvents.attachmentData,
data: {
resolverId: (editorMessage.value as any).resolverId,
data: undefined
}
})
);
});
}
};
useEffect(() => {
const groupId = noteId.current;
return () => {
if (groupId) {
db.fs().cancel(groupId);
}
};
}, [loading]);
return (
<>
<WebView
ref={editorRef}
key={"readonly-editor:" + props.editorId}
nestedScrollEnabled
injectedJavaScriptBeforeContentLoaded={`globalThis.readonlyEditor=true;`}
injectedJavaScript="globalThis.readonlyEditor=true;"
useSharedProcessPool={false}
javaScriptEnabled={true}
focusable={true}
setSupportMultipleWindows={false}
overScrollMode="never"
scrollEnabled={false}
keyboardDisplayRequiresUserAction={false}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
cacheMode="LOAD_DEFAULT"
cacheEnabled={true}
domStorageEnabled={true}
bounces={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
allowFileAccess={true}
scalesPageToFit={true}
hideKeyboardAccessoryView={false}
allowsFullscreenVideo={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
originWhitelist={["*"]}
source={{
uri: EDITOR_URI
}}
style={style}
autoManageStatusBarEnabled={false}
onMessage={onMessage || undefined}
/>
{loading ? (
<View
style={[
{
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
justifyContent: "center",
alignItems: "center",
zIndex: 100
}
]}
>
<View
style={{
width: "100%",
backgroundColor: colors.primary.background,
borderRadius: 5,
height: "100%",
alignItems: "flex-start",
paddingTop: insets.top
}}
>
<View
style={{
paddingHorizontal: 12,
width: "100%",
alignItems: "flex-start"
}}
>
<View
style={{
height: 25,
width: "100%",
backgroundColor: colors.secondary.background,
borderRadius: 5
}}
/>
<View
style={{
height: 12,
width: "100%",
marginTop: 10,
flexDirection: "row"
}}
>
<View
style={{
height: 12,
width: 60,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginRight: 10
}}
/>
<View
style={{
height: 12,
width: 60,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginRight: 10
}}
/>
<View
style={{
height: 12,
width: 60,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginRight: 10
}}
/>
</View>
<View
style={{
height: 16,
width: "100%",
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<View
style={{
height: 16,
width: "100%",
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<View
style={{
height: 16,
width: 200,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
</View>
</View>
</View>
) : null}
</>
);
}

View File

@@ -46,5 +46,6 @@ export const EventTypes = {
load: "editor-events:load",
unlock: "editor-events:unlock",
unlockWithBiometrics: "editor-events:unlock-biometrics",
disableReadonlyMode: "editor-events:disable-readonly-mode"
disableReadonlyMode: "editor-events:disable-readonly-mode",
readonlyEditorLoaded: "readonlyEditorLoaded"
};

View File

@@ -123,7 +123,7 @@ async function createNotes(bundle) {
if (attached) {
if (isImage(file.type)) {
content = `<img data-hash="${hash}" data-mime="${file.type}" data-filename="${file.name}" />`;
content = `<img data-hash="${hash}" data-mime="${file.type}" data-filename="${file.name}" data-size="${file.size}" />`;
} else {
content = `<p><span data-hash="${hash}" data-mime="${file.type}" data-filename="${file.name}" data-size="${file.size}" /></p>`;
}

View File

@@ -30,6 +30,7 @@ import Tiptap from "./components/editor";
import { TabContext, useTabStore } from "./hooks/useTabStore";
import { EmotionEditorTheme } from "./theme-factory";
import { getTheme } from "./utils";
import { ReadonlyEditorProvider } from "./components/readonly-editor";
const currentTheme = getTheme();
if (currentTheme) {
@@ -43,13 +44,18 @@ function App(): JSX.Element {
<ScopedThemeProvider value="base">
<EmotionEditorTheme>
<GlobalStyles />
{tabs.map((tab) => (
<TabContext.Provider key={tab.id} value={tab}>
<Freeze freeze={currentTab !== tab.id}>
<Tiptap />
</Freeze>
</TabContext.Provider>
))}
{globalThis["readonlyEditor"] ? (
<ReadonlyEditorProvider />
) : (
tabs.map((tab) => (
<TabContext.Provider key={tab.id} value={tab}>
<Freeze freeze={currentTab !== tab.id}>
<Tiptap />
</Freeze>
</TabContext.Provider>
))
)}
</EmotionEditorTheme>
</ScopedThemeProvider>
);

View File

@@ -0,0 +1,306 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
PortalProvider,
TiptapOptions,
getFontById,
useTiptap
} from "@notesnook/editor";
import { useThemeColors } from "@notesnook/theme";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from "react";
import { useSettings } from "../hooks/useSettings";
import { EventTypes, Settings, isReactNative, randId } from "../utils";
export const ReadonlyEditorProvider = (): JSX.Element => {
const settings = useSettings();
const { colors } = useThemeColors("editor");
const contentRef = useRef<HTMLElement>();
const getContentDiv = useCallback(() => {
if (contentRef.current) {
return contentRef.current;
}
const editorContainer = document.createElement("div");
editorContainer.classList.add("selectable");
editorContainer.style.flex = "1";
editorContainer.style.cursor = "text";
editorContainer.style.padding = "0px 12px";
editorContainer.style.color = colors.primary.paragraph;
editorContainer.style.paddingBottom = `150px`;
editorContainer.style.fontSize = `${settings.fontSize}px`;
editorContainer.style.fontFamily =
getFontById(settings.fontFamily)?.font || "sans-serif";
contentRef.current = editorContainer;
return editorContainer;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.color = colors.primary.paragraph;
}
}, [colors]);
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.fontSize = `${settings.fontSize}px`;
contentRef.current.style.fontFamily =
getFontById(settings.fontFamily)?.font || "sans-serif";
}
}, [settings.fontSize, settings.fontFamily]);
return (
<PortalProvider>
<Tiptap settings={settings} getContentDiv={getContentDiv} />
</PortalProvider>
);
};
const Tiptap = ({
settings,
getContentDiv
}: {
settings: Settings;
getContentDiv: () => HTMLElement;
}) => {
const contentPlaceholderRef = useRef<HTMLDivElement>(null);
const { colors } = useThemeColors();
const containerRef = useRef<HTMLDivElement>(null);
const editorRoot = useRef<HTMLDivElement>(null);
const content = useRef();
const [tick, setTick] = useState(0);
const [loading, setLoading] = useState(true);
const update = () => setTick((tick) => tick + 1);
const tiptapOptions = useMemo<Partial<TiptapOptions>>(() => {
return {
getAttachmentData(attachment) {
return new Promise<string>((resolve, reject) => {
const resolverId = randId("get_attachment_data");
pendingResolvers[resolverId] = (data) => {
delete pendingResolvers[resolverId];
resolve(data);
};
post(EventTypes.getAttachmentData, {
attachment,
resolverId: resolverId
});
});
},
element: getContentDiv(),
editable: false,
editorProps: {
editable: () => false
},
content: content.current,
isMobile: true,
doubleSpacedLines: settings.doubleSpacedLines,
downloadOptions: {
corsHost: settings.corsProxy
},
dateFormat: settings.dateFormat,
timeFormat: settings.timeFormat as "12-hour" | "24-hour" | undefined,
enableInputRules: settings.markdownShortcuts
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
getContentDiv,
settings.doubleSpacedLines,
settings.corsProxy,
settings.dateFormat,
settings.timeFormat,
settings.markdownShortcuts,
tick
]);
const _editor = useTiptap(tiptapOptions, [tiptapOptions]);
useLayoutEffect(() => {
if (!getContentDiv().parentElement) {
contentPlaceholderRef.current?.appendChild(getContentDiv());
}
}, [getContentDiv]);
useEffect(() => {
if (!isReactNative()) return; // Subscribe only in react native webview.
const isSafari = navigator.vendor.match(/apple/i);
let root: Document | Window = document;
if (isSafari) {
root = window;
}
post(EventTypes.readonlyEditorLoaded);
const onMessage = (event: any) => {
if (event?.data?.[0] !== "{") return;
const message = JSON.parse(event.data);
const type = message.type;
const value = message.value;
if (type === "native:html") {
content.current = value;
update();
setTimeout(() => {
setLoading(false);
});
}
if (type === "native:attachment-data") {
if (pendingResolvers[value.resolverId]) {
logger("info", "resolved data for attachment", value.resolverId);
pendingResolvers[value.resolverId](value.data);
}
}
};
root.addEventListener("message", onMessage);
return () => {
root.removeEventListener("message", onMessage);
};
}, []);
return (
<>
<div
style={{
display: "flex",
flex: 1,
flexDirection: "column",
maxWidth: "100vw"
}}
ref={editorRoot}
>
<div
ref={containerRef}
style={{
overflowY: loading ? "hidden" : "scroll",
height: "100%",
display: "block",
position: "relative"
}}
>
{loading ? (
<div
style={{
width: "100%",
height: "90%",
position: "absolute",
zIndex: 999,
backgroundColor: colors.primary.background,
paddingRight: 12,
paddingLeft: 12,
display: "flex",
flexDirection: "column",
boxSizing: "border-box",
rowGap: 10
}}
>
<div
style={{
height: 25,
width: "94%",
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<div
style={{
flexDirection: "row",
display: "flex",
gap: 10
}}
>
<div
style={{
height: 12,
width: 40,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<div
style={{
height: 12,
width: 50,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<div
style={{
height: 12,
width: 100,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
</div>
<div
style={{
height: 16,
width: "94%",
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<div
style={{
height: 16,
width: "94%",
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
<div
style={{
height: 16,
width: 200,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginTop: 10
}}
/>
</div>
) : null}
<div ref={contentPlaceholderRef} className="theme-scope-editor" />
</div>
</div>
</>
);
};

View File

@@ -103,7 +103,7 @@ export type EditorController = {
setTitlePlaceholder: React.Dispatch<React.SetStateAction<string>>;
countWords: (ms: number) => void;
copyToClipboard: (text: string) => void;
getAttachmentData: (attachment: Attachment) => Promise<string>;
getAttachmentData: (attachment: Partial<Attachment>) => Promise<string>;
updateTab: () => void;
loading: boolean;
setLoading: (value: boolean) => void;
@@ -327,7 +327,7 @@ export function useEditorController({
post(EventTypes.copyToClipboard, text);
};
const getAttachmentData = (attachment: Attachment) => {
const getAttachmentData = (attachment: Partial<Attachment>) => {
return new Promise<string>((resolve, reject) => {
const resolverId = randId("get_attachment_data");
pendingResolvers[resolverId] = (data) => {

View File

@@ -57,6 +57,7 @@ declare global {
var pendingResolvers: {
[key: string]: (value: any) => void;
};
var readonlyEditor: boolean;
var statusBars: Record<
number,
| React.MutableRefObject<{
@@ -189,7 +190,8 @@ export const EventTypes = {
load: "editor-events:load",
unlock: "editor-events:unlock",
unlockWithBiometrics: "editor-events:unlock-biometrics",
disableReadonlyMode: "editor-events:disable-readonly-mode"
disableReadonlyMode: "editor-events:disable-readonly-mode",
readonlyEditorLoaded: "readonlyEditorLoaded"
} as const;
export function randId(prefix: string) {