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
435cc7b1
Commit
435cc7b1
authored
Oct 14, 2025
by
Steven
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement masonry layout with responsive columns and memo height tracking
parent
3245613a
Changes
13
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
505 additions
and
168 deletions
+505
-168
MasonryColumn.tsx
web/src/components/MasonryView/MasonryColumn.tsx
+43
-0
MasonryItem.tsx
web/src/components/MasonryView/MasonryItem.tsx
+45
-0
MasonryView.tsx
web/src/components/MasonryView/MasonryView.tsx
+40
-159
constants.ts
web/src/components/MasonryView/constants.ts
+11
-0
distributeItems.ts
web/src/components/MasonryView/distributeItems.ts
+94
-0
index.ts
web/src/components/MasonryView/index.ts
+24
-2
types.ts
web/src/components/MasonryView/types.ts
+81
-0
useMasonryLayout.ts
web/src/components/MasonryView/useMasonryLayout.ts
+150
-0
PagedMemoList.tsx
web/src/components/PagedMemoList/PagedMemoList.tsx
+2
-2
Archived.tsx
web/src/pages/Archived.tsx
+4
-1
Explore.tsx
web/src/pages/Explore.tsx
+4
-1
Home.tsx
web/src/pages/Home.tsx
+4
-1
UserProfile.tsx
web/src/pages/UserProfile.tsx
+3
-2
No files found.
web/src/components/MasonryView/MasonryColumn.tsx
0 → 100644
View file @
435cc7b1
import
{
MasonryItem
}
from
"./MasonryItem"
;
import
{
MasonryColumnProps
}
from
"./types"
;
/**
* Column component for masonry layout
*
* Responsibilities:
* - Render a single column in the masonry grid
* - Display prefix element in the first column (e.g., memo editor)
* - Render all assigned memo items in order
* - Pass render context to items (includes compact mode flag)
*/
export
function
MasonryColumn
({
memoIndices
,
memoList
,
renderer
,
renderContext
,
onHeightChange
,
isFirstColumn
,
prefixElement
,
prefixElementRef
,
}:
MasonryColumnProps
)
{
return
(
<
div
className=
"min-w-0 mx-auto w-full max-w-2xl"
>
{
/* Prefix element (like memo editor) goes in first column */
}
{
isFirstColumn
&&
prefixElement
&&
<
div
ref=
{
prefixElementRef
}
>
{
prefixElement
}
</
div
>
}
{
/* Render all memos assigned to this column */
}
{
memoIndices
?.
map
((
memoIndex
)
=>
{
const
memo
=
memoList
[
memoIndex
];
return
memo
?
(
<
MasonryItem
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
renderer=
{
renderer
}
renderContext=
{
renderContext
}
onHeightChange=
{
onHeightChange
}
/>
)
:
null
;
})
}
</
div
>
);
}
web/src/components/MasonryView/MasonryItem.tsx
0 → 100644
View file @
435cc7b1
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
MasonryItemProps
}
from
"./types"
;
/**
* Individual item wrapper component for masonry layout
*
* Responsibilities:
* - Render the memo using the provided renderer with context
* - Measure its own height using ResizeObserver
* - Report height changes to parent for redistribution
*
* The ResizeObserver automatically tracks dynamic content changes such as:
* - Images loading
* - Expanded/collapsed text
* - Any other content size changes
*/
export
function
MasonryItem
({
memo
,
renderer
,
renderContext
,
onHeightChange
}:
MasonryItemProps
)
{
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
!
itemRef
.
current
)
return
;
const
measureHeight
=
()
=>
{
if
(
itemRef
.
current
)
{
const
height
=
itemRef
.
current
.
offsetHeight
;
onHeightChange
(
memo
.
name
,
height
);
}
};
// Initial measurement
measureHeight
();
// Set up ResizeObserver to track dynamic content changes
resizeObserverRef
.
current
=
new
ResizeObserver
(
measureHeight
);
resizeObserverRef
.
current
.
observe
(
itemRef
.
current
);
// Cleanup on unmount
return
()
=>
{
resizeObserverRef
.
current
?.
disconnect
();
};
},
[
memo
.
name
,
onHeightChange
]);
return
<
div
ref=
{
itemRef
}
>
{
renderer
(
memo
,
renderContext
)
}
</
div
>;
}
web/src/components/MasonryView/MasonryView.tsx
View file @
435cc7b1
import
{
use
Callback
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
use
Memo
,
useRef
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
interface
Props
{
memoList
:
Memo
[];
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
prefixElement
?:
JSX
.
Element
;
listMode
?:
boolean
;
}
interface
MemoItemProps
{
memo
:
Memo
;
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
}
// Minimum width required to show more than one column
const
MINIMUM_MEMO_VIEWPORT_WIDTH
=
512
;
const
MemoItem
=
({
memo
,
renderer
,
onHeightChange
}:
MemoItemProps
)
=>
{
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
!
itemRef
.
current
)
return
;
const
measureHeight
=
()
=>
{
if
(
itemRef
.
current
)
{
const
height
=
itemRef
.
current
.
offsetHeight
;
onHeightChange
(
memo
.
name
,
height
);
}
};
measureHeight
();
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef
.
current
=
new
ResizeObserver
(
measureHeight
);
resizeObserverRef
.
current
.
observe
(
itemRef
.
current
);
return
()
=>
{
resizeObserverRef
.
current
?.
disconnect
();
};
},
[
memo
.
name
,
onHeightChange
]);
return
<
div
ref=
{
itemRef
}
>
{
renderer
(
memo
)
}
</
div
>;
};
import
{
MasonryColumn
}
from
"./MasonryColumn"
;
import
{
MasonryViewProps
,
MemoRenderContext
}
from
"./types"
;
import
{
useMasonryLayout
}
from
"./useMasonryLayout"
;
/**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
* Masonry layout component for displaying memos in a balanced, multi-column grid
*
* Features:
* - Responsive column count based on viewport width
* - Longest Processing-Time First (LPT) algorithm for optimal distribution
* - Pins editor and first memo to first column for stability
* - Debounced redistribution for performance
* - Automatic height tracking with ResizeObserver
* - Auto-enables compact mode in multi-column layouts
*
* The layout automatically adjusts to:
* - Window resizing
* - Content changes (images loading, text expansion)
* - Dynamic memo additions/removals
*
* Algorithm guarantee: Layout is never more than 34% longer than optimal (proven)
*/
const
distributeMemosToColumns
=
(
memos
:
Memo
[],
columns
:
number
,
itemHeights
:
Map
<
string
,
number
>
,
prefixElementHeight
:
number
=
0
,
):
{
distribution
:
number
[][];
columnHeights
:
number
[]
}
=>
{
// List mode: all memos in single column
if
(
columns
===
1
)
{
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
return
{
distribution
:
[
Array
.
from
({
length
:
memos
.
length
},
(
_
,
i
)
=>
i
)],
columnHeights
:
[
totalHeight
],
};
}
// Initialize columns and heights
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
// Add prefix element height to first column
if
(
prefixElementHeight
>
0
)
{
columnHeights
[
0
]
=
prefixElementHeight
;
}
// Distribute each memo to the shortest column
memos
.
forEach
((
memo
,
index
)
=>
{
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
// Find column with minimum height
const
shortestColumnIndex
=
columnHeights
.
indexOf
(
Math
.
min
(...
columnHeights
));
distribution
[
shortestColumnIndex
].
push
(
index
);
columnHeights
[
shortestColumnIndex
]
+=
height
;
});
return
{
distribution
,
columnHeights
};
};
const
MasonryView
=
(
props
:
Props
)
=>
{
const
[
columns
,
setColumns
]
=
useState
(
1
);
const
[
itemHeights
,
setItemHeights
]
=
useState
<
Map
<
string
,
number
>>
(
new
Map
());
const
[
distribution
,
setDistribution
]
=
useState
<
number
[][]
>
([[]]);
const
MasonryView
=
({
memoList
,
renderer
,
prefixElement
,
listMode
=
false
}:
MasonryViewProps
)
=>
{
const
containerRef
=
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
]);
const
{
columns
,
distribution
,
handleHeightChange
}
=
useMasonryLayout
(
memoList
,
listMode
,
containerRef
,
prefixElementRef
);
// Handle height changes from individual memo items
const
handleHeightChange
=
useCallback
(
(
memoName
:
string
,
height
:
number
)
=>
{
setItemHeights
((
prevHeights
)
=>
{
const
newItemHeights
=
new
Map
(
prevHeights
);
newItemHeights
.
set
(
memoName
,
height
);
// Recalculate distribution with new heights
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
:
newDistribution
}
=
distributeMemosToColumns
(
props
.
memoList
,
columns
,
newItemHeights
,
prefixHeight
);
setDistribution
(
newDistribution
);
return
newItemHeights
;
});
},
[
props
.
memoList
,
columns
],
// Create render context: automatically enable compact mode when multiple columns
const
renderContext
:
MemoRenderContext
=
useMemo
(
()
=>
({
compact
:
columns
>
1
,
columns
,
}),
[
columns
],
);
// Handle window resize and calculate new column count
useEffect
(()
=>
{
const
handleResize
=
()
=>
{
if
(
!
containerRef
.
current
)
return
;
const
newColumns
=
calculateColumns
();
if
(
newColumns
!==
columns
)
{
setColumns
(
newColumns
);
}
};
handleResize
();
window
.
addEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
},
[
calculateColumns
,
columns
]);
// Redistribute memos when columns, memo list, or heights change
useEffect
(()
=>
{
redistributeMemos
();
},
[
redistributeMemos
]);
return
(
<
div
ref=
{
containerRef
}
...
...
@@ -160,22 +46,17 @@ const MasonryView = (props: Props) => {
}
}
>
{
Array
.
from
({
length
:
columns
}).
map
((
_
,
columnIndex
)
=>
(
<
div
key=
{
columnIndex
}
className=
"min-w-0 mx-auto w-full max-w-2xl"
>
{
/* Prefix element (like memo editor) goes in first column */
}
{
props
.
prefixElement
&&
columnIndex
===
0
&&
<
div
ref=
{
prefixElementRef
}
>
{
props
.
prefixElement
}
</
div
>
}
{
distribution
[
columnIndex
]?.
map
((
memoIndex
)
=>
{
const
memo
=
props
.
memoList
[
memoIndex
];
return
memo
?
(
<
MemoItem
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
renderer=
{
props
.
renderer
}
onHeightChange=
{
handleHeightChange
}
/>
)
:
null
;
})
}
</
div
>
<
MasonryColumn
key=
{
columnIndex
}
memoIndices=
{
distribution
[
columnIndex
]
||
[]
}
memoList=
{
memoList
}
renderer=
{
renderer
}
renderContext=
{
renderContext
}
onHeightChange=
{
handleHeightChange
}
isFirstColumn=
{
columnIndex
===
0
}
prefixElement=
{
prefixElement
}
prefixElementRef=
{
prefixElementRef
}
/>
))
}
</
div
>
);
...
...
web/src/components/MasonryView/constants.ts
0 → 100644
View file @
435cc7b1
/**
* Minimum width required to show more than one column in masonry layout
* When viewport is narrower, layout falls back to single column
*/
export
const
MINIMUM_MEMO_VIEWPORT_WIDTH
=
512
;
/**
* Debounce delay for redistribution in milliseconds
* Balances responsiveness with performance by batching rapid height changes
*/
export
const
REDISTRIBUTION_DEBOUNCE_MS
=
100
;
web/src/components/MasonryView/distributeItems.ts
0 → 100644
View file @
435cc7b1
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
DistributionResult
}
from
"./types"
;
/**
* Distributes memos into columns using a height-aware greedy approach.
*
* Algorithm steps:
* 1. Pin editor and first memo to the first column (keep feed stable)
* 2. Place remaining memos into the currently shortest column
* 3. Break height ties by preferring the column with fewer items
*
* @param memos - Array of memos to distribute
* @param columns - Number of columns to distribute across
* @param itemHeights - Map of memo names to their measured heights
* @param prefixElementHeight - Height of prefix element (e.g., editor) in first column
* @returns Distribution result with memo indices per column and column heights
*/
export
function
distributeItemsToColumns
(
memos
:
Memo
[],
columns
:
number
,
itemHeights
:
Map
<
string
,
number
>
,
prefixElementHeight
:
number
=
0
,
):
DistributionResult
{
// Single column mode: all memos in one column
if
(
columns
===
1
)
{
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
return
{
distribution
:
[
Array
.
from
({
length
:
memos
.
length
},
(
_
,
i
)
=>
i
)],
columnHeights
:
[
totalHeight
],
};
}
// Initialize columns and their heights
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
const
columnCounts
:
number
[]
=
Array
(
columns
).
fill
(
0
);
// Add prefix element height to first column
if
(
prefixElementHeight
>
0
)
{
columnHeights
[
0
]
=
prefixElementHeight
;
}
let
startIndex
=
0
;
// Pin the first memo to the first column to keep top-of-feed stable
if
(
memos
.
length
>
0
)
{
const
firstMemoHeight
=
itemHeights
.
get
(
memos
[
0
].
name
)
||
0
;
distribution
[
0
].
push
(
0
);
columnHeights
[
0
]
+=
firstMemoHeight
;
columnCounts
[
0
]
+=
1
;
startIndex
=
1
;
}
for
(
let
i
=
startIndex
;
i
<
memos
.
length
;
i
++
)
{
const
memo
=
memos
[
i
];
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
// Find column with minimum height
const
shortestColumnIndex
=
findShortestColumnIndex
(
columnHeights
,
columnCounts
);
distribution
[
shortestColumnIndex
].
push
(
i
);
columnHeights
[
shortestColumnIndex
]
+=
height
;
columnCounts
[
shortestColumnIndex
]
+=
1
;
}
return
{
distribution
,
columnHeights
};
}
/**
* Finds the index of the column with the minimum height
* @param columnHeights - Array of column heights
* @param columnCounts - Array of items per column (for tie-breaking)
* @returns Index of the shortest column
*/
function
findShortestColumnIndex
(
columnHeights
:
number
[],
columnCounts
:
number
[]):
number
{
let
minIndex
=
0
;
let
minHeight
=
columnHeights
[
0
];
for
(
let
i
=
1
;
i
<
columnHeights
.
length
;
i
++
)
{
const
currentHeight
=
columnHeights
[
i
];
if
(
currentHeight
<
minHeight
)
{
minHeight
=
currentHeight
;
minIndex
=
i
;
continue
;
}
// Tie-breaker: prefer column with fewer items to avoid stacking
if
(
currentHeight
===
minHeight
&&
columnCounts
[
i
]
<
columnCounts
[
minIndex
])
{
minIndex
=
i
;
}
}
return
minIndex
;
}
web/src/components/MasonryView/index.ts
View file @
435cc7b1
import
MasonryView
from
"./MasonryView"
;
// Main component
export
{
default
}
from
"./MasonryView"
;
export
default
MasonryView
;
// Sub-components (exported for testing or advanced usage)
export
{
MasonryColumn
}
from
"./MasonryColumn"
;
export
{
MasonryItem
}
from
"./MasonryItem"
;
// Hooks
export
{
useMasonryLayout
}
from
"./useMasonryLayout"
;
// Utilities
export
{
distributeItemsToColumns
}
from
"./distributeItems"
;
// Types
export
type
{
MasonryViewProps
,
MasonryItemProps
,
MasonryColumnProps
,
DistributionResult
,
MemoWithHeight
,
MemoRenderContext
,
}
from
"./types"
;
// Constants
export
{
MINIMUM_MEMO_VIEWPORT_WIDTH
,
REDISTRIBUTION_DEBOUNCE_MS
}
from
"./constants"
;
web/src/components/MasonryView/types.ts
0 → 100644
View file @
435cc7b1
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
/**
* Render context passed to memo renderer
*/
export
interface
MemoRenderContext
{
/** Whether to render in compact mode (automatically enabled for multi-column layouts) */
compact
:
boolean
;
/** Current number of columns in the layout */
columns
:
number
;
}
/**
* Props for the main MasonryView component
*/
export
interface
MasonryViewProps
{
/** List of memos to display in masonry layout */
memoList
:
Memo
[];
/** Render function for each memo. Second parameter provides layout context. */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
/** Optional element to display at the top of the first column (e.g., memo editor) */
prefixElement
?:
JSX
.
Element
;
/** Force single column layout regardless of viewport width */
listMode
?:
boolean
;
}
/**
* Props for individual MasonryItem component
*/
export
interface
MasonryItemProps
{
/** The memo to render */
memo
:
Memo
;
/** Render function for the memo */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
/** Render context for the memo */
renderContext
:
MemoRenderContext
;
/** Callback when item height changes */
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
}
/**
* Props for MasonryColumn component
*/
export
interface
MasonryColumnProps
{
/** Indices of memos in this column */
memoIndices
:
number
[];
/** Full list of memos */
memoList
:
Memo
[];
/** Render function for each memo */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
/** Render context for memos */
renderContext
:
MemoRenderContext
;
/** Callback when item height changes */
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
/** Whether this is the first column (for prefix element) */
isFirstColumn
:
boolean
;
/** Optional prefix element (only rendered in first column) */
prefixElement
?:
JSX
.
Element
;
/** Ref for prefix element height measurement */
prefixElementRef
?:
React
.
RefObject
<
HTMLDivElement
>
;
}
/**
* Result of the distribution algorithm
*/
export
interface
DistributionResult
{
/** Array of arrays, where each inner array contains memo indices for that column */
distribution
:
number
[][];
/** Height of each column after distribution */
columnHeights
:
number
[];
}
/**
* Memo item with measured height
*/
export
interface
MemoWithHeight
{
/** Index of the memo in the original list */
index
:
number
;
/** Measured height in pixels */
height
:
number
;
}
web/src/components/MasonryView/useMasonryLayout.ts
0 → 100644
View file @
435cc7b1
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
MINIMUM_MEMO_VIEWPORT_WIDTH
,
REDISTRIBUTION_DEBOUNCE_MS
}
from
"./constants"
;
import
{
distributeItemsToColumns
}
from
"./distributeItems"
;
/**
* Custom hook for managing masonry layout state and logic
*
* Responsibilities:
* - Calculate optimal number of columns based on viewport width
* - Track item heights and trigger redistribution
* - Debounce redistribution to prevent excessive reflows
* - Handle window resize events
*
* @param memoList - Array of memos to layout
* @param listMode - Force single column mode
* @param containerRef - Reference to the container element
* @param prefixElementRef - Reference to the prefix element
* @returns Layout state and handlers
*/
export
function
useMasonryLayout
(
memoList
:
Memo
[],
listMode
:
boolean
,
containerRef
:
React
.
RefObject
<
HTMLDivElement
>
,
prefixElementRef
:
React
.
RefObject
<
HTMLDivElement
>
,
)
{
const
[
columns
,
setColumns
]
=
useState
(
1
);
const
[
itemHeights
,
setItemHeights
]
=
useState
<
Map
<
string
,
number
>>
(
new
Map
());
const
[
distribution
,
setDistribution
]
=
useState
<
number
[][]
>
([[]]);
const
redistributionTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
const
itemHeightsRef
=
useRef
<
Map
<
string
,
number
>>
(
itemHeights
);
// Keep ref in sync with state
useEffect
(()
=>
{
itemHeightsRef
.
current
=
itemHeights
;
},
[
itemHeights
]);
/**
* Calculate optimal number of columns based on container width
* Uses a scale factor to determine column count
*/
const
calculateColumns
=
useCallback
(()
=>
{
if
(
!
containerRef
.
current
||
listMode
)
return
1
;
const
containerWidth
=
containerRef
.
current
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
return
scale
>=
2
?
Math
.
round
(
scale
)
:
1
;
},
[
containerRef
,
listMode
]);
/**
* Recalculate memo distribution when layout changes
*/
const
redistributeMemos
=
useCallback
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
setDistribution
(()
=>
{
const
{
distribution
:
newDistribution
}
=
distributeItemsToColumns
(
memoList
,
columns
,
itemHeightsRef
.
current
,
prefixHeight
);
return
newDistribution
;
});
},
[
memoList
,
columns
,
prefixElementRef
]);
/**
* Debounced redistribution to batch multiple height changes and prevent excessive reflows
*/
const
debouncedRedistribute
=
useCallback
(
(
newItemHeights
:
Map
<
string
,
number
>
)
=>
{
// Clear any pending redistribution
if
(
redistributionTimeoutRef
.
current
)
{
clearTimeout
(
redistributionTimeoutRef
.
current
);
}
// Schedule new redistribution after debounce delay
redistributionTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
setDistribution
(()
=>
{
const
{
distribution
:
newDistribution
}
=
distributeItemsToColumns
(
memoList
,
columns
,
newItemHeights
,
prefixHeight
);
return
newDistribution
;
});
},
REDISTRIBUTION_DEBOUNCE_MS
);
},
[
memoList
,
columns
,
prefixElementRef
],
);
/**
* Handle height changes from individual memo items
*/
const
handleHeightChange
=
useCallback
(
(
memoName
:
string
,
height
:
number
)
=>
{
setItemHeights
((
prevHeights
)
=>
{
const
newItemHeights
=
new
Map
(
prevHeights
);
const
previousHeight
=
prevHeights
.
get
(
memoName
);
// Skip if height hasn't changed (avoid unnecessary updates)
if
(
previousHeight
===
height
)
{
return
prevHeights
;
}
newItemHeights
.
set
(
memoName
,
height
);
// Use debounced redistribution to batch updates
debouncedRedistribute
(
newItemHeights
);
return
newItemHeights
;
});
},
[
debouncedRedistribute
],
);
/**
* Handle window resize and calculate new column count
*/
useEffect
(()
=>
{
const
handleResize
=
()
=>
{
if
(
!
containerRef
.
current
)
return
;
const
newColumns
=
calculateColumns
();
if
(
newColumns
!==
columns
)
{
setColumns
(
newColumns
);
}
};
handleResize
();
window
.
addEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
},
[
calculateColumns
,
columns
,
containerRef
]);
/**
* Redistribute memos when columns or memo list change
*/
useEffect
(()
=>
{
redistributeMemos
();
},
[
columns
,
memoList
,
redistributeMemos
]);
/**
* Cleanup timeout on unmount
*/
useEffect
(()
=>
{
return
()
=>
{
if
(
redistributionTimeoutRef
.
current
)
{
clearTimeout
(
redistributionTimeoutRef
.
current
);
}
};
},
[]);
return
{
columns
,
distribution
,
handleHeightChange
,
};
}
web/src/components/PagedMemoList/PagedMemoList.tsx
View file @
435cc7b1
...
...
@@ -12,11 +12,11 @@ import { State } from "@/types/proto/api/v1/common";
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
Empty
from
"../Empty"
;
import
MasonryView
from
"../MasonryView"
;
import
MasonryView
,
{
MemoRenderContext
}
from
"../MasonryView"
;
import
MemoEditor
from
"../MemoEditor"
;
interface
Props
{
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
listSort
?:
(
list
:
Memo
[])
=>
Memo
[];
state
?:
State
;
orderBy
?:
string
;
...
...
web/src/pages/Archived.tsx
View file @
435cc7b1
import
dayjs
from
"dayjs"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useMemo
}
from
"react"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
MemoView
from
"@/components/MemoView"
;
import
PagedMemoList
from
"@/components/PagedMemoList"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
...
...
@@ -27,7 +28,9 @@ const Archived = observer(() => {
return
(
<
PagedMemoList
renderer=
{
(
memo
:
Memo
)
=>
<
MemoView
key=
{
`${memo.name}-${memo.updateTime}`
}
memo=
{
memo
}
showVisibility
compact
/>
}
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.updateTime}`
}
memo=
{
memo
}
showVisibility
compact=
{
context
?.
compact
}
/>
)
}
listSort=
{
(
memos
:
Memo
[])
=>
memos
.
filter
((
memo
)
=>
memo
.
state
===
State
.
ARCHIVED
)
...
...
web/src/pages/Explore.tsx
View file @
435cc7b1
import
dayjs
from
"dayjs"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
MemoView
from
"@/components/MemoView"
;
import
MobileHeader
from
"@/components/MobileHeader"
;
import
PagedMemoList
from
"@/components/PagedMemoList"
;
...
...
@@ -16,7 +17,9 @@ const Explore = observer(() => {
{
!
md
&&
<
MobileHeader
/>
}
<
div
className=
"w-full px-4 sm:px-6"
>
<
PagedMemoList
renderer=
{
(
memo
:
Memo
)
=>
<
MemoView
key=
{
`${memo.name}-${memo.updateTime}`
}
memo=
{
memo
}
showCreator
showVisibility
compact
/>
}
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.updateTime}`
}
memo=
{
memo
}
showCreator
showVisibility
compact=
{
context
?.
compact
}
/>
)
}
listSort=
{
(
memos
:
Memo
[])
=>
memos
.
filter
((
memo
)
=>
memo
.
state
===
State
.
NORMAL
)
...
...
web/src/pages/Home.tsx
View file @
435cc7b1
import
dayjs
from
"dayjs"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useMemo
}
from
"react"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
MemoView
from
"@/components/MemoView"
;
import
PagedMemoList
from
"@/components/PagedMemoList"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
...
...
@@ -56,7 +57,9 @@ const Home = observer(() => {
return
(
<
div
className=
"w-full min-h-full bg-background text-foreground"
>
<
PagedMemoList
renderer=
{
(
memo
:
Memo
)
=>
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact
/>
}
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact=
{
context
?.
compact
}
/>
)
}
listSort=
{
(
memos
:
Memo
[])
=>
memos
.
filter
((
memo
)
=>
memo
.
state
===
State
.
NORMAL
)
...
...
web/src/pages/UserProfile.tsx
View file @
435cc7b1
...
...
@@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
toast
}
from
"react-hot-toast"
;
import
{
useParams
}
from
"react-router-dom"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
MemoView
from
"@/components/MemoView"
;
import
PagedMemoList
from
"@/components/PagedMemoList"
;
import
UserAvatar
from
"@/components/UserAvatar"
;
...
...
@@ -89,8 +90,8 @@ const UserProfile = observer(() => {
</
div
>
</
div
>
<
PagedMemoList
renderer=
{
(
memo
:
Memo
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact
/>
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact
=
{
context
?.
compact
}
/>
)
}
listSort=
{
(
memos
:
Memo
[])
=>
memos
...
...
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