mirror of
https://github.com/usememos/memos.git
synced 2025-12-15 21:29:52 +08:00
feat: add LocationDialog and related hooks for location management in MemoEditor
- Implemented LocationDialog component for selecting and entering location coordinates. - Created useLocation hook to manage location state and updates. - Added LocationState type for managing location data. - Introduced useLinkMemo hook for linking memos with search functionality. - Added VisibilitySelector component for selecting memo visibility. - Refactored MemoEditor to integrate new hooks and components for improved functionality. - Removed obsolete handlers and streamlined memo save logic with useMemoSave hook. - Enhanced focus mode functionality with dedicated components for overlay and exit button.
This commit is contained in:
parent
c1765fc246
commit
50199fe998
20 changed files with 483 additions and 235 deletions
91
web/src/components/MemoEditor/Editor/markdownShortcuts.ts
Normal file
91
web/src/components/MemoEditor/Editor/markdownShortcuts.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type { EditorRefActions } from "./index";
|
||||
|
||||
/**
|
||||
* Handles keyboard shortcuts for markdown formatting
|
||||
* Requires Cmd/Ctrl key to be pressed
|
||||
*
|
||||
* @alias handleEditorKeydownWithMarkdownShortcuts - for backward compatibility
|
||||
*/
|
||||
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case "b":
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, "**"); // Bold
|
||||
break;
|
||||
case "i":
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, "*"); // Italic
|
||||
break;
|
||||
case "k":
|
||||
event.preventDefault();
|
||||
insertHyperlink(editor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
export const handleEditorKeydownWithMarkdownShortcuts = handleMarkdownShortcuts;
|
||||
|
||||
/**
|
||||
* Inserts a hyperlink for the selected text
|
||||
* If selected text is a URL, creates a link with empty text
|
||||
* Otherwise, creates a link with placeholder URL
|
||||
*/
|
||||
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const placeholderUrl = "url";
|
||||
const urlRegex = /^https?:\/\/[^\s]+$/;
|
||||
|
||||
// If selected content looks like a URL and no URL provided, use it as the href
|
||||
if (!url && urlRegex.test(selectedContent.trim())) {
|
||||
editor.insertText(`[](${selectedContent})`);
|
||||
// Move cursor between brackets for text input
|
||||
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const href = url ?? placeholderUrl;
|
||||
editor.insertText(`[${selectedContent}](${href})`);
|
||||
|
||||
// If using placeholder URL, select it for easy replacement
|
||||
if (href === placeholderUrl) {
|
||||
const urlStart = cursorPosition + selectedContent.length + 3; // After "]("
|
||||
editor.setCursorPosition(urlStart, urlStart + href.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles text styling (bold, italic, etc.)
|
||||
* If already styled, removes the style; otherwise adds it
|
||||
*/
|
||||
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
|
||||
// Check if already styled - remove style
|
||||
if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) {
|
||||
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
|
||||
editor.insertText(unstyled);
|
||||
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
|
||||
} else {
|
||||
// Add style
|
||||
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
|
||||
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyperlinks the currently highlighted/selected text with the given URL
|
||||
* Used when pasting a URL while text is selected
|
||||
*/
|
||||
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
|
||||
editor.insertText(`[${selectedContent}](${url})`);
|
||||
|
||||
// Position cursor after the link
|
||||
const newPosition = cursorPosition + selectedContent.length + url.length + 4; // []()
|
||||
editor.setCursorPosition(newPosition, newPosition);
|
||||
}
|
||||
3
web/src/components/MemoEditor/Toolbar/index.ts
Normal file
3
web/src/components/MemoEditor/Toolbar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Toolbar components for MemoEditor
|
||||
export { default as InsertMenu } from "./InsertMenu";
|
||||
export { default as VisibilitySelector } from "./VisibilitySelector";
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { Minimize2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FOCUS_MODE_STYLES } from "../constants";
|
||||
|
||||
interface FocusModeOverlayProps {
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus mode overlay with backdrop and exit button
|
||||
* Renders the semi-transparent backdrop when focus mode is active
|
||||
*/
|
||||
export function FocusModeOverlay({ isActive, onToggle }: FocusModeOverlayProps) {
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={FOCUS_MODE_STYLES.backdrop}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => e.key === "Escape" && onToggle()}
|
||||
aria-label="Exit focus mode"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FocusModeExitButtonProps {
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit button for focus mode
|
||||
* Displayed in the top-right corner when focus mode is active
|
||||
*/
|
||||
export function FocusModeExitButton({ isActive, onToggle, title }: FocusModeExitButtonProps) {
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className={FOCUS_MODE_STYLES.exitButton} onClick={onToggle} title={title}>
|
||||
<Minimize2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
2
web/src/components/MemoEditor/components/index.ts
Normal file
2
web/src/components/MemoEditor/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// UI components for MemoEditor
|
||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { EditorRefActions } from "./Editor";
|
||||
|
||||
export const handleEditorKeydownWithMarkdownShortcuts = (event: React.KeyboardEvent, editorRef: EditorRefActions) => {
|
||||
if (event.key === "b") {
|
||||
const boldDelimiter = "**";
|
||||
event.preventDefault();
|
||||
styleHighlightedText(editorRef, boldDelimiter);
|
||||
} else if (event.key === "i") {
|
||||
const italicsDelimiter = "*";
|
||||
event.preventDefault();
|
||||
styleHighlightedText(editorRef, italicsDelimiter);
|
||||
} else if (event.key === "k") {
|
||||
event.preventDefault();
|
||||
hyperlinkHighlightedText(editorRef);
|
||||
}
|
||||
};
|
||||
|
||||
export const hyperlinkHighlightedText = (editor: EditorRefActions, url?: string) => {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const blankURL = "url";
|
||||
|
||||
// If the selected content looks like a URL and no URL is provided,
|
||||
// create a link with empty text and the URL
|
||||
const urlRegex = /^(https?:\/\/[^\s]+)$/;
|
||||
if (!url && urlRegex.test(selectedContent.trim())) {
|
||||
editor.insertText(`[](${selectedContent})`);
|
||||
// insertText places cursor at end, move it between the brackets
|
||||
const linkTextPosition = cursorPosition + 1; // After the opening bracket
|
||||
editor.setCursorPosition(linkTextPosition, linkTextPosition);
|
||||
} else {
|
||||
url = url ?? blankURL;
|
||||
|
||||
editor.insertText(`[${selectedContent}](${url})`);
|
||||
|
||||
if (url === blankURL) {
|
||||
// insertText places cursor at end, select the placeholder URL
|
||||
const urlStart = cursorPosition + selectedContent.length + 3; // After "]("
|
||||
const urlEnd = urlStart + url.length;
|
||||
editor.setCursorPosition(urlStart, urlEnd);
|
||||
}
|
||||
// If url is provided, cursor stays at end (default insertText behavior)
|
||||
}
|
||||
};
|
||||
|
||||
const styleHighlightedText = (editor: EditorRefActions, delimiter: string) => {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) {
|
||||
editor.insertText(selectedContent.slice(delimiter.length, -delimiter.length));
|
||||
const newContentLength = selectedContent.length - delimiter.length * 2;
|
||||
editor.setCursorPosition(cursorPosition, cursorPosition + newContentLength);
|
||||
} else {
|
||||
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
|
||||
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
|
||||
}
|
||||
};
|
||||
8
web/src/components/MemoEditor/hooks/index.ts
Normal file
8
web/src/components/MemoEditor/hooks/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Custom hooks for MemoEditor
|
||||
export { useAbortController } from "./useAbortController";
|
||||
export { useBlobUrls } from "./useBlobUrls";
|
||||
export { useDebounce } from "./useDebounce";
|
||||
export { useDragAndDrop } from "./useDragAndDrop";
|
||||
export { useFocusMode } from "./useFocusMode";
|
||||
export { useLocalFileManager } from "./useLocalFileManager";
|
||||
export { useMemoSave } from "./useMemoSave";
|
||||
40
web/src/components/MemoEditor/hooks/useFocusMode.ts
Normal file
40
web/src/components/MemoEditor/hooks/useFocusMode.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useCallback, useEffect } from "react";
|
||||
|
||||
interface UseFocusModeOptions {
|
||||
isFocusMode: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
interface UseFocusModeReturn {
|
||||
toggleFocusMode: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing focus mode functionality
|
||||
* Handles:
|
||||
* - Body scroll lock when focus mode is active
|
||||
* - Toggle functionality
|
||||
* - Cleanup on unmount
|
||||
*/
|
||||
export function useFocusMode({ isFocusMode, onToggle }: UseFocusModeOptions): UseFocusModeReturn {
|
||||
// Lock body scroll when focus mode is active to prevent background scrolling
|
||||
useEffect(() => {
|
||||
if (isFocusMode) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isFocusMode]);
|
||||
|
||||
const toggleFocusMode = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return {
|
||||
toggleFocusMode,
|
||||
};
|
||||
}
|
||||
199
web/src/components/MemoEditor/hooks/useMemoSave.ts
Normal file
199
web/src/components/MemoEditor/hooks/useMemoSave.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { isEqual } from "lodash-es";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { attachmentStore, memoStore } from "@/store";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
|
||||
interface MemoSaveContext {
|
||||
/** Current memo name (for update mode) */
|
||||
memoName?: string;
|
||||
/** Parent memo name (for comment mode) */
|
||||
parentMemoName?: string;
|
||||
/** Current visibility setting */
|
||||
visibility: Visibility;
|
||||
/** Current attachments */
|
||||
attachmentList: Attachment[];
|
||||
/** Current relations */
|
||||
relationList: MemoRelation[];
|
||||
/** Current location */
|
||||
location?: Location;
|
||||
/** Local files pending upload */
|
||||
localFiles: LocalFile[];
|
||||
/** Create time override */
|
||||
createTime?: Date;
|
||||
/** Update time override */
|
||||
updateTime?: Date;
|
||||
}
|
||||
|
||||
interface MemoSaveCallbacks {
|
||||
/** Called when upload state changes */
|
||||
onUploadingChange: (uploading: boolean) => void;
|
||||
/** Called when request state changes */
|
||||
onRequestingChange: (requesting: boolean) => void;
|
||||
/** Called on successful save */
|
||||
onSuccess: (memoName: string) => void;
|
||||
/** Called on cancellation (no changes) */
|
||||
onCancel: () => void;
|
||||
/** Called to reset after save */
|
||||
onReset: () => void;
|
||||
/** Translation function */
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads local files and creates attachments
|
||||
*/
|
||||
async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (uploading: boolean) => void): Promise<Attachment[]> {
|
||||
if (localFiles.length === 0) return [];
|
||||
|
||||
onUploadingChange(true);
|
||||
try {
|
||||
const attachments: Attachment[] = [];
|
||||
for (const { file } of localFiles) {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
} finally {
|
||||
onUploadingChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an update mask by comparing memo properties
|
||||
*/
|
||||
function buildUpdateMask(
|
||||
prevMemo: Memo,
|
||||
content: string,
|
||||
allAttachments: Attachment[],
|
||||
context: MemoSaveContext,
|
||||
): { mask: Set<string>; patch: Partial<Memo> } {
|
||||
const mask = new Set<string>();
|
||||
const patch: Partial<Memo> = {
|
||||
name: prevMemo.name,
|
||||
content,
|
||||
};
|
||||
|
||||
if (!isEqual(content, prevMemo.content)) {
|
||||
mask.add("content");
|
||||
patch.content = content;
|
||||
}
|
||||
if (!isEqual(context.visibility, prevMemo.visibility)) {
|
||||
mask.add("visibility");
|
||||
patch.visibility = context.visibility;
|
||||
}
|
||||
if (!isEqual(allAttachments, prevMemo.attachments)) {
|
||||
mask.add("attachments");
|
||||
patch.attachments = allAttachments;
|
||||
}
|
||||
if (!isEqual(context.relationList, prevMemo.relations)) {
|
||||
mask.add("relations");
|
||||
patch.relations = context.relationList;
|
||||
}
|
||||
if (!isEqual(context.location, prevMemo.location)) {
|
||||
mask.add("location");
|
||||
patch.location = context.location;
|
||||
}
|
||||
|
||||
// Auto-update timestamp if content changed
|
||||
if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) {
|
||||
mask.add("update_time");
|
||||
}
|
||||
|
||||
// Handle custom timestamps
|
||||
if (context.createTime && !isEqual(context.createTime, prevMemo.createTime)) {
|
||||
mask.add("create_time");
|
||||
patch.createTime = context.createTime;
|
||||
}
|
||||
if (context.updateTime && !isEqual(context.updateTime, prevMemo.updateTime)) {
|
||||
mask.add("update_time");
|
||||
patch.updateTime = context.updateTime;
|
||||
}
|
||||
|
||||
return { mask, patch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for saving/updating memos
|
||||
* Extracts complex save logic from MemoEditor
|
||||
*/
|
||||
export function useMemoSave(callbacks: MemoSaveCallbacks) {
|
||||
const { onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t } = callbacks;
|
||||
|
||||
const saveMemo = useCallback(
|
||||
async (content: string, context: MemoSaveContext) => {
|
||||
onRequestingChange(true);
|
||||
|
||||
try {
|
||||
// 1. Upload local files
|
||||
const newAttachments = await uploadLocalFiles(context.localFiles, onUploadingChange);
|
||||
const allAttachments = [...context.attachmentList, ...newAttachments];
|
||||
|
||||
// 2. Update existing memo
|
||||
if (context.memoName) {
|
||||
const prevMemo = await memoStore.getOrFetchMemoByName(context.memoName);
|
||||
if (prevMemo) {
|
||||
const { mask, patch } = buildUpdateMask(prevMemo, content, allAttachments, context);
|
||||
|
||||
if (mask.size === 0) {
|
||||
toast.error(t("editor.no-changes-detected"));
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const memo = await memoStore.updateMemo(patch, Array.from(mask));
|
||||
onSuccess(memo.name);
|
||||
}
|
||||
} else {
|
||||
// 3. Create new memo or comment
|
||||
const memo = context.parentMemoName
|
||||
? await memoServiceClient.createMemoComment({
|
||||
name: context.parentMemoName,
|
||||
comment: {
|
||||
content,
|
||||
visibility: context.visibility,
|
||||
attachments: context.attachmentList,
|
||||
relations: context.relationList,
|
||||
location: context.location,
|
||||
},
|
||||
})
|
||||
: await memoStore.createMemo({
|
||||
memo: {
|
||||
content,
|
||||
visibility: context.visibility,
|
||||
attachments: allAttachments,
|
||||
relations: context.relationList,
|
||||
location: context.location,
|
||||
} as Memo,
|
||||
memoId: "",
|
||||
});
|
||||
|
||||
onSuccess(memo.name);
|
||||
}
|
||||
|
||||
onReset();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = error instanceof Error ? (error as { details?: string }).details || error.message : "Unknown error";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
onRequestingChange(false);
|
||||
}
|
||||
},
|
||||
[onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t],
|
||||
);
|
||||
|
||||
return { saveMemo };
|
||||
}
|
||||
|
|
@ -1,36 +1,34 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { LoaderIcon, Minimize2Icon } from "lucide-react";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { TAB_SPACE_WIDTH } from "@/helpers/consts";
|
||||
import { isValidUrl } from "@/helpers/utils";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { attachmentStore, instanceStore, memoStore, userStore } from "@/store";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { extractMemoIdFromName } from "@/store/common";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { type Location, type MemoRelation, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import DateTimeInput from "../DateTimeInput";
|
||||
import type { LocalFile } from "../memo-metadata";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import InsertMenu from "./ActionButton/InsertMenu";
|
||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||
import { FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||
import { FOCUS_MODE_EXIT_KEY, FOCUS_MODE_STYLES, FOCUS_MODE_TOGGLE_KEY, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||
import Editor, { EditorRefActions } from "./Editor";
|
||||
import Editor, { type EditorRefActions } from "./Editor";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./Editor/markdownShortcuts";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
||||
import { useDebounce } from "./hooks/useDebounce";
|
||||
import { useDragAndDrop } from "./hooks/useDragAndDrop";
|
||||
import { useLocalFileManager } from "./hooks/useLocalFileManager";
|
||||
import { useDebounce, useDragAndDrop, useFocusMode, useLocalFileManager, useMemoSave } from "./hooks";
|
||||
import InsertMenu from "./Toolbar/InsertMenu";
|
||||
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
||||
import { MemoEditorContext } from "./types";
|
||||
import type { MemoEditorProps, MemoEditorState } from "./types/memo-editor";
|
||||
|
||||
|
|
@ -73,6 +71,39 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
// Memo save hook - handles create/update logic
|
||||
const { saveMemo } = useMemoSave({
|
||||
onUploadingChange: useCallback((uploading: boolean) => {
|
||||
setState((s) => ({ ...s, isUploadingAttachment: uploading }));
|
||||
}, []),
|
||||
onRequestingChange: useCallback((requesting: boolean) => {
|
||||
setState((s) => ({ ...s, isRequesting: requesting }));
|
||||
}, []),
|
||||
onSuccess: useCallback(
|
||||
(savedMemoName: string) => {
|
||||
editorRef.current?.setContent("");
|
||||
clearFiles();
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
if (onConfirm) onConfirm(savedMemoName);
|
||||
},
|
||||
[clearFiles, contentCacheKey, onConfirm],
|
||||
),
|
||||
onCancel: useCallback(() => {
|
||||
if (onCancel) onCancel();
|
||||
}, [onCancel]),
|
||||
onReset: useCallback(() => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isRequesting: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
}, []),
|
||||
t,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.setContent(contentCache || "");
|
||||
}, []);
|
||||
|
|
@ -121,6 +152,17 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
}
|
||||
}, [memoName]);
|
||||
|
||||
// Focus mode management with body scroll lock
|
||||
const { toggleFocusMode } = useFocusMode({
|
||||
isFocusMode: state.isFocusMode,
|
||||
onToggle: () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isFocusMode: !prevState.isFocusMode,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
|
@ -184,21 +226,6 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle Focus Mode on/off
|
||||
* Focus Mode provides a distraction-free writing experience with:
|
||||
* - Expanded editor taking ~80-90% of viewport
|
||||
* - Semi-transparent backdrop
|
||||
* - Centered layout with optimal width
|
||||
* - All editor functionality preserved
|
||||
*/
|
||||
const toggleFocusMode = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
isFocusMode: !prevState.isFocusMode,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMemoVisibilityChange = (visibility: Visibility) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
|
@ -240,7 +267,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
addFiles(event.clipboardData.files);
|
||||
} else if (
|
||||
editorRef.current != null &&
|
||||
editorRef.current.getSelectedContent().length != 0 &&
|
||||
editorRef.current.getSelectedContent().length !== 0 &&
|
||||
isValidUrl(event.clipboardData.getData("Text"))
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
|
@ -266,135 +293,17 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
if (state.isRequesting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((state) => ({ ...state, isRequesting: true }));
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
try {
|
||||
// 1. Upload all local files and create attachments
|
||||
const newAttachments: Attachment[] = [];
|
||||
if (localFiles.length > 0) {
|
||||
setState((state) => ({ ...state, isUploadingAttachment: true }));
|
||||
try {
|
||||
for (const { file } of localFiles) {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
} finally {
|
||||
// Always reset upload state, even on error
|
||||
setState((state) => ({ ...state, isUploadingAttachment: false }));
|
||||
}
|
||||
}
|
||||
// 2. Update attachmentList with new attachments
|
||||
const allAttachments = [...state.attachmentList, ...newAttachments];
|
||||
// 3. Save memo (create or update)
|
||||
if (memoName) {
|
||||
const prevMemo = await memoStore.getOrFetchMemoByName(memoName);
|
||||
if (prevMemo) {
|
||||
const updateMask = new Set<string>();
|
||||
const memoPatch: Partial<Memo> = {
|
||||
name: prevMemo.name,
|
||||
content,
|
||||
};
|
||||
if (!isEqual(content, prevMemo.content)) {
|
||||
updateMask.add("content");
|
||||
memoPatch.content = content;
|
||||
}
|
||||
if (!isEqual(state.memoVisibility, prevMemo.visibility)) {
|
||||
updateMask.add("visibility");
|
||||
memoPatch.visibility = state.memoVisibility;
|
||||
}
|
||||
if (!isEqual(allAttachments, prevMemo.attachments)) {
|
||||
updateMask.add("attachments");
|
||||
memoPatch.attachments = allAttachments;
|
||||
}
|
||||
if (!isEqual(state.relationList, prevMemo.relations)) {
|
||||
updateMask.add("relations");
|
||||
memoPatch.relations = state.relationList;
|
||||
}
|
||||
if (!isEqual(state.location, prevMemo.location)) {
|
||||
updateMask.add("location");
|
||||
memoPatch.location = state.location;
|
||||
}
|
||||
if (["content", "attachments", "relations", "location"].some((key) => updateMask.has(key))) {
|
||||
updateMask.add("update_time");
|
||||
}
|
||||
if (createTime && !isEqual(createTime, prevMemo.createTime)) {
|
||||
updateMask.add("create_time");
|
||||
memoPatch.createTime = createTime;
|
||||
}
|
||||
if (updateTime && !isEqual(updateTime, prevMemo.updateTime)) {
|
||||
updateMask.add("update_time");
|
||||
memoPatch.updateTime = updateTime;
|
||||
}
|
||||
if (updateMask.size === 0) {
|
||||
toast.error(t("editor.no-changes-detected"));
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const memo = await memoStore.updateMemo(memoPatch, Array.from(updateMask));
|
||||
if (onConfirm) {
|
||||
onConfirm(memo.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create memo or memo comment.
|
||||
const request = !parentMemoName
|
||||
? memoStore.createMemo({
|
||||
memo: Memo.fromPartial({
|
||||
content,
|
||||
visibility: state.memoVisibility,
|
||||
attachments: allAttachments,
|
||||
relations: state.relationList,
|
||||
location: state.location,
|
||||
}),
|
||||
memoId: "",
|
||||
})
|
||||
: memoServiceClient
|
||||
.createMemoComment({
|
||||
name: parentMemoName,
|
||||
comment: {
|
||||
content,
|
||||
visibility: state.memoVisibility,
|
||||
attachments: state.attachmentList,
|
||||
relations: state.relationList,
|
||||
location: state.location,
|
||||
},
|
||||
})
|
||||
.then((memo) => memo);
|
||||
const memo = await request;
|
||||
if (onConfirm) {
|
||||
onConfirm(memo.name);
|
||||
}
|
||||
}
|
||||
editorRef.current?.setContent("");
|
||||
// Clean up local files after successful save
|
||||
clearFiles();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
location: undefined,
|
||||
isDraggingFile: false,
|
||||
};
|
||||
await saveMemo(content, {
|
||||
memoName,
|
||||
parentMemoName,
|
||||
visibility: state.memoVisibility,
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
location: state.location,
|
||||
localFiles,
|
||||
createTime,
|
||||
updateTime,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -440,7 +349,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
}}
|
||||
>
|
||||
{/* Focus Mode Backdrop */}
|
||||
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={toggleFocusMode} />}
|
||||
<FocusModeOverlay isActive={state.isFocusMode} onToggle={toggleFocusMode} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -456,17 +365,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
onFocus={handleEditorFocus}
|
||||
>
|
||||
{/* Focus Mode Exit Button */}
|
||||
{state.isFocusMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={FOCUS_MODE_STYLES.exitButton}
|
||||
onClick={toggleFocusMode}
|
||||
title={t("editor.exit-focus-mode")}
|
||||
>
|
||||
<Minimize2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<FocusModeExitButton isActive={state.isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
|
||||
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<LocationDisplay
|
||||
|
|
@ -542,15 +441,16 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
</>
|
||||
)}
|
||||
<span className="text-left">ID</span>
|
||||
<span
|
||||
className="px-1 border border-transparent cursor-default"
|
||||
<button
|
||||
type="button"
|
||||
className="px-1 border border-transparent cursor-default text-left"
|
||||
onClick={() => {
|
||||
copy(extractMemoIdFromName(memoName));
|
||||
toast.success(t("message.copied"));
|
||||
}}
|
||||
>
|
||||
{extractMemoIdFromName(memoName)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,30 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
|||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import type { LocalFile } from "../../memo-metadata";
|
||||
|
||||
interface Context {
|
||||
/**
|
||||
* Context interface for MemoEditor
|
||||
* Provides access to editor state and actions for child components
|
||||
*/
|
||||
export interface MemoEditorContextValue {
|
||||
/** List of uploaded attachments */
|
||||
attachmentList: Attachment[];
|
||||
/** List of memo relations/links */
|
||||
relationList: MemoRelation[];
|
||||
/** Update the attachment list */
|
||||
setAttachmentList: (attachmentList: Attachment[]) => void;
|
||||
/** Update the relation list */
|
||||
setRelationList: (relationList: MemoRelation[]) => void;
|
||||
/** Name of memo being edited (undefined for new memos) */
|
||||
memoName?: string;
|
||||
// For local file upload/preview
|
||||
/** Add local files for upload preview */
|
||||
addLocalFiles?: (files: LocalFile[]) => void;
|
||||
/** Remove a local file by preview URL */
|
||||
removeLocalFile?: (previewUrl: string) => void;
|
||||
/** List of local files pending upload */
|
||||
localFiles?: LocalFile[];
|
||||
}
|
||||
|
||||
export const MemoEditorContext = createContext<Context>({
|
||||
const defaultContextValue: MemoEditorContextValue = {
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
setAttachmentList: () => {},
|
||||
|
|
@ -23,4 +34,6 @@ export const MemoEditorContext = createContext<Context>({
|
|||
addLocalFiles: () => {},
|
||||
removeLocalFile: () => {},
|
||||
localFiles: [],
|
||||
});
|
||||
};
|
||||
|
||||
export const MemoEditorContext = createContext<MemoEditorContextValue>(defaultContextValue);
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
export * from "./context";
|
||||
// MemoEditor type exports
|
||||
export type { Command } from "./command";
|
||||
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
|
||||
export type { EditorConfig, MemoEditorProps, MemoEditorState } from "./memo-editor";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue