Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
ea4e7a16
Unverified
Commit
ea4e7a16
authored
May 28, 2025
by
Johnny
Committed by
GitHub
May 28, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: memo editor (#4730)
parent
77d3853f
Changes
7
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
336 additions
and
314 deletions
+336
-314
MasonryView.tsx
web/src/components/MasonryView/MasonryView.tsx
+59
-85
VisibilitySelector.tsx
...components/MemoEditor/ActionButton/VisibilitySelector.tsx
+64
-0
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+10
-7
index.tsx
web/src/components/MemoEditor/index.tsx
+17
-33
MemoView.tsx
web/src/components/MemoView.tsx
+105
-111
PagedMemoList.tsx
web/src/components/PagedMemoList/PagedMemoList.tsx
+78
-76
VisibilityIcon.tsx
web/src/components/VisibilityIcon.tsx
+3
-2
No files found.
web/src/components/MasonryView/MasonryView.tsx
View file @
ea4e7a16
...
@@ -9,22 +9,15 @@ interface Props {
...
@@ -9,22 +9,15 @@ interface Props {
listMode
?:
boolean
;
listMode
?:
boolean
;
}
}
interface
LocalState
{
columns
:
number
;
itemHeights
:
Map
<
string
,
number
>
;
columnHeights
:
number
[];
distribution
:
number
[][];
}
interface
MemoItemProps
{
interface
MemoItemProps
{
memo
:
Memo
;
memo
:
Memo
;
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
}
}
// Minimum width required to show more than one column
const
MINIMUM_MEMO_VIEWPORT_WIDTH
=
512
;
const
MINIMUM_MEMO_VIEWPORT_WIDTH
=
512
;
// Component to wrap each memo and measure its height
const
MemoItem
=
({
memo
,
renderer
,
onHeightChange
}:
MemoItemProps
)
=>
{
const
MemoItem
=
({
memo
,
renderer
,
onHeightChange
}:
MemoItemProps
)
=>
{
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
...
@@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
...
@@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
}
}
};
};
// Initial measurement
measureHeight
();
measureHeight
();
// Set up ResizeObserver for dynamic content changes
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef
.
current
=
new
ResizeObserver
(()
=>
{
resizeObserverRef
.
current
=
new
ResizeObserver
(
measureHeight
);
measureHeight
();
});
resizeObserverRef
.
current
.
observe
(
itemRef
.
current
);
resizeObserverRef
.
current
.
observe
(
itemRef
.
current
);
return
()
=>
{
return
()
=>
{
if
(
resizeObserverRef
.
current
)
{
resizeObserverRef
.
current
?.
disconnect
();
resizeObserverRef
.
current
.
disconnect
();
}
};
};
},
[
memo
.
name
,
onHeightChange
]);
},
[
memo
.
name
,
onHeightChange
]);
return
<
div
ref=
{
itemRef
}
>
{
renderer
(
memo
)
}
</
div
>;
return
<
div
ref=
{
itemRef
}
>
{
renderer
(
memo
)
}
</
div
>;
};
};
// Algorithm to distribute memos into columns based on height
/**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
*/
const
distributeMemosToColumns
=
(
const
distributeMemosToColumns
=
(
memos
:
Memo
[],
memos
:
Memo
[],
columns
:
number
,
columns
:
number
,
itemHeights
:
Map
<
string
,
number
>
,
itemHeights
:
Map
<
string
,
number
>
,
prefixElementHeight
:
number
=
0
,
prefixElementHeight
:
number
=
0
,
):
{
distribution
:
number
[][];
columnHeights
:
number
[]
}
=>
{
):
{
distribution
:
number
[][];
columnHeights
:
number
[]
}
=>
{
// List mode: all memos in single column
if
(
columns
===
1
)
{
if
(
columns
===
1
)
{
// List mode - all memos in single column
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
return
{
return
{
distribution
:
[
Array
.
from
(
Array
(
memos
.
length
).
keys
()
)],
distribution
:
[
Array
.
from
(
{
length
:
memos
.
length
},
(
_
,
i
)
=>
i
)],
columnHeights
:
[
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
)
],
columnHeights
:
[
totalHeight
],
};
};
}
}
// Initialize columns and heights
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
...
@@ -82,15 +74,12 @@ const distributeMemosToColumns = (
...
@@ -82,15 +74,12 @@ const distributeMemosToColumns = (
columnHeights
[
0
]
=
prefixElementHeight
;
columnHeights
[
0
]
=
prefixElementHeight
;
}
}
// Distribute
memos to the shortest column each time
// Distribute
each memo to the shortest column
memos
.
forEach
((
memo
,
index
)
=>
{
memos
.
forEach
((
memo
,
index
)
=>
{
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
// Find the shortest column
// Find column with minimum height
const
shortestColumnIndex
=
columnHeights
.
reduce
(
const
shortestColumnIndex
=
columnHeights
.
indexOf
(
Math
.
min
(...
columnHeights
));
(
minIndex
,
currentHeight
,
currentIndex
)
=>
(
currentHeight
<
columnHeights
[
minIndex
]
?
currentIndex
:
minIndex
),
0
,
);
distribution
[
shortestColumnIndex
].
push
(
index
);
distribution
[
shortestColumnIndex
].
push
(
index
);
columnHeights
[
shortestColumnIndex
]
+=
height
;
columnHeights
[
shortestColumnIndex
]
+=
height
;
...
@@ -100,97 +89,82 @@ const distributeMemosToColumns = (
...
@@ -100,97 +89,82 @@ const distributeMemosToColumns = (
};
};
const
MasonryView
=
(
props
:
Props
)
=>
{
const
MasonryView
=
(
props
:
Props
)
=>
{
const
[
state
,
setState
]
=
useState
<
LocalState
>
({
const
[
columns
,
setColumns
]
=
useState
(
1
);
columns
:
1
,
const
[
itemHeights
,
setItemHeights
]
=
useState
<
Map
<
string
,
number
>>
(
new
Map
());
itemHeights
:
new
Map
(),
const
[
distribution
,
setDistribution
]
=
useState
<
number
[][]
>
([[]]);
columnHeights
:
[
0
],
distribution
:
[[]],
});
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
prefixElementRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
prefixElementRef
=
useRef
<
HTMLDivElement
>
(
null
);
// Calculate optimal number of columns based on container width
const
calculateColumns
=
useCallback
(()
=>
{
if
(
!
containerRef
.
current
||
props
.
listMode
)
return
1
;
const
containerWidth
=
containerRef
.
current
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
return
scale
>=
2
?
Math
.
round
(
scale
)
:
1
;
},
[
props
.
listMode
]);
// Recalculate memo distribution when layout changes
const
redistributeMemos
=
useCallback
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
:
newDistribution
}
=
distributeMemosToColumns
(
props
.
memoList
,
columns
,
itemHeights
,
prefixHeight
);
setDistribution
(
newDistribution
);
},
[
props
.
memoList
,
columns
,
itemHeights
]);
// Handle height changes from individual memo items
// Handle height changes from individual memo items
const
handleHeightChange
=
useCallback
(
const
handleHeightChange
=
useCallback
(
(
memoName
:
string
,
height
:
number
)
=>
{
(
memoName
:
string
,
height
:
number
)
=>
{
set
State
((
prevState
)
=>
{
set
ItemHeights
((
prevHeights
)
=>
{
const
newItemHeights
=
new
Map
(
prev
State
.
item
Heights
);
const
newItemHeights
=
new
Map
(
prevHeights
);
newItemHeights
.
set
(
memoName
,
height
);
newItemHeights
.
set
(
memoName
,
height
);
// Recalculate distribution with new heights
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
prevState
.
columns
,
newItemHeights
,
prefixHeight
);
const
{
distribution
:
newDistribution
}
=
distributeMemosToColumns
(
props
.
memoList
,
columns
,
newItemHeights
,
prefixHeight
);
setDistribution
(
newDistribution
);
return
{
...
prevState
,
return
newItemHeights
;
itemHeights
:
newItemHeights
,
distribution
,
columnHeights
,
};
});
});
},
},
[
props
.
memoList
],
[
props
.
memoList
,
columns
],
);
);
// Handle window resize and c
olumn count changes
// Handle window resize and c
alculate new column count
useEffect
(()
=>
{
useEffect
(()
=>
{
const
handleResize
=
()
=>
{
const
handleResize
=
()
=>
{
if
(
!
containerRef
.
current
)
{
if
(
!
containerRef
.
current
)
return
;
return
;
}
const
newColumns
=
props
.
listMode
?
1
:
(()
=>
{
const
containerWidth
=
containerRef
.
current
!
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
return
scale
>=
2
?
Math
.
round
(
scale
)
:
1
;
})();
if
(
newColumns
!==
state
.
columns
)
{
const
newColumns
=
calculateColumns
();
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
if
(
newColumns
!==
columns
)
{
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
newColumns
,
state
.
itemHeights
,
prefixHeight
);
setColumns
(
newColumns
);
setState
((
prevState
)
=>
({
...
prevState
,
columns
:
newColumns
,
distribution
,
columnHeights
,
}));
}
}
};
};
handleResize
();
handleResize
();
window
.
addEventListener
(
"resize"
,
handleResize
);
window
.
addEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
},
[
props
.
listMode
,
state
.
columns
,
state
.
itemHeights
,
props
.
memoList
]);
},
[
calculateColumns
,
columns
]);
// Redistribute
when memo list changes
// Redistribute
memos when columns, memo list, or heights change
useEffect
(()
=>
{
useEffect
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
redistributeMemos
();
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
state
.
columns
,
state
.
itemHeights
,
prefixHeight
);
},
[
redistributeMemos
]);
setState
((
prevState
)
=>
({
...
prevState
,
distribution
,
columnHeights
,
}));
},
[
props
.
memoList
,
state
.
columns
,
state
.
itemHeights
]);
return
(
return
(
<
div
<
div
ref=
{
containerRef
}
ref=
{
containerRef
}
className=
{
cn
(
"w-full grid gap-2"
)
}
className=
{
cn
(
"w-full grid gap-2"
)
}
style=
{
{
style=
{
{
gridTemplateColumns
:
`repeat(${
state.
columns}, 1fr)`
,
gridTemplateColumns
:
`repeat(${columns}, 1fr)`
,
}
}
}
}
>
>
{
Array
.
from
({
length
:
state
.
columns
}).
map
((
_
,
columnIndex
)
=>
(
{
Array
.
from
({
length
:
columns
}).
map
((
_
,
columnIndex
)
=>
(
<
div
key=
{
columnIndex
}
className=
"min-w-0 mx-auto w-full max-w-2xl"
>
<
div
key=
{
columnIndex
}
className=
"min-w-0 mx-auto w-full max-w-2xl"
>
{
props
.
prefixElement
&&
columnIndex
===
0
&&
(
{
/* Prefix element (like memo editor) goes in first column */
}
<
div
ref=
{
prefixElementRef
}
className=
"mb-2"
>
{
props
.
prefixElement
&&
columnIndex
===
0
&&
<
div
ref=
{
prefixElementRef
}
>
{
props
.
prefixElement
}
</
div
>
}
{
props
.
prefixElement
}
</
div
>
{
distribution
[
columnIndex
]?.
map
((
memoIndex
)
=>
{
)
}
{
state
.
distribution
[
columnIndex
]?.
map
((
memoIndex
)
=>
{
const
memo
=
props
.
memoList
[
memoIndex
];
const
memo
=
props
.
memoList
[
memoIndex
];
return
memo
?
(
return
memo
?
(
<
MemoItem
<
MemoItem
...
...
web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx
0 → 100644
View file @
ea4e7a16
import
{
ChevronDownIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
VisibilityIcon
from
"@/components/VisibilityIcon"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"@/components/ui/Popover"
;
import
{
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
Props
{
value
:
Visibility
;
onChange
:
(
visibility
:
Visibility
)
=>
void
;
className
?:
string
;
}
const
VisibilitySelector
=
(
props
:
Props
)
=>
{
const
{
value
,
onChange
,
className
}
=
props
;
const
t
=
useTranslate
();
const
[
open
,
setOpen
]
=
useState
(
false
);
const
visibilityOptions
=
[
{
value
:
Visibility
.
PRIVATE
,
label
:
t
(
"memo.visibility.private"
)
},
{
value
:
Visibility
.
PROTECTED
,
label
:
t
(
"memo.visibility.protected"
)
},
{
value
:
Visibility
.
PUBLIC
,
label
:
t
(
"memo.visibility.public"
)
},
];
const
currentOption
=
visibilityOptions
.
find
((
option
)
=>
option
.
value
===
value
);
const
handleSelect
=
(
visibility
:
Visibility
)
=>
{
onChange
(
visibility
);
setOpen
(
false
);
};
return
(
<
Popover
open=
{
open
}
onOpenChange=
{
setOpen
}
>
<
PopoverTrigger
asChild
>
<
button
className=
{
`flex items-center justify-center gap-1 px-0.5 text-xs rounded hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 transition-colors ${className || ""}`
}
type=
"button"
>
<
VisibilityIcon
className=
"w-3 h-3"
visibility=
{
value
}
/>
<
span
className=
"hidden sm:inline"
>
{
currentOption
?.
label
}
</
span
>
<
ChevronDownIcon
className=
"w-3 h-3 opacity-60"
/>
</
button
>
</
PopoverTrigger
>
<
PopoverContent
className=
"!p-1"
align=
"end"
sideOffset=
{
2
}
alignOffset=
{
-
4
}
>
<
div
className=
"flex flex-col gap-0.5"
>
{
visibilityOptions
.
map
((
option
)
=>
(
<
button
key=
{
option
.
value
}
onClick=
{
()
=>
handleSelect
(
option
.
value
)
}
className=
{
`flex items-center gap-1 px-1 py-1 text-xs text-left dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded transition-colors ${
option.value === value ? "bg-gray-50 dark:bg-zinc-800" : ""
}`
}
>
<
VisibilityIcon
className=
"w-3 h-3"
visibility=
{
option
.
value
}
/>
<
span
>
{
option
.
label
}
</
span
>
</
button
>
))
}
</
div
>
</
PopoverContent
>
</
Popover
>
);
};
export
default
VisibilitySelector
;
web/src/components/MemoEditor/Editor/index.tsx
View file @
ea4e7a16
...
@@ -169,11 +169,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
...
@@ -169,11 +169,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
if
(
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
{
if
(
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
{
return
;
return
;
}
}
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event
.
preventDefault
();
const
cursorPosition
=
editorActions
.
getCursorPosition
();
const
cursorPosition
=
editorActions
.
getCursorPosition
();
const
prevContent
=
editorActions
.
getContent
().
substring
(
0
,
cursorPosition
);
const
prevContent
=
editorActions
.
getContent
().
substring
(
0
,
cursorPosition
);
...
@@ -210,7 +205,15 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
...
@@ -210,7 +205,15 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
insertText
+=
" |"
;
insertText
+=
" |"
;
}
}
editorActions
.
insertText
(
"
\n
"
+
insertText
);
if
(
insertText
)
{
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event
.
preventDefault
();
// Insert the text at the current cursor position
editorActions
.
insertText
(
"
\n
"
+
insertText
);
}
}
}
};
};
...
@@ -220,7 +223,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
...
@@ -220,7 +223,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
>
>
<
textarea
<
textarea
className=
"w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break"
className=
"w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break"
rows=
{
1
}
rows=
{
2
}
placeholder=
{
placeholder
}
placeholder=
{
placeholder
}
ref=
{
editorRef
}
ref=
{
editorRef
}
onPaste=
{
onPaste
}
onPaste=
{
onPaste
}
...
...
web/src/components/MemoEditor/index.tsx
View file @
ea4e7a16
import
{
Select
,
Option
,
Divider
}
from
"@mui/joy"
;
import
{
Button
}
from
"@usememos/mui"
;
import
{
Button
}
from
"@usememos/mui"
;
import
{
isEqual
}
from
"lodash-es"
;
import
{
isEqual
}
from
"lodash-es"
;
import
{
LoaderIcon
,
SendIcon
}
from
"lucide-react"
;
import
{
LoaderIcon
,
SendIcon
}
from
"lucide-react"
;
...
@@ -17,14 +16,15 @@ import { memoStore, resourceStore, userStore, workspaceStore } from "@/store/v2"
...
@@ -17,14 +16,15 @@ import { memoStore, resourceStore, userStore, workspaceStore } from "@/store/v2"
import
{
Location
,
Memo
,
MemoRelation
,
MemoRelation_Type
,
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Location
,
Memo
,
MemoRelation
,
MemoRelation_Type
,
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Resource
}
from
"@/types/proto/api/v1/resource_service"
;
import
{
Resource
}
from
"@/types/proto/api/v1/resource_service"
;
import
{
UserSetting
}
from
"@/types/proto/api/v1/user_service"
;
import
{
UserSetting
}
from
"@/types/proto/api/v1/user_service"
;
import
{
cn
}
from
"@/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
convertVisibilityFromString
,
convertVisibilityToString
}
from
"@/utils/memo"
;
import
{
convertVisibilityFromString
}
from
"@/utils/memo"
;
import
VisibilityIcon
from
"../VisibilityIcon"
;
import
AddMemoRelationPopover
from
"./ActionButton/AddMemoRelationPopover"
;
import
AddMemoRelationPopover
from
"./ActionButton/AddMemoRelationPopover"
;
import
LocationSelector
from
"./ActionButton/LocationSelector"
;
import
LocationSelector
from
"./ActionButton/LocationSelector"
;
import
MarkdownMenu
from
"./ActionButton/MarkdownMenu"
;
import
MarkdownMenu
from
"./ActionButton/MarkdownMenu"
;
import
TagSelector
from
"./ActionButton/TagSelector"
;
import
TagSelector
from
"./ActionButton/TagSelector"
;
import
UploadResourceButton
from
"./ActionButton/UploadResourceButton"
;
import
UploadResourceButton
from
"./ActionButton/UploadResourceButton"
;
import
VisibilitySelector
from
"./ActionButton/VisibilitySelector"
;
import
Editor
,
{
EditorRefActions
}
from
"./Editor"
;
import
Editor
,
{
EditorRefActions
}
from
"./Editor"
;
import
RelationListView
from
"./RelationListView"
;
import
RelationListView
from
"./RelationListView"
;
import
ResourceListView
from
"./ResourceListView"
;
import
ResourceListView
from
"./ResourceListView"
;
...
@@ -468,13 +468,13 @@ const MemoEditor = observer((props: Props) => {
...
@@ -468,13 +468,13 @@ const MemoEditor = observer((props: Props) => {
}
}
}
}
>
>
<
div
<
div
className=
{
`${
className=
{
cn
(
className ?? ""
"group relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-3 pb-2 rounded-lg border"
,
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border ${
state
.
isDraggingFile
state
.
isDraggingFile
?
"border-dashed border-gray-400 dark:border-primary-400 cursor-copy"
?
"border-dashed border-gray-400 dark:border-primary-400 cursor-copy"
: "border-gray-200 dark:border-zinc-700 cursor-auto"
:
"border-gray-200 dark:border-zinc-700 cursor-auto"
,
}`
}
className
,
)
}
tabIndex=
{
0
}
tabIndex=
{
0
}
onKeyDown=
{
handleKeyDown
}
onKeyDown=
{
handleKeyDown
}
onDrop=
{
handleDropEvent
}
onDrop=
{
handleDropEvent
}
...
@@ -500,7 +500,7 @@ const MemoEditor = observer((props: Props) => {
...
@@ -500,7 +500,7 @@ const MemoEditor = observer((props: Props) => {
<
Editor
ref=
{
editorRef
}
{
...
editorConfig
}
/>
<
Editor
ref=
{
editorRef
}
{
...
editorConfig
}
/>
<
ResourceListView
resourceList=
{
state
.
resourceList
}
setResourceList=
{
handleSetResourceList
}
/>
<
ResourceListView
resourceList=
{
state
.
resourceList
}
setResourceList=
{
handleSetResourceList
}
/>
<
RelationListView
relationList=
{
referenceRelations
}
setRelationList=
{
handleSetRelationList
}
/>
<
RelationListView
relationList=
{
referenceRelations
}
setRelationList=
{
handleSetRelationList
}
/>
<
div
className=
"relative w-full flex flex-row justify-between items-center p
t-2
"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"relative w-full flex flex-row justify-between items-center p
y-1
"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2"
>
<
div
className=
"flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2"
>
<
TagSelector
editorRef=
{
editorRef
}
/>
<
TagSelector
editorRef=
{
editorRef
}
/>
<
MarkdownMenu
editorRef=
{
editorRef
}
/>
<
MarkdownMenu
editorRef=
{
editorRef
}
/>
...
@@ -516,31 +516,9 @@ const MemoEditor = observer((props: Props) => {
...
@@ -516,31 +516,9 @@ const MemoEditor = observer((props: Props) => {
}
}
/>
/>
</
div
>
</
div
>
</
div
>
<
div
className=
"shrink-0 -mr-1 flex flex-row justify-end items-center"
>
<
Divider
className=
"!mt-2 opacity-40"
/>
<
div
className=
"w-full flex flex-row justify-between items-center py-3 gap-2 overflow-auto dark:border-t-zinc-500"
>
<
div
className=
"relative flex flex-row justify-start items-center"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Select
variant=
"plain"
size=
"sm"
value=
{
state
.
memoVisibility
}
startDecorator=
{
<
VisibilityIcon
visibility=
{
state
.
memoVisibility
}
/>
}
onChange=
{
(
_
,
visibility
)
=>
{
if
(
visibility
)
{
handleMemoVisibilityChange
(
visibility
);
}
}
}
>
{
[
Visibility
.
PRIVATE
,
Visibility
.
PROTECTED
,
Visibility
.
PUBLIC
].
map
((
item
)
=>
(
<
Option
key=
{
item
}
value=
{
item
}
className=
"whitespace-nowrap !text-sm"
>
{
t
(
`memo.visibility.${convertVisibilityToString(item).toLowerCase()}`
as
any
)
}
</
Option
>
))
}
</
Select
>
</
div
>
<
div
className=
"shrink-0 flex flex-row justify-end items-center gap-2"
>
{
props
.
onCancel
&&
(
{
props
.
onCancel
&&
(
<
Button
variant=
"plain"
disabled=
{
state
.
isRequesting
}
onClick=
{
handleCancelBtnClick
}
>
<
Button
variant=
"plain"
className=
"opacity-60"
disabled=
{
state
.
isRequesting
}
onClick=
{
handleCancelBtnClick
}
>
{
t
(
"common.cancel"
)
}
{
t
(
"common.cancel"
)
}
</
Button
>
</
Button
>
)
}
)
}
...
@@ -550,6 +528,12 @@ const MemoEditor = observer((props: Props) => {
...
@@ -550,6 +528,12 @@ const MemoEditor = observer((props: Props) => {
</
Button
>
</
Button
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"absolute invisible group-focus-within:visible group-hover:visible right-1 top-1 opacity-60"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
VisibilitySelector
value=
{
state
.
memoVisibility
}
onChange=
{
handleMemoVisibilityChange
}
/>
</
div
>
</
div
>
</
div
>
</
MemoEditorContext
.
Provider
>
</
MemoEditorContext
.
Provider
>
);
);
...
...
web/src/components/MemoView.tsx
View file @
ea4e7a16
This diff is collapsed.
Click to expand it.
web/src/components/PagedMemoList/PagedMemoList.tsx
View file @
ea4e7a16
...
@@ -26,108 +26,115 @@ interface Props {
...
@@ -26,108 +26,115 @@ interface Props {
pageSize
?:
number
;
pageSize
?:
number
;
}
}
interface
LocalState
{
isRequesting
:
boolean
;
nextPageToken
:
string
;
}
const
PagedMemoList
=
observer
((
props
:
Props
)
=>
{
const
PagedMemoList
=
observer
((
props
:
Props
)
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
{
md
}
=
useResponsiveWidth
();
const
{
md
}
=
useResponsiveWidth
();
const
[
state
,
setState
]
=
useState
<
LocalState
>
({
isRequesting
:
true
,
// Initial request
// Simplified state management - separate state variables for clarity
nextPageToken
:
""
,
const
[
isRequesting
,
setIsRequesting
]
=
useState
(
true
);
});
const
[
nextPageToken
,
setNextPageToken
]
=
useState
(
""
);
const
checkTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
// Ref to manage auto-fetch timeout to prevent memory leaks
const
autoFetchTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
// Apply custom sorting if provided, otherwise use store memos directly
const
sortedMemoList
=
props
.
listSort
?
props
.
listSort
(
memoStore
.
state
.
memos
)
:
memoStore
.
state
.
memos
;
const
sortedMemoList
=
props
.
listSort
?
props
.
listSort
(
memoStore
.
state
.
memos
)
:
memoStore
.
state
.
memos
;
// Show memo editor only on the root route
const
showMemoEditor
=
Boolean
(
matchPath
(
Routes
.
ROOT
,
window
.
location
.
pathname
));
const
showMemoEditor
=
Boolean
(
matchPath
(
Routes
.
ROOT
,
window
.
location
.
pathname
));
const
fetchMoreMemos
=
async
(
nextPageToken
:
string
)
=>
{
// Fetch more memos with pagination support
setState
((
state
)
=>
({
...
state
,
isRequesting
:
true
}));
const
fetchMoreMemos
=
async
(
pageToken
:
string
)
=>
{
const
response
=
await
memoStore
.
fetchMemos
({
setIsRequesting
(
true
);
parent
:
props
.
owner
||
""
,
state
:
props
.
state
||
State
.
NORMAL
,
try
{
direction
:
props
.
direction
||
Direction
.
DESC
,
const
response
=
await
memoStore
.
fetchMemos
({
filter
:
props
.
filter
||
""
,
parent
:
props
.
owner
||
""
,
oldFilter
:
props
.
oldFilter
||
""
,
state
:
props
.
state
||
State
.
NORMAL
,
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
direction
:
props
.
direction
||
Direction
.
DESC
,
pageToken
:
nextPageToken
,
filter
:
props
.
filter
||
""
,
});
oldFilter
:
props
.
oldFilter
||
""
,
setState
(()
=>
({
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
isRequesting
:
false
,
pageToken
,
nextPageToken
:
response
?.
nextPageToken
||
""
,
});
}));
setNextPageToken
(
response
?.
nextPageToken
||
""
);
}
finally
{
setIsRequesting
(
false
);
}
};
// Helper function to check if page has enough content to be scrollable
const
isPageScrollable
=
()
=>
{
const
documentHeight
=
Math
.
max
(
document
.
body
.
scrollHeight
,
document
.
documentElement
.
scrollHeight
);
return
documentHeight
>
window
.
innerHeight
+
100
;
// 100px buffer for safe measure
};
};
//
Check if content fills the viewport and fetch more if needed
//
Auto-fetch more content if page isn't scrollable and more data is available
const
checkAndFetchIfNeeded
=
useCallback
(
async
()
=>
{
const
checkAndFetchIfNeeded
=
useCallback
(
async
()
=>
{
// Clear any pending
checks
// Clear any pending
auto-fetch timeout
if
(
check
TimeoutRef
.
current
)
{
if
(
autoFetch
TimeoutRef
.
current
)
{
clearTimeout
(
check
TimeoutRef
.
current
);
clearTimeout
(
autoFetch
TimeoutRef
.
current
);
}
}
// Wait
a bit for DOM to update after memo list changes
// Wait
for DOM to update before checking scrollability
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
200
));
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
200
));
// Check if page is scrollable using multiple methods for better reliability
// Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
const
documentHeight
=
Math
.
max
(
const
shouldFetch
=
!
isPageScrollable
()
&&
nextPageToken
&&
!
isRequesting
&&
sortedMemoList
.
length
>
0
;
document
.
body
.
scrollHeight
,
document
.
body
.
offsetHeight
,
if
(
shouldFetch
)
{
document
.
documentElement
.
clientHeight
,
await
fetchMoreMemos
(
nextPageToken
);
document
.
documentElement
.
scrollHeight
,
document
.
documentElement
.
offsetHeight
,
// Schedule another check with delay to prevent rapid successive calls
);
autoFetchTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
const
windowHeight
=
window
.
innerHeight
;
const
isScrollable
=
documentHeight
>
windowHeight
+
100
;
// 100px buffer
// If not scrollable and we have more data to fetch and not currently fetching
if
(
!
isScrollable
&&
state
.
nextPageToken
&&
!
state
.
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
await
fetchMoreMemos
(
state
.
nextPageToken
);
// Schedule another check after a delay to prevent rapid successive calls
checkTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
checkAndFetchIfNeeded
();
checkAndFetchIfNeeded
();
},
500
);
},
500
);
}
}
},
[
state
.
nextPageToken
,
state
.
isRequesting
,
sortedMemoList
.
length
]);
},
[
nextPageToken
,
isRequesting
,
sortedMemoList
.
length
]);
// Refresh the entire memo list from the beginning
const
refreshList
=
async
()
=>
{
const
refreshList
=
async
()
=>
{
memoStore
.
state
.
updateStateId
();
memoStore
.
state
.
updateStateId
();
set
State
((
state
)
=>
({
...
state
,
nextPageToken
:
""
})
);
set
NextPageToken
(
""
);
await
fetchMoreMemos
(
""
);
await
fetchMoreMemos
(
""
);
};
};
// Initial load and reload when props change
useEffect
(()
=>
{
useEffect
(()
=>
{
refreshList
();
refreshList
();
},
[
props
.
owner
,
props
.
state
,
props
.
direction
,
props
.
filter
,
props
.
oldFilter
,
props
.
pageSize
]);
},
[
props
.
owner
,
props
.
state
,
props
.
direction
,
props
.
filter
,
props
.
oldFilter
,
props
.
pageSize
]);
//
Check if we need to fetch more data when content changes.
//
Auto-fetch more content when list changes and page isn't full
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
state
.
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
if
(
!
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
checkAndFetchIfNeeded
();
checkAndFetchIfNeeded
();
}
}
},
[
sortedMemoList
.
length
,
state
.
isRequesting
,
state
.
nextPageToken
,
checkAndFetchIfNeeded
]);
},
[
sortedMemoList
.
length
,
isRequesting
,
nextPageToken
,
checkAndFetchIfNeeded
]);
// Cleanup timeout on
unmount.
// Cleanup timeout on
component unmount
useEffect
(()
=>
{
useEffect
(()
=>
{
return
()
=>
{
return
()
=>
{
if
(
check
TimeoutRef
.
current
)
{
if
(
autoFetch
TimeoutRef
.
current
)
{
clearTimeout
(
check
TimeoutRef
.
current
);
clearTimeout
(
autoFetch
TimeoutRef
.
current
);
}
}
};
};
},
[]);
},
[]);
// Infinite scroll: fetch more when user scrolls near bottom
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
state
.
nextPageToken
)
return
;
if
(
!
nextPageToken
)
return
;
const
handleScroll
=
()
=>
{
const
handleScroll
=
()
=>
{
const
nearBottom
=
window
.
innerHeight
+
window
.
scrollY
>=
document
.
body
.
offsetHeight
-
300
;
const
nearBottom
=
window
.
innerHeight
+
window
.
scrollY
>=
document
.
body
.
offsetHeight
-
300
;
if
(
nearBottom
&&
!
state
.
isRequesting
)
{
if
(
nearBottom
&&
!
isRequesting
)
{
fetchMoreMemos
(
state
.
nextPageToken
);
fetchMoreMemos
(
nextPageToken
);
}
}
};
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
window
.
addEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
state
.
nextPageToken
,
state
.
isRequesting
]);
},
[
nextPageToken
,
isRequesting
]);
const
children
=
(
const
children
=
(
<
div
className=
"flex flex-col justify-start items-start w-full max-w-full"
>
<
div
className=
"flex flex-col justify-start items-start w-full max-w-full"
>
...
@@ -137,14 +144,18 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -137,14 +144,18 @@ const PagedMemoList = observer((props: Props) => {
prefixElement=
{
showMemoEditor
?
<
MemoEditor
className=
"mb-2"
cacheKey=
"home-memo-editor"
/>
:
undefined
}
prefixElement=
{
showMemoEditor
?
<
MemoEditor
className=
"mb-2"
cacheKey=
"home-memo-editor"
/>
:
undefined
}
listMode=
{
viewStore
.
state
.
layout
===
"LIST"
}
listMode=
{
viewStore
.
state
.
layout
===
"LIST"
}
/>
/>
{
state
.
isRequesting
&&
(
{
/* Loading indicator */
}
{
isRequesting
&&
(
<
div
className=
"w-full flex flex-row justify-center items-center my-4"
>
<
div
className=
"w-full flex flex-row justify-center items-center my-4"
>
<
LoaderIcon
className=
"animate-spin text-zinc-500"
/>
<
LoaderIcon
className=
"animate-spin text-zinc-500"
/>
</
div
>
</
div
>
)
}
)
}
{
!
state
.
isRequesting
&&
(
{
/* Empty state or back-to-top button */
}
{
!
isRequesting
&&
(
<>
<>
{
!
state
.
nextPageToken
&&
sortedMemoList
.
length
===
0
?
(
{
!
nextPageToken
&&
sortedMemoList
.
length
===
0
?
(
<
div
className=
"w-full mt-12 mb-8 flex flex-col justify-center items-center italic"
>
<
div
className=
"w-full mt-12 mb-8 flex flex-col justify-center items-center italic"
>
<
Empty
/>
<
Empty
/>
<
p
className=
"mt-2 text-gray-600 dark:text-gray-400"
>
{
t
(
"message.no-data"
)
}
</
p
>
<
p
className=
"mt-2 text-gray-600 dark:text-gray-400"
>
{
t
(
"message.no-data"
)
}
</
p
>
...
@@ -159,7 +170,6 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -159,7 +170,6 @@ const PagedMemoList = observer((props: Props) => {
</
div
>
</
div
>
);
);
// In case of md screen, we don't need pull to refresh.
if
(
md
)
{
if
(
md
)
{
return
children
;
return
children
;
}
}
...
@@ -186,25 +196,16 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -186,25 +196,16 @@ const PagedMemoList = observer((props: Props) => {
const
BackToTop
=
()
=>
{
const
BackToTop
=
()
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
[
isVisible
,
setIsVisible
]
=
useState
(
false
);
const
[
isVisible
,
setIsVisible
]
=
useState
(
false
);
const
[
shouldRender
,
setShouldRender
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
const
handleScroll
=
()
=>
{
const
handleScroll
=
()
=>
{
const
shouldBeVisible
=
window
.
scrollY
>
400
;
const
shouldShow
=
window
.
scrollY
>
400
;
if
(
shouldBeVisible
!==
isVisible
)
{
setIsVisible
(
shouldShow
);
if
(
shouldBeVisible
)
{
setShouldRender
(
true
);
setIsVisible
(
true
);
}
else
{
setShouldRender
(
false
);
setIsVisible
(
false
);
}
}
};
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
window
.
addEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
isVisible
]);
},
[]);
const
scrollToTop
=
()
=>
{
const
scrollToTop
=
()
=>
{
window
.
scrollTo
({
window
.
scrollTo
({
...
@@ -213,7 +214,8 @@ const BackToTop = () => {
...
@@ -213,7 +214,8 @@ const BackToTop = () => {
});
});
};
};
if
(
!
shouldRender
)
{
// Don't render if not visible
if
(
!
isVisible
)
{
return
null
;
return
null
;
}
}
...
...
web/src/components/VisibilityIcon.tsx
View file @
ea4e7a16
...
@@ -4,10 +4,11 @@ import { cn } from "@/utils";
...
@@ -4,10 +4,11 @@ import { cn } from "@/utils";
interface
Props
{
interface
Props
{
visibility
:
Visibility
;
visibility
:
Visibility
;
className
?:
string
;
}
}
const
VisibilityIcon
=
(
props
:
Props
)
=>
{
const
VisibilityIcon
=
(
props
:
Props
)
=>
{
const
{
visibility
}
=
props
;
const
{
className
,
visibility
}
=
props
;
let
VIcon
=
null
;
let
VIcon
=
null
;
if
(
visibility
===
Visibility
.
PRIVATE
)
{
if
(
visibility
===
Visibility
.
PRIVATE
)
{
...
@@ -21,7 +22,7 @@ const VisibilityIcon = (props: Props) => {
...
@@ -21,7 +22,7 @@ const VisibilityIcon = (props: Props) => {
return
null
;
return
null
;
}
}
return
<
VIcon
className=
{
cn
(
"w-4 h-auto text-gray-500 dark:text-gray-400"
)
}
/>;
return
<
VIcon
className=
{
cn
(
"w-4 h-auto text-gray-500 dark:text-gray-400"
,
className
)
}
/>;
};
};
export
default
VisibilityIcon
;
export
default
VisibilityIcon
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment