Skip to content

Commit 4c11d93

Browse files
committed
feat(): Add scrollbars to extensions
1 parent b294849 commit 4c11d93

File tree

10 files changed

+407
-0
lines changed

10 files changed

+407
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [next]
44

5+
- feat(): Add scrollbars to extensions [#10371](https://github.com/fabricjs/fabric.js/discussions/10371)
56
- fix(): BREAKING Fix text positioning [#10803](https://github.com/fabricjs/fabric.js/pull/10803)
67
- fix(AligningGuidelines): Guidelines features updates [#10120] (https://github.com/fabricjs/fabric.js/pull/10120)
78
- chore(deps-dev): bump inquirer from 12.9.6 to 12.10.0 [#10789](https://github.com/fabricjs/fabric.js/pull/10789)

e2e/tests/scrollbars/index.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect, test } from '../../fixtures/base';
2+
3+
test('Scrollbars', async ({ page, canvasUtil }) => {
4+
await test.step('zoom canvas', async () => {
5+
await canvasUtil.executeInBrowser((canvas) => {
6+
canvas.setZoom(2);
7+
});
8+
expect(await canvasUtil.screenshot()).toMatchSnapshot({
9+
name: 'zoom-double.png',
10+
});
11+
await canvasUtil.executeInBrowser((canvas) => {
12+
canvas.setZoom(0.5);
13+
});
14+
expect(await canvasUtil.screenshot()).toMatchSnapshot({
15+
name: 'zoom-half.png',
16+
});
17+
});
18+
19+
await test.step('pan canvas', async () => {
20+
await canvasUtil.executeInBrowser((canvas) => {
21+
canvas.setZoom(1);
22+
canvas.absolutePan(new window.fabric.Point(100, 100));
23+
});
24+
expect(await canvasUtil.screenshot()).toMatchSnapshot({
25+
name: 'pan100.png',
26+
});
27+
});
28+
});
935 Bytes
Loading
887 Bytes
Loading
809 Bytes
Loading

e2e/tests/scrollbars/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Runs in the **BROWSER**
3+
* Imports are defined in 'e2e/imports.ts'
4+
*/
5+
6+
import { Rect } from 'fabric';
7+
import { Scrollbars } from 'fabric/extensions';
8+
import { beforeAll } from '../test';
9+
10+
beforeAll(async (canvas) => {
11+
canvas.setDimensions({ width: 400, height: 150 });
12+
const rect1 = new Rect({
13+
originX: 'center',
14+
originY: 'center',
15+
left: 100,
16+
top: 100,
17+
width: 100,
18+
height: 100,
19+
fill: 'green',
20+
});
21+
22+
const rect2 = new Rect({
23+
originX: 'center',
24+
originY: 'center',
25+
left: 200,
26+
top: 200,
27+
width: 50,
28+
height: 50,
29+
fill: 'yellow',
30+
});
31+
32+
new Scrollbars(canvas);
33+
34+
canvas.add(rect1, rect2);
35+
36+
return { rect1, rect2 };
37+
});

extensions/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
export { AligningGuidelines } from './aligning_guidelines';
22
export type * from './aligning_guidelines/typedefs';
33

4+
export { Scrollbars } from './scrollbars';
5+
export type * from './scrollbars/typedefs';
6+
47
export {
58
originUpdaterWrapper,
69
installOriginWrapperUpdater,

extensions/scrollbars/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Scrollbars
2+
3+
## How to use it
4+
5+
```ts
6+
import { Scrollbars } from 'fabric/extensions';
7+
8+
const config = {
9+
/** Scrollbar fill color */
10+
fill = 'rgba(0,0,0,.3)';
11+
/** Scrollbar stroke color */
12+
stroke = 'rgba(255,255,255,.3)';
13+
/** Scrollbar line width */
14+
lineWidth = 1;
15+
/** Hide horizontal scrollbar */
16+
hideX = false;
17+
/** Hide vertical scrollbar */
18+
hideY = false;
19+
/** Scrollbar minimum width */
20+
scrollbarMinWidth = 40;
21+
/** Scrollbar size */
22+
scrollbarSize = 5;
23+
/** Scrollbar distance from the boundary */
24+
scrollSpace = 4;
25+
/** Scrollbar expansion size, the distance from which the user can effectively slide the scrollbar */
26+
padding = 4;
27+
};
28+
29+
const scrollbars = new Scrollbars(myCanvas, options);
30+
31+
// in order to disable alignment guidelines later:
32+
33+
scrollbars.dispose();
34+
```

extensions/scrollbars/index.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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

Comments
 (0)