mirror of https://github.com/usememos/memos
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.pull/5294/head
parent
c1765fc246
commit
50199fe998
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 +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…
Reference in New Issue