Skip to content

Commit

Permalink
Merge pull request #60 from js-tool-pack/image
Browse files Browse the repository at this point in the history
Image
  • Loading branch information
mengxinssfd authored Nov 22, 2023
2 parents 734eb4b + f76dbf4 commit a3d379c
Show file tree
Hide file tree
Showing 35 changed files with 930 additions and 8 deletions.
5 changes: 5 additions & 0 deletions internal/playground/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ export const baseRouter = [
name: 'input 输入框',
path: '/input',
},
{
element: getDemos(import.meta.glob('~/image/demo/*.tsx')),
name: 'image 图像',
path: '/image',
},
/*insert target*/
];

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@testing-library/user-event": "^14.4.3",
"@tool-pack/basic": "^0.1.2",
"@tool-pack/bom": "0.0.1-beta.0",
"@tool-pack/dom": "^0.0.13",
"@tool-pack/dom": "^0.2.0",
"@tool-pack/types": "^0.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jest": "^29.5.4",
Expand Down
139 changes: 139 additions & 0 deletions packages/components/src/image/Image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React, { useContext, useEffect, useState, useRef } from 'react';
import { ImagePreviewGroupContext } from '~/image/components';
import { useForceUpdate, getClasses } from '@pkg/shared';
import type { RequiredPart } from '@tool-pack/types';
import { getClassNames } from '@tool-pack/basic';
import type { ImageProps } from './image.types';
import { ImagePreview } from '@pkg/components';

const cls = getClasses('image', ['img', 'fallback'], ['preview']);
const defaultProps = {
fallback:
'data:image/png;base64,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',
preview: true,
} satisfies Partial<ImageProps>;

export const Image: React.FC<ImageProps> = React.forwardRef<
HTMLDivElement,
ImageProps
>((props, ref) => {
const {
imgAttrs = {},
attrs = {},
fallback,
preview,
height,
width,
lazy,
src,
fit,
alt,
} = props as RequiredPart<ImageProps, keyof typeof defaultProps>;

const context = useContext(ImagePreviewGroupContext);

useEffect(() => {
if (!context || !preview) return;
const url = src || imgAttrs.src || '';
if (context.includes(url)) return;
context.push(url);
return () => {
const index = context.indexOf(url);
if (index === -1) return;
context.splice(index, 1);
};
}, [preview]);

const [imgVisible, setImgVisible] = useState(true);
const [previewVisible, setPreviewVisible] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const forceUpdate = useForceUpdate();
const lazyingRef = useRef<boolean | void>(undefined);

if (lazyingRef.current === undefined && lazy) {
lazyingRef.current = true;
}

useEffect(() => {
const el = imgRef.current;
if (!lazy) return;
if (!el || !lazyingRef.current) return;

const handler = (entries: IntersectionObserverEntry[]) => {
if (entries[0]!.intersectionRatio <= 0) return;
lazyingRef.current = false;
forceUpdate();
};
const observer = new IntersectionObserver(handler);
observer.observe(el);
return () => observer.disconnect();
}, [lazy]);

const Img = (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions
<img
onClick={(e) => {
if (lazyingRef.current) return;
imgAttrs.onClick?.(e);
setPreviewVisible(true);
}}
style={{
...imgAttrs.style,
objectFit: fit || imgAttrs.style?.objectFit,
}}
src={lazyingRef.current ? fallback : src || imgAttrs.src}
height={height || imgAttrs.height}
width={width || imgAttrs.width}
alt={alt || imgAttrs.alt}
className={cls.__.img}
onError={hideImg}
onAbort={hideImg}
ref={imgRef}
key={1}
/>
);

const Fallback = (
<img
style={{
...imgAttrs.style,
objectFit: fit || imgAttrs.style?.objectFit,
}}
height={height || imgAttrs.height}
width={width || imgAttrs.width}
className={cls.__.fallback}
alt={alt || imgAttrs.alt}
src={fallback}
/>
);

return (
<div
{...attrs}
className={getClassNames(cls.root, attrs.className, {
[cls['--'].preview]: preview,
})}
ref={ref}
>
{imgVisible
? [
Img,
preview && previewVisible && (
<ImagePreview
onHide={() => setPreviewVisible(false)}
images={[src]}
key={2}
/>
),
]
: Fallback}
</div>
);

function hideImg(): void {
setImgVisible(false);
}
});

Image.defaultProps = defaultProps;
Image.displayName = 'Image';
43 changes: 43 additions & 0 deletions packages/components/src/image/__tests__/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render } from '@testing-library/react';
import { ImagePreviewGroup, Image } from '~/image';
import { testAttrs } from '~/testAttrs';

describe('Image', () => {
testAttrs(Image);

test('basic', () => {
render(<Image />);
expect(document.body).toMatchSnapshot();
});

describe('preview', () => {
test('disabled', () => {
render(<Image preview={false} />);
expect(document.body).toMatchSnapshot();
});

test('enabled', () => {
render(<Image />);
expect(document.body).toMatchSnapshot();
});
});

test('group', () => {
render(
<ImagePreviewGroup>
<Image src="https://test.com/a.png" />
<Image src="https://test.com/b.png" />
<Image src="https://test.com/c.png" />
</ImagePreviewGroup>,
);

fireEvent.click(document.querySelectorAll('.t-image__img')![1]!);

expect(
document.querySelector<HTMLImageElement>('.t-image-preview img')!.src,
).toBe('https://test.com/b.png');
expect(
document.querySelector('.t-image-preview__progress'),
).toHaveTextContent('2 / 3');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Image basic 1`] = `
<body>
<div>
<div
class="t-image t-image--preview"
>
<img
class="t-image__img"
/>
</div>
</div>
</body>
`;

exports[`Image preview disabled 1`] = `
<body>
<div>
<div
class="t-image"
>
<img
class="t-image__img"
/>
</div>
</div>
</body>
`;

exports[`Image preview enabled 1`] = `
<body>
<div>
<div
class="t-image t-image--preview"
>
<img
class="t-image__img"
/>
</div>
</div>
</body>
`;
14 changes: 14 additions & 0 deletions packages/components/src/image/components/Preview.Group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, { createContext } from 'react';

export const ImagePreviewGroupContext = createContext<string[] | null>(null);
export const ImagePreviewGroup: React.FC<{ children?: React.ReactNode }> = (
props,
) => {
return (
<ImagePreviewGroupContext.Provider value={[]}>
{props.children}
</ImagePreviewGroupContext.Provider>
);
};

ImagePreviewGroup.displayName = 'ImagePreviewGroup';
67 changes: 67 additions & 0 deletions packages/components/src/image/components/Preview.Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
RotateRight,
RotateLeft,
Download,
ZoomOut,
ZoomIn,
Right,
Close,
Reset,
FlipV,
FlipH,
Left,
} from '@pkg/icons';
import { getClasses } from '@pkg/shared';
import { Icon } from '~/icon';
import React from 'react';

export const ImagePreviewToolbarActions = [
'prev',
'next',
'rotate-left',
'rotate-right',
'zoom-out',
'zoom-in',
'reset',
'flip-horizontal',
'flip-vertically',
'download',
'close',
] as const;

const Actions = ImagePreviewToolbarActions;

const IconMap: Record<(typeof Actions)[number], React.ReactNode> = {
'rotate-right': <RotateRight />,
'rotate-left': <RotateLeft />,
'flip-horizontal': <FlipH />,
'flip-vertically': <FlipV />,
'zoom-out': <ZoomOut />,
download: <Download />,
'zoom-in': <ZoomIn />,
close: <Close />,
reset: <Reset />,
next: <Right />,
prev: <Left />,
};

const cls = getClasses('image-preview-toolbar', Actions, ['visible']);

interface Props {
onTrigger(action: (typeof Actions)[number]): void;
}

export const ImagePreviewToolbar: React.FC<Props> = (props) => {
return (
<div className={cls.root}>
{Actions.map((a) => {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div onClick={() => props.onTrigger(a)} className={cls.__[a]} key={a}>
<Icon size={22}>{IconMap[a]}</Icon>
</div>
);
})}
</div>
);
};
Loading

0 comments on commit a3d379c

Please sign in to comment.