refactor: streamline tag sorting and update coordinate handling in MemoEditor components

This commit is contained in:
Johnny 2025-12-22 22:54:09 +08:00
parent d537591005
commit 8a7c976758
9 changed files with 116 additions and 204 deletions

View File

@ -13,11 +13,9 @@ interface TagSuggestionsProps {
const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => { const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => {
const sortedTags = useMemo(() => { const sortedTags = useMemo(() => {
const tags = Object.entries(userStore.state.tagCount) return Object.entries(userStore.state.tagCount)
.sort((a, b) => b[1] - a[1]) // Sort by usage count (descending) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag); .map(([tag]) => tag);
// Secondary sort by name for stable ordering
return tags.sort((a, b) => (userStore.state.tagCount[a] === userStore.state.tagCount[b] ? a.localeCompare(b) : 0));
}, [userStore.state.tagCount]); }, [userStore.state.tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({

View File

@ -8,8 +8,8 @@ import { useListCompletion } from "./useListCompletion";
export interface EditorRefActions { export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null; getEditor: () => HTMLTextAreaElement | null;
focus: FunctionType; focus: () => void;
scrollToCursor: FunctionType; scrollToCursor: () => void;
insertText: (text: string, prefix?: string, suffix?: string) => void; insertText: (text: string, prefix?: string, suffix?: string) => void;
removeText: (start: number, length: number) => void; removeText: (start: number, length: number) => void;
setContent: (text: string) => void; setContent: (text: string) => void;

View File

@ -214,8 +214,7 @@ const InsertMenu = observer((props: Props) => {
state={location.state} state={location.state}
locationInitialized={location.locationInitialized} locationInitialized={location.locationInitialized}
onPositionChange={handlePositionChange} onPositionChange={handlePositionChange}
onLatChange={location.handleLatChange} onUpdateCoordinate={location.updateCoordinate}
onLngChange={location.handleLngChange}
onPlaceholderChange={location.setPlaceholder} onPlaceholderChange={location.setPlaceholder}
onCancel={handleLocationCancel} onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm} onConfirm={handleLocationConfirm}

View File

@ -18,7 +18,7 @@ const VisibilitySelector = (props: Props) => {
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") }, { value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") }, { value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") }, { value: Visibility.PUBLIC, label: t("memo.visibility.public") },
]; ] as const;
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || ""; const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";

View File

@ -1,70 +0,0 @@
import { AlertCircle } from "lucide-react";
import React from "react";
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class MemoEditorErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error to console for debugging
console.error("MemoEditor Error:", error, errorInfo);
// You can also log the error to an error reporting service here
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
// Default fallback UI
return (
<div className="w-full flex flex-col justify-center items-center bg-card px-4 py-8 rounded-lg border border-destructive/50">
<AlertCircle className="w-8 h-8 text-destructive mb-3" />
<h3 className="text-lg font-semibold text-foreground mb-2">Editor Error</h3>
<p className="text-sm text-muted-foreground mb-4 text-center max-w-md">
Something went wrong with the memo editor. Please try refreshing the page.
</p>
{this.state.error && (
<details className="text-xs text-muted-foreground mb-4 max-w-md">
<summary className="cursor-pointer hover:text-foreground">Error details</summary>
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">{this.state.error.toString()}</pre>
</details>
)}
<button
onClick={this.handleReset}
className="px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
export default MemoEditorErrorBoundary;

View File

@ -15,8 +15,7 @@ interface LocationDialogProps {
state: LocationState; state: LocationState;
locationInitialized: boolean; locationInitialized: boolean;
onPositionChange: (position: LatLng) => void; onPositionChange: (position: LatLng) => void;
onLatChange: (value: string) => void; onUpdateCoordinate: (type: "lat" | "lng", value: string) => void;
onLngChange: (value: string) => void;
onPlaceholderChange: (value: string) => void; onPlaceholderChange: (value: string) => void;
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
@ -28,8 +27,7 @@ export const LocationDialog = ({
state, state,
locationInitialized, locationInitialized,
onPositionChange, onPositionChange,
onLatChange, onUpdateCoordinate,
onLngChange,
onPlaceholderChange, onPlaceholderChange,
onCancel, onCancel,
onConfirm, onConfirm,
@ -67,7 +65,7 @@ export const LocationDialog = ({
min="-90" min="-90"
max="90" max="90"
value={latInput} value={latInput}
onChange={(e) => onLatChange(e.target.value)} onChange={(e) => onUpdateCoordinate("lat", e.target.value)}
className="h-9" className="h-9"
/> />
</div> </div>
@ -83,7 +81,7 @@ export const LocationDialog = ({
min="-180" min="-180"
max="180" max="180"
value={lngInput} value={lngInput}
onChange={(e) => onLngChange(e.target.value)} onChange={(e) => onUpdateCoordinate("lng", e.target.value)}
className="h-9" className="h-9"
/> />
</div> </div>

View File

@ -1,5 +1,4 @@
// UI components for MemoEditor // UI components for MemoEditor
export { default as ErrorBoundary } from "./ErrorBoundary";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog"; export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog"; export { LocationDialog } from "./LocationDialog";

View File

@ -23,25 +23,16 @@ export const useLocation = (initialLocation?: Location) => {
}; };
const handlePositionChange = (position: LatLng) => { const handlePositionChange = (position: LatLng) => {
if (!locationInitialized) { if (!locationInitialized) setLocationInitialized(true);
setLocationInitialized(true);
}
updatePosition(position); updatePosition(position);
}; };
const handleLatChange = (value: string) => { const updateCoordinate = (type: "lat" | "lng", value: string) => {
setState((prev) => ({ ...prev, latInput: value })); setState((prev) => ({ ...prev, [type === "lat" ? "latInput" : "lngInput"]: value }));
const lat = parseFloat(value); const num = parseFloat(value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 && state.position) { const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
updatePosition(new LatLng(lat, state.position.lng)); if (isValid && state.position) {
} updatePosition(type === "lat" ? new LatLng(num, state.position.lng) : new LatLng(state.position.lat, num));
};
const handleLngChange = (value: string) => {
setState((prev) => ({ ...prev, lngInput: value }));
const lng = parseFloat(value);
if (!isNaN(lng) && lng >= -180 && lng <= 180 && state.position) {
updatePosition(new LatLng(state.position.lat, lng));
} }
}; };
@ -74,8 +65,7 @@ export const useLocation = (initialLocation?: Location) => {
state, state,
locationInitialized, locationInitialized,
handlePositionChange, handlePositionChange,
handleLatChange, updateCoordinate,
handleLngChange,
setPlaceholder, setPlaceholder,
reset, reset,
getLocation, getLocation,

View File

@ -14,7 +14,7 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import DateTimeInput from "../DateTimeInput"; import DateTimeInput from "../DateTimeInput";
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata"; import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components"; import { FocusModeExitButton, FocusModeOverlay } from "./components";
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants"; import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
import Editor, { type EditorRefActions } from "./Editor"; import Editor, { type EditorRefActions } from "./Editor";
import { import {
@ -224,113 +224,111 @@ const MemoEditor = observer((props: Props) => {
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting; const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
return ( return (
<ErrorBoundary> <MemoEditorContext.Provider
<MemoEditorContext.Provider value={{
value={{ attachmentList,
attachmentList, relationList,
relationList, setAttachmentList,
setAttachmentList, addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))),
addLocalFiles: (files) => addFiles(Array.from(files.map((f) => f.file))), removeLocalFile: removeFile,
removeLocalFile: removeFile, localFiles,
localFiles, setRelationList,
setRelationList, memoName,
memoName, }}
}} >
{/* Focus Mode Backdrop */}
<FocusModeOverlay isActive={isFocusMode} onToggle={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,
isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto",
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className,
)}
tabIndex={0}
onKeyDown={handleKeyDown}
{...dragHandlers}
onFocus={handleEditorFocus}
> >
{/* Focus Mode Backdrop */} {/* Focus Mode Exit Button */}
<FocusModeOverlay isActive={isFocusMode} onToggle={toggleFocusMode} /> <FocusModeExitButton isActive={isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} />
<div <Editor ref={editorRef} {...editorConfig} />
className={cn( <LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} />
"group relative w-full flex flex-col justify-start items-start bg-card px-4 pt-3 pb-2 rounded-lg border", {/* Show attachments and pending files together */}
FOCUS_MODE_STYLES.transition, <AttachmentList
isDraggingFile ? "border-dashed border-muted-foreground cursor-copy" : "border-border cursor-auto", mode="edit"
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing), attachments={attachmentList}
className, onAttachmentsChange={setAttachmentList}
)} localFiles={localFiles}
tabIndex={0} onRemoveLocalFile={removeFile}
onKeyDown={handleKeyDown} />
{...dragHandlers} <RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
onFocus={handleEditorFocus} <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">
{/* Focus Mode Exit Button */} <InsertMenu
<FocusModeExitButton isActive={isFocusMode} onToggle={toggleFocusMode} title={t("editor.exit-focus-mode")} /> isUploading={isUploadingAttachment}
location={location}
<Editor ref={editorRef} {...editorConfig} /> onLocationChange={setLocation}
<LocationDisplay mode="edit" location={location} onRemove={() => setLocation(undefined)} /> onToggleFocusMode={toggleFocusMode}
{/* Show attachments and pending files together */} />
<AttachmentList </div>
mode="edit" <div className="shrink-0 flex flex-row justify-end items-center">
attachments={attachmentList} <VisibilitySelector value={memoVisibility} onChange={setMemoVisibility} />
onAttachmentsChange={setAttachmentList} <div className="flex flex-row justify-end gap-1">
localFiles={localFiles} {props.onCancel && (
onRemoveLocalFile={removeFile} <Button
/> variant="ghost"
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} /> disabled={isRequesting}
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}> onClick={() => {
<div className="flex flex-row justify-start items-center gap-1"> clearFiles();
<InsertMenu if (props.onCancel) props.onCancel();
isUploading={isUploadingAttachment} }}
location={location} >
onLocationChange={setLocation} {t("common.cancel")}
onToggleFocusMode={toggleFocusMode}
/>
</div>
<div className="shrink-0 flex flex-row justify-end items-center">
<VisibilitySelector value={memoVisibility} onChange={setMemoVisibility} />
<div className="flex flex-row justify-end gap-1">
{props.onCancel && (
<Button
variant="ghost"
disabled={isRequesting}
onClick={() => {
clearFiles();
if (props.onCancel) props.onCancel();
}}
>
{t("common.cancel")}
</Button>
)}
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
</Button> </Button>
</div> )}
<Button disabled={!allowSave || isRequesting} onClick={handleSaveBtnClick}>
{isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
</Button>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Show memo metadata if memoName is provided */} {/* Show memo metadata if memoName is provided */}
{memoName && ( {memoName && (
<div className="w-full -mt-1 mb-4 text-xs leading-5 px-4 opacity-60 font-mono text-muted-foreground"> <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"> <div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 items-center">
{!isEqual(createTime, updateTime) && updateTime && ( {!isEqual(createTime, updateTime) && updateTime && (
<> <>
<span className="text-left">Updated</span> <span className="text-left">Updated</span>
<DateTimeInput value={updateTime} onChange={setUpdateTime} /> <DateTimeInput value={updateTime} onChange={setUpdateTime} />
</> </>
)} )}
{createTime && ( {createTime && (
<> <>
<span className="text-left">Created</span> <span className="text-left">Created</span>
<DateTimeInput value={createTime} onChange={setCreateTime} /> <DateTimeInput value={createTime} onChange={setCreateTime} />
</> </>
)} )}
<span className="text-left">ID</span> <span className="text-left">ID</span>
<button <button
type="button" type="button"
className="px-1 border border-transparent cursor-default text-left" className="px-1 border border-transparent cursor-default text-left"
onClick={() => { onClick={() => {
copy(extractMemoIdFromName(memoName)); copy(extractMemoIdFromName(memoName));
toast.success(t("message.copied")); toast.success(t("message.copied"));
}} }}
> >
{extractMemoIdFromName(memoName)} {extractMemoIdFromName(memoName)}
</button> </button>
</div>
</div> </div>
)} </div>
</MemoEditorContext.Provider> )}
</ErrorBoundary> </MemoEditorContext.Provider>
); );
}); });