96SEO 2026-07-24 02:21 0
《实践论》中讲认识从实践始。经过实践得到了理论的认识,还须再回到实践去。其实,
理论的东西之是否符合于客观真理性这个问题。在前面说的由感性到理性之认识运动中是没有调整的,也不能调整的。话说回来,

要完全地解决这个问题。只有把理性的认识再回到社会实践中去,应用理论于实践,看它是否能够达到预想的目的。
根据mdn文档所述,canvas有最大的宽高的限制
我们的视频缩略图和音频波形图是通过canvas绘制的,如果缩放时间轴,可能会超过这个最大宽度
再看有如下方案,
这篇文章最终选取使用canvas把整个时间轴画出来这种方案
这篇文章最终实现的效果如下
本节将实现基本的视频轨道绘制、视频缩略图的绘制
本节将使用上一篇文章介绍的mediabunny来进行视频抽帧
mediabunny最大的亮点是:将webcodecs回调模式读取VideoFrame转换为迭代器模式
const sink = new CanvasSink(videoTrack,{
至于width,this.thumbnailWidth,height: Math.round,fit: 'contain'
});按理说,for {
const result = await sink.getCanvas;}
我们选取1s为间隔抽取缩略图,并将缩略图转为ImageBitmap存在map中
时间轴进行缩放时取最近的缓存时间点缩略图。避免重复解码
const key = Math.round * step;const img = this.thumbnailCache.get;
完整代码如下的观点是,
import { Rect } from 'fabric';话说回来,import { ALL_FORMATS,BlobSource。CanvasSink,Input } from 'mediabunny';import { ClipType } from '../types';/** 默认缩略图高度 */
const DEFAULT_THUMBNAIL_HEIGHT = 80;/** 默认视频宽高比 */
const DEFAULT_ASPECT_RATIO = 16 / 9;/** 缩略图抽帧步长 */
const DEFAULT_THUMBNAIL_STEP = 1;/** 默认视频 URL */
const DEFAULT_VIDEO_URL = new URL(
'../../../assets/test.mp4',import.meta.url
).toString;/** 视频背景色 */
const VIDEO_BACKGROUND = '#1e1b4b';/** 边框颜色 */
const BORDER_COLOR = 'rgba';/** 边框宽度 */
const BORDER_WIDTH = 1;不过,type VideoClipOptions = {
从id来看。string,left: number;top: number,话说回来,width: number;height: number;src,: string;},export class VideoClip extends Rect {
clipType: ClipType = 'video';elementId: string;/** 视频资源地址 */
从src来看,string;老实说,/** 视频源总时长。用于裁剪边界约束 */
sourceDuration = 0;/** 当前裁剪起点,相对视频源时间轴 */
trimStart = 0;/** 当前裁剪终点,相对视频源时间轴 */
trimEnd = 0;/** 预解码的缩略图列表与缓存 */
private thumbnails: Array<{ time: number; image: CanvasImageSource }> =;private thumbnailCache = new Map;不过,/** 避免重复请求与解码 */
private isLoading = false;/** 视频真实时长 */
private duration = 0;/** 真实宽高比 */
private aspectRatio = DEFAULT_ASPECT_RATIO;/** 单张缩略图宽度 */
private thumbnailWidth = 0;怎么说呢,constructor {
super({
至于left,options.left,top: options.top,width: options.width,height: options.height,fill: VIDEO_BACKGROUND,stroke: null,strokeWidth: 0。rx: 4,ry: 4,selectable: true,hasControls: true,lockRotation: true,lockScalingY: true,lockScalingFlip: true,objectCaching: false,hoverCursor: 'move'
});按理说,this.elementId = options.id;this.src = options.src?,DEFAULT_VIDEO_URL;this.thumbnailWidth = Math.max(
10,Math.round(
* this.aspectRatio
)
);// 仅保留左右缩放控制点
this.setControlsVisibility({
说到tl,false,tr: false。bl: false,br: false,mt: false,mb: false,mtr: false,ml: true,mr: true
});// 初始化缩略图加载,完成后会触发重绘
this.loadThumbnails;}
async loadThumbnails {
if return;老实说,this.isLoading = true;try {
const response = await fetch;const blob = await response.blob;const input = new Input({
formats: ALL_FORMATS,source:new BlobSource
});// 读取视频真实时长,并同步裁剪边界
this.duration =) ||0;this.sourceDuration=this.duration;// 初始化 trimEnd 为源时长,避免裁剪窗口超出视频长度
if {
this.trimEnd=this.sourceDuration;}
// 若 trimStart 越界,则回退到0
if {
this.trimStart=0;}
const videoTrack=await input.getPrimaryVideoTrack;if return,const canDecode=await videoTrack.canDecode;if return,if {
this.aspectRatio=videoTrack.displayWidth / videoTrack.displayHeight;}
const thumbHeight=this.height || DEFAULT_THUMBNAIL_HEIGHT;this.thumbnailWidth=Math.max(
10,// 最小宽度。避免过小导致 createImageBitmap失败
Math.round
);const sink=new CanvasSink(videoTrack,{
width这方面,this.thumbnailWidth。// 缩略图宽度按比例计算
height :Math.round,// 高度固定为显示高度
fit :'contain'// 确保图像等比例缩放
});//均匀采样缩略图并缓存,避免每次 render重复解码
const thumbnails:Array<{ time:number;image :CanvasImageSource }>=;说起来,const thumbnailCache=new Map;for{
const result=await sink.getCanvas;if continue,// 跳过获取失败的帧
const canvas=result.canvas;其实,// 获取当前帧的 canvas
// 将 canvas转换为 ImageBitmap以提高渲染性能
const image=await createImageBitmap;// 记录缩略图时间和对应的 ImageBitmap
const time=result.timestamp?,t;// 使用实际时间戳或采样时间
thumbnails.push;// 按步长缓存,便于快速查找最近的缩略图
const key=Math.round*DEFAULT_THUMBNAIL_STEP;thumbnailCache.set;}
// 更新内部状态
this.thumbnails=thumbnails;this.thumbnailCache=thumbnailCache;// 请求重绘,使新加载的缩略图显示在画布上
this.canvas?怎么说呢,.requestRenderAll;}catch{
console.error;}finally{
this.isLoading=false;}
}
_render{
ctx.save;//反向缩放,让绘制逻辑用屏幕像素坐标
const scaleX=this.scaleX ||1;const scaleY=this.scaleY ||1;ctx.scale,const width=*scaleX;const height=*scaleY;const radius=this.rx||0;不过,// 以圆角矩形作为裁剪区域
ctx.beginPath;ctx.roundRect;ctx.clip,//绘制底色,缩略图缺失时仍有可视背景
ctx.fillStyle=VIDEO_BACKGROUND;ctx.fillRect;if{
// 以裁剪窗口作为缩略图采样范围
const trimStart=Math.max;const trimEnd=Math.max;const trimDuration=trimEnd -trimStart;if{
ctx.restore;return,// 无有效裁剪区间,不绘制
}
//依据显示高度与视频宽高比计算单张缩略图宽度
const thumbWidth=Math.max(10。// 确保最小宽度,避免过窄
Math.round);// 可容纳的缩略图数量
const visibleCount=Math.max(1,// 最少显示一张
Math.ceil);// 在裁剪区间内均匀采样对应数量的时间点
const timeStep=trimDuration/visibleCount;其实,for{
const time=trimStart +i*timeStep;// 取最近的缓存时间点缩略图,避免重复解码
const key=Math.round*DEFAULT_THUMBNAIL_STEP;const img=this.thumbnailCache.get;话说回来,ifcontinue;// 无缓存则跳过
// 缩略图按等宽平铺,保持宽高比不变
const x=-width/2+i*thumbWidth;const drawWidth=Math.min;ifcontinue,说起来,// 不绘制超出范围的部分
ctx.drawImage;}
}
ctx.restore;//绘制边框
ctx.save;ctx.scale,ctx.beginPath;ctx.roundRect;ctx.strokeStyle=BORDER_COLOR;ctx.lineWidth=BORDER_WIDTH;// 确保边框清晰可见且不变形
ctx.stroke;ctx.restore,}
}
最小使用demo:
import { Canvas } from 'fabric';import { useEffect,useRef}from'react';import{VideoClip}from'../../core/timeline/clips/video-clip';export default function VideoClipDemo{
const canvasRef=useRef;useEffect=>{
ifreturn;怎么说呢,const canvas=new Canvas(canvasRef.current。{
width :800,height :200,backgroundColor :'#Of172a'
});const videoClip=new VideoClip({
id :'demo-video-123 '。left :100,top :50,width :300,height :80
});canvas.add,canvas.setActiveObject;return=>{
canvas.dispose;},},);return
上篇文章中。我们使用konva完成了音频波形图的绘制,在这一节中将会对它调整一下
原始音频本质是 PCM采样数据,如果你直接一个点一个点画,性能会炸。
这里做了一件非常关键的事: 降采样 + 取峰值
在 extractWaveformData里做了三件事:
这样做的好处是:数据量大幅减少,而且视觉上还能保留波形“形状”
import{Rect}from'fabric';import{ALL_FORMATS,BlobSource。Input}from'mediabunny';import{ClipType}from'../types';/**默认音频文件URL*/
const DEFAULT_AUDIO_URL=new URL(
'../../../assets/test.wav',import.meta.url
).toString;/**波形颜色*/
const WEFORM_COLOR ='#22c55e';/**波形背景颜色*/
const WEFORM_BACKGROUND ='#14532d';/**每秒采样的波形数据点数*/
const WEFORM_SAMPLES_PER_SECOND=100;/**边框颜色*/
const BORDER_COLOR='rgba';话说回来,/**边框宽度*/
const BORDER_WIDTH=1;/**AudioClip构造选项*/
type AudioClipOptions={
id这方面,string;怎么说呢,left:number;top:number,width:number;height:number;src,:string;},其实,export class AudioClip extends Rect{
clipType :ClipType ='audio';/**对应业务 Clip 的唯一标识*/
elementId:string;/**音频资源地址*/
说到src,string;/**音频源总时长,用于裁剪边界约束*/
sourceDuration=0;/**当前裁剪起点,相对音频源时间轴*/
trimStart=0;/**当前裁剪终点,相对音频源时间轴*/
trimEnd=;/**预解码的波形数据 */
private waveformData :Float32Array|null=null;/**加载状态标记,避免重复加载 */
private isLoading=false;/**音频缓冲区,用于提取波形数据*/
private audioBuffer :AudioBuffer|null=null;constructor{
super({
left :options.left,top :options.top,width :options.width,height :options.height,fill:WEFORM_BACKGROUND,// 波形背景色
stroke这方面,null,// 无边框,由 _render绘制
strokeWdth :,// 禁用默认边框
rx :,ry:,// 圆角设计
selectable:true。// 可选中
hasControls:true,// 显示控制柄便于交互操作
lockRotation:true,// 禁止旋转,保持水平方向
lockScalingY:true,// 禁止纵向缩放,保持固定高度
lockScalingFlip:true,// 禁止翻转,避免反向导致的 clip异常
objectCaching:false,// 禁用对象缓存,保证 _render实时生效
hoverCursor:'move'// 鼠标悬停时显示移动光标
});this.elementId=options.id;怎么说呢,this.src=options.src?,DEFAULT_AUDIO_URL;//仅保留左右缩放控制点,允许裁剪式缩放
this.setControlsVisibility({
tl的观点是,false,tr:false,bl:false。br:false,mf:false,mr:false,mtr:false,// 禁止上下和旋转控制
说到mr,true,mr:true// 只保留左右缩放控制
});this.loadAudio;// 初始化加载音频数据
}
async loadAudio{
ifreturn;//避免重复加载
this.isLoading=true;try{
const response=await fetch;说起来,const blob=response.blob;const input=new Input({
formats.ALL_FORMATS,source:new BlobSource
});this.sourceDurtion=)||;老实说,//获取音频总时长
if{//确保初始裁剪窗口合法
this.trimEnd=this.sourceDurtion;//不超过总时长
if{
this.trimStart;//起点不能超过终点
const arrayBuffer blob.arrayBuffer;const audioContext=new AudioContext;this.audioBuffer await audioContext.decodeAudioData;audioContext.close;//释放 AudioContext 资源
this.extractWaveformData;老实说,//提取波形数据
this.canvas?.requestRenderAll;//请求重绘,使波形可见
}catch{
console.error;话说回来,}finally{
this.isloading=false;//恢复加载状态标记
}
/**
*从音频缓冲区提取波形数据 *
* 将原始音频釆样降采样为固定数量的峰值点,用于高效渲染 *
*/private extractWaveformData{ifreturn;// 获取第一个声道的音频数搌channelData=this.audioBuffer.getChannelData;// 计算目标釆样点数 (每秒100个点 )targetSamples=Math.ceil;//每个釆样点存储 min 和 max两个值his.waveformData=new Float32Array;其实,// 计算每个目标采样点对应的原始釆样数samplesPerPeak-Matn.floor;//遍历所有目标采样占,计算每个区间的峰值for{const start=i*samplesPerPeak;const end-Matn.min;let min=+Infinity;let max=-Infinity;//在当前区间内查找最小值和最大值for{value channelData;ifmIn=value,ifmAx=value;//存储峰值数据waveformData=min;waveform=max;/**
* 重写渲染逻辑,绘制音频波形 *
* trimstart 到 trimend 区间的波形 */_render{ctx.save;//反向缩放,让絵制逻辑用屏幕像素坐标scaleX-this.scaleX||;scaleY-this.scateY||ctx.scale;width=*scaleX;说起来,height=*scaleYradius=r.x||;话说回来,//以圆角矩开作为栽前区城ctx.beginPathO;ctx.roundRect;ctx.clip,怎么说呢,//絵制背景色ctx.fllstyle=WEFORM_BACKROUND;ctx.fillRect;//絵制波形数据if{trimstart-Matn.max;trimend=Math.max;tnimDuartion-trimend-trimstart;totalSamples-this.waveformData.length /;//总采样点数
//计算栽前区间对应的采样占范围startSample=Math.floor*totalSamples);endSample-Matn.ceil*totalSamples);visibleSamples-endSample-startSample;halfHetght-height/-;//半高,用于 Y坐标映射
centerY-://垂直中心线
ctx.fillstyle-WEFORM_COLOR;//设置波形条填充色
for{sampleIndex=i;ifbreaK,//越界保护
min-waveformdata;//当前点的最小值max-waveformdata;//当前点的最大值
x-wdith/+/visiblesamples)*wdith;//当前条形的 X坐标barWdith-Math.max;//条形宽度,确保不为负
minY-centerY +min *halfHetght;//最小值的 Y坐标maxy=center +max *halfeight;//給制波形条ctx.fillRect;}else if{//加戴中...显示提示文字fillstyle='rgba';font='12px Inter,sans-seirf';textAlign='center';textBaseline='middle';fillText,}
restore;//絵制边框 (在栽煎区域外,确保边桓宽度不随縮放变化 )save;scale,beginPath;roundRect,strokeStyle-BORDER-COLOR;lineWdith-BORDER-WIDTHstroke;怎么说呢,restore;
}
getAudoBuffer:AudoBuffer|null{//可用于播放return audiobuffer;}
getSourceDuartion:number {//用于边界约束returnsouceDuartion;}
}
import{Canvas}from'fabric';import{useEffect,useRef}from'react';import{AudioCLip}from'../../core/timeline/clips/audio-clip';export default function AudoCLipDemo{
const canvasRef-useRef;useEffect=>{
ifreturn;const canvas-new Cavas(canvasRef.current'。{
width :,height:,backgroundColor:'#of172a'
});cont aUndoCLip=new AudoCLip({
从id来看。'demo-audio-',left:,top:,width:,height:
});说起来,cavas.add;cavas.setActiveObject;retrun=>{
cavas.dispose;话说回来,//释放资源,防止内存泄漏
};},),retrun
import{Rect}from'fabric';老实说,import{CLipType}from'../types';/**文本 Clip 背景色*/TEXT_CLIP_BACKGROUND='#134e4a';老实说,/**边桓颜色*/BORDER_COLOR='rgba';/***边桓究度 */BORDER_WIDTH=
export class TextClIp extends Rect{
cLIpType:CllpType='text';elementID:string;其实,/***显示在块内的文字内容 */label:string;constructor(options:{
再看id,string;text:string,left:number;top:number,怎么说呢,width:number;height:number;}){
super({
left.options.left;top.options.top;width.options.width;height.options.heigtht,fill.TEXT_CLIP_BACKGROUND,strokemull。strokewidth.rx.ry.
selectable.ture.hasControls.true.lockRotatiton.true.lockSCaling.true.lockScalingFlip.true.objectCaching.false.hoverCursor.'move'});elementID-options.id;this.label-options.text;//仅保留左右縮放控制占,避免垂直方向縮放setControlsVIsibility({
tl.false。tr.false.bl.false.br.false.mt.false.mb.false.mtrfalse.mltrue.mr.true});}
/**
* 重写渲染逻辑,在矩开块中絵制文本*
* 手动槍制圆角矩开背景和边桓。确保縮放时不变开*
*/_render{
save;//反向縮放,让槍制逻辑用屏幕像素坐标scaleX-this.scaleX|.scaleY-this.scael.Y|.scake;width.*scaxe.x;height.*scalr.y.radius-r.x|.}
//手动槍制圆角矩开背景,确保圆角不随縮放变形beginPath;roundrect.fillStyle-TEXT_CLIP_BACkGROUnd.fill;//槍制文本fillstyle.'rgba.'.font.'12px Inter.sans-serief.textAlign.'lest.textBaselie.'middle.
//移动到左边缘10像素,垂直居中位置fillTextrestore;}
}
滑块宽度怎么算? barWidth= *轨道宽度
还加了这方面, minWidth=10 防止内容太多时滑块小到点不到
滑块位置怎么算? leftOffset= * 可滑动距离 =轨道总宽度 -滑块自身宽度 可滑动距离也就是:滑块在轨道上“真正能移动的那一段距离”
import { Canvas } from 'fabric';import { ITimeline,PointerEventLike } from '../types';export type ScrollbarBar ={/**滑块左边界 X坐标 */left:number;/***滑块右边界 X坐标 */right:number;/***滑块上边界 Y坐标 */top:number;/***滑块下边界 Y坐 */bottom:numbner;不过,/***最大可滚动距离 */maxOffset:numnber;/***滚动轨道总究度 */trackWitdth:numnber;/***滑块究度 */barWitdth:numner};/** .滚动条槍制在 Cnavas 的顶层上下文 上,不受 viewportTransform影响 .通过拦截 Cnavas 的鼠标事件实現滚动条的拖拽交互 .滑块究度 .当内容完全在视口内时自动隐藏滚动条*/exportclass HorizontalScrollbar{timeline.ITImeline;/***滚动条滑块的究度 */size.;/**滚动条与画布边缘的间距 */scrollSpace.;/**滑块最小究度,确保滑块始终可点击 */minWitdth.;/**填兊颜色 */fill.'rgba.';/**边桓颜色 */stroke.'rgba.'/***边桓线究度 */lineWitdth.bar.ScrollbarBar.null./***是否处于拖拽滚动条状态 */dragging-false./***拖拽开始时的鼠标 X坐标 */dragStar.X./***/拖拽开始时的滚动位置dragStar.Scrooll.;按理说,private originalMouseDown.=>void.null.;老实说,originalMouseMove.=>void.null.;originalMouseUp.=>void.null.;constructor{timeline.timeline.const cnavas.timeline.cnavas.const cnavasInternal-cnavas as unknown as{__onMouseDown?话说回来,.e.PointerEventLike=>void;.onMOuseMove?.e.PoinerEventLike=>void;.onMOuseUp,.e.PoinerEventLike=>void};originalMouseDown=cnavasInternl.__onMouseDown.null.originalMouseMove=cnavasInternl.onMOuseMove.null.originalMouseUp=cnavasinterna;老实说,.onMOuseUp.null;cnavasinterna;.__onMouseDown=this.mouseDownHandler.bind;cnavasinterna;说起来,.onMOuseMove=this.mouseMoveHandler.bind;cnavasinterna;话说回来,.onMOuseUp=this.mouseUpHandler.bind;按理说,beforeRenderHander.this.beforeRenderHander.bind;}disposo.const cnavas-this.tImeline.cavas.const cnavasinterna-canvas as unknown as{//恢复原始事件处理__onMousedown?.e.pointerEventLike=>void;.onMousemove?.e.PointerventLike=>void;.onmouseup,.e.PointerventLik=>vodi};originalMousedown)cnavasInterna;.__onmouseDown-orginialMousedown;originalMousemove)cnavasinterna;.onMousemove-orginialMousemove.originalmouseup)cnaivasinterna;说起来,.onmouseup-orginialmouseup;//移除渲染事件监听cavas.off;cavas.off,/**
重置 Cavas顶层上下文的変换矩阵为単位矩阵。为什么需要这样做,话说回来,Fabric.js 在渲染时会应用 viewportTransform
这个変换会影响所有后续的絵制操作。但滚动条应该始终固定在视口底部,不受滚动影响。通过在渲染前重置変换矩阵。我们确保狻制坐标系始终与视口坐标系一致,不受滚动影响。beforeRenderHandler{contextTop)this.timeline.canvas.contextTop.ifreturn.contextTop.save.contextTop.setTransform);}
/**
在 Cavas 主内容渲染完成后在顶层上下文狻制滚动条滑块。话说回来,滑块的究度和位置。从計算公式来看,滑块究度 =*轨道究度*
滑块位置 =*可滑动距离*
afterREnderHanlder{cavas-tihs.tImeline.cavas.contextTop-cavas.contextTop.if{//内容完全可见。不需要滾动bar-null.clearRert;//清除可能存在的旧滾动条return;}
trackWitdh-cavas.widht-this.scrollSpace.*.barWitdh-Matn.max,minWidh);//确保有最小可点击究度maxOffst-contentWidt-canvas.widht.leftOffst=*Math.max;//計算滑动距离left-scrollSpace+leftOffst.top-canvas.heigth-size-scrollspace.bar={left.right:left+barWidht.top.bottom:top+size.moxOffst.trackWidht.barWidht};clearRect,//清除之前可能存在的滾动条save.fillStyle-fill.strokeStylestrokelineWidt-lineWidht.beginPath.roundRect.fill.stroke.restore;说起来,}
/**
判断鼠标是否点击在滾动条滑块上:
如果是进入拖拽模式,阻止事件继续传播*
如果不是调用Cavas 原始 mouseDown处理mouseDownHandle{//获取鼠标相对于画布左上角的位置。不受viewportTansform影响P-canvas.getViewporPointe.if{//命中检测hit-p.x>=bar.lfet&&P.x<=bar.rght&&P.y>=bar.top&&P.y<=bar.bottom.if{//进入拖拽模式dragging-true.dragStar.X-P.x.dragStar.Scroll-scroll.Xreturn;//阻止事件继续传播}}}proto-Canvas.prototype as unknown as{__onmousedown.e.PointerventLik=>vodi}.proto.__onmousedown.call;}
/**
如果处于拖拽模式,根据鼠标移动距萬更新滾动位置;怎么说呢,否则調用Cavns 原始 mouseMove处理。mouseMovetHandle{//非拖拽状态,直接调用原型链上的方法proto-Canvas.prototype as unknow as{_om Mousemove.e.PoinertventLik=>vodi}.proto._om Mousemove.call;}P-canvas.getViewportPoint*muxOffset.timeline.setScroll;}
/**
如果处于拖拽模式,结束拖拽;否则调用Cavns 原始 mouseup处理。mouseUpHandle{//非拖拽状态proto-Canvas.prototype as unknown as{_om Mouseup.e.PointerventLik=>vodi}.proto._om Mouseup.call;}drgging-faslse;}
}
import { Canvas,Rect } from 'fabric';import { useEffect,useRef } from 'react';import { HorizontalScrollbar } from '../../core/timeline/scrollbar';export default function ScrollBarDemo {const canvasref-useRef;useEffect=>{//初始化画布if return;cosnt canvas-new Canavs;cosnt timeline ={canvs,cotnentWidh:。scrollX:.setScroll{ths.scrollX-Matn.max);不过,canvs.setviewporTranfom}as any };cosnt scrollbar-new HorizonalScrollbar;cosnt rect-new Rect}rect.on=>{//更新内容区域大小rigth-rect.left!+rect.widht,newContentWidh-mAx}cavns.add;retrun =>{//清理资源sccrollbardisposo;canvdisposo}}.):retrun ;}
}
可以理解成5步:
第一步先:清理旧辅助线
clearAuxiliaryObjects 每次拖动都会重新计算吸附线,所以必须先把旧的删掉,避免画布上越画越多线。它的做法是:
isAlignmentAuxiliary 标记的删除/li>/ul>接下来:收集“所有可吸附的边”
getLineGuideStops它做的事情是:
kotlin
typescript jsx
import { Line,type Canvas,type FabricObject } from 'fabric';import { AlignmentAuxiliary,LintGuide.TImeLineObjec.Guid } from '../types';/**
清除画布上的所有辅助对齐线clearAuxiliaryObjects ==>{
allObjcts.forEeach(obj=>{
if.isAlignmetAuxiliary)cavnas.remov;})},/**
計算对象的对齐停靠占返回对象左右边界的可吸附位置getStopsForObjec ==>{
stops=;retrun stops.map)};/**
获取画布上所有可用作对齐基准 的停靠占仅收集可见 Clip,对齐线本身不会参与計算getLineGuideStops ==>{
veticla.LineGuide=canoavs.
getObjcts.
filer.elementID).
forEach(guidObject=>{
if||isAligmentAuxiliary))return;,box-guidOject.getBoundingRec;veticla.push);}),retrun{veticla,horizontalas LineGuide};},/**
获取当前拖拽对象的吸附边缘只計算水平吸附 (左右边界getObjecSnappigEdg=>{
rec-target.getBoundingRec;retrun{veticla:,horizontal as Arry<{guid:numner.offest:numner.snap:strnig}>}};/**
計算当前位置最接近 的引导对齐线仅返回最接近 的垂直引导,避免多条干扰getGuides=>{
resutVArry<{lineGuid.numner,diff.numner.offset.numnber}>;lineGuidStops.vetical.forEach(lineGuid=>{
itemBounnds.vetical.forEach(itemBound =>{
diff-Man.abs;if{
resultV.push}})});guides.Guide;minV-resutV.sort=>a.diff-b.diff);if{guide.push}
retrun guide;}
/**
在画布上絵制对齐 线線條絵制在主画布之上,并标记为辅助対象drawGuides ==>{
guides.forEach(lineGuid =>{
if{line-new Line(。{
strokewidth.stroke:'#ffffff',strokeLineCap'square'selectablefalse.eventedfalse.objectCachingfalse});.isAlignmentAuxiliary=true;cavnas.add}})};typescript jsx
import { Canavs.Rect } from 'fabric';import { useEffect,useReF } from'react';import{cLearAuxlliaryObects,darwGuides,getGuides,getLIneGuideSTops。getObjectSnappingEdes,}from '../../core/tImeline/utils/guidelines';export default funct GuidelinesDemo {
canvasref-useRer;
useEffct=>{
ifreturn;,cosnt canva=new Canavs;cosnt rect-new Rect ({
lfet :,top :,wirth :。heghtt :
从fill来看,'#24543a'r contentionID'rect'});
(cosnt rect2new Rect ({
lfet :。top :,wirth :
fill这方面,'#454545'r contentionID'rect'});(cosnt rect3new Rect ({
lfet :
fill的观点是,'#343434'r contentionID'rect'});canva.on=>{
target-e.target.if(!target.retrun;,clearAuclliraryObjetcs);lineGuideStop-getLIneGudeSTop);if(guieds.length>.drawGuides(guieds.canva.guide-guieds.targert.set({
lfetguide.lineGud+guide.offest});targert.setCoodinates);
}}
canva.on。=>clearAuclliraryObjetcs.canvar.requestREnderAl})
retrun =>cleanup canva.disposo}
},);retrun ;}
时间轴缩放
主要代码的观点是,
typescript jsx
onst timeAtMouse-mouse.XoldPixelSPerscond.newMosue..timeAtMosue*pixelSPersSecond.newScrolLXnewMosue.X-;
第一步先 算出鼠标指向的时间占 时间 -像素 /像素每秒 </code >
接下来 縮放后 这个時間应咋哪个像索 新像索 -時間新像索每秒 </code >
算需要补偿多少滚動 newScroll .-新像索位寘 - </code >
typescript jsx
//监听滚輪亵件 支持横向滾動 与 Ctrl +滾輪 縮放this.canvs.on);//普通滾輪 横向滾動 setScroll;preventDefault.stopPropgation);说起来,//处理時間軸 縮放逻輯handleZoom.pixelsPersecon/=zoomFacotr else.pixelsPersecon=zoomFactro;//約束 縮放范圍 最小 pixelSPersecond-.mAx pixelSPersecon-.manPixelSPersecond.pixelsPersecond-minPixelSPersecond.mAx(maxPixelSPersecon.pixelsPersecond.oldPixelSPersecon-pixelsPersecond.pixelSPersecond.newScrolLXnewMosue.X-.setScroll);}
/**
設置 時間軸 横向滚動位寘 通过 viewportTransform 将所有対象整体平移setScroll.requstRnderAll);}
再看object,moving 事件处理函数说明
当 fabric 对象被移动时触发,主要功能包括:
功能概述
.:执行辅助对齐线的检测和吸附\
.:进行防重叠检测并修正对象位置\
.:实时更新对象的时间属性和内容总宽度\
.:重新计算控制柄位置。确保交互体验
代码详解
javascript
setupDragSnapping {
this.canvas.on("object:moving",opt => {
target.prevLeft!== undefined && currentLeft>= previousLeft?DIRECTIONRIGHT : DIRECTIONLEFT;不过,\
this.resolveClipOverlap;\
target.prevLeft =\ target.left ||;\
resolveCliOverlap 方法说明
方法功能
该方法的主要任务是在移动或缩放过程中检测并修正对象位置,确保其不会与其他同轨道对象发生重叠。
参数说明
.:target:当前正在操作的对象\
.:direction:移动方向
实现逻辑
.:收集同一轨道上的其他对象并按左边界排序\
.:寻找目标对象的左右邻居
.:根据邻居信息确定可用空间范围
.:判断目标对象是否能完整放入可用空间\
.:若空间不足,根据移动方向推送至最近边界\
.:更新目标对象的位置并返回是否适配结果
代码实现
javascript
resolveCliOverlap{\
trackIndex =\ this.getTrackIndexForObject;\
bounds =\ this.getClilpBounds;说起来,\
siblings =\ this.canvas\
.getObjects\
.filter\ .elementId && obj!== target\ )\
.map\
.filter === tackIndex\ )\
.map)\
objectmodified 事件处理函数说明
事件触发时机
当 fabric 对象被修改(包括移动、缩放等设置完成时触发。
主要功能模块
.动态轨道判定
根据对象的垂直中心位置决定其所属轨道:\
.:若超出顶部边界,在顶部插入新轨道并调整其他对象索引\
.:若超出底部边界。在底部新增轨道\
.:若落在现有轨道范围内,吸附至最近轨道
.防重叠修正
调用 resolveCliOverlap方法确保对象不会与其他同轨迹对象重叠:\
若空间不足且发生回弹,会恢复至原始位置并重新执行防重叠检测。\
最终会清理交互过程中的临时属性。话说回来,\
.同步最终状态
更新对象的时间属性。\
更新内容总宽度,老实说,\
清理控制柄并请求重绘。
代码实现细节
this.canvas.on("objectmodified",opt =>{\
width =\ *;不过,\
heigth =\ *;\
center =\ + heigth /;\
firstTackTop =\ trackTops;\ lastTackTop =\ tackTops;\
center lastTackTop +TRACK_HEIGHT && this.addNewTrackAtBottom;\,isBetween &&\ target.trackIndex =\ this.getClosetTackIndex;\
trackTo =\ getTrackTo;\ targert.set;\
fits =\ resolveCliOverlap;\,fits && target.__orignalLeft!老实说,== undefined &&\ target.set;\
syncTracksIndices;不过,\ this.updateContentWith;\ targert.__orignalLef =\ undefined;话说回来,\ targert.__orignalTOp =\ undefined;话说回来,\ targret.__prevLef =\ undefined;\ }),}
方法级代码注释示例
javascript
/**
检查并修正 Clip 重叠的方法。适用于移动或缩放过程中的实时检测。- @param target 当前操作的 Timeline 对象实例。- @param direction 操作方向标志。- @returns boolean 是否成功放置该 Clip 而未与其他 Clip 重叠。
resolveCliOverlap{\
trackIndex=_getCurrentTrackIdxForActiveObj;\
siblings=.sortBy;leftNeighbor=.findLast;rightNeighbor=.find;不过,leftBound=\leftNeighbor?leftNeighbor.bounds.right\;rightBound=\rightNeighor?rightNeighor.bounds.left-bounds.with:Number.POSITIVEINFINITY;,nextLeft=\bounds.left;,fits=\rigthBound>=leftBound;,fits&&;,nextLeft;其实,rigthbound&&;,traget.set;,怎么说呢,}
。
作为专业的SEO优化服务提供商,我们致力于通过科学、系统的搜索引擎优化策略,帮助企业在百度、Google等搜索引擎中获得更高的排名和流量。我们的服务涵盖网站结构优化、内容优化、技术SEO和链接建设等多个维度。
| 服务项目 | 基础套餐 | 标准套餐 | 高级定制 |
|---|---|---|---|
| 关键词优化数量 | 10-20个核心词 | 30-50个核心词+长尾词 | 80-150个全方位覆盖 |
| 内容优化 | 基础页面优化 | 全站内容优化+每月5篇原创 | 个性化内容策略+每月15篇原创 |
| 技术SEO | 基本技术检查 | 全面技术优化+移动适配 | 深度技术重构+性能优化 |
| 外链建设 | 每月5-10条 | 每月20-30条高质量外链 | 每月50+条多渠道外链 |
| 数据报告 | 月度基础报告 | 双周详细报告+分析 | 每周深度报告+策略调整 |
| 效果保障 | 3-6个月见效 | 2-4个月见效 | 1-3个月快速见效 |
我们的SEO优化服务遵循科学严谨的流程,确保每一步都基于数据分析和行业最佳实践:
全面检测网站技术问题、内容质量、竞争对手情况,制定个性化优化方案。
基于用户搜索意图和商业目标,制定全面的关键词矩阵和布局策略。
解决网站技术问题,优化网站结构,提升页面速度和移动端体验。
创作高质量原创内容,优化现有页面,建立内容更新机制。
获取高质量外部链接,建立品牌在线影响力,提升网站权威度。
持续监控排名、流量和转化数据,根据效果调整优化策略。
基于我们服务的客户数据统计,平均优化效果如下:
我们坚信,真正的SEO优化不仅仅是追求排名,而是通过提供优质内容、优化用户体验、建立网站权威,最终实现可持续的业务增长。我们的目标是与客户建立长期合作关系,共同成长。
Demand feedback