feat(view):基本实现连续视图的功能。
This commit is contained in:
parent
848c8c01e7
commit
9437e45b8d
|
@ -14,7 +14,7 @@ tauri-build = { version = "1.2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
once_cell = "1.17.0"
|
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 = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4.23", features = ["serde"] }
|
chrono = { version = "0.4.23", features = ["serde"] }
|
||||||
|
|
|
@ -7,6 +7,7 @@ pub mod prelude {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用于持有应用实例,可存放不同的应用实例。
|
/// 用于持有应用实例,可存放不同的应用实例。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum AppHold<'a, R: Runtime> {
|
pub enum AppHold<'a, R: Runtime> {
|
||||||
Instance(&'a App<R>),
|
Instance(&'a App<R>),
|
||||||
Handle(&'a AppHandle<R>),
|
Handle(&'a AppHandle<R>),
|
||||||
|
|
|
@ -13,6 +13,22 @@
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
"all": false,
|
"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": {
|
"shell": {
|
||||||
"all": false,
|
"all": false,
|
||||||
"open": true
|
"open": true
|
||||||
|
@ -39,7 +55,7 @@
|
||||||
"targets": "all"
|
"targets": "all"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost"
|
||||||
},
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"active": false
|
"active": false
|
||||||
|
|
|
@ -1,6 +1,26 @@
|
||||||
import { Box } from '@mantine/core';
|
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 = () => {
|
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 { 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 { FC } from 'react';
|
||||||
import { useFileListStore } from '../states/files';
|
import { useFileListStore } from '../states/files';
|
||||||
|
|
||||||
export const FileList: FC = () => {
|
export const FileList: FC = () => {
|
||||||
const files = useFileListStore.use.files();
|
const files = useFileListStore.use.files();
|
||||||
console.log('[debug]files from store: ', files);
|
const activeFiles = useFileListStore.use.actives();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box w="100%" h="100%" pl={4} sx={{ flexGrow: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
<Box w="100%" h="100%" pl={4} sx={{ flexGrow: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
{pipe(
|
{pipe(
|
||||||
sort((fa, fb) => fa.sort - fb.sort),
|
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)}
|
)(files)}
|
||||||
{isEmpty(files) && (
|
{isEmpty(files) && (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
|
|
|
@ -16,7 +16,6 @@ export const FileToolbar: FC = () => {
|
||||||
multiple: false
|
multiple: false
|
||||||
});
|
});
|
||||||
const files = await invoke('scan_directory', { target: directory });
|
const files = await invoke('scan_directory', { target: directory });
|
||||||
console.log('[debug]file list: ', files);
|
|
||||||
storeFiles(files);
|
storeFiles(files);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[error]打开文件夹', 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 {
|
import {
|
||||||
IconArrowAutofitWidth,
|
IconArrowAutofitWidth,
|
||||||
IconLock,
|
IconLock,
|
||||||
|
@ -6,11 +14,12 @@ import {
|
||||||
IconZoomIn,
|
IconZoomIn,
|
||||||
IconZoomOut
|
IconZoomOut
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { FC } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { useZoomState } from '../states/zoom';
|
import { useZoomState } from '../states/zoom';
|
||||||
|
|
||||||
export const PicToolbar: FC = () => {
|
export const PicToolbar: FC = () => {
|
||||||
const { lock, autoFit, currentZoom, viewMode } = useZoomState();
|
const { lock, autoFit, currentZoom, viewMode, zoom } = useZoomState();
|
||||||
|
const zoomHandlers = useRef<NumberInputHandlers>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group w="100%" position="right" spacing={8} px={4} py={4}>
|
<Group w="100%" position="right" spacing={8} px={4} py={4}>
|
||||||
|
@ -25,7 +34,11 @@ export const PicToolbar: FC = () => {
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="缩小">
|
<Tooltip label="缩小">
|
||||||
<ActionIcon variant="subtle" color="grape">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="grape"
|
||||||
|
onClick={() => zoomHandlers.current?.decrement()}
|
||||||
|
>
|
||||||
<IconZoomOut stroke={1.5} size={24} />
|
<IconZoomOut stroke={1.5} size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -36,11 +49,17 @@ export const PicToolbar: FC = () => {
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
value={currentZoom}
|
value={currentZoom}
|
||||||
|
onChange={value => zoom(value)}
|
||||||
|
handlersRef={zoomHandlers}
|
||||||
styles={{ input: { width: rem(58), textAlign: 'center' } }}
|
styles={{ input: { width: rem(58), textAlign: 'center' } }}
|
||||||
rightSection={<IconPercentage stroke={1.5} size={16} />}
|
rightSection={<IconPercentage stroke={1.5} size={16} />}
|
||||||
/>
|
/>
|
||||||
<Tooltip label="放大">
|
<Tooltip label="放大">
|
||||||
<ActionIcon variant="subtle" color="grape">
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="grape"
|
||||||
|
onClick={() => zoomHandlers.current?.increment()}
|
||||||
|
>
|
||||||
<IconZoomIn stroke={1.5} size={24} />
|
<IconZoomIn stroke={1.5} size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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 {
|
interface FileListState {
|
||||||
files: FileItem[];
|
files: FileItem[];
|
||||||
|
actives: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileListActions = {
|
type FileListActions = {
|
||||||
updateFiles: SyncObjectCallback<Omit<FileItem, 'sort'>[]>;
|
updateFiles: SyncObjectCallback<Omit<FileItem, 'sort'>[]>;
|
||||||
|
updateActiveFiles: SyncObjectCallback<string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: FileListState = {
|
const initialState: FileListState = {
|
||||||
files: []
|
files: [],
|
||||||
|
actives: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFileListStore = createStoreHook<FileListState & FileListActions>(set => ({
|
export const useFileListStore = createStoreHook<FileListState & FileListActions>(set => ({
|
||||||
|
@ -24,5 +27,10 @@ export const useFileListStore = createStoreHook<FileListState & FileListActions>
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
updateActiveFiles(filenames) {
|
||||||
|
set(df => {
|
||||||
|
df.actives = filenames;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { SyncObjectCallback } from '../types';
|
||||||
import { createStoreHook } from '../utils/store_creator';
|
import { createStoreHook } from '../utils/store_creator';
|
||||||
|
|
||||||
interface ZoomState {
|
interface ZoomState {
|
||||||
|
@ -5,15 +6,32 @@ interface ZoomState {
|
||||||
autoFit: boolean;
|
autoFit: boolean;
|
||||||
currentZoom: number;
|
currentZoom: number;
|
||||||
viewMode: 'single' | 'double' | 'continuation';
|
viewMode: 'single' | 'double' | 'continuation';
|
||||||
|
viewHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ZoomActions = {
|
||||||
|
zoom: SyncObjectCallback<number>;
|
||||||
|
updateViewHeight: SyncObjectCallback<number>;
|
||||||
|
};
|
||||||
|
|
||||||
const initialState: ZoomState = {
|
const initialState: ZoomState = {
|
||||||
lock: true,
|
lock: true,
|
||||||
autoFit: false,
|
autoFit: false,
|
||||||
currentZoom: 100,
|
currentZoom: 100,
|
||||||
viewMode: 'continuation'
|
viewMode: 'continuation',
|
||||||
|
viewHeight: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useZoomState = createStoreHook<ZoomState>(set => ({
|
export const useZoomState = createStoreHook<ZoomState & ZoomActions>(set => ({
|
||||||
...initialState
|
...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));
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user