From c4bbb188eec50f7326ab312c2b459a37cb3d8887 Mon Sep 17 00:00:00 2001 From: vec Date: Sat, 13 Jan 2024 23:40:33 +0800 Subject: [PATCH] =?UTF-8?q?[improve]=20=E4=BC=98=E5=8C=96=E7=80=91?= =?UTF-8?q?=E5=B8=83=E6=B5=81=E5=B8=83=E5=B1=80=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/components/PhotoBox/index.scss | 11 +- front/src/components/PhotoBox/index.tsx | 58 ++- front/src/components/Waterfall/index.tsx | 459 +++++++++++++++++++---- 3 files changed, 410 insertions(+), 118 deletions(-) diff --git a/front/src/components/PhotoBox/index.scss b/front/src/components/PhotoBox/index.scss index df60af0..ef337ad 100644 --- a/front/src/components/PhotoBox/index.scss +++ b/front/src/components/PhotoBox/index.scss @@ -17,9 +17,7 @@ $avatar-size-compact: 36px; position: absolute; - &:not(:last-child) { - padding-bottom: var(--vertical-gutter); - } + padding-bottom: var(--vertical-gutter); } $horizontalPhotoShadowOffset: 3px; @@ -395,13 +393,6 @@ $horizontalPhotoShadowColor: rgba(0, 0, 0, 0.382); padding-left: 0px; width: 100%; - &:not(:last-child) { - // padding-bottom: 12px; - } - - &:first-child { - } - .bottom-area { .bottom-block { padding: calc((#{$avatar-size} - #{$avatar-size-compact}) / 2) 10px; diff --git a/front/src/components/PhotoBox/index.tsx b/front/src/components/PhotoBox/index.tsx index d54c79e..4bbc6e6 100644 --- a/front/src/components/PhotoBox/index.tsx +++ b/front/src/components/PhotoBox/index.tsx @@ -57,35 +57,8 @@ export type Props = { onClickCover(clickInfo: CoverClickEvent): void } -export const PhotoBoxDimension = forwardRef< DimensionUnknown, Props>((props, ref) => { - const _setRef = useCallback((val: DimensionUnknown) => { - if (typeof ref === 'function') { - ref(val) - } else if ((ref !== null) && (typeof ref === 'object')) { - ref.current = val - } - }, [ref]) - - const setRef = useCallback(({ width, height }: { - width: number | null; - height: number | null; - }) => { - if ((width !== null) && (height !== null)) { - const dim = [ width, height ] as const - _setRef(dim) - } else { - _setRef(null) - } - }, [_setRef]) - - const [measure_ref] = useMeasure(({ width, height }) => { - setRef({ width, height }) - }) - - return -}) -const PhotoBox = forwardRef((props, ref) => { +const PhotoBox = forwardRef<() => Dimension, Props>((props, ref) => { const { type, vertial_gutter, box_width, photo, hideMember, avatar, desc, style, vote_button_status } = props @@ -96,20 +69,31 @@ const PhotoBox = forwardRef((props, ref) => { const ratio = (photo.height / photo.width).toFixed(4) - const height = `calc((${box_width}px) * ${ratio})` - - const coverFrameStyle = useMemo(() => ({ - height, - background: thumb_loaded ? 'white' : '' - }), [thumb_loaded, height]) + const cover_frame_height = `calc((${box_width}px) * ${ratio})` const show_desc = Boolean(desc.trim().length) const show_bottom_block = !hideMember || show_desc const none_bottom_block = !show_bottom_block + const el_ref = useRef(null) + useEffect(() => { + if (el_ref.current) { + const el = el_ref.current + const getDim = () => { + const dim = el.getBoundingClientRect() + return [dim.width, dim.height] as const + } + if (typeof ref === 'function') { + ref(getDim) + } + } else { + console.warn('NONE :(', el_ref.current) + } + }, [ref]) + return (
((props, ref) => {
{ e.preventDefault() if (coverFrameEl.current) { @@ -157,7 +141,7 @@ const PhotoBox = forwardRef((props, ref) => {
-
+
diff --git a/front/src/components/Waterfall/index.tsx b/front/src/components/Waterfall/index.tsx index 6578605..39738bb 100644 --- a/front/src/components/Waterfall/index.tsx +++ b/front/src/components/Waterfall/index.tsx @@ -1,20 +1,22 @@ -import { FunctionComponent, memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { FunctionComponent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Memo, MemoGetter, MemoSetter, Signal, nextTick } from 'new-vait' import { Photo } from 'api/photo' import './index.scss' -import { Props as PhotoBoxProps, CoverClickEvent, Dimension, DimensionUnknown, PhotoBoxDimension, postDimesions } from 'components/PhotoBox' +import PhotoBox, { Props as PhotoBoxProps, CoverClickEvent, Dimension, DimensionUnknown, postDimesions } from 'components/PhotoBox' import useSafeState from 'hooks/useSafeState' +import { findListByProperty } from 'utils/common' +import { AppCriticalError } from 'App' type Pos = { top: string left: string zIndex: number } - -type PosMap = Record +type PhotoID = number +type PosMap = Record const Empty: FunctionComponent = memo(() => (
{ const { box_type, vertial_gutter, gallery_width } = layout_configure const [ box_width ] = calcTotalBoxWidth(layout_configure) - const { refFn, waterfall_height, pos_map } = useLayout({ + const { refFn, waterfall_height, pos_map, refresh_signal } = useLayout({ photos, box_width, layout_configure }) @@ -79,7 +81,7 @@ export default (props: Props) => { left: `calc(${pos.left})`, zIndex: `calc(${pos.zIndex})`, opacity: 1, - // transition: pos_map[photo.id] && 'left 382ms, top 382ms' + // transition: pos && 'left 382ms, top 382ms' } } else { return { opacity: 0 } @@ -104,10 +106,10 @@ export default (props: Props) => { > { photos.map(photo => ( - refFn(dim, String(photo.id), photo)} + ref={getDim => refFn(getDim, String(photo.id), photo)} handleClickVote={() => props.onClickVote(photo.id)} onClickCover={(click_info) => props.onClickCover(click_info, photo.id)} {...{ @@ -162,6 +164,302 @@ type DimessionInfo = { width: number } +type Columns = DimessionInfo[][] +type DimOperateResult = readonly[undefined | DimessionInfo, Columns] + +function countDim(cols: Columns) { + let total = 0 + for (let i = 0; i < cols.length; ++i) { + total = total + cols[i].length + } + return total +} + +function toHeightList(cols: Columns): ColumnsHeightList { + return cols.map(dim => computeColumnHeight(dim)) +} + +function popColumn(cols: Columns, select_col: number): readonly [DimessionInfo | undefined, Columns] { + return dropDim(cols, select_col, cols[select_col].length - 1) +} + +function whichMaxniumColumnSafe(cols: Columns): undefined | number { + if (countDim(cols) <= cols.length) { + return undefined + } else { + const top_dim_removed = cols.map(col => { + return col.slice(1, col.length) + }) + const h_list = top_dim_removed.map((col, idx) => { + if (col.length) { + return computeColumnHeight(cols[idx]) + } else { + return 0 + } + }) + const max_height = Math.max(...h_list) + const max_col = h_list.indexOf(max_height) + if (top_dim_removed[max_col].length === 0) { + return undefined + } else { + if (cols[max_col].length === 0) { + return undefined + } else { + return max_col + } + } + } +} + +function dropDim( + cols: Columns, + select_col: number, + select_idx: number, +): DimOperateResult { + let selected: DimessionInfo | undefined = undefined + + const droped = cols.map((col, col_idx) => { + if (select_col !== col_idx) { + return col + } else { + return col.filter((dim, idx) => { + if (select_idx !== idx) { + return true + } else { + selected = dim + return false + } + }) + } + }) + + return [ selected, droped ] as const +} + +function columnsPopSafe(cols: Columns): DimOperateResult { + if (countDim(cols) <= cols.length) { + return [undefined, cols] + } else { + const select_cols = cols.map(col => { + return col.slice(1, col.length) + }) + const h_list = select_cols.map((col, idx) => { + if (col.length) { + return computeColumnHeight(cols[idx]) + } else { + return 0 + } + }) + const max_height = Math.max(...h_list) + const max_idx = h_list.indexOf(max_height) + if (select_cols[max_idx].length === 0) { + return [undefined, cols] + } else { + return dropDim(cols, max_idx, cols[max_idx].length - 1) + } + } +} + +function isBalanced(cols: Columns) { + if (countDim(cols) <= cols.length) { + return true + } else if (toHeightList(cols).includes(0)) { + return false + } else { + const max_column = whichMaxniumColumnSafe(cols) + if (max_column === undefined) { + return true + } else { + const [ , poped_cols ] = popColumn(cols, max_column) + return max_column === whichMinimum(toHeightList(poped_cols)) + } + } +} + +function toDimList(cols: Columns) { + return cols.flat() +} + +function toDimListWithSorted(cols: Columns) { + return cols + .map( + col => col.map( + (dim, idx) => ({ + dim, + height: computeColumnHeight( col.slice(0, idx) ) + }) + ) + ) + .flat() + .sort( + (a, b) => (a.height < b.height) ? -1 : 1 + ) + .map(h => h.dim) +} + +function filterByIDList(dim_list: DimessionInfo[], id_list: Set) { + return dim_list.filter( + dim => id_list.has(dim.id) + ) +} + +function toIDList(dim_list: DimessionInfo[]) { + const exists = new Set() + for (const d of dim_list) { exists.add(d.id) } + return exists +} + +function appendDim(cols: Columns, dim: DimessionInfo) { + const height_list = cols.map(col => { + return computeColumnHeight(col) + }) + + const min_height_index = whichMinimum(height_list) + return cols.map((col, idx) => { + if (min_height_index === idx) { + return [...col, dim] + } else { + return col + } + }) +} + +function appendMultiDim(cols: Columns, dim_list: DimessionInfo[]) { + return dim_list.reduce((cols, dim) => { + return appendDim(cols, dim) + }, cols) +} + +function concatColumns(left: Columns, right: Columns): Columns { + return [...left, ...right] +} + +function addColumn(cols: Columns, col: DimessionInfo[]) { + return [...cols, col] +} + +function columnCount(cols: Columns) { + return cols.length +} + +function selectColumns(cols: Columns, from: number, to: number) { + return cols.slice(from, to) +} + +function createPlainColumns(col_count: number): Columns { + return Array.from(Array(col_count)).map(() => []) +} + +function extendColumns(col_count: number, cols: Columns) { + let new_cols = createPlainColumns(col_count).map((_, idx) => { + const col: DimessionInfo[] | undefined = cols[idx] + if (col) { + return col + } else { + return [] + } + }) + + let count = 0 + while (!isBalanced(new_cols)) { + ++count + const [ dim, droped_cols ] = columnsPopSafe(new_cols) + if (dim === undefined) { + throw Error('dim is undefined') + } else { + new_cols = appendDim(droped_cols, dim) + } + + if (count > 10000) { + AppCriticalError('count over 10000') + throw Error('count over 10000') + } + } + return new_cols +} + +function adjustColumns(target_column: number, cols: Columns): Columns { + const current_column = cols.length + if (target_column > current_column) { + const extended = extendColumns(target_column, cols) + return ( + concatColumns( + selectColumns(extended, 0, current_column), + appendMultiDim( + createPlainColumns(target_column - current_column), + filterByIDList( + toDimListWithSorted(cols), + toIDList( + toDimList( + selectColumns(extended, current_column, target_column) + ) + ) + ) + ) + ) + ) + } else if (target_column < current_column) { + return ( + appendMultiDim( + selectColumns(cols, 0, target_column), + toDimListWithSorted( + selectColumns(cols, target_column, current_column) + ) + ) + ) + } else { + return cols + } +} + +function updateColumnsKeepPosition(prev_cols: Columns, latest_cols: Columns): Columns { + const latest_list = toDimListWithSorted(latest_cols) + + const exists_list = new Set() + + const keep_pos_cols = prev_cols.reduce((left_cols, col) => { + return addColumn( + left_cols, + col + .filter(dim => { + const idx = findListByProperty(latest_list, 'id', dim.id) + if (idx !== -1) { + exists_list.add(dim.id) + return true + } else { + return false + } + }) + .map(dim => { + const idx = findListByProperty(latest_list, 'id', dim.id) + return latest_list[idx] + }) + ) + }, []) + + return appendMultiDim( + keep_pos_cols, + latest_list.filter(dim => { + return exists_list.has(dim.id) !== true + }) + ) +} + +function mergeColumns(prev_cols: Columns, latest_cols: Columns) { + if (columnCount(prev_cols) === 0) { + return latest_cols + } else { + return adjustColumns( + columnCount(latest_cols), + updateColumnsKeepPosition(prev_cols, latest_cols) + ) + } +} + +function computeWaterfallHeight(waterfall_columns: Columns) { + return Math.max(...toHeightList(waterfall_columns)) +} + function useLayout({ photos, box_width, @@ -174,12 +472,18 @@ function useLayout({ box_width: number photos: Photo[] }) { - const [ refFn, dim_map_changed_signal, getDimMap ] = useDimensionMap() - - const getWaterfallColumns = useCallback(() => { - const dim_map = getDimMap() + const [ refresh_signal ] = useState(Signal()) + const [ refFn, dim_map_changed_signal, getDimMap ] = useDimensionMap( + useCallback(() => { + refresh_signal.trigger() + }, [refresh_signal]) + ) - const init_columns: DimessionInfo[][] = Array.from(Array(column_count)).map(() => []) + const computeWaterfallColumns = useCallback(( + photos: Photo[], + column_count: number, + dim_map: DimensionMap + ) => { return photos.reduce((columns, photo) => { const height_list = columns.map(col => { return computeColumnHeight(col) @@ -191,7 +495,7 @@ function useLayout({ if (idx === min_height_index) { const dim = dim_map[photo.id] if (dim) { - const [ width, height ] = dim + const [ width, height ] = dim() return [...col, { id: photo.id, width, height }] } else { return col @@ -200,98 +504,111 @@ function useLayout({ return col } }) - }, init_columns) - }, [column_count, getDimMap, photos]) + }, createPlainColumns(column_count)) + }, []) const [pos_map, refreshPosMap] = useSafeState({}) - const calcPosMap = useCallback(() => { + const computePosMap = useCallback((waterfall_columns: Columns) => { const init_pos: PosMap = {} - return getWaterfallColumns().reduce((pos_info, column, x) => { + + return waterfall_columns.reduce((column_pos_init, column, x) => { const left = `(${box_width}px * ${x} + ${column_gutter}px * ${x})` + return column.reduce((pos_info, heightInfo, y) => { const h = computeColumnHeight(column.slice(0, y)) - const top = `${h}px` return { ...pos_info, - [heightInfo.id]: { top, left, zIndex: h } + [heightInfo.id]: { + top: `${h}px`, + left, + zIndex: h + } } - }, pos_info) + }, column_pos_init) }, init_pos) - }, [box_width, getWaterfallColumns, column_gutter]) + }, [box_width, column_gutter]) const [ waterfall_height, refreshWaterfallHeight ] = useSafeState(0) - const computeWaterfallHeight = useCallback(() => { - const height_list = getWaterfallColumns().map(col => { - return computeColumnHeight(col) - }) - return Math.max(...height_list) - }, [getWaterfallColumns]) + + const cacheID = (...vals: number[]) => vals.join('-') + + const columns_cache = useRef>() + + const prev_columns = useRef([]) + + const applyNewLayout = useCallback((new_cols: Columns) => { + prev_columns.current = new_cols + refreshPosMap( computePosMap(new_cols) ) + refreshWaterfallHeight( computeWaterfallHeight(new_cols) ) + refresh_signal.trigger() + }, [computePosMap, refreshPosMap, refreshWaterfallHeight, refresh_signal]) const refreshLayout = useCallback(() => { - const dim_map = getDimMap() - refreshPosMap(calcPosMap()) - refreshWaterfallHeight(computeWaterfallHeight()) - }, [calcPosMap, computeWaterfallHeight, getDimMap, refreshPosMap, refreshWaterfallHeight]) + if (columns_cache.current !== undefined) { + const cache_id = cacheID(box_width, column_count, column_gutter) + const cached = columns_cache.current.get(cache_id) + if (cached) { + applyNewLayout(cached) + } else { + const latest_columns = computeWaterfallColumns( + photos, column_count, getDimMap() + ) + const merged = mergeColumns(prev_columns.current, latest_columns) + columns_cache.current.set(cache_id, merged) + applyNewLayout(merged) + } + } else { + applyNewLayout( + mergeColumns( + prev_columns.current, + computeWaterfallColumns( + photos, column_count, getDimMap() + ) + ) + ) + } + }, [applyNewLayout, box_width, column_count, column_gutter, computeWaterfallColumns, getDimMap, photos]) useEffect(() => { - dim_map_changed_signal.receive(refreshLayout) - return () => dim_map_changed_signal.cancelReceive(refreshLayout) - }, [dim_map_changed_signal, refreshLayout]) - - useEffect(refreshLayout, [refreshLayout]) + if (columns_cache.current === undefined) { + columns_cache.current = new Map() + } + refreshLayout() + }, [refreshLayout]) return { refFn, - waterfall_height, pos_map + pos_map, + waterfall_height, + refresh_signal, } as const } -function useDimensionMap() { +type DimensionMap = Record Dimension> +function useDimensionMap(onDimChange: () => void) { const dim_map_changed_signal = useMemo(() => Signal(), []) const dim_map_ref = useRef( - Memo>({}) + Memo({}) ) const getDimMap = useCallback(() => { const [ getDimMap ] = dim_map_ref.current return getDimMap() }, []) - const setDimMap = useCallback>>((...args) => { + const setDimMap = useCallback Dimension>>>((...args) => { const [ ,setDimMap ] = dim_map_ref.current return setDimMap(...args) }, []) - const refFn = useCallback((dim: DimensionUnknown, id: string, photo: { width: number, height: number }) => { + const refFn = useCallback((getDim: null | (() => Dimension), id: string, photo: { width: number, height: number }) => { const dim_map = getDimMap() - if (dim !== null) { - const [ new_w, new_h ] = dim - if (dim_map[id]) { - const [ old_w, old_h ] = dim_map[id] - if ((old_w !== new_w) || (old_h !== new_h)) { - setDimMap({ - ...dim_map, - [String(id)]: postDimesions( - dim[0], dim[1], - photo.width, photo.height - ) - }) - dim_map_changed_signal.trigger() - } - } else { - setDimMap({ - ...dim_map, - [id]: postDimesions( - dim[0], dim[1], - photo.width, photo.height - ) - }) - dim_map_changed_signal.trigger() - } - } else { - const new_dim_map = { ...dim_map } - delete new_dim_map[String(id)] - setDimMap(new_dim_map) + + if (getDim !== null) { + setDimMap({ + ...dim_map, + [String(id)]: getDim + }) } - }, [dim_map_changed_signal, getDimMap, setDimMap]) + }, [getDimMap, setDimMap]) return [ refFn, dim_map_changed_signal, getDimMap ] as const }