diff --git a/proto/api/v1/instance_service.proto b/proto/api/v1/instance_service.proto index 91bbcf632e566..eede0cb391440 100644 --- a/proto/api/v1/instance_service.proto +++ b/proto/api/v1/instance_service.proto @@ -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; } diff --git a/proto/gen/api/v1/instance_service.pb.go b/proto/gen/api/v1/instance_service.pb.go index d308d1ec2b1c7..58da3a437e244 100644 --- a/proto/gen/api/v1/instance_service.pb.go +++ b/proto/gen/api/v1/instance_service.pb.go @@ -762,6 +762,8 @@ type InstanceSetting_MemoRelatedSetting struct { ContentLengthLimit int32 `protobuf:"varint,3,opt,name=content_length_limit,json=contentLengthLimit,proto3" json:"content_length_limit,omitempty"` // enable_double_click_edit enables editing on double click. EnableDoubleClickEdit bool `protobuf:"varint,4,opt,name=enable_double_click_edit,json=enableDoubleClickEdit,proto3" json:"enable_double_click_edit,omitempty"` + // enable_single_click_memo_expand enables toggling compact memo content with a single click. + EnableSingleClickMemoExpand bool `protobuf:"varint,5,opt,name=enable_single_click_memo_expand,json=enableSingleClickMemoExpand,proto3" json:"enable_single_click_memo_expand,omitempty"` // reactions is the list of reactions. Reactions []string `protobuf:"bytes,7,rep,name=reactions,proto3" json:"reactions,omitempty"` unknownFields protoimpl.UnknownFields @@ -819,6 +821,13 @@ func (x *InstanceSetting_MemoRelatedSetting) GetEnableDoubleClickEdit() bool { return false } +func (x *InstanceSetting_MemoRelatedSetting) GetEnableSingleClickMemoExpand() bool { + if x != nil { + return x.EnableSingleClickMemoExpand + } + return false +} + func (x *InstanceSetting_MemoRelatedSetting) GetReactions() []string { if x != nil { return x.Reactions @@ -1392,7 +1401,7 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x04demo\x18\x03 \x01(\bR\x04demo\x12!\n" + "\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\x12(\n" + "\x05admin\x18\a \x01(\v2\x12.memos.api.v1.UserR\x05admin\"\x1b\n" + - "\x19GetInstanceProfileRequest\"\xff\x19\n" + + "\x19GetInstanceProfileRequest\"\xc5\x1a\n" + "\x0fInstanceSetting\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" + "\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" + @@ -1431,11 +1440,12 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x18STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\f\n" + "\bDATABASE\x10\x01\x12\t\n" + "\x05LOCAL\x10\x02\x12\x06\n" + - "\x02S3\x10\x03\x1a\xd6\x01\n" + + "\x02S3\x10\x03\x1a\x9c\x02\n" + "\x12MemoRelatedSetting\x127\n" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + - "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12D\n" + + "\x1fenable_single_click_memo_expand\x18\x05 \x01(\bR\x1benableSingleClickMemoExpand\x12\x1c\n" + "\treactions\x18\a \x03(\tR\treactions\x1ao\n" + "\vTagMetadata\x12=\n" + "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" + diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 6d3b444c0d44f..b3b45ad4637e3 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -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: diff --git a/proto/gen/store/instance_setting.pb.go b/proto/gen/store/instance_setting.pb.go index 8cf5ff605af23..f3f4ca245aa67 100644 --- a/proto/gen/store/instance_setting.pb.go +++ b/proto/gen/store/instance_setting.pb.go @@ -757,6 +757,8 @@ type InstanceMemoRelatedSetting struct { ContentLengthLimit int32 `protobuf:"varint,3,opt,name=content_length_limit,json=contentLengthLimit,proto3" json:"content_length_limit,omitempty"` // enable_double_click_edit enables editing on double click. EnableDoubleClickEdit bool `protobuf:"varint,4,opt,name=enable_double_click_edit,json=enableDoubleClickEdit,proto3" json:"enable_double_click_edit,omitempty"` + // enable_single_click_memo_expand enables toggling compact memo content with a single click. + EnableSingleClickMemoExpand bool `protobuf:"varint,5,opt,name=enable_single_click_memo_expand,json=enableSingleClickMemoExpand,proto3" json:"enable_single_click_memo_expand,omitempty"` // reactions is the list of reactions. Reactions []string `protobuf:"bytes,7,rep,name=reactions,proto3" json:"reactions,omitempty"` unknownFields protoimpl.UnknownFields @@ -814,6 +816,13 @@ func (x *InstanceMemoRelatedSetting) GetEnableDoubleClickEdit() bool { return false } +func (x *InstanceMemoRelatedSetting) GetEnableSingleClickMemoExpand() bool { + if x != nil { + return x.EnableSingleClickMemoExpand + } + return false +} + func (x *InstanceMemoRelatedSetting) GetReactions() []string { if x != nil { return x.Reactions @@ -1255,11 +1264,12 @@ const file_store_instance_setting_proto_rawDesc = "" + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\x12\x16\n" + "\x06bucket\x18\x05 \x01(\tR\x06bucket\x12$\n" + - "\x0euse_path_style\x18\x06 \x01(\bR\fusePathStyle\"\xde\x01\n" + + "\x0euse_path_style\x18\x06 \x01(\bR\fusePathStyle\"\xa4\x02\n" + "\x1aInstanceMemoRelatedSetting\x127\n" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + - "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12D\n" + + "\x1fenable_single_click_memo_expand\x18\x05 \x01(\bR\x1benableSingleClickMemoExpand\x12\x1c\n" + "\treactions\x18\a \x03(\tR\treactions\"w\n" + "\x13InstanceTagMetadata\x12=\n" + "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" + diff --git a/proto/store/instance_setting.proto b/proto/store/instance_setting.proto index f1010b7ab81ce..a3542362f7306 100644 --- a/proto/store/instance_setting.proto +++ b/proto/store/instance_setting.proto @@ -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; } diff --git a/server/router/api/v1/instance_service.go b/server/router/api/v1/instance_service.go index f4abf54f75d75..5c2f233679a0b 100644 --- a/server/router/api/v1/instance_service.go +++ b/server/router/api/v1/instance_service.go @@ -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, } } @@ -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, } } diff --git a/web/src/components/MemoContent/hooks.ts b/web/src/components/MemoContent/hooks.ts index bc8187472b8ee..aa7cdbe6307ab 100644 --- a/web/src/components/MemoContent/hooks.ts +++ b/web/src/components/MemoContent/hooks.ts @@ -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(null); const [mode, setMode] = useState(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 => { diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 484911e715638..049de6dc792fd 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -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); diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts index ed46b1cfee3f0..ebbc3462c83eb 100644 --- a/web/src/components/MemoContent/types.ts +++ b/web/src/components/MemoContent/types.ts @@ -3,6 +3,7 @@ import type React from "react"; export interface MemoContentProps { content: string; compact?: boolean; + compactMode?: MemoContentCompactMode; className?: string; contentClassName?: string; onClick?: (e: React.MouseEvent) => void; @@ -10,3 +11,9 @@ export interface MemoContentProps { } export type ContentCompactView = "ALL" | "SNIPPET"; + +export interface MemoContentCompactMode { + containerRef: React.RefObject; + mode: ContentCompactView | undefined; + toggle: () => void; +} diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 53a4549b0d087..f30422f32b086 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -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"; @@ -32,6 +33,11 @@ const STUB_CONTEXT: MemoViewContextValue = { readonly: true, showBlurredContent: false, blurred: false, + compactMode: { + containerRef: createRef(), + mode: undefined, + toggle: () => {}, + }, openEditor: () => {}, toggleBlurVisibility: () => {}, openPreview: () => {}, diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 7f45321ee1ae5..65d0731f78e2d 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -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"; @@ -23,7 +24,7 @@ const MemoView: React.FC = (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); @@ -35,6 +36,7 @@ const MemoView: React.FC = (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), []); @@ -43,6 +45,25 @@ const MemoView: React.FC = (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) => { + 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], + ); + useEffect(() => { const card = cardRef.current; if (!card) { @@ -81,6 +102,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { readonly, showBlurredContent, blurred, + compactMode, openEditor, toggleBlurVisibility, openPreview, @@ -95,6 +117,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { readonly, showBlurredContent, blurred, + compactMode, openEditor, toggleBlurVisibility, openPreview, @@ -120,6 +143,7 @@ const MemoView: React.FC = (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} > diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index 569934b11840e..cd94e52160d1e 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -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 { @@ -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; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index 33839b19b48a4..0ffe010bd9e83 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -23,7 +23,8 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { }; const MemoBody: React.FC = ({ 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 }); @@ -42,6 +43,7 @@ const MemoBody: React.FC = ({ compact }) => { content={memo.content} onClick={handleMemoContentClick} onDoubleClick={handleMemoContentDoubleClick} + compactMode={compactMode} compact={memo.pinned ? false : compact} // Always show full content when pinned /> diff --git a/web/src/components/Settings/MemoRelatedSettings.tsx b/web/src/components/Settings/MemoRelatedSettings.tsx index 8c7d2620fd5a7..3e9ec0918d820 100644 --- a/web/src/components/Settings/MemoRelatedSettings.tsx +++ b/web/src/components/Settings/MemoRelatedSettings.tsx @@ -86,6 +86,13 @@ const MemoRelatedSettings = () => { /> + + updatePartialSetting({ enableSingleClickMemoExpand: checked })} + /> + + = /*@__PURE__*/ serviceDesc(file_api_v1_instance_service, 0); -