Add keyboard-activated Focus Mode to provide an immersive writing experience: Features: - Toggle with Cmd/Ctrl+Shift+F (matches GitHub, Google Docs) - Exit with Escape, toggle shortcut, button click, or backdrop click - Expands editor to ~80-90% of viewport with centered layout - Semi-transparent backdrop with blur effect - Maintains all editor functionality (attachments, shortcuts, etc.) - Smooth 300ms transitions Responsive Design: - Mobile (< 640px): 8px margins, 50vh min-height - Tablet (640-768px): 16px margins - Desktop (> 768px): 32px margins, 60vh min-height, 1024px max-width Implementation: - Centralized constants for easy maintenance (FOCUS_MODE_STYLES) - Extracted keyboard shortcuts and heights to named constants - JSDoc documentation for all new functions and interfaces - TypeScript type safety with 'as const' - Explicit positioning (top/left/right/bottom) to avoid width overflow Files Modified: - web/src/components/MemoEditor/index.tsx - Main Focus Mode logic - web/src/components/MemoEditor/Editor/index.tsx - Height adjustments - web/src/locales/en.json - Translation keys Design follows industry standards (GitHub Focus Mode, Notion, Obsidian) and maintains code quality with single source of truth pattern.
645 lines
21 KiB
TypeScript
645 lines
21 KiB
TypeScript
import copy from "copy-to-clipboard";
|
|
import { isEqual } from "lodash-es";
|
|
import { LoaderIcon, Minimize2Icon } from "lucide-react";
|
|
import { observer } from "mobx-react-lite";
|
|
import React, { 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 { 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 { useTranslate } from "@/utils/i18n";
|
|
import { convertVisibilityFromString } from "@/utils/memo";
|
|
import DateTimeInput from "../DateTimeInput";
|
|
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
|
import InsertMenu from "./ActionButton/InsertMenu";
|
|
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
|
import Editor, { EditorRefActions } from "./Editor";
|
|
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
|
import { MemoEditorContext } from "./types";
|
|
|
|
/**
|
|
* Focus Mode keyboard shortcuts
|
|
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
|
|
* - Exit: Escape key
|
|
*/
|
|
const FOCUS_MODE_TOGGLE_KEY = "f";
|
|
const FOCUS_MODE_EXIT_KEY = "Escape";
|
|
|
|
/**
|
|
* Focus Mode styling constants
|
|
* Centralized to make it easy to adjust the appearance and maintain consistency
|
|
*/
|
|
const FOCUS_MODE_STYLES = {
|
|
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
|
|
container: {
|
|
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
|
|
/**
|
|
* Responsive spacing using explicit positioning to avoid width conflicts:
|
|
* - Mobile (< 640px): 8px margin (0.5rem)
|
|
* - Tablet (640-768px): 16px margin (1rem)
|
|
* - Desktop (> 768px): 32px margin (2rem)
|
|
*/
|
|
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
|
|
},
|
|
transition: "transition-all duration-300 ease-in-out",
|
|
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
|
|
} as const;
|
|
|
|
export interface Props {
|
|
className?: string;
|
|
cacheKey?: string;
|
|
placeholder?: string;
|
|
// The name of the memo to be edited.
|
|
memoName?: string;
|
|
// The name of the parent memo if the memo is a comment.
|
|
parentMemoName?: string;
|
|
autoFocus?: boolean;
|
|
onConfirm?: (memoName: string) => void;
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
interface State {
|
|
memoVisibility: Visibility;
|
|
attachmentList: Attachment[];
|
|
relationList: MemoRelation[];
|
|
location: Location | undefined;
|
|
isUploadingAttachment: boolean;
|
|
isRequesting: boolean;
|
|
isComposing: boolean;
|
|
isDraggingFile: boolean;
|
|
/** Whether Focus Mode (distraction-free writing) is enabled */
|
|
isFocusMode: boolean;
|
|
}
|
|
|
|
const MemoEditor = observer((props: Props) => {
|
|
const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props;
|
|
const t = useTranslate();
|
|
const { i18n } = useTranslation();
|
|
const currentUser = useCurrentUser();
|
|
const [state, setState] = useState<State>({
|
|
memoVisibility: Visibility.PRIVATE,
|
|
isFocusMode: false,
|
|
attachmentList: [],
|
|
relationList: [],
|
|
location: undefined,
|
|
isUploadingAttachment: false,
|
|
isRequesting: false,
|
|
isComposing: false,
|
|
isDraggingFile: false,
|
|
});
|
|
const [createTime, setCreateTime] = useState<Date | undefined>();
|
|
const [updateTime, setUpdateTime] = useState<Date | undefined>();
|
|
const [hasContent, setHasContent] = useState<boolean>(false);
|
|
const editorRef = useRef<EditorRefActions>(null);
|
|
const userGeneralSetting = userStore.state.userGeneralSetting;
|
|
const contentCacheKey = `${currentUser.name}-${cacheKey || ""}`;
|
|
const [contentCache, setContentCache] = useLocalStorage<string>(contentCacheKey, "");
|
|
const referenceRelations = memoName
|
|
? state.relationList.filter(
|
|
(relation) =>
|
|
relation.memo?.name === memoName && relation.relatedMemo?.name !== memoName && relation.type === MemoRelation_Type.REFERENCE,
|
|
)
|
|
: state.relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
|
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
|
|
|
useEffect(() => {
|
|
editorRef.current?.setContent(contentCache || "");
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (autoFocus) {
|
|
handleEditorFocus();
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
useAsyncEffect(async () => {
|
|
let visibility = convertVisibilityFromString(userGeneralSetting?.memoVisibility || "PRIVATE");
|
|
if (instanceMemoRelatedSetting.disallowPublicVisibility && visibility === Visibility.PUBLIC) {
|
|
visibility = Visibility.PROTECTED;
|
|
}
|
|
if (parentMemoName) {
|
|
const parentMemo = await memoStore.getOrFetchMemoByName(parentMemoName);
|
|
visibility = parentMemo.visibility;
|
|
}
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
memoVisibility: convertVisibilityFromString(visibility),
|
|
}));
|
|
}, [parentMemoName, userGeneralSetting?.memoVisibility, instanceMemoRelatedSetting.disallowPublicVisibility]);
|
|
|
|
useAsyncEffect(async () => {
|
|
if (!memoName) {
|
|
return;
|
|
}
|
|
|
|
const memo = await memoStore.getOrFetchMemoByName(memoName);
|
|
if (memo) {
|
|
handleEditorFocus();
|
|
setCreateTime(memo.createTime);
|
|
setUpdateTime(memo.updateTime);
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
memoVisibility: memo.visibility,
|
|
attachmentList: memo.attachments,
|
|
relationList: memo.relations,
|
|
location: memo.location,
|
|
}));
|
|
if (!contentCache) {
|
|
editorRef.current?.setContent(memo.content ?? "");
|
|
}
|
|
}
|
|
}, [memoName]);
|
|
|
|
const handleCompositionStart = () => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
isComposing: true,
|
|
}));
|
|
};
|
|
|
|
const handleCompositionEnd = () => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
isComposing: false,
|
|
}));
|
|
};
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
if (!editorRef.current) {
|
|
return;
|
|
}
|
|
|
|
const isMetaKey = event.ctrlKey || event.metaKey;
|
|
|
|
// Focus Mode toggle: Cmd/Ctrl + Shift + F
|
|
if (isMetaKey && event.shiftKey && event.key.toLowerCase() === FOCUS_MODE_TOGGLE_KEY) {
|
|
event.preventDefault();
|
|
toggleFocusMode();
|
|
return;
|
|
}
|
|
|
|
// Exit Focus Mode: Escape
|
|
if (event.key === FOCUS_MODE_EXIT_KEY && state.isFocusMode) {
|
|
event.preventDefault();
|
|
toggleFocusMode();
|
|
return;
|
|
}
|
|
|
|
if (isMetaKey) {
|
|
if (event.key === "Enter") {
|
|
handleSaveBtnClick();
|
|
return;
|
|
}
|
|
if (!instanceMemoRelatedSetting.disableMarkdownShortcuts) {
|
|
handleEditorKeydownWithMarkdownShortcuts(event, editorRef.current);
|
|
}
|
|
}
|
|
if (event.key === "Tab" && !state.isComposing) {
|
|
event.preventDefault();
|
|
const tabSpace = " ".repeat(TAB_SPACE_WIDTH);
|
|
const cursorPosition = editorRef.current.getCursorPosition();
|
|
const selectedContent = editorRef.current.getSelectedContent();
|
|
editorRef.current.insertText(tabSpace);
|
|
if (selectedContent) {
|
|
editorRef.current.setCursorPosition(cursorPosition + TAB_SPACE_WIDTH);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
memoVisibility: visibility,
|
|
}));
|
|
};
|
|
|
|
const handleSetAttachmentList = (attachmentList: Attachment[]) => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
attachmentList,
|
|
}));
|
|
};
|
|
|
|
const handleSetRelationList = (relationList: MemoRelation[]) => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
relationList,
|
|
}));
|
|
};
|
|
|
|
const handleUploadResource = async (file: File) => {
|
|
setState((state) => {
|
|
return {
|
|
...state,
|
|
isUploadingAttachment: true,
|
|
};
|
|
});
|
|
|
|
const { name: filename, size, type } = file;
|
|
const buffer = new Uint8Array(await file.arrayBuffer());
|
|
|
|
try {
|
|
const attachment = await attachmentStore.createAttachment({
|
|
attachment: Attachment.fromPartial({
|
|
filename,
|
|
size,
|
|
type,
|
|
content: buffer,
|
|
}),
|
|
attachmentId: "",
|
|
});
|
|
setState((state) => {
|
|
return {
|
|
...state,
|
|
isUploadingAttachment: false,
|
|
};
|
|
});
|
|
return attachment;
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
toast.error(error.details);
|
|
setState((state) => {
|
|
return {
|
|
...state,
|
|
isUploadingAttachment: false,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const uploadMultiFiles = async (files: FileList) => {
|
|
const uploadedAttachmentList: Attachment[] = [];
|
|
for (const file of files) {
|
|
const attachment = await handleUploadResource(file);
|
|
if (attachment) {
|
|
uploadedAttachmentList.push(attachment);
|
|
if (memoName) {
|
|
await attachmentStore.updateAttachment({
|
|
attachment: Attachment.fromPartial({
|
|
name: attachment.name,
|
|
memo: memoName,
|
|
}),
|
|
updateMask: ["memo"],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (uploadedAttachmentList.length > 0) {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleDropEvent = async (event: React.DragEvent) => {
|
|
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
|
event.preventDefault();
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
isDraggingFile: false,
|
|
}));
|
|
|
|
await uploadMultiFiles(event.dataTransfer.files);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (event: React.DragEvent) => {
|
|
if (event.dataTransfer && event.dataTransfer.types.includes("Files")) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
if (!state.isDraggingFile) {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
isDraggingFile: true,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
isDraggingFile: false,
|
|
}));
|
|
};
|
|
|
|
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
|
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
|
event.preventDefault();
|
|
await uploadMultiFiles(event.clipboardData.files);
|
|
} else if (
|
|
editorRef.current != null &&
|
|
editorRef.current.getSelectedContent().length != 0 &&
|
|
isValidUrl(event.clipboardData.getData("Text"))
|
|
) {
|
|
event.preventDefault();
|
|
hyperlinkHighlightedText(editorRef.current, event.clipboardData.getData("Text"));
|
|
}
|
|
};
|
|
|
|
const handleContentChange = (content: string) => {
|
|
setHasContent(content !== "");
|
|
if (content !== "") {
|
|
setContentCache(content);
|
|
} else {
|
|
localStorage.removeItem(contentCacheKey);
|
|
}
|
|
};
|
|
|
|
const handleSaveBtnClick = async () => {
|
|
if (state.isRequesting) {
|
|
return;
|
|
}
|
|
|
|
setState((state) => {
|
|
return {
|
|
...state,
|
|
isRequesting: true,
|
|
};
|
|
});
|
|
const content = editorRef.current?.getContent() ?? "";
|
|
try {
|
|
// Update memo.
|
|
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(state.attachmentList, prevMemo.attachments)) {
|
|
updateMask.add("attachments");
|
|
memoPatch.attachments = state.attachmentList;
|
|
}
|
|
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: state.attachmentList,
|
|
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("");
|
|
} 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,
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleCancelBtnClick = () => {
|
|
localStorage.removeItem(contentCacheKey);
|
|
|
|
if (onCancel) {
|
|
onCancel();
|
|
}
|
|
};
|
|
|
|
const handleEditorFocus = () => {
|
|
editorRef.current?.focus();
|
|
};
|
|
|
|
const editorConfig = useMemo(
|
|
() => ({
|
|
className: "",
|
|
initialContent: "",
|
|
placeholder: props.placeholder ?? t("editor.any-thoughts"),
|
|
onContentChange: handleContentChange,
|
|
onPaste: handlePasteEvent,
|
|
isFocusMode: state.isFocusMode,
|
|
}),
|
|
[i18n.language, state.isFocusMode],
|
|
);
|
|
|
|
const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
|
|
|
return (
|
|
<MemoEditorContext.Provider
|
|
value={{
|
|
attachmentList: state.attachmentList,
|
|
relationList: state.relationList,
|
|
setAttachmentList: (attachmentList: Attachment[]) => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
attachmentList,
|
|
}));
|
|
},
|
|
setRelationList: (relationList: MemoRelation[]) => {
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
relationList,
|
|
}));
|
|
},
|
|
memoName,
|
|
}}
|
|
>
|
|
{/* Focus Mode Backdrop */}
|
|
{state.isFocusMode && <div className={FOCUS_MODE_STYLES.backdrop} onClick={toggleFocusMode} />}
|
|
|
|
<div
|
|
className={cn(
|
|
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border",
|
|
FOCUS_MODE_STYLES.transition,
|
|
state.isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
|
|
state.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
|
className,
|
|
)}
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
onDrop={handleDropEvent}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onFocus={handleEditorFocus}
|
|
onCompositionStart={handleCompositionStart}
|
|
onCompositionEnd={handleCompositionEnd}
|
|
>
|
|
{/* 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>
|
|
)}
|
|
|
|
<Editor ref={editorRef} {...editorConfig} />
|
|
<LocationDisplay
|
|
mode="edit"
|
|
location={state.location}
|
|
onRemove={() =>
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
location: undefined,
|
|
}))
|
|
}
|
|
/>
|
|
<AttachmentList mode="edit" attachments={state.attachmentList} onAttachmentsChange={handleSetAttachmentList} />
|
|
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
|
|
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
|
<div className="flex flex-row justify-start items-center gap-1">
|
|
<InsertMenu
|
|
isUploading={state.isUploadingAttachment}
|
|
location={state.location}
|
|
onLocationChange={(location) =>
|
|
setState((prevState) => ({
|
|
...prevState,
|
|
location,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0 flex flex-row justify-end items-center">
|
|
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
|
<div className="flex flex-row justify-end gap-1">
|
|
{props.onCancel && (
|
|
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
)}
|
|
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
|
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show memo metadata if memoName is provided */}
|
|
{memoName && (
|
|
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground">
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
|
|
{!isEqual(createTime, updateTime) && updateTime && (
|
|
<>
|
|
<span className="text-left">Updated</span>
|
|
<DateTimeInput value={updateTime} onChange={setUpdateTime} />
|
|
</>
|
|
)}
|
|
{createTime && (
|
|
<>
|
|
<span className="text-left">Created</span>
|
|
<DateTimeInput value={createTime} onChange={setCreateTime} />
|
|
</>
|
|
)}
|
|
<span className="text-left">ID</span>
|
|
<span
|
|
className="px-1 border border-transparent cursor-default"
|
|
onClick={() => {
|
|
copy(extractMemoIdFromName(memoName));
|
|
toast.success(t("message.copied"));
|
|
}}
|
|
>
|
|
{extractMemoIdFromName(memoName)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</MemoEditorContext.Provider>
|
|
);
|
|
});
|
|
|
|
export default MemoEditor;
|