Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions proto/api/v1/instance_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ message InstanceSetting {
int32 content_length_limit = 3;
// enable_double_click_edit enables editing on double click.
bool enable_double_click_edit = 4;
// enable_single_click_memo_expand enables toggling compact memo content with a single click.
bool enable_single_click_memo_expand = 5;
// reactions is the list of reactions.
repeated string reactions = 7;
}
Expand Down
16 changes: 13 additions & 3 deletions proto/gen/api/v1/instance_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions proto/gen/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2492,6 +2492,9 @@ components:
enableDoubleClickEdit:
type: boolean
description: enable_double_click_edit enables editing on double click.
enableSingleClickMemoExpand:
type: boolean
description: enable_single_click_memo_expand enables toggling compact memo content with a single click.
reactions:
type: array
items:
Expand Down
14 changes: 12 additions & 2 deletions proto/gen/store/instance_setting.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions proto/store/instance_setting.proto
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ message InstanceMemoRelatedSetting {
int32 content_length_limit = 3;
// enable_double_click_edit enables editing on double click.
bool enable_double_click_edit = 4;
// enable_single_click_memo_expand enables toggling compact memo content with a single click.
bool enable_single_click_memo_expand = 5;
// reactions is the list of reactions.
repeated string reactions = 7;
}
Expand Down
18 changes: 10 additions & 8 deletions server/router/api/v1/instance_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,11 @@ func convertInstanceMemoRelatedSettingFromStore(setting *storepb.InstanceMemoRel
return nil
}
return &v1pb.InstanceSetting_MemoRelatedSetting{
DisplayWithUpdateTime: setting.DisplayWithUpdateTime,
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
DisplayWithUpdateTime: setting.DisplayWithUpdateTime,
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
EnableSingleClickMemoExpand: setting.EnableSingleClickMemoExpand,
Reactions: setting.Reactions,
}
}

Expand All @@ -337,10 +338,11 @@ func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_Memo
return nil
}
return &storepb.InstanceMemoRelatedSetting{
DisplayWithUpdateTime: setting.DisplayWithUpdateTime,
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
DisplayWithUpdateTime: setting.DisplayWithUpdateTime,
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
EnableSingleClickMemoExpand: setting.EnableSingleClickMemoExpand,
Reactions: setting.Reactions,
}
}

Expand Down
17 changes: 12 additions & 5 deletions web/src/components/MemoContent/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
import type { ContentCompactView } from "./types";

export const useCompactMode = (enabled: boolean) => {
export const useCompactMode = (enabled: boolean, resetKey?: string) => {
const containerRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);

useEffect(() => {
if (!enabled || !containerRef.current) return;
if (!enabled || !containerRef.current) {
setMode(undefined);
return;
}

const maxHeight = getMaxDisplayHeight();
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
setMode("ALL");
return;
}
}, [enabled]);

setMode(undefined);
}, [enabled, resetKey]);

const toggle = useCallback(() => {
if (!mode) return;
setMode(COMPACT_STATES[mode].next);
}, [mode]);

return { containerRef, mode, toggle };
return useMemo(() => ({ containerRef, mode, toggle }), [mode, toggle]);
};

export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/MemoContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
const internalCompactMode = useCompactMode(Boolean(props.compact));
const {
containerRef: memoContentContainerRef,
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
} = props.compactMode ?? internalCompactMode;
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);

Expand Down
7 changes: 7 additions & 0 deletions web/src/components/MemoContent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import type React from "react";
export interface MemoContentProps {
content: string;
compact?: boolean;
compactMode?: MemoContentCompactMode;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
}

export type ContentCompactView = "ALL" | "SNIPPET";

export interface MemoContentCompactMode {
containerRef: React.RefObject<HTMLDivElement>;
mode: ContentCompactView | undefined;
toggle: () => void;
}
6 changes: 6 additions & 0 deletions web/src/components/MemoPreview/MemoPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { FileIcon } from "lucide-react";
import { createRef } from "react";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
Expand Down Expand Up @@ -32,6 +33,11 @@ const STUB_CONTEXT: MemoViewContextValue = {
readonly: true,
showBlurredContent: false,
blurred: false,
compactMode: {
containerRef: createRef<HTMLDivElement>(),
mode: undefined,
toggle: () => {},
},
openEditor: () => {},
toggleBlurVisibility: () => {},
openPreview: () => {},
Expand Down
26 changes: 25 additions & 1 deletion web/src/components/MemoView/MemoView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { isSuperUser } from "@/utils/user";
import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog";
import { useCompactMode } from "../MemoContent/hooks";
import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
Expand All @@ -23,7 +24,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const [cardWidth, setCardWidth] = useState(0);

const currentUser = useCurrentUser();
const { tagsSetting } = useInstance();
const { memoRelatedSetting, tagsSetting } = useInstance();
const creator = useUser(memoData.creator).data;
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
Expand All @@ -35,6 +36,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);

const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const compactMode = useCompactMode(Boolean(compact && !memoData.pinned), `${memoData.name}-${memoData.updateTime}`);

const openEditor = useCallback(() => setShowEditor(true), []);
const closeEditor = useCallback(() => setShowEditor(false), []);
Expand All @@ -43,6 +45,25 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;

const handleArticleClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!memoRelatedSetting.enableSingleClickMemoExpand || !compactMode.mode || event.defaultPrevented || event.detail > 1) {
return;
}

const target = event.target as HTMLElement;
if (target.closest("a, button, input, textarea, select, [role='button'], [data-ignore-memo-expand]") || target.tagName === "IMG") {
return;
}
if (window.getSelection()?.toString()) {
return;
}

compactMode.toggle();
},
[compactMode, memoRelatedSetting.enableSingleClickMemoExpand],
);
Comment on lines +48 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l web/src/components/MemoView/MemoView.tsx

Repository: usememos/memos

Length of output: 103


🏁 Script executed:

cat -n web/src/components/MemoView/MemoView.tsx | head -200

Repository: usememos/memos

Length of output: 7270


🏁 Script executed:

rg -n "dblclick|double" web/src/components/MemoView/MemoView.tsx

Repository: usememos/memos

Length of output: 40


🏁 Script executed:

rg -n "openEditor|dblclick" web/src/components/MemoView/

Repository: usememos/memos

Length of output: 1433


🏁 Script executed:

rg -n "onDoubleClick|dblclick" web/src/components/MemoContent/

Repository: usememos/memos

Length of output: 358


🏁 Script executed:

rg -n "openEditor" web/src/components/MemoView/components/

Repository: usememos/memos

Length of output: 723


🏁 Script executed:

cat -n web/src/components/MemoView/hooks/useMemoHandlers.ts

Repository: usememos/memos

Length of output: 1651


🏁 Script executed:

cat -n web/src/components/MemoView/components/MemoBody.tsx | head -50

Repository: usememos/memos

Length of output: 2659


🏁 Script executed:

cat -n web/src/components/MemoContent/index.tsx | head -100

Repository: usememos/memos

Length of output: 5397


🏁 Script executed:

cat -n web/src/components/MemoContent/index.tsx | sed -n '40,90p'

Repository: usememos/memos

Length of output: 2390


🏁 Script executed:

# Verify browser event sequence for double-click: does first click fire before dblclick?
python3 <<'EOF'
print("Browser Double-Click Event Sequence (W3C Standard):")
print("=" * 50)
print("1. mousedown (first press)")
print("2. mouseup (first release)")  
print("3. click (detail=1) <- This fires before dblclick")
print("4. mousedown (second press)")
print("5. mouseup (second release)")
print("6. click (detail=2)")
print("7. dblclick")
print()
print("The key insight:")
print("- First 'click' event fires with detail=1 BEFORE 'dblclick' event")
print("- event.detail > 1 only catches the SECOND click (after dblclick)")
print("- So first click's toggle() executes before dblclick's preventDefault()/openEditor()")
EOF

Repository: usememos/memos

Length of output: 588


🏁 Script executed:

# Check if there's any stopPropagation or other event handling that might prevent bubbling
rg -n "stopPropagation|preventDefault|bubbl" web/src/components/MemoView/components/MemoBody.tsx

Repository: usememos/memos

Length of output: 40


🏁 Script executed:

# Verify the actual element structure and event attachment points
rg -A5 "onClick=\{handleArticleClick\}" web/src/components/MemoView/MemoView.tsx

Repository: usememos/memos

Length of output: 241


🏁 Script executed:

rg -n "stopPropagation" web/src/components/MemoView/components/MemoBody.tsx web/src/components/MemoContent/index.tsx

Repository: usememos/memos

Length of output: 40


Use a timeout to delay expand/collapse, canceling if a double-click occurs within the window.

Line 50's event.detail > 1 check suppresses only the second click event. On a real double-click sequence, the first click fires with detail === 1 before the dblclick event, so compactMode.toggle() executes during the first click. When the double-click edit handler fires moments later, the card has already expanded/collapsed and reflowed, creating unwanted visual motion before entering edit mode.

Replace the detail check with a debounce/timeout pattern that can be canceled by detecting a dblclick event within the window, ensuring single-click and double-click behaviors don't interfere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/MemoView/MemoView.tsx` around lines 48 - 65, The
single-click handler handleArticleClick currently uses event.detail to try to
ignore double-clicks but fires on the first click; change it to schedule
compactMode.toggle() via a short setTimeout (e.g., 200–300ms) and store that
timer in a ref (timerRef) so it can be cancelled; add a window 'dblclick'
listener (registered/cleaned up in a useEffect) that clears timerRef and
prevents the pending toggle when a double-click occurs, and also clear the timer
on unmount and when handleArticleClick early-returns; keep references to
memoRelatedSetting.enableSingleClickMemoExpand and compactMode (and call
compactMode.toggle()) in the callback dependencies.


useEffect(() => {
const card = cardRef.current;
if (!card) {
Expand Down Expand Up @@ -81,6 +102,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
readonly,
showBlurredContent,
blurred,
compactMode,
openEditor,
toggleBlurVisibility,
openPreview,
Expand All @@ -95,6 +117,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
readonly,
showBlurredContent,
blurred,
compactMode,
openEditor,
toggleBlurVisibility,
openPreview,
Expand All @@ -120,6 +143,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onClick={handleArticleClick}
>
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} />

Expand Down
2 changes: 2 additions & 0 deletions web/src/components/MemoView/MemoViewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import type { PreviewMediaItem } from "@/utils/media-item";
import type { MemoContentCompactMode } from "../MemoContent/types";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";

export interface MemoViewContextValue {
Expand All @@ -17,6 +18,7 @@ export interface MemoViewContextValue {
readonly: boolean;
showBlurredContent: boolean;
blurred: boolean;
compactMode: MemoContentCompactMode;
openEditor: () => void;
toggleBlurVisibility: () => void;
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/MemoView/components/MemoBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
};

const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext();
const { memo, parentPage, showBlurredContent, blurred, readonly, compactMode, openEditor, openPreview, toggleBlurVisibility } =
useMemoViewContext();

const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });

Expand All @@ -42,6 +43,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
content={memo.content}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compactMode={compactMode}
compact={memo.pinned ? false : compact} // Always show full content when pinned
/>
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/Settings/MemoRelatedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ const MemoRelatedSettings = () => {
/>
</SettingRow>

<SettingRow label={t("setting.system.enable-single-click-memo-expand")}>
<Switch
checked={memoRelatedSetting.enableSingleClickMemoExpand}
onCheckedChange={(checked) => updatePartialSetting({ enableSingleClickMemoExpand: checked })}
/>
</SettingRow>

<SettingRow label={t("setting.memo.content-length-limit")}>
<Input
className="w-24"
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@
"display-with-updated-time": "Display with updated time",
"enable-auto-compact": "Enable auto compact",
"enable-double-click-to-edit": "Enable double click to edit",
"enable-single-click-memo-expand": "Enable single click to show more or less",
"enable-password-login": "Enable password login",
"enable-password-login-warning": "This will enable password login for all users. Continue only if you want to users to be able to log in using both SSO and password",
"label": "System",
Expand Down
Loading