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:
Johnny 2025-11-28 09:21:53 +08:00
parent c1765fc246
commit 50199fe998
20 changed files with 483 additions and 235 deletions

View 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);
}

View file

@ -0,0 +1,3 @@
// Toolbar components for MemoEditor
export { default as InsertMenu } from "./InsertMenu";
export { default as VisibilitySelector } from "./VisibilitySelector";

View file

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

View file

@ -0,0 +1,2 @@
// UI components for MemoEditor
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";

View file

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

View 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";

View 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,
};
}

View 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 };
}

View file

@ -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>
)}

View file

@ -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);

View file

@ -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";