跳到主要内容

更新了,图片美化工具迎来两大功能

· 阅读需 8 分钟
编程范儿

最近给图片套壳美化工具更新了两个重要的功能,简而言之就是:

  1. 直接操作图片的放大缩小
  2. 快速截图后上传图片(通过浏览器的 getDisplayMedia 接口)

直接操作放大缩小

第一个功能是为了方便操作,之前是在左侧通过滑动区块来调节图片区域的显示大小,不够直观或不方便实现更精细化的调节。

于是采用更常规的操作方式,在图片区域的四个角上新增操作手柄,当鼠标放在操作手柄上时,显示可拖拽的弧线。

下图是对应的四个角的手柄位置和样式,和拖拽放大缩小的演示过程:



关键代码

export default function Preview({ transform, showBtn }) {
const [handSide, setHandSide] = useState('left');
const [clientX, setClientX] = useState(0)

// 是否在拖动
const [isResizing, setIsResizing] = useState(false)

const handleStartResize = useCallback((e, side) => {
setHandSide(side)
setIsResizing(true)
setClientX(e.clientX)
}, [])

const handleStopResize = useCallback(() => {
setIsResizing(false);
}, [])

const didHandleResize = debounce(e => {
if (!isResizing) return;
const offset = e.clientX - clientX;
let scale = 0;
const pixelToScale = 0.01;

if (handSide === 'left') {
scale = config.scale - offset * pixelToScale;
}
if (handSide === 'right') {
scale = config.scale + offset * pixelToScale;
}
setClientX(e.clientX);
handleConfigChange(parseFloat(scale.toFixed(2)), 'scale');
}, 0)

const handleResize = useCallback(didHandleResize, [isResizing, clientX, didHandleResize])

useEffect(() => {
document.addEventListener('mouseup', handleStopResize)
document.addEventListener('mousemove', handleResize)

return () => {
document.removeEventListener('mouseup', handleStopResize)
document.removeEventListener('mousemove', handleResize)
}
}, [handleStopResize, handleResize])

return (
<div className={styles.frameContent}>

<div className={styles.displayContainer}>
<div className={clsx(styles.mockupModule, styles[config.mockup.theme])} style={{ transform: transform, display: config.hideMockup ? 'none' : 'block' }}>
<div className={styles.devices}>
<div className={styles.item}>
<div className={clsx(styles.itemContainer, 'item-container')}>
{ /*省略*/ }
</div>
</div>
<div className={styles.resizeHandle}>
<div className={clsx(styles.handleArea, styles.leftTop)} onMouseDown={e => handleStartResize(e, 'left')}>
<div className={styles.handle} />
</div>
<div className={clsx(styles.handleArea, styles.rightTop)} onMouseDown={e => handleStartResize(e, 'right')}>
<div className={styles.handle} />
</div>
<div className={clsx(styles.handleArea, styles.rightBottom)} onMouseDown={e => handleStartResize(e, 'right')}>
<div className={styles.handle} />
</div>
<div className={clsx(styles.handleArea, styles.leftBottom)} onMouseDown={e => handleStartResize(e, 'left')}>
<div className={styles.handle} />
</div>
</div>
</div>
</div>
</div>
</div>
)
}

一键截图上传功能

第二个功能是我从另一个工具上参考来的,因为我根本不知道浏览器上还能这么操作。

当调起 navigator.getDisplayMedia 这个接口的时候, 会弹起浏览器的系统弹窗:有三个可选项,分别是:Chrome 标签页,窗口,整个屏幕,选中某个界面进行分享之后,我们就能获取到对应的视频流。

弹窗效果如下图所示:

截图分享

在 Web 中我们就可以通过创建 Video 标签将视频流渲染出来。最后怎么将视频转化为图片呢?熟悉 canvas 的不难,它有一个 drawImage 方法,可以将视频绘制在画布中。

最后我们将 canvas 对象通过 toDataURL() 方法转化为图片数据编码显示在 img 标签中。

原理就是这样,我们再来看下详细过程:

媒体接口截获视频流

通过 getDisplayMedia 获取到媒体流,然后在 DOM 中手动创建 video 标签,将媒体流渲染到视频组件中。然后从视频组件中截获某个帧,渲染到 canvas 中来,以 drawImage 接口的形式拿到最终的图片二进制文件。

getDisplayMedia 在不同浏览器下的兼容性处理:

function getDisplayMedia(options) {
if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
return navigator.mediaDevices.getDisplayMedia(options);
}
if (navigator.getDisplayMedia) {
return navigator.getDisplayMedia(options);
}
if (navigator.webkitGetDisplayMedia) {
return navigator.webkitGetDisplayMedia(options);
}
if (navigator.mozGetDisplayMedia) {
return navigator.mozGetDisplayMedia(options);
}
throw new Error('getDisplayMedia is not defined');
}

看下 MDN 上改方法的用法:

var promise = navigator.mediaDevices.getDisplayMedia(constraints);

参数 constraints 的用法参考:
https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getDisplayMedia

它的返回值是一个被解析为 MediaStream 的 Promise 对象,其中包含一个视频轨道。

async function takeScreenshotStream() {
const width = screen.width * (window.devicePixelRatio || 1);
const height = screen.height * (window.devicePixelRatio || 1);

const errors = [];
let stream;
const mediaStreamConstraints = {
audio: false,
video: { width, height, frameRate: 1 }
};

try {
stream = await getDisplayMedia(mediaStreamConstraints);
} catch (ex) {
errors.push(ex);
}

if (errors.length) {
console.debug(...errors);
if (!stream) {
throw errors[errors.length - 1];
}
}

return stream;
}

这一步核心是配置参数,我们禁用音频,获取屏幕尺寸的和分辨率来设置视频的长宽。也就是最终捕获的图片的长宽。

视频流转图片

这里会主动向当前的文档中创建两个标签 videocanvas 标签,首先把上面获取到的视频流通过 video 标签渲染出来,视频数据加载出来后,再通过 创建的 canvas 的 drawImage 接口绘制到画布中。最终就对 canvas 对象通过 toDataURL() 方法转化为图片数据编码渲染到 img 标签上。

async function takeScreenshotCanvas() {
const stream = await takeScreenshotStream();
const video = document.createElement('video');
const result = await new Promise((resolve, reject) => {
video.onloadedmetadata = () => {
video.play();
video.pause();
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
resolve(canvas);
}
video.srcObject = stream;
})

stream.getTracks().forEach(function (track) {
track.stop();
})

if (result == null) {
throw new Error('Cannot take canvas screenshot');
}

return result;
}

这个方法中高亮的部分可能比较疑惑,它的作用是我们使用媒体流获取到当前的某个瞬间的视频流后,通过调用 stop 方法暂停所有轨道上数据的获取。其实我们截图 就只需要视频里某一帧的数据就可以了。关闭通道以免造成资源浪费。

通过流程图来解释就是这样:


不过它有个小小的缺点,就是点击分享后会离开当前页面,导致整个操作不连贯。在用户不知道该特性的情况下使用的时候会有点迷惑。

不过这个功能还是大大简化了当你需要在其它页面或者窗口中截图来美化时,需要先隐藏当前窗口,然后手动调起截屏,选取截屏区域,保存截图后额外再点击上传图片。有了该功能,繁杂的流程简化了不少。

欢迎大家将使用过程中的问题反馈出来,助力我将这个工具做的越来越好用。为有图片美化和设备套壳需求的人带来便利。

太空编程
分享硬核的前端编程知识。
想及时了解前端相关资讯,请关注作者公众号“太空编程”,回复关键字,获取丰富的学习资料。