mirror of
https://github.com/haiwen/seahub.git
synced 2025-08-09 10:57:27 +00:00
Image zoom rotate (#7425)
* [image file view] added a zoomer(enable users to zoom in/out the image) * [image file view] added 'rotate'(rotate the image in counter-clockwise direction and save it) * [image file view] fixup * [image file view] keep the 'prev/next' icons displayed on top of the image which is zoomed in * [image file view] improved the display of the 'image saved' tip * [image file view] don't offer 'zoom in/out' & 'rotate' for images with errors * [image file view] don't offer 'rotate' for PSD & HEIC files * [image file view] enable HEIC files to be viewed online
This commit is contained in:
parent
d65e86731e
commit
3d7b8b3a6b
14
frontend/src/assets/icons/rotate.svg
Normal file
14
frontend/src/assets/icons/rotate.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>icon-rotate</title>
|
||||||
|
<g id="seafile" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="icon-rotate" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)translate(-0, 0)">
|
||||||
|
<rect id="image2" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)" x="0" y="0" width="16" height="16"></rect>
|
||||||
|
<path d="M13.8526316,16 L4.54736842,16 C3.36370614,16 2.4,15.057073 2.4,13.9003426 L2.4,6.89965737 C2.4,5.74109969 3.36370614,4.8 4.54736842,4.8 L13.8526316,4.8 C15.0362938,4.8 16,5.74292707 16,6.89965737 L16,13.9003426 C16,15.057073 15.0362938,16 13.8526316,16 Z M4.69333334,6.4 C4.31055555,6.4 4,6.69932565 4,7.06652165 L4,13.7334783 C4,14.1006743 4.31055555,14.4 4.69333334,14.4 L13.7066666,14.4 C14.0894445,14.4 14.4,14.1006743 14.4,13.7334783 L14.4,7.06652165 C14.4,6.69932565 14.0894445,6.4 13.7066666,6.4 L4.69333334,6.4 Z" id="image" fill="#666666" fill-rule="nonzero"></path>
|
||||||
|
<g id="group" transform="translate(4.8173, 3.52) scale(-1, 1) translate(-4.8173, -3.52)translate(0.0347, 0)" fill="#666666" fill-rule="nonzero">
|
||||||
|
<path d="M8.87810624,7.04 C8.61343976,7.04 8.36129136,6.88219178 8.24862928,6.61795476 C6.78581083,3.19571838 3.28255715,2.68559414 1.32438325,2.68559414 C0.945266472,2.68559414 0.637681159,2.3699777 0.637681159,1.98279707 C0.637681159,1.59561644 0.945266472,1.28 1.32438325,1.28 C5.22642483,1.28 8.20928696,3.02139535 9.50758312,6.0564511 C9.65958752,6.41243708 9.50043,6.8271424 9.1535024,6.98311564 C9.064088,7.02165021 8.9693088,7.04 8.87810624,7.04 Z" id="path2"></path>
|
||||||
|
<path d="M1.42164886,3.84 C1.29607734,3.84 1.1692245,3.77584105 1.07312386,3.64578912 L0.144150966,2.39035448 C-0.0480503219,2.13025062 -0.0480503219,1.71061639 0.144150966,1.45051253 L1.07312386,0.195077896 C1.26532514,-0.0650259653 1.57669123,-0.0650259653 1.76889251,0.195077896 C1.96109379,0.455181757 1.96109379,0.874815984 1.76889251,1.13491985 L1.18716329,1.9204335 L1.76889251,2.70594717 C1.96109379,2.96605102 1.96109379,3.38568526 1.76889251,3.64578912 C1.67407323,3.77410702 1.54722036,3.84 1.42164886,3.84 Z" id="path1"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
@ -13,7 +13,8 @@ const {
|
|||||||
xmindImageSrc // for xmind file
|
xmindImageSrc // for xmind file
|
||||||
} = window.app.pageOptions;
|
} = window.app.pageOptions;
|
||||||
|
|
||||||
let previousImageUrl; let nextImageUrl;
|
let previousImageUrl;
|
||||||
|
let nextImageUrl;
|
||||||
if (previousImage) {
|
if (previousImage) {
|
||||||
previousImageUrl = `${siteRoot}lib/${repoID}/file${Utils.encodePath(previousImage)}`;
|
previousImageUrl = `${siteRoot}lib/${repoID}/file${Utils.encodePath(previousImage)}`;
|
||||||
}
|
}
|
||||||
@ -55,7 +56,7 @@ class FileContent extends React.Component {
|
|||||||
// request thumbnails for some files
|
// request thumbnails for some files
|
||||||
// only for 'file view'. not for 'history/trash file view'
|
// only for 'file view'. not for 'history/trash file view'
|
||||||
let thumbnailURL = '';
|
let thumbnailURL = '';
|
||||||
const fileExtList = ['tif', 'tiff', 'psd'];
|
const fileExtList = ['tif', 'tiff', 'psd', 'heic'];
|
||||||
if (!repoEncrypted && fileExtList.includes(fileExt)) {
|
if (!repoEncrypted && fileExtList.includes(fileExt)) {
|
||||||
thumbnailURL = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${Utils.encodePath(filePath)}`;
|
thumbnailURL = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${Utils.encodePath(filePath)}`;
|
||||||
}
|
}
|
||||||
@ -63,6 +64,17 @@ class FileContent extends React.Component {
|
|||||||
// for xmind file
|
// for xmind file
|
||||||
const xmindSrc = xmindImageSrc ? `${siteRoot}${xmindImageSrc}` : '';
|
const xmindSrc = xmindImageSrc ? `${siteRoot}${xmindImageSrc}` : '';
|
||||||
|
|
||||||
|
const { scale, angle } = this.props;
|
||||||
|
let style = {};
|
||||||
|
if (scale && angle != undefined) {
|
||||||
|
style = { transform: `scale(${scale}) rotate(${angle}deg)` };
|
||||||
|
} else if (scale) {
|
||||||
|
style = { transform: `scale(${scale})` };
|
||||||
|
} else if (angle != undefined) {
|
||||||
|
style = { transform: `rotate(${angle}deg)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-view-content flex-1 image-file-view">
|
<div className="file-view-content flex-1 image-file-view">
|
||||||
{previousImage && (
|
{previousImage && (
|
||||||
@ -71,14 +83,16 @@ class FileContent extends React.Component {
|
|||||||
{nextImage && (
|
{nextImage && (
|
||||||
<a href={nextImageUrl} id="img-next" title={gettext('you can also press →')}><span className="sf3-font sf3-font-down rotate-270 d-inline-block"></span></a>
|
<a href={nextImageUrl} id="img-next" title={gettext('you can also press →')}><span className="sf3-font sf3-font-down rotate-270 d-inline-block"></span></a>
|
||||||
)}
|
)}
|
||||||
<img src={xmindSrc || thumbnailURL || rawPath} alt={fileName} id="image-view" onError={this.handleLoadFailure} />
|
<img src={xmindSrc || thumbnailURL || rawPath} alt={fileName} id="image-view" onError={this.handleLoadFailure} style={ style } />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileContent.propTypes = {
|
FileContent.propTypes = {
|
||||||
tip: PropTypes.string.isRequired,
|
tip: PropTypes.object.isRequired,
|
||||||
|
scale: PropTypes.number,
|
||||||
|
angle: PropTypes.number
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileContent;
|
export default FileContent;
|
||||||
|
@ -9,6 +9,7 @@ import ShareDialog from '../dialog/share-dialog';
|
|||||||
import { seafileAPI } from '../../utils/seafile-api';
|
import { seafileAPI } from '../../utils/seafile-api';
|
||||||
import toaster from '../toast';
|
import toaster from '../toast';
|
||||||
import Icon from '../../components/icon';
|
import Icon from '../../components/icon';
|
||||||
|
import ImageZoomer from './image-zoomer';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
isLocked: PropTypes.bool.isRequired,
|
isLocked: PropTypes.bool.isRequired,
|
||||||
@ -17,13 +18,15 @@ const propTypes = {
|
|||||||
isSaving: PropTypes.bool,
|
isSaving: PropTypes.bool,
|
||||||
needSave: PropTypes.bool,
|
needSave: PropTypes.bool,
|
||||||
toggleLockFile: PropTypes.func.isRequired,
|
toggleLockFile: PropTypes.func.isRequired,
|
||||||
toggleDetailsPanel: PropTypes.func.isRequired
|
toggleDetailsPanel: PropTypes.func.isRequired,
|
||||||
|
setImageScale: PropTypes.func,
|
||||||
|
rotateImage: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
canLockUnlockFile,
|
canLockUnlockFile,
|
||||||
repoID, repoName, repoEncrypted, parentDir, filePerm, filePath,
|
repoID, repoName, repoEncrypted, parentDir, filePerm, filePath,
|
||||||
fileType,
|
fileType, fileExt,
|
||||||
fileName,
|
fileName,
|
||||||
canEditFile, err,
|
canEditFile, err,
|
||||||
// fileEnc, // for 'edit', not undefined only for some kinds of files (e.g. text file)
|
// fileEnc, // for 'edit', not undefined only for some kinds of files (e.g. text file)
|
||||||
@ -119,6 +122,19 @@ class FileToolbar extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="d-none d-md-flex justify-content-between align-items-center flex-shrink-0 ml-4">
|
<div className="d-none d-md-flex justify-content-between align-items-center flex-shrink-0 ml-4">
|
||||||
|
{(fileType == 'Image' && !err) && (
|
||||||
|
<>
|
||||||
|
<ImageZoomer setImageScale={this.props.setImageScale} />
|
||||||
|
{['psd', 'heic'].indexOf(fileExt) == -1 && (
|
||||||
|
<IconButton
|
||||||
|
id="rotate-image"
|
||||||
|
icon="rotate"
|
||||||
|
text={gettext('Rotate')}
|
||||||
|
onClick={this.props.rotateImage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{fileType == 'PDF' && (
|
{fileType == 'PDF' && (
|
||||||
<IconButton
|
<IconButton
|
||||||
id="seafile-pdf-find"
|
id="seafile-pdf-find"
|
||||||
|
@ -25,8 +25,8 @@ const propTypes = {
|
|||||||
isSaving: PropTypes.bool,
|
isSaving: PropTypes.bool,
|
||||||
needSave: PropTypes.bool,
|
needSave: PropTypes.bool,
|
||||||
isOnlyofficeFile: PropTypes.bool,
|
isOnlyofficeFile: PropTypes.bool,
|
||||||
participants: PropTypes.array,
|
setImageScale: PropTypes.func,
|
||||||
onParticipantsChange: PropTypes.func
|
rotateImage: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isStarred, isLocked, lockedByMe,
|
const { isStarred, isLocked, lockedByMe,
|
||||||
@ -143,6 +143,8 @@ class FileView extends React.Component {
|
|||||||
needSave={this.props.needSave}
|
needSave={this.props.needSave}
|
||||||
toggleLockFile={this.toggleLockFile}
|
toggleLockFile={this.toggleLockFile}
|
||||||
toggleDetailsPanel={this.toggleDetailsPanel}
|
toggleDetailsPanel={this.toggleDetailsPanel}
|
||||||
|
setImageScale={this.props.setImageScale}
|
||||||
|
rotateImage={this.props.rotateImage}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
63
frontend/src/components/file-view/image-zoomer.js
Normal file
63
frontend/src/components/file-view/image-zoomer.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Button, Input } from 'reactstrap';
|
||||||
|
import Icon from '../../components/icon';
|
||||||
|
|
||||||
|
import '../../metadata/components/data-process-setter/gallery-slider-setter/index.css';
|
||||||
|
|
||||||
|
const SCALE_OPTIONS = [0.25, 0.5, 1, 1.5, 2];
|
||||||
|
const SCALE_MIN = SCALE_OPTIONS[0];
|
||||||
|
const SCALE_MAX = SCALE_OPTIONS[SCALE_OPTIONS.length - 1];
|
||||||
|
|
||||||
|
const ImageZoomer = ({ setImageScale }) => {
|
||||||
|
|
||||||
|
const [curScale, setScale] = useState(1);
|
||||||
|
|
||||||
|
const scaleImage = useCallback((scale) => {
|
||||||
|
setImageScale(scale);
|
||||||
|
}, [setImageScale]);
|
||||||
|
|
||||||
|
const changeScale = useCallback((e) => {
|
||||||
|
const scale = Number(e.target.value);
|
||||||
|
setScale(scale);
|
||||||
|
scaleImage(scale);
|
||||||
|
}, [scaleImage]);
|
||||||
|
|
||||||
|
const zoomIn = useCallback(() => {
|
||||||
|
const scale = SCALE_OPTIONS[SCALE_OPTIONS.indexOf(curScale) + 1];
|
||||||
|
setScale(scale);
|
||||||
|
scaleImage(scale);
|
||||||
|
}, [curScale, scaleImage]);
|
||||||
|
|
||||||
|
const zoomOut = useCallback(() => {
|
||||||
|
const scale = SCALE_OPTIONS[SCALE_OPTIONS.indexOf(curScale) - 1];
|
||||||
|
setScale(scale);
|
||||||
|
scaleImage(scale);
|
||||||
|
}, [curScale, scaleImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='metadata-slider-container image-zoomer ml-0'>
|
||||||
|
<Button className="metadata-slider-icon-button" onClick={zoomOut} disabled={curScale == SCALE_MIN}>
|
||||||
|
<Icon symbol='minus_sign' className='metadata-slider-icon' />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
type="range"
|
||||||
|
min={SCALE_MIN}
|
||||||
|
max={SCALE_MAX}
|
||||||
|
step="any"
|
||||||
|
value={curScale}
|
||||||
|
onChange={changeScale}
|
||||||
|
className="metadata-slider"
|
||||||
|
/>
|
||||||
|
<Button className="metadata-slider-icon-button" onClick={zoomIn} disabled={curScale == SCALE_MAX}>
|
||||||
|
<Icon symbol='plus_sign' className='metadata-slider-icon' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ImageZoomer.propTypes = {
|
||||||
|
setImageScale: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageZoomer;
|
@ -35,6 +35,7 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#img-prev {
|
#img-prev {
|
||||||
@ -49,3 +50,7 @@
|
|||||||
#img-next:hover {
|
#img-next:hover {
|
||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-zoomer {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
@ -7,12 +7,44 @@ import SVG from './components/file-content-view/svg';
|
|||||||
import PDF from './components/file-content-view/pdf';
|
import PDF from './components/file-content-view/pdf';
|
||||||
import Video from './components/file-content-view/video';
|
import Video from './components/file-content-view/video';
|
||||||
import Audio from './components/file-content-view/audio';
|
import Audio from './components/file-content-view/audio';
|
||||||
|
import { Utils } from './utils/utils';
|
||||||
|
import { gettext } from './utils/constants';
|
||||||
|
import ImageAPI from './utils/image-api';
|
||||||
|
import toaster from './components/toast';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
repoID, filePath,
|
||||||
fileType, err
|
fileType, err
|
||||||
} = window.app.pageOptions;
|
} = window.app.pageOptions;
|
||||||
|
|
||||||
class InnerFileView extends React.Component {
|
class InnerFileView extends React.Component {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
imageScale: 1,
|
||||||
|
imageAngle: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageScale = (scale) => {
|
||||||
|
this.setState({
|
||||||
|
imageScale: scale
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rotateImage = () => {
|
||||||
|
this.setState({
|
||||||
|
imageAngle: (this.state.imageAngle - 90) % 360 // counter-clockwise
|
||||||
|
}, () => {
|
||||||
|
// const angleClockwise = this.state.imageAngle + 360; // keep this line for the moment
|
||||||
|
const angleClockwise = 270; // the API only accept clockwise angles
|
||||||
|
ImageAPI.rotateImage(repoID, filePath, 360 - angleClockwise).then((res) => {
|
||||||
|
toaster.success(gettext('Image saved'), { 'id': 'image-saved-tip' });
|
||||||
|
}).catch(error => {
|
||||||
|
toaster.danger(Utils.getErrorMsg(error));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -21,10 +53,11 @@ class InnerFileView extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { imageScale, imageAngle } = this.state;
|
||||||
let content;
|
let content;
|
||||||
switch (fileType) {
|
switch (fileType) {
|
||||||
case 'Image':
|
case 'Image':
|
||||||
content = <Image tip={<FileViewTip />} />;
|
content = <Image tip={<FileViewTip />} scale={imageScale} angle={imageAngle} />;
|
||||||
break;
|
break;
|
||||||
case 'XMind':
|
case 'XMind':
|
||||||
content = <Image tip={<FileViewTip />} />;
|
content = <Image tip={<FileViewTip />} />;
|
||||||
@ -46,7 +79,11 @@ class InnerFileView extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileView content={content} />
|
<FileView
|
||||||
|
content={content}
|
||||||
|
setImageScale={this.setImageScale}
|
||||||
|
rotateImage={this.rotateImage}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user