|
| 1 | +import type { TMat2D, TPointerEvent } from 'fabric'; |
| 2 | +import { Canvas, util } from 'fabric'; |
| 3 | +import type { |
| 4 | + ScrollbarProps, |
| 5 | + ScrollbarsProps, |
| 6 | + ScrollbarXProps, |
| 7 | + ScrollbarYProps, |
| 8 | +} from './typedefs'; |
| 9 | + |
| 10 | +export class Scrollbars { |
| 11 | + canvas: Canvas; |
| 12 | + /** Scrollbar fill color */ |
| 13 | + fill = 'rgba(0,0,0,.3)'; |
| 14 | + /** Scrollbar stroke color */ |
| 15 | + stroke = 'rgba(255,255,255,.3)'; |
| 16 | + /** Scrollbar line width */ |
| 17 | + lineWidth = 1; |
| 18 | + /** Hide horizontal scrollbar */ |
| 19 | + hideX = false; |
| 20 | + /** Hide vertical scrollbar */ |
| 21 | + hideY = false; |
| 22 | + /** Scrollbar minimum width */ |
| 23 | + scrollbarMinWidth = 40; |
| 24 | + /** Scrollbar size */ |
| 25 | + scrollbarSize = 5; |
| 26 | + /** Scrollbar distance from the boundary */ |
| 27 | + scrollSpace = 4; |
| 28 | + /** Scrollbar expansion size, the distance from which the user can effectively slide the scrollbar */ |
| 29 | + padding = 4; |
| 30 | + |
| 31 | + /** The scrollbar currently hit */ |
| 32 | + private _bar?: { type: string; start: number; vpt: TMat2D }; |
| 33 | + /** The current area that can hit the scrollbar */ |
| 34 | + private _barViewport = { |
| 35 | + left: 1, |
| 36 | + right: -1, |
| 37 | + top: 1, |
| 38 | + bottom: -1, |
| 39 | + sx: 1, |
| 40 | + sy: 1, |
| 41 | + }; |
| 42 | + |
| 43 | + constructor(canvas: Canvas, props: ScrollbarsProps = {}) { |
| 44 | + this.canvas = canvas; |
| 45 | + Object.assign(this, props); |
| 46 | + |
| 47 | + this.canvas.__onMouseDown = this.mouseDownHandler.bind(this); |
| 48 | + this.canvas.__onMouseMove = this.mouseMoveHandler.bind(this); |
| 49 | + this.canvas.__onMouseUp = this.mouseUpHandler.bind(this); |
| 50 | + this.beforeRenderHandler = this.beforeRenderHandler.bind(this); |
| 51 | + this.afterRenderHandler = this.afterRenderHandler.bind(this); |
| 52 | + |
| 53 | + this.initBehavior(); |
| 54 | + } |
| 55 | + initBehavior() { |
| 56 | + this.canvas.on('before:render', this.beforeRenderHandler); |
| 57 | + this.canvas.on('after:render', this.afterRenderHandler); |
| 58 | + } |
| 59 | + getScrollbar(e: TPointerEvent) { |
| 60 | + const p = this.canvas.getViewportPoint(e); |
| 61 | + const vpt = this.canvas.viewportTransform.slice(0) as TMat2D; |
| 62 | + if (!this.hideX) { |
| 63 | + const b = |
| 64 | + p.x > this._barViewport.left && |
| 65 | + p.x < this._barViewport.right && |
| 66 | + p.y > |
| 67 | + this.canvas.height - |
| 68 | + this.scrollbarSize - |
| 69 | + this.scrollSpace - |
| 70 | + this.padding && |
| 71 | + p.y < this.canvas.height - this.scrollSpace + this.padding; |
| 72 | + |
| 73 | + if (b) return { type: 'x', start: p.x, vpt }; |
| 74 | + } |
| 75 | + if (!this.hideY) { |
| 76 | + const b = |
| 77 | + p.y > this._barViewport.top && |
| 78 | + p.y < this._barViewport.bottom && |
| 79 | + p.x > |
| 80 | + this.canvas.width - |
| 81 | + this.scrollbarSize - |
| 82 | + this.scrollSpace - |
| 83 | + this.padding && |
| 84 | + p.x < this.canvas.width - this.scrollSpace + this.padding; |
| 85 | + |
| 86 | + if (b) return { type: 'y', start: p.y, vpt }; |
| 87 | + } |
| 88 | + } |
| 89 | + mouseDownHandler(e: TPointerEvent) { |
| 90 | + this._bar = this.getScrollbar(e); |
| 91 | + if (!this._bar) return Canvas.prototype.__onMouseDown.call(this.canvas, e); |
| 92 | + } |
| 93 | + mouseMoveHandler(e: TPointerEvent) { |
| 94 | + // When the mouse is not pressed and the mouse is in the scrollbar area, it will trigger the object's mouse:over/mouse:out, but it cannot select the object (because pressing the mouse will select the scrollbar). |
| 95 | + // For the simplicity of the code, this situation is not judged, it does not affect the use |
| 96 | + if (!this._bar) return Canvas.prototype.__onMouseMove.call(this.canvas, e); |
| 97 | + const p = this.canvas.getViewportPoint(e); |
| 98 | + const s = |
| 99 | + this._bar.type == 'x' ? this._barViewport.sx : this._barViewport.sy; |
| 100 | + const n = this._bar.type == 'x' ? 4 : 5; |
| 101 | + const end = this._bar.type == 'x' ? p.x : p.y; |
| 102 | + const vpt = this._bar.vpt.slice(0) as TMat2D; |
| 103 | + vpt[n] -= (end - this._bar.start) * s; |
| 104 | + |
| 105 | + this.canvas.setViewportTransform(vpt); |
| 106 | + this.canvas.requestRenderAll(); |
| 107 | + } |
| 108 | + mouseUpHandler(e: TPointerEvent) { |
| 109 | + if (!this._bar) Canvas.prototype.__onMouseUp.call(this.canvas, e); |
| 110 | + delete this._bar; |
| 111 | + } |
| 112 | + beforeRenderHandler() { |
| 113 | + const ctx = this.canvas.contextTop; |
| 114 | + // Clear horizontal scrollbar |
| 115 | + if (!this.hideX) { |
| 116 | + ctx.clearRect( |
| 117 | + this.scrollSpace - this.lineWidth / 2, |
| 118 | + this.canvas.height - |
| 119 | + this.scrollbarSize - |
| 120 | + this.scrollSpace - |
| 121 | + this.lineWidth / 2, |
| 122 | + this.canvas.width - this.scrollSpace * 2 + this.lineWidth, |
| 123 | + this.scrollbarSize + this.lineWidth, |
| 124 | + ); |
| 125 | + } |
| 126 | + |
| 127 | + // Clear vertical scrollbar |
| 128 | + if (!this.hideY) { |
| 129 | + ctx.clearRect( |
| 130 | + this.canvas.width - |
| 131 | + this.scrollbarSize - |
| 132 | + this.scrollSpace - |
| 133 | + this.lineWidth / 2, |
| 134 | + this.scrollSpace - this.lineWidth / 2, |
| 135 | + this.scrollbarSize + this.lineWidth, |
| 136 | + this.canvas.height - this.scrollSpace * 2 + this.lineWidth, |
| 137 | + ); |
| 138 | + } |
| 139 | + } |
| 140 | + afterRenderHandler() { |
| 141 | + const { tl, br } = this.canvas.vptCoords; |
| 142 | + /** Visible area */ |
| 143 | + const mapRect = { left: tl.x, top: tl.y, right: br.x, bottom: br.y }; |
| 144 | + /** The area where all shapes are located */ |
| 145 | + const objectRect = this.getObjectsBoundingRect(); |
| 146 | + if (objectRect.left > mapRect.left) objectRect.left = mapRect.left; |
| 147 | + if (objectRect.top > mapRect.top) objectRect.top = mapRect.top; |
| 148 | + if (objectRect.bottom < mapRect.bottom) objectRect.bottom = mapRect.bottom; |
| 149 | + if (objectRect.right < mapRect.right) objectRect.right = mapRect.right; |
| 150 | + |
| 151 | + this.render(this.canvas.contextTop, mapRect, objectRect); |
| 152 | + } |
| 153 | + render( |
| 154 | + ctx: CanvasRenderingContext2D, |
| 155 | + mapRect: ScrollbarProps, |
| 156 | + objectRect: ScrollbarProps, |
| 157 | + ) { |
| 158 | + ctx.save(); |
| 159 | + ctx.fillStyle = this.fill; |
| 160 | + ctx.strokeStyle = this.stroke; |
| 161 | + ctx.lineWidth = this.lineWidth; |
| 162 | + |
| 163 | + // Draw horizontal scrollbar |
| 164 | + if (!this.hideX) this.drawScrollbarX(ctx, mapRect, objectRect); |
| 165 | + // Draw vertical scrollbar |
| 166 | + if (!this.hideY) this.drawScrollbarY(ctx, mapRect, objectRect); |
| 167 | + |
| 168 | + ctx.restore(); |
| 169 | + } |
| 170 | + drawScrollbarX( |
| 171 | + ctx: CanvasRenderingContext2D, |
| 172 | + mapRect: ScrollbarXProps, |
| 173 | + objectRect: ScrollbarXProps, |
| 174 | + ) { |
| 175 | + const mapWidth = mapRect.right - mapRect.left; |
| 176 | + const objectWidth = objectRect.right - objectRect.left; |
| 177 | + if (mapWidth == objectWidth) { |
| 178 | + this._barViewport.left = 1; |
| 179 | + this._barViewport.right = -1; |
| 180 | + this._barViewport.sx = 1; |
| 181 | + return; |
| 182 | + } |
| 183 | + |
| 184 | + const scaleX = Math.min(mapWidth / objectWidth, 1); |
| 185 | + const w = this.canvas.width - this.scrollbarSize - this.scrollSpace * 2; |
| 186 | + const width = Math.max((w * scaleX) | 0, this.scrollbarMinWidth); |
| 187 | + const left = |
| 188 | + ((mapRect.left - objectRect.left) / (objectWidth - mapWidth)) * |
| 189 | + (w - width); |
| 190 | + |
| 191 | + const x = this.scrollSpace + left; |
| 192 | + const y = this.canvas.height - this.scrollbarSize - this.scrollSpace; |
| 193 | + this._barViewport.left = x; |
| 194 | + this._barViewport.right = x + width; |
| 195 | + this._barViewport.sx = objectWidth / mapWidth; |
| 196 | + |
| 197 | + this.drawRect(ctx, { |
| 198 | + x, |
| 199 | + y, |
| 200 | + w: width, |
| 201 | + h: this.scrollbarSize, |
| 202 | + }); |
| 203 | + } |
| 204 | + drawScrollbarY( |
| 205 | + ctx: CanvasRenderingContext2D, |
| 206 | + mapRect: ScrollbarYProps, |
| 207 | + objectRect: ScrollbarYProps, |
| 208 | + ) { |
| 209 | + const mapHeight = mapRect.bottom - mapRect.top; |
| 210 | + const objectHeight = objectRect.bottom - objectRect.top; |
| 211 | + if (mapHeight == objectHeight) { |
| 212 | + this._barViewport.top = 1; |
| 213 | + this._barViewport.bottom = -1; |
| 214 | + this._barViewport.sy = 1; |
| 215 | + } |
| 216 | + |
| 217 | + const scaleY = Math.min(mapHeight / objectHeight, 1); |
| 218 | + const h = this.canvas.height - this.scrollbarSize - this.scrollSpace * 2; |
| 219 | + const height = Math.max((h * scaleY) | 0, this.scrollbarMinWidth); |
| 220 | + const top = |
| 221 | + ((mapRect.top - objectRect.top) / (objectHeight - mapHeight)) * |
| 222 | + (h - height); |
| 223 | + |
| 224 | + const x = this.canvas.width - this.scrollbarSize - this.scrollSpace; |
| 225 | + const y = this.scrollSpace + top; |
| 226 | + this._barViewport.top = y; |
| 227 | + this._barViewport.bottom = y + height; |
| 228 | + this._barViewport.sy = objectHeight / mapHeight; |
| 229 | + this.drawRect(ctx, { |
| 230 | + x, |
| 231 | + y, |
| 232 | + w: this.scrollbarSize, |
| 233 | + h: height, |
| 234 | + }); |
| 235 | + } |
| 236 | + drawRect( |
| 237 | + ctx: CanvasRenderingContext2D, |
| 238 | + props: { x: number; y: number; w: number; h: number }, |
| 239 | + ) { |
| 240 | + const { x, y, w, h } = props; |
| 241 | + const r = Math.min(w, h) / 2; |
| 242 | + ctx.beginPath(); |
| 243 | + ctx.moveTo(x + r, y); |
| 244 | + ctx.lineTo(x + w - r, y); |
| 245 | + ctx.arcTo(x + w, y, x + w, y + r, r); |
| 246 | + ctx.lineTo(x + w, y + h - r); |
| 247 | + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); |
| 248 | + ctx.lineTo(x + r, y + h); |
| 249 | + ctx.arcTo(x, y + h, x, y + h - r, r); |
| 250 | + ctx.lineTo(x, y + r); |
| 251 | + ctx.arcTo(x, y, x + r, y, r); |
| 252 | + ctx.closePath(); |
| 253 | + ctx.fill(); |
| 254 | + ctx.stroke(); |
| 255 | + } |
| 256 | + getObjectsBoundingRect() { |
| 257 | + const objects = this.canvas.getObjects(); |
| 258 | + const { left, top, width, height } = util.makeBoundingBoxFromPoints( |
| 259 | + objects.map((x) => x.getCoords()).flat(1), |
| 260 | + ); |
| 261 | + return { left, top, right: left + width, bottom: top + height }; |
| 262 | + } |
| 263 | + |
| 264 | + dispose() { |
| 265 | + // @ts-expect-error: In the initialization, __onMouseDown was overridden, here it is restored |
| 266 | + delete this.canvas.__onMouseDown; |
| 267 | + // @ts-expect-error: In the initialization, __onMouseMove was overridden, here it is restored |
| 268 | + delete this.canvas.__onMouseMove; |
| 269 | + // @ts-expect-error: In the initialization, __onMouseUp was overridden, here it is restored |
| 270 | + delete this.canvas.__onMouseUp; |
| 271 | + this.canvas.off('before:render', this.beforeRenderHandler); |
| 272 | + this.canvas.off('after:render', this.afterRenderHandler); |
| 273 | + } |
| 274 | +} |
0 commit comments