feat(view):基本实现连续视图的功能。
This commit is contained in:
		| @@ -14,7 +14,7 @@ tauri-build = { version = "1.2", features = [] } | ||||
|  | ||||
| [dependencies] | ||||
| once_cell = "1.17.0" | ||||
| tauri = { version = "1.2", features = ["dialog-open", "shell-open"] } | ||||
| tauri = { version = "1.2", features = ["dialog-open", "fs-read-file", "protocol-all", "shell-open"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| chrono = { version = "0.4.23", features = ["serde"] } | ||||
|   | ||||
| @@ -7,6 +7,7 @@ pub mod prelude { | ||||
| } | ||||
|  | ||||
| /// 用于持有应用实例,可存放不同的应用实例。 | ||||
| #[allow(dead_code)] | ||||
| pub enum AppHold<'a, R: Runtime> { | ||||
|     Instance(&'a App<R>), | ||||
|     Handle(&'a AppHandle<R>), | ||||
|   | ||||
| @@ -13,6 +13,22 @@ | ||||
|   "tauri": { | ||||
|     "allowlist": { | ||||
|       "all": false, | ||||
|       "fs": { | ||||
|         "all": false, | ||||
|         "copyFile": false, | ||||
|         "createDir": false, | ||||
|         "exists": false, | ||||
|         "readDir": false, | ||||
|         "readFile": true, | ||||
|         "removeDir": false, | ||||
|         "removeFile": false, | ||||
|         "renameFile": false, | ||||
|         "scope": [], | ||||
|         "writeFile": false | ||||
|       }, | ||||
|       "protocol": { | ||||
|         "all": true | ||||
|       }, | ||||
|       "shell": { | ||||
|         "all": false, | ||||
|         "open": true | ||||
| @@ -39,7 +55,7 @@ | ||||
|       "targets": "all" | ||||
|     }, | ||||
|     "security": { | ||||
|       "csp": null | ||||
|       "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost" | ||||
|     }, | ||||
|     "updater": { | ||||
|       "active": false | ||||
|   | ||||
| @@ -1,6 +1,26 @@ | ||||
| import { Box } from '@mantine/core'; | ||||
| import { FC } from 'react'; | ||||
| import { equals } from 'ramda'; | ||||
| import { FC, useLayoutEffect } from 'react'; | ||||
| import { useMeasure } from 'react-use'; | ||||
| import { useZoomState } from '../states/zoom'; | ||||
| import { ContinuationView } from './ContinuationView'; | ||||
| import { DoubleView } from './DoubleView'; | ||||
| import { SingleView } from './SingleView'; | ||||
|  | ||||
| export const ComicView: FC = () => { | ||||
|   return <Box w="100%" h="100%" sx={{ overflow: 'hidden' }}></Box>; | ||||
|   const viewMode = useZoomState.use.viewMode(); | ||||
|   const updateViewHeight = useZoomState.use.updateViewHeight(); | ||||
|   const [containerRef, { height }] = useMeasure(); | ||||
|  | ||||
|   useLayoutEffect(() => { | ||||
|     updateViewHeight(height); | ||||
|   }, [height]); | ||||
|  | ||||
|   return ( | ||||
|     <Box w="100%" h="100%" sx={{ overflow: 'hidden' }} ref={containerRef}> | ||||
|       {equals(viewMode, 'single') && <SingleView />} | ||||
|       {equals(viewMode, 'double') && <DoubleView />} | ||||
|       {equals(viewMode, 'continuation') && <ContinuationView />} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										64
									
								
								src/components/ContinuationView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/components/ContinuationView.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { Box, Stack } from '@mantine/core'; | ||||
| import { useVirtualizer } from '@tanstack/react-virtual'; | ||||
| import { convertFileSrc } from '@tauri-apps/api/tauri'; | ||||
| import { filter, isEmpty, length, map, pluck } from 'ramda'; | ||||
| import { FC, useLayoutEffect, useMemo, useRef } from 'react'; | ||||
| import { useFileListStore } from '../states/files'; | ||||
| import { useZoomState } from '../states/zoom'; | ||||
| import { withinRange } from '../utils/offset_func'; | ||||
|  | ||||
| export const ContinuationView: FC = () => { | ||||
|   const files = useFileListStore.use.files(); | ||||
|   const zoom = useZoomState.use.currentZoom(); | ||||
|   const viewHeight = useZoomState.use.viewHeight(); | ||||
|   const updateActives = useFileListStore.use.updateActiveFiles(); | ||||
|   const fileCount = useMemo(() => length(files), [files]); | ||||
|   const parentRef = useRef(); | ||||
|   const virtualizer = useVirtualizer({ | ||||
|     count: fileCount, | ||||
|     getScrollElement: () => parentRef.current, | ||||
|     estimateSize: () => zoom * 10 | ||||
|   }); | ||||
|   const items = virtualizer.getVirtualItems(); | ||||
|  | ||||
|   useLayoutEffect(() => { | ||||
|     let rangeStart = virtualizer.scrollOffset; | ||||
|     let rangeEnd = virtualizer.scrollOffset + viewHeight; | ||||
|     let onShowItems = pluck( | ||||
|       'index', | ||||
|       filter(item => withinRange(item.start, item.end, rangeStart, rangeEnd), items) | ||||
|     ); | ||||
|     updateActives(map(i => files[i].filename, onShowItems)); | ||||
|   }, [virtualizer.scrollOffset, viewHeight, items]); | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ overflow: 'auto', contain: 'strict', height: '100%' }} ref={parentRef}> | ||||
|       {!isEmpty(files) && ( | ||||
|         <Box pos="relative" w="100%" h={virtualizer.getTotalSize()}> | ||||
|           <Stack | ||||
|             pos="absolute" | ||||
|             top={0} | ||||
|             left={0} | ||||
|             w="100%" | ||||
|             justify="start" | ||||
|             align="center" | ||||
|             spacing={0} | ||||
|             style={{ | ||||
|               transform: `translateY(${items[0].start}px)` | ||||
|             }} | ||||
|           > | ||||
|             {items.map(row => ( | ||||
|               <img | ||||
|                 key={files[row.index].filename} | ||||
|                 src={convertFileSrc(files[row.index].path)} | ||||
|                 ref={virtualizer.measureElement} | ||||
|                 data-index={row.index} | ||||
|                 style={{ width: `${zoom}%` }} | ||||
|               /> | ||||
|             ))} | ||||
|           </Stack> | ||||
|         </Box> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										5
									
								
								src/components/DoubleView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/DoubleView.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { FC } from 'react'; | ||||
|  | ||||
| export const DoubleView: FC = () => { | ||||
|   return <div></div>; | ||||
| }; | ||||
| @@ -1,17 +1,26 @@ | ||||
| import { Box, Center, Text } from '@mantine/core'; | ||||
| import { isEmpty, map, pipe, sort } from 'ramda'; | ||||
| import { includes, isEmpty, map, pipe, sort } from 'ramda'; | ||||
| import { FC } from 'react'; | ||||
| import { useFileListStore } from '../states/files'; | ||||
|  | ||||
| export const FileList: FC = () => { | ||||
|   const files = useFileListStore.use.files(); | ||||
|   console.log('[debug]files from store: ', files); | ||||
|   const activeFiles = useFileListStore.use.actives(); | ||||
|  | ||||
|   return ( | ||||
|     <Box w="100%" h="100%" pl={4} sx={{ flexGrow: 1, overflowY: 'auto', overflowX: 'hidden' }}> | ||||
|       {pipe( | ||||
|         sort((fa, fb) => fa.sort - fb.sort), | ||||
|         map(item => <div key={item.filename}>{item.filename}</div>) | ||||
|         map(item => ( | ||||
|           <Box | ||||
|             bg={includes(item.filename, activeFiles) && 'grape'} | ||||
|             key={item.filename} | ||||
|             px={4} | ||||
|             py={2} | ||||
|           > | ||||
|             {item.filename} | ||||
|           </Box> | ||||
|         )) | ||||
|       )(files)} | ||||
|       {isEmpty(files) && ( | ||||
|         <Center h="100%"> | ||||
|   | ||||
| @@ -16,7 +16,6 @@ export const FileToolbar: FC = () => { | ||||
|         multiple: false | ||||
|       }); | ||||
|       const files = await invoke('scan_directory', { target: directory }); | ||||
|       console.log('[debug]file list: ', files); | ||||
|       storeFiles(files); | ||||
|     } catch (e) { | ||||
|       console.error('[error]打开文件夹', e); | ||||
|   | ||||
| @@ -1,4 +1,12 @@ | ||||
| import { ActionIcon, Group, NumberInput, rem, SegmentedControl, Tooltip } from '@mantine/core'; | ||||
| import { | ||||
|   ActionIcon, | ||||
|   Group, | ||||
|   NumberInput, | ||||
|   NumberInputHandlers, | ||||
|   rem, | ||||
|   SegmentedControl, | ||||
|   Tooltip | ||||
| } from '@mantine/core'; | ||||
| import { | ||||
|   IconArrowAutofitWidth, | ||||
|   IconLock, | ||||
| @@ -6,11 +14,12 @@ import { | ||||
|   IconZoomIn, | ||||
|   IconZoomOut | ||||
| } from '@tabler/icons-react'; | ||||
| import { FC } from 'react'; | ||||
| import { FC, useRef } from 'react'; | ||||
| import { useZoomState } from '../states/zoom'; | ||||
|  | ||||
| export const PicToolbar: FC = () => { | ||||
|   const { lock, autoFit, currentZoom, viewMode } = useZoomState(); | ||||
|   const { lock, autoFit, currentZoom, viewMode, zoom } = useZoomState(); | ||||
|   const zoomHandlers = useRef<NumberInputHandlers>(); | ||||
|  | ||||
|   return ( | ||||
|     <Group w="100%" position="right" spacing={8} px={4} py={4}> | ||||
| @@ -25,7 +34,11 @@ export const PicToolbar: FC = () => { | ||||
|         </ActionIcon> | ||||
|       </Tooltip> | ||||
|       <Tooltip label="缩小"> | ||||
|         <ActionIcon variant="subtle" color="grape"> | ||||
|         <ActionIcon | ||||
|           variant="subtle" | ||||
|           color="grape" | ||||
|           onClick={() => zoomHandlers.current?.decrement()} | ||||
|         > | ||||
|           <IconZoomOut stroke={1.5} size={24} /> | ||||
|         </ActionIcon> | ||||
|       </Tooltip> | ||||
| @@ -36,11 +49,17 @@ export const PicToolbar: FC = () => { | ||||
|         max={100} | ||||
|         step={5} | ||||
|         value={currentZoom} | ||||
|         onChange={value => zoom(value)} | ||||
|         handlersRef={zoomHandlers} | ||||
|         styles={{ input: { width: rem(58), textAlign: 'center' } }} | ||||
|         rightSection={<IconPercentage stroke={1.5} size={16} />} | ||||
|       /> | ||||
|       <Tooltip label="放大"> | ||||
|         <ActionIcon variant="subtle" color="grape"> | ||||
|         <ActionIcon | ||||
|           variant="subtle" | ||||
|           color="grape" | ||||
|           onClick={() => zoomHandlers.current?.increment()} | ||||
|         > | ||||
|           <IconZoomIn stroke={1.5} size={24} /> | ||||
|         </ActionIcon> | ||||
|       </Tooltip> | ||||
|   | ||||
							
								
								
									
										5
									
								
								src/components/SingleView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/SingleView.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import { FC } from 'react'; | ||||
|  | ||||
| export const SingleView: FC = () => { | ||||
|   return <div></div>; | ||||
| }; | ||||
| @@ -5,14 +5,17 @@ import { createStoreHook } from '../utils/store_creator'; | ||||
|  | ||||
| interface FileListState { | ||||
|   files: FileItem[]; | ||||
|   actives: string[]; | ||||
| } | ||||
|  | ||||
| type FileListActions = { | ||||
|   updateFiles: SyncObjectCallback<Omit<FileItem, 'sort'>[]>; | ||||
|   updateActiveFiles: SyncObjectCallback<string[]>; | ||||
| }; | ||||
|  | ||||
| const initialState: FileListState = { | ||||
|   files: [] | ||||
|   files: [], | ||||
|   actives: [] | ||||
| }; | ||||
|  | ||||
| export const useFileListStore = createStoreHook<FileListState & FileListActions>(set => ({ | ||||
| @@ -24,5 +27,10 @@ export const useFileListStore = createStoreHook<FileListState & FileListActions> | ||||
|         files | ||||
|       ); | ||||
|     }); | ||||
|   }, | ||||
|   updateActiveFiles(filenames) { | ||||
|     set(df => { | ||||
|       df.actives = filenames; | ||||
|     }); | ||||
|   } | ||||
| })); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { SyncObjectCallback } from '../types'; | ||||
| import { createStoreHook } from '../utils/store_creator'; | ||||
|  | ||||
| interface ZoomState { | ||||
| @@ -5,15 +6,32 @@ interface ZoomState { | ||||
|   autoFit: boolean; | ||||
|   currentZoom: number; | ||||
|   viewMode: 'single' | 'double' | 'continuation'; | ||||
|   viewHeight: number; | ||||
| } | ||||
|  | ||||
| type ZoomActions = { | ||||
|   zoom: SyncObjectCallback<number>; | ||||
|   updateViewHeight: SyncObjectCallback<number>; | ||||
| }; | ||||
|  | ||||
| const initialState: ZoomState = { | ||||
|   lock: true, | ||||
|   autoFit: false, | ||||
|   currentZoom: 100, | ||||
|   viewMode: 'continuation' | ||||
|   viewMode: 'continuation', | ||||
|   viewHeight: 0 | ||||
| }; | ||||
|  | ||||
| export const useZoomState = createStoreHook<ZoomState>(set => ({ | ||||
|   ...initialState | ||||
| export const useZoomState = createStoreHook<ZoomState & ZoomActions>(set => ({ | ||||
|   ...initialState, | ||||
|   zoom(ratio) { | ||||
|     set(df => { | ||||
|       df.currentZoom = ratio; | ||||
|     }); | ||||
|   }, | ||||
|   updateViewHeight(height) { | ||||
|     set(df => { | ||||
|       df.viewHeight = height; | ||||
|     }); | ||||
|   } | ||||
| })); | ||||
|   | ||||
							
								
								
									
										10
									
								
								src/utils/offset_func.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/utils/offset_func.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import { and, gt, lt } from 'ramda'; | ||||
|  | ||||
| export function withinRange( | ||||
|   itemStart: number, | ||||
|   itemEnd: number, | ||||
|   offsetStart: number, | ||||
|   offsetEnd: number | ||||
| ): boolean { | ||||
|   return and(lt(itemStart, offsetEnd), gt(itemEnd, offsetStart)); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user