mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-03 04:31:46 +02:00
feat: further simplify editor logic
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TitleBox from "./title-box";
|
||||
import { useStore } from "../../stores/editor-store";
|
||||
import { Input } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
@@ -18,8 +17,6 @@ function Header({ readonly }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleBox readonly={readonly} />
|
||||
|
||||
{!readonly && id && (
|
||||
<Flex alignItems="center" flexWrap="wrap" sx={{ lineHeight: 2.5 }}>
|
||||
{tags?.map((tag) => (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { downloadAttachment } from "../../common/attachments";
|
||||
import { EV, EVENTS } from "notes-core/common";
|
||||
import { db } from "../../common/db";
|
||||
import useMobile from "../../utils/use-mobile";
|
||||
import Titlebox from "./title-box";
|
||||
|
||||
function updateWordCount(counter?: CharacterCounter) {
|
||||
AppEventManager.publish(
|
||||
@@ -38,8 +39,16 @@ function onEditorChange(noteId: string, sessionId: string, content: string) {
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
function onTitleChange(noteId: string, title: string) {
|
||||
if (!title) return;
|
||||
|
||||
editorstore.get().setTitle(noteId, title);
|
||||
}
|
||||
|
||||
const debouncedUpdateWordCount = debounce(updateWordCount, 1000);
|
||||
const debouncedOnEditorChange = debounceWithId(onEditorChange, 100);
|
||||
const debouncedOnTitleChange = debounceWithId(onTitleChange, 100);
|
||||
|
||||
export default function EditorManager({
|
||||
noteId,
|
||||
@@ -52,6 +61,7 @@ export default function EditorManager({
|
||||
const isOldSession = !nonce && !!noteId;
|
||||
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const arePropertiesVisible = useStore((store) => store.arePropertiesVisible);
|
||||
const toggleProperties = useStore((store) => store.toggleProperties);
|
||||
const isPreviewMode = useStore(
|
||||
@@ -61,7 +71,6 @@ export default function EditorManager({
|
||||
(store) => store.session.readonly || isPreviewMode
|
||||
);
|
||||
const [dropRef, overlayRef] = useDragOverlay();
|
||||
const editor = useEditorInstance();
|
||||
// TODO move this somewhere more appropriate
|
||||
// const init = useStore((store) => store.init);
|
||||
|
||||
@@ -70,6 +79,7 @@ export default function EditorManager({
|
||||
(async function () {
|
||||
await editorstore.newSession(nonce);
|
||||
setContent("");
|
||||
setTitle("");
|
||||
})();
|
||||
}, [isNewSession, nonce]);
|
||||
|
||||
@@ -87,13 +97,13 @@ export default function EditorManager({
|
||||
if (!isOldSession) return;
|
||||
|
||||
(async function () {
|
||||
await editorstore.openSession(noteId);
|
||||
await editorstore.get().openSession(noteId);
|
||||
|
||||
let content = await editorstore.get().getSessionContent();
|
||||
const { getSessionContent, session } = editorstore.get();
|
||||
const content = await getSessionContent();
|
||||
|
||||
setTitle(session.title);
|
||||
setContent(content?.data);
|
||||
// editorstore.set(
|
||||
// (state: any) => (state.session.state = SESSION_STATES.stale)
|
||||
// );
|
||||
if (noteId && content) await db.attachments?.downloadImages(noteId);
|
||||
})();
|
||||
}, [noteId, isOldSession]);
|
||||
@@ -112,60 +122,26 @@ export default function EditorManager({
|
||||
>
|
||||
{isPreviewMode && <PreviewModeNotice />}
|
||||
<Editor
|
||||
title={title}
|
||||
content={content}
|
||||
readonly={isReadonly}
|
||||
onRequestFocus={() => toggleProperties(false)}
|
||||
/>
|
||||
{arePropertiesVisible && <Properties />}
|
||||
|
||||
<Box
|
||||
ref={overlayRef}
|
||||
id="drag-overlay"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bg: "overlay",
|
||||
zIndex: 3,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: "none",
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
if (!editor) return;
|
||||
for (let file of e.dataTransfer.files) {
|
||||
const result = await attachFile(file);
|
||||
if (!result) continue;
|
||||
editor.attachFile(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
border: "2px dashed var(--fontTertiary)",
|
||||
borderRadius: "default",
|
||||
p: 70,
|
||||
flexDirection: "column",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Attachment size={72} />
|
||||
<Text variant={"heading"} sx={{ color: "icon", mt: 2 }}>
|
||||
Drop your files here to attach
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
<DropZone overlayRef={overlayRef} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type EditorProps = {
|
||||
title: string;
|
||||
readonly?: boolean;
|
||||
focusMode?: boolean;
|
||||
content: string;
|
||||
onRequestFocus?: () => void;
|
||||
};
|
||||
function Editor({ content, readonly, focusMode, onRequestFocus }: EditorProps) {
|
||||
function Editor(props: EditorProps) {
|
||||
const { content, readonly, focusMode, onRequestFocus, title } = props;
|
||||
const editor = useEditorInstance();
|
||||
const isMobile = useMobile();
|
||||
|
||||
@@ -238,6 +214,14 @@ function Editor({ content, readonly, focusMode, onRequestFocus }: EditorProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Titlebox
|
||||
readonly={readonly || false}
|
||||
setTitle={(title) => {
|
||||
const { sessionId, id } = editorstore.get().session;
|
||||
debouncedOnTitleChange(sessionId, id, title);
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
<Header readonly={readonly} />
|
||||
<Tiptap
|
||||
readonly={readonly}
|
||||
@@ -329,6 +313,54 @@ function PreviewModeNotice() {
|
||||
);
|
||||
}
|
||||
|
||||
type DropZoneProps = {
|
||||
overlayRef: React.MutableRefObject<HTMLElement | undefined>;
|
||||
};
|
||||
function DropZone(props: DropZoneProps) {
|
||||
const { overlayRef } = props;
|
||||
const editor = useEditorInstance();
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={overlayRef}
|
||||
id="drag-overlay"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bg: "overlay",
|
||||
zIndex: 3,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: "none",
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
if (!editor) return;
|
||||
for (let file of e.dataTransfer.files) {
|
||||
const result = await attachFile(file);
|
||||
if (!result) continue;
|
||||
editor.attachFile(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
border: "2px dashed var(--fontTertiary)",
|
||||
borderRadius: "default",
|
||||
p: 70,
|
||||
flexDirection: "column",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Attachment size={72} />
|
||||
<Text variant={"heading"} sx={{ color: "icon", mt: 2 }}>
|
||||
Drop your files here to attach
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function useDragOverlay() {
|
||||
const dropElementRef = useRef<HTMLElement>();
|
||||
const overlayRef = useRef<HTMLElement>();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "emotion-theming";
|
||||
import { Toolbar, useTiptap, PortalProvider, Editor } from "notesnook-editor";
|
||||
import { Box, Flex } from "rebass";
|
||||
import "notesnook-editor/dist/styles.css";
|
||||
import { PropsWithChildren, useEffect, useRef } from "react";
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import useMobile from "../../utils/use-mobile";
|
||||
import { Attachment } from "./plugins/picker";
|
||||
import { CharacterCounter, IEditor } from "./types";
|
||||
@@ -13,6 +13,7 @@ import { AttachmentType } from "notesnook-editor/dist/extensions/attachment";
|
||||
import { getCurrentPreset } from "../../common/toolbar-config";
|
||||
|
||||
type TipTapProps = {
|
||||
editorContainer: HTMLElement;
|
||||
onChange?: (content: string, counter?: CharacterCounter) => void;
|
||||
onInsertAttachment?: (type: AttachmentType) => void;
|
||||
onDownloadAttachment?: (attachment: Attachment) => void;
|
||||
@@ -30,10 +31,10 @@ function TipTap(props: TipTapProps) {
|
||||
onFocus = () => {},
|
||||
content,
|
||||
toolbarContainerId,
|
||||
editorContainer,
|
||||
readonly,
|
||||
} = props;
|
||||
|
||||
const editorContentRef = useRef<HTMLDivElement>();
|
||||
const theme: Theme = useTheme();
|
||||
const isMobile = useMobile();
|
||||
const counter = useRef<CharacterCounter>();
|
||||
@@ -43,7 +44,7 @@ function TipTap(props: TipTapProps) {
|
||||
|
||||
const editor = useTiptap(
|
||||
{
|
||||
element: editorContentRef.current,
|
||||
element: editorContainer,
|
||||
editable: !readonly,
|
||||
content,
|
||||
autofocus: "start",
|
||||
@@ -84,7 +85,7 @@ function TipTap(props: TipTapProps) {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[content, readonly, theme]
|
||||
[content, readonly]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +99,7 @@ function TipTap(props: TipTapProps) {
|
||||
}, [toggleSearch, editor?.storage.searchreplace?.isSearching]);
|
||||
|
||||
return (
|
||||
<Flex sx={{ flex: 1, flexDirection: "column" }}>
|
||||
<>
|
||||
<Portal containerId={toolbarContainerId}>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
@@ -108,19 +109,38 @@ function TipTap(props: TipTapProps) {
|
||||
tools={toolbarConfig}
|
||||
/>
|
||||
</Portal>
|
||||
<Box
|
||||
className="selectable"
|
||||
ref={editorContentRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: "text",
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TiptapWrapper(props: Omit<TipTapProps, "editorContainer">) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const editorContainerRef = useRef<HTMLDivElement>();
|
||||
useEffect(() => {
|
||||
setIsReady(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Flex sx={{ flex: 1, flexDirection: "column" }}>
|
||||
{isReady && editorContainerRef.current ? (
|
||||
<TipTap {...props} editorContainer={editorContainerRef.current} />
|
||||
) : null}
|
||||
<Box
|
||||
ref={editorContainerRef}
|
||||
className="selectable"
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: "text",
|
||||
color: "var(--text)", // TODO!
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</PortalProvider>
|
||||
);
|
||||
}
|
||||
export default TiptapWrapper;
|
||||
|
||||
function Portal(props: PropsWithChildren<{ containerId?: string }>) {
|
||||
const { containerId, children } = props;
|
||||
const container = containerId && document.getElementById(containerId);
|
||||
@@ -131,15 +151,6 @@ function Portal(props: PropsWithChildren<{ containerId?: string }>) {
|
||||
);
|
||||
}
|
||||
|
||||
function TiptapProvider(props: TipTapProps) {
|
||||
return (
|
||||
<PortalProvider>
|
||||
<TipTap {...props} />
|
||||
</PortalProvider>
|
||||
);
|
||||
}
|
||||
export default TiptapProvider;
|
||||
|
||||
function toIEditor(editor: Editor): IEditor {
|
||||
return {
|
||||
focus: () => editor.commands.focus("start"),
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Input } from "@rebass/forms";
|
||||
import { useStore, store, SESSION_STATES } from "../../stores/editor-store";
|
||||
|
||||
type TitleBoxProps = {
|
||||
readonly: boolean;
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
};
|
||||
|
||||
function TitleBox(props: TitleBoxProps) {
|
||||
const { readonly } = props;
|
||||
|
||||
const state = useStore((store) => store.session.state);
|
||||
const sessionId = useStore((store) => store.session.id);
|
||||
const title = useStore((store) => store.session.title);
|
||||
const setTitle = useStore((store) => store.setTitle);
|
||||
|
||||
const { readonly, setTitle, title } = props;
|
||||
const [currentTitle, setCurrentTitle] = useState<string>();
|
||||
const [placeholder, setPlaceholder] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const noteTitle = store.get().session.title;
|
||||
if (state === SESSION_STATES.new && noteTitle !== currentTitle) {
|
||||
setCurrentTitle("");
|
||||
setPlaceholder("");
|
||||
} else if (state === SESSION_STATES.stale) {
|
||||
setCurrentTitle(noteTitle);
|
||||
}
|
||||
// We do not want to update when currentTitle changes.
|
||||
}, [state, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTitle !== title) setPlaceholder(title);
|
||||
// if (currentTitle !== title) setPlaceholder(title);
|
||||
// We do not want to update when currentTitle changes.
|
||||
setCurrentTitle(title);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
@@ -39,7 +23,7 @@ function TitleBox(props: TitleBoxProps) {
|
||||
variant="clean"
|
||||
data-test-id="editor-title"
|
||||
className="editorTitle"
|
||||
placeholder={placeholder || "Note title"}
|
||||
placeholder={"Note title"}
|
||||
width="100%"
|
||||
readOnly={readonly}
|
||||
sx={{
|
||||
@@ -50,12 +34,16 @@ function TitleBox(props: TitleBoxProps) {
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setCurrentTitle(e.target.value);
|
||||
setTitle(sessionId, e.target.value);
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TitleBox, (prevProps, nextProps) => {
|
||||
return prevProps.readonly === nextProps.readonly;
|
||||
return (
|
||||
prevProps.readonly === nextProps.readonly &&
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.setTitle === nextProps.setTitle
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,26 +35,10 @@ export function hashNavigate(
|
||||
if (addNonce) url += `/${++last}`;
|
||||
|
||||
window.history[`${replace ? "replace" : "push"}State`](null, null, `#${url}`);
|
||||
if (notify) dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
// if (typeof url !== "string") {
|
||||
// throw new Error(`"url" must be a string, was provided a(n) ${typeof url}`);
|
||||
// }
|
||||
// if (Array.isArray(replaceOrQuery)) {
|
||||
// throw new Error(
|
||||
// '"replaceOrQuery" must be boolean, object, or URLSearchParams'
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (replaceOrQuery !== null && typeof replaceOrQuery === "object") {
|
||||
// url += "?" + new URLSearchParams(replaceOrQuery).toString();
|
||||
// } else if (replace === undefined && replaceOrQuery !== undefined) {
|
||||
// replace = replaceOrQuery;
|
||||
// } else if (replace === undefined && replaceOrQuery === undefined) {
|
||||
// replace = false;
|
||||
// }
|
||||
|
||||
// window.history[`${replace ? "replace" : "push"}State`](null, null, url);
|
||||
// dispatchEvent(new PopStateEvent("popstate", null));
|
||||
const event = new HashChangeEvent("hashchange");
|
||||
event.notify = notify;
|
||||
dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function useQueryParams(parseFn = parseQuery) {
|
||||
|
||||
@@ -237,12 +237,12 @@ class EditorStore extends BaseStore {
|
||||
appStore.setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
setTitle = (sessionId, title) => {
|
||||
return this.saveSession(sessionId, { title });
|
||||
setTitle = (noteId, title) => {
|
||||
return this.saveSession(noteId, { title });
|
||||
};
|
||||
|
||||
toggle = (sessionId, name, value) => {
|
||||
return this.saveSession(sessionId, { [name]: value });
|
||||
toggle = (noteId, name, value) => {
|
||||
return this.saveSession(noteId, { [name]: value });
|
||||
};
|
||||
|
||||
saveSessionContent = (noteId, sessionId, content) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { hashNavigate } from "../navigation";
|
||||
// (excluding the leading '#' symbol)
|
||||
const currentLocation = () => {
|
||||
const location = window.location.hash.replace(/^#/, "") || "/";
|
||||
let end = location.indexOf("?");
|
||||
let end: number | undefined = location.indexOf("?");
|
||||
if (end <= -1) end = undefined;
|
||||
return location.substring(0, end);
|
||||
};
|
||||
@@ -17,14 +17,22 @@ const currentQuery = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type HashLocation = { location: string; update: boolean };
|
||||
export default function useHashLocation() {
|
||||
const [loc, setLoc] = useState(currentLocation());
|
||||
const [loc, setLoc] = useState<HashLocation>({
|
||||
location: currentLocation(),
|
||||
update: true,
|
||||
});
|
||||
const [queryParams, setQueryParams] = useState(currentQuery());
|
||||
|
||||
useEffect(() => {
|
||||
// this function is called whenever the hash changes
|
||||
const handler = () => {
|
||||
setLoc(currentLocation());
|
||||
const handler = (e: HashChangeEvent) => {
|
||||
const notify = (e as any).notify === undefined ? true : (e as any).notify;
|
||||
setLoc({
|
||||
location: currentLocation(),
|
||||
update: notify,
|
||||
});
|
||||
setQueryParams(currentQuery());
|
||||
};
|
||||
|
||||
@@ -32,5 +40,5 @@ export default function useHashLocation() {
|
||||
window.addEventListener("hashchange", handler);
|
||||
return () => window.removeEventListener("hashchange", handler);
|
||||
}, []);
|
||||
return [loc, queryParams, hashNavigate];
|
||||
return [loc, queryParams, hashNavigate] as const;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import useHashLocation from "./use-hash-location";
|
||||
|
||||
var lastRoute = null;
|
||||
export default function useHashRoutes(routes) {
|
||||
const [location] = useHashLocation();
|
||||
const [{ location, update }] = useHashLocation();
|
||||
if (!update) return lastRoute;
|
||||
|
||||
const matcher = makeMatcher();
|
||||
for (var key in routes) {
|
||||
const [match, params] = matcher(key, location);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import makeMatcher from "wouter/matcher";
|
||||
import useHashLocation from "./use-hash-location";
|
||||
|
||||
export default function useHashRoute(pattern) {
|
||||
const [location] = useHashLocation();
|
||||
const matcher = makeMatcher();
|
||||
return matcher(pattern, location);
|
||||
}
|
||||
Reference in New Issue
Block a user