Commit 50199fe9 authored by Johnny's avatar Johnny

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.
parent c1765fc2
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);
}
// Toolbar components for MemoEditor
export { default as InsertMenu } from "./InsertMenu";
export { default as VisibilitySelector } from "./VisibilitySelector";
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>
);
}
// UI components for MemoEditor
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
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);
}
};
// 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";
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,
};
}
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 };
}
This diff is collapsed.
...@@ -3,19 +3,30 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service"; ...@@ -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 { MemoRelation } from "@/types/proto/api/v1/memo_service";
import type { LocalFile } from "../../memo-metadata"; 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[]; attachmentList: Attachment[];
/** List of memo relations/links */
relationList: MemoRelation[]; relationList: MemoRelation[];
/** Update the attachment list */
setAttachmentList: (attachmentList: Attachment[]) => void; setAttachmentList: (attachmentList: Attachment[]) => void;
/** Update the relation list */
setRelationList: (relationList: MemoRelation[]) => void; setRelationList: (relationList: MemoRelation[]) => void;
/** Name of memo being edited (undefined for new memos) */
memoName?: string; memoName?: string;
// For local file upload/preview /** Add local files for upload preview */
addLocalFiles?: (files: LocalFile[]) => void; addLocalFiles?: (files: LocalFile[]) => void;
/** Remove a local file by preview URL */
removeLocalFile?: (previewUrl: string) => void; removeLocalFile?: (previewUrl: string) => void;
/** List of local files pending upload */
localFiles?: LocalFile[]; localFiles?: LocalFile[];
} }
export const MemoEditorContext = createContext<Context>({ const defaultContextValue: MemoEditorContextValue = {
attachmentList: [], attachmentList: [],
relationList: [], relationList: [],
setAttachmentList: () => {}, setAttachmentList: () => {},
...@@ -23,4 +34,6 @@ export const MemoEditorContext = createContext<Context>({ ...@@ -23,4 +34,6 @@ export const MemoEditorContext = createContext<Context>({
addLocalFiles: () => {}, addLocalFiles: () => {},
removeLocalFile: () => {}, removeLocalFile: () => {},
localFiles: [], localFiles: [],
}); };
export const MemoEditorContext = createContext<MemoEditorContextValue>(defaultContextValue);
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";
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment