mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import type { IVec, IVec3 } from '@blocksuite/global/gfx';
|
||||
import { almostEqual } from '@blocksuite/global/gfx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AStarRunner } from '../utils/a-star.js';
|
||||
|
||||
function mergePath(points: IVec3[]) {
|
||||
if (points.length === 0) return points;
|
||||
const rst: IVec3[] = [points[0]];
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const cur = points[i];
|
||||
const last = points[i - 1];
|
||||
const next = points[i + 1];
|
||||
if (
|
||||
almostEqual(last[0], cur[0], 0.02) &&
|
||||
almostEqual(cur[0], next[0], 0.02)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
almostEqual(last[1], cur[1], 0.02) &&
|
||||
almostEqual(cur[1], next[1], 0.02)
|
||||
)
|
||||
continue;
|
||||
rst.push(cur);
|
||||
}
|
||||
rst.push(points[points.length - 1]);
|
||||
return rst;
|
||||
}
|
||||
|
||||
describe('a* algorithm', () => {
|
||||
/**
|
||||
* 0 ----------------
|
||||
* |
|
||||
* |
|
||||
* ------------------- 0
|
||||
*/
|
||||
it('width is greater than height', () => {
|
||||
const sp: IVec3 = [0, 0, 0];
|
||||
const ep: IVec3 = [200, 100, 0];
|
||||
const osp: IVec3 = [-1, 0, 0];
|
||||
const oep: IVec3 = [201, 100, 0];
|
||||
const points: IVec3[] = [
|
||||
sp,
|
||||
ep,
|
||||
[100, 0, 0],
|
||||
[200, 0, 0],
|
||||
[0, 50, 0],
|
||||
[100, 50, 3],
|
||||
[200, 50, 0],
|
||||
[0, 100, 0],
|
||||
[100, 100, 0],
|
||||
] as IVec3[];
|
||||
const aStar = new AStarRunner(points, sp, ep, osp, oep);
|
||||
aStar.run();
|
||||
let path: IVec[] | IVec3[] = aStar.path;
|
||||
path.pop();
|
||||
path.shift();
|
||||
path = mergePath(path as IVec3[]);
|
||||
const expected = [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 100],
|
||||
[200, 100],
|
||||
];
|
||||
path.forEach((p, i) => {
|
||||
expect(p[0]).toBe(expected[i][0]);
|
||||
expect(p[1]).toBe(expected[i][1]);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* 0
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* |----|
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* 0
|
||||
*/
|
||||
it('height is greater than width', () => {
|
||||
const sp: IVec3 = [0, 0, 0];
|
||||
const ep: IVec3 = [100, 200, 0];
|
||||
const osp: IVec3 = [0, -1, 0];
|
||||
const oep: IVec3 = [100, 201, 0];
|
||||
const points: IVec3[] = [
|
||||
sp,
|
||||
[50, 0, 0],
|
||||
[100, 0, 0],
|
||||
[0, 100, 0],
|
||||
[50, 100, 3],
|
||||
[100, 100, 0],
|
||||
[0, 200, 0],
|
||||
[50, 200, 0],
|
||||
ep,
|
||||
];
|
||||
const aStar = new AStarRunner(points, sp, ep, osp, oep);
|
||||
aStar.run();
|
||||
let path = aStar.path;
|
||||
path.pop();
|
||||
path.shift();
|
||||
path = mergePath(path);
|
||||
const expected = [
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[100, 100],
|
||||
[100, 200],
|
||||
];
|
||||
path.forEach((p, i) => {
|
||||
expect(p[0]).toBe(expected[i][0]);
|
||||
expect(p[1]).toBe(expected[i][1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Bound,
|
||||
getCommonBound,
|
||||
inflateBound,
|
||||
transformPointsToNewBound,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('bound utils', () => {
|
||||
it('Bound basic', () => {
|
||||
const bound = new Bound(1, 1, 2, 2);
|
||||
const serialized = bound.serialize();
|
||||
expect(serialized).toBe('[1,1,2,2]');
|
||||
expect(Bound.deserialize(serialized)).toMatchObject(bound);
|
||||
expect(bound.center).toMatchObject([2, 2]);
|
||||
expect(bound.minX).toBe(1);
|
||||
expect(bound.minY).toBe(1);
|
||||
expect(bound.maxX).toBe(3);
|
||||
expect(bound.maxY).toBe(3);
|
||||
expect(bound.tl).toMatchObject([1, 1]);
|
||||
expect(bound.tr).toMatchObject([3, 1]);
|
||||
expect(bound.bl).toMatchObject([1, 3]);
|
||||
expect(bound.br).toMatchObject([3, 3]);
|
||||
});
|
||||
|
||||
it('from', () => {
|
||||
const b1 = new Bound(1, 1, 2, 2);
|
||||
const b2 = Bound.from(b1);
|
||||
expect(b1).toMatchObject(b2);
|
||||
});
|
||||
|
||||
it('getCommonBound basic', () => {
|
||||
const bounds = Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return {
|
||||
x: index,
|
||||
y: index,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
});
|
||||
expect(getCommonBound(bounds)).toMatchObject({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 10,
|
||||
h: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('getCommonBound parameters length equal to 0', () => {
|
||||
expect(getCommonBound([])).toBeNull();
|
||||
});
|
||||
|
||||
it('getCommonBound parameters length less than 2', () => {
|
||||
const b1 = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
expect(getCommonBound([b1])).toMatchObject(b1);
|
||||
});
|
||||
|
||||
it('inflateBound', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = inflateBound(a, 4);
|
||||
expect(b.serialize()).toBe('[-2,-2,14,14]');
|
||||
|
||||
expect(() => inflateBound(a, -12)).toThrowError(
|
||||
'Invalid delta range or bound size.'
|
||||
);
|
||||
});
|
||||
|
||||
it('transformPointsToNewBound basic', () => {
|
||||
const a = new Bound(0, 0, 20, 20);
|
||||
const b = new Bound(4, 4, 18, 18);
|
||||
const marginA = 4;
|
||||
const marginB = 6;
|
||||
const points = [{ x: 6, y: 6, other: 10 }];
|
||||
const transformed = transformPointsToNewBound(
|
||||
points,
|
||||
a,
|
||||
marginA,
|
||||
b,
|
||||
marginB
|
||||
);
|
||||
|
||||
expect(transformed.bound.serialize()).toBe(b.serialize());
|
||||
expect(transformed.points[0]).toMatchObject({
|
||||
x: 7,
|
||||
y: 7,
|
||||
other: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('transformPointsToNewBound, new bound too small', () => {
|
||||
const a = new Bound(0, 0, 20, 20);
|
||||
const b = new Bound(4, 4, 4, 4);
|
||||
const marginA = 4;
|
||||
const marginB = 6;
|
||||
const points = [{ x: 6, y: 6, other: 10 }];
|
||||
const transformed = transformPointsToNewBound(
|
||||
points,
|
||||
a,
|
||||
marginA,
|
||||
b,
|
||||
marginB
|
||||
);
|
||||
|
||||
expect(transformed.bound.serialize()).toBe('[4,4,13,13]');
|
||||
expect(transformed.points[0].x).toBeCloseTo(6.1667);
|
||||
expect(transformed.points[0].y).toBeCloseTo(6.1667);
|
||||
expect(transformed.points[0].other).toBe(10);
|
||||
});
|
||||
|
||||
it('intersectLine', () => {
|
||||
const bound = new Bound(0, 0, 10, 10);
|
||||
expect(bound.intersectLine([0, 0], [10, 10])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('intersectline no intersection', () => {
|
||||
const bound = new Bound(0, 0, 10, 10);
|
||||
expect(bound.intersectLine([0, -1], [10, -10])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('isIntersectWithBound', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(5, 5, 10, 10);
|
||||
expect(a.isIntersectWithBound(b)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('isIntersectWithBound no intersection', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(11, 11, 10, 10);
|
||||
expect(a.isIntersectWithBound(b)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('unite', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(5, 5, 10, 10);
|
||||
expect(a.unite(b).serialize()).toBe('[0,0,15,15]');
|
||||
});
|
||||
|
||||
it('isHorizontalCross', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(5, 5, 10, 10);
|
||||
expect(a.isHorizontalCross(b)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('isHorizontalCross no intersection', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(11, 11, 10, 10);
|
||||
expect(a.isHorizontalCross(b)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('isVerticalCross', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(5, 5, 10, 10);
|
||||
expect(a.isVerticalCross(b)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('isVerticalCross no intersection', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(11, 11, 10, 10);
|
||||
expect(a.isVerticalCross(b)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('horizontalDistance', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(15, 15, 10, 10);
|
||||
expect(a.horizontalDistance(b)).toBe(5);
|
||||
});
|
||||
|
||||
it('verticalDistance', () => {
|
||||
const a = new Bound(0, 0, 10, 10);
|
||||
const b = new Bound(15, 15, 10, 10);
|
||||
expect(a.verticalDistance(b)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Graph } from '../utils/graph.js';
|
||||
|
||||
describe('graph', () => {
|
||||
it('neighbors', () => {
|
||||
const bound = new Bound(-5, 5, 10, 10);
|
||||
const graph = new Graph(
|
||||
[
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[0, 100],
|
||||
[100, 100],
|
||||
],
|
||||
[bound]
|
||||
);
|
||||
const neighbors = graph.neighbors([0, 0]);
|
||||
expect(neighbors.length).toBe(1);
|
||||
expect(neighbors[0][0]).toBe(100);
|
||||
expect(neighbors[0][1]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
almostEqual,
|
||||
isPointOnLineSegment,
|
||||
type IVec,
|
||||
lineEllipseIntersects,
|
||||
lineIntersects,
|
||||
linePolygonIntersects,
|
||||
linePolylineIntersects,
|
||||
pointAlmostEqual,
|
||||
polygonGetPointTangent,
|
||||
rotatePoints,
|
||||
toDegree,
|
||||
toRadian,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Line', () => {
|
||||
it('should intersect', () => {
|
||||
let rst = lineIntersects([0, 0], [1, 1], [0, 1], [1, 0]);
|
||||
expect(rst).toBeDefined();
|
||||
expect(rst).toMatchObject([0.5, 0.5]);
|
||||
|
||||
rst = lineIntersects([5, 5], [15, 5], [10, 0], [10, 10]);
|
||||
expect(rst).toBeDefined();
|
||||
expect(rst).toMatchObject([10, 5]);
|
||||
});
|
||||
|
||||
it('should not intersect', () => {
|
||||
const rst = lineIntersects([0, 0], [1, 0], [0, 1], [1, 1]);
|
||||
expect(rst).toBeNull();
|
||||
});
|
||||
|
||||
it('should intersect when infinity', () => {
|
||||
const rst = lineIntersects([0, 0], [0, 10], [1, 1], [10, 1], true);
|
||||
expect(rst).toBeDefined();
|
||||
expect(rst).toMatchObject([0, 1]);
|
||||
});
|
||||
|
||||
it('lineEllipseIntersects', () => {
|
||||
const rst = lineEllipseIntersects([0, -5], [0, 5], [0, 0], 1, 1);
|
||||
const expected: IVec[] = [
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
if (!rst) throw new Error('Failed to get line ellipse intersects');
|
||||
expect(
|
||||
rst.every((point, index) => pointAlmostEqual(point, expected[index]))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lineEllipseIntersects with rotate', () => {
|
||||
const rst = lineEllipseIntersects(
|
||||
[0, -5],
|
||||
[0, 5],
|
||||
[0, 0],
|
||||
3,
|
||||
2,
|
||||
Math.PI / 2
|
||||
);
|
||||
expect(rst).toBeDefined();
|
||||
if (rst) {
|
||||
pointAlmostEqual(rst[0], [0, 3]);
|
||||
pointAlmostEqual(rst[1], [0, -3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('linePolygonIntersects', () => {
|
||||
const rst = linePolygonIntersects(
|
||||
[5, 5],
|
||||
[15, 5],
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
]
|
||||
);
|
||||
if (!rst) throw new Error('Failed to get line polygon intersects');
|
||||
expect(pointAlmostEqual(rst[0], [10, 5])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('linePolylineIntersects', () => {
|
||||
const rst = linePolylineIntersects(
|
||||
[5, 5],
|
||||
[-5, 5],
|
||||
[
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10],
|
||||
]
|
||||
);
|
||||
|
||||
expect(rst).toBeNull();
|
||||
});
|
||||
|
||||
it('isPointOnLineSegment', () => {
|
||||
const line: IVec[] = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
];
|
||||
const point: IVec = [0.5, 0];
|
||||
expect(isPointOnLineSegment(point, line)).toBe(true);
|
||||
expect(isPointOnLineSegment([0.01, 0], line)).toBe(true);
|
||||
expect(isPointOnLineSegment([-0.01, 0], line)).toBe(false);
|
||||
expect(isPointOnLineSegment([0.5, 0.1], line)).toBe(false);
|
||||
expect(isPointOnLineSegment([0.5, -0.1], line)).toBe(false);
|
||||
});
|
||||
|
||||
it('rotatePoints', () => {
|
||||
const points: IVec[] = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
];
|
||||
const rst = rotatePoints(points, [0.5, 0.5], 90);
|
||||
const expected = [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
[0, 0],
|
||||
];
|
||||
expect(
|
||||
rst.every((p, i) => {
|
||||
return (
|
||||
almostEqual(p[0], expected[i][0]) && almostEqual(p[1], expected[i][1])
|
||||
);
|
||||
})
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('polygonGetPointTangent', () => {
|
||||
const points: IVec[] = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
];
|
||||
expect(polygonGetPointTangent(points, [0, 0.5])).toMatchObject([0, -1]);
|
||||
expect(polygonGetPointTangent(points, [0.5, 0])).toMatchObject([1, 0]);
|
||||
});
|
||||
|
||||
it('toRadian', () => {
|
||||
expect(toRadian(180)).toBe(Math.PI);
|
||||
expect(toRadian(90)).toBe(Math.PI / 2);
|
||||
expect(toRadian(0)).toBe(0);
|
||||
expect(toRadian(360)).toBe(Math.PI * 2);
|
||||
});
|
||||
|
||||
it('toDegree', () => {
|
||||
expect(toDegree(Math.PI)).toBe(180);
|
||||
expect(toDegree(Math.PI / 2)).toBe(90);
|
||||
expect(toDegree(0)).toBe(0);
|
||||
expect(toDegree(Math.PI * 2)).toBe(360);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PriorityQueue } from '../utils/priority-queue.js';
|
||||
|
||||
describe('priority queue', () => {
|
||||
it('should dequeue the smallest item', () => {
|
||||
const pq = new PriorityQueue<string, number>((a, b) => a - b);
|
||||
pq.enqueue('d', 4);
|
||||
pq.enqueue('c', 3);
|
||||
expect(pq.dequeue()).toBe('c');
|
||||
|
||||
pq.enqueue('b', 2);
|
||||
pq.enqueue('a', 1);
|
||||
expect(pq.dequeue()).toBe('a');
|
||||
expect(pq.dequeue()).toBe('b');
|
||||
|
||||
pq.enqueue('e', 5);
|
||||
expect(pq.dequeue()).toBe('d');
|
||||
expect(pq.dequeue()).toBe('e');
|
||||
expect(pq.dequeue()).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadingSort } from '../utils/sort.js';
|
||||
|
||||
describe('loadingSort', () => {
|
||||
it('should sort correctly', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: '1',
|
||||
deps: ['2', '3'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
deps: ['4', '5'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
deps: ['a'],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
deps: ['b', '5'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
deps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const sorted = loadingSort(elements);
|
||||
|
||||
expect(sorted.map(val => val.id)).toEqual(['3', '5', '4', '2', '1']);
|
||||
});
|
||||
|
||||
it('should sort correctly when no deps', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: '1',
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
deps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const sorted = loadingSort(elements);
|
||||
|
||||
expect(sorted.map(val => val.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('should sort correctly elements deps same element', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: '1',
|
||||
deps: ['2', '3'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
deps: ['4', '5'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
deps: ['6', '7'],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
deps: ['b', '5'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
deps: ['7'],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
deps: [],
|
||||
},
|
||||
];
|
||||
|
||||
const sorted = loadingSort(elements);
|
||||
expect(sorted.map(val => val.id)).toEqual([
|
||||
'6',
|
||||
'7',
|
||||
'3',
|
||||
'5',
|
||||
'4',
|
||||
'2',
|
||||
'1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
EdgelessSurfaceBlockMarkdownAdapterExtension,
|
||||
SurfaceBlockMarkdownAdapterExtension,
|
||||
} from './markdown/markdown.js';
|
||||
import {
|
||||
EdgelessSurfaceBlockPlainTextAdapterExtension,
|
||||
SurfaceBlockPlainTextAdapterExtension,
|
||||
} from './plain-text/plain-text.js';
|
||||
|
||||
export const SurfaceBlockAdapterExtensions = [
|
||||
SurfaceBlockPlainTextAdapterExtension,
|
||||
SurfaceBlockMarkdownAdapterExtension,
|
||||
];
|
||||
|
||||
export const EdgelessSurfaceBlockAdapterExtensions = [
|
||||
EdgelessSurfaceBlockPlainTextAdapterExtension,
|
||||
EdgelessSurfaceBlockMarkdownAdapterExtension,
|
||||
];
|
||||
|
||||
export * from './markdown/element-adapter/index.js';
|
||||
export * from './plain-text/element-adapter/index.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const surfaceBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: 'affine:surface',
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === 'affine:surface',
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (_, context) => {
|
||||
context.walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SurfaceBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
surfaceBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './extension.js';
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MarkdownAST } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import {
|
||||
ElementModelAdapter,
|
||||
type ElementModelAdapterContext,
|
||||
} from '../../type.js';
|
||||
import type { ElementToMarkdownAdapterMatcher } from './type.js';
|
||||
|
||||
export class MarkdownElementModelAdapter extends ElementModelAdapter<
|
||||
MarkdownAST,
|
||||
MarkdownAST
|
||||
> {
|
||||
constructor(
|
||||
readonly elementModelMatchers: ElementToMarkdownAdapterMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
fromElementModel(
|
||||
element: Record<string, unknown>,
|
||||
context: ElementModelAdapterContext<MarkdownAST>
|
||||
) {
|
||||
const markdownAST: MarkdownAST | null = null;
|
||||
for (const matcher of this.elementModelMatchers) {
|
||||
if (matcher.match(element)) {
|
||||
return matcher.toAST(element, context);
|
||||
}
|
||||
}
|
||||
return markdownAST;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './type.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { MarkdownAST } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
|
||||
import type { ElementModelMatcher } from '../../type.js';
|
||||
|
||||
export type ElementToMarkdownAdapterMatcher = ElementModelMatcher<MarkdownAST>;
|
||||
|
||||
export const ElementToMarkdownAdapterMatcherIdentifier =
|
||||
createIdentifier<ElementToMarkdownAdapterMatcher>(
|
||||
'elementToMarkdownAdapterMatcher'
|
||||
);
|
||||
|
||||
export function ElementToMarkdownAdapterExtension(
|
||||
matcher: ElementToMarkdownAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<ElementToMarkdownAdapterMatcher>;
|
||||
} {
|
||||
const identifier = ElementToMarkdownAdapterMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { getMindMapNodeMap } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { MarkdownElementModelAdapter } from './element-adapter/index.js';
|
||||
import { ElementToMarkdownAdapterMatcherIdentifier } from './element-adapter/type.js';
|
||||
|
||||
export const surfaceBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: 'affine:surface',
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === 'affine:surface',
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (_, context) => {
|
||||
context.walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SurfaceBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(surfaceBlockMarkdownAdapterMatcher);
|
||||
|
||||
export const edgelessSurfaceBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: 'affine:surface',
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === 'affine:surface',
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, provider } = context;
|
||||
if (!provider) {
|
||||
context.walkerContext.skipAllChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const elementModelMatchers = Array.from(
|
||||
provider.getAll(ElementToMarkdownAdapterMatcherIdentifier).values()
|
||||
);
|
||||
const markdownElementModelAdapter = new MarkdownElementModelAdapter(
|
||||
elementModelMatchers
|
||||
);
|
||||
if ('elements' in o.node.props) {
|
||||
const elements = o.node.props.elements as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
// Get all the node maps of mindMap elements
|
||||
const mindMapArray = Object.entries(elements)
|
||||
.filter(([_, element]) => element.type === 'mindmap')
|
||||
.map(([_, element]) => getMindMapNodeMap(element));
|
||||
walkerContext.setGlobalContext(
|
||||
'surface:mindMap:nodeMapArray',
|
||||
mindMapArray
|
||||
);
|
||||
|
||||
Object.entries(
|
||||
o.node.props.elements as Record<string, Record<string, unknown>>
|
||||
).forEach(([_, element]) => {
|
||||
const markdownAST = markdownElementModelAdapter.fromElementModel(
|
||||
element,
|
||||
{ walkerContext, elements }
|
||||
);
|
||||
if (markdownAST) {
|
||||
walkerContext.openNode(markdownAST, 'children').closeNode();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgelessSurfaceBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(edgelessSurfaceBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TextBuffer } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import {
|
||||
ElementModelAdapter,
|
||||
type ElementModelAdapterContext,
|
||||
} from '../../type.js';
|
||||
import type { ElementToPlainTextAdapterMatcher } from './type.js';
|
||||
|
||||
export class PlainTextElementModelAdapter extends ElementModelAdapter<
|
||||
string,
|
||||
TextBuffer
|
||||
> {
|
||||
constructor(
|
||||
readonly elementModelMatchers: ElementToPlainTextAdapterMatcher[]
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
fromElementModel(
|
||||
element: Record<string, unknown>,
|
||||
context: ElementModelAdapterContext<TextBuffer>
|
||||
) {
|
||||
for (const matcher of this.elementModelMatchers) {
|
||||
if (matcher.match(element)) {
|
||||
return matcher.toAST(element, context)?.content ?? '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export * from './type.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TextBuffer } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { ElementModelMatcher } from '../../type.js';
|
||||
|
||||
export type ElementToPlainTextAdapterMatcher = ElementModelMatcher<TextBuffer>;
|
||||
|
||||
export const ElementToPlainTextAdapterMatcherIdentifier =
|
||||
createIdentifier<ElementToPlainTextAdapterMatcher>(
|
||||
'elementToPlainTextAdapterMatcher'
|
||||
);
|
||||
|
||||
export function ElementToPlainTextAdapterExtension(
|
||||
matcher: ElementToPlainTextAdapterMatcher
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<ElementToPlainTextAdapterMatcher>;
|
||||
} {
|
||||
const identifier = ElementToPlainTextAdapterMatcherIdentifier(matcher.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => matcher);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getMindMapNodeMap } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { PlainTextElementModelAdapter } from './element-adapter/index.js';
|
||||
import { ElementToPlainTextAdapterMatcherIdentifier } from './element-adapter/type.js';
|
||||
|
||||
export const surfaceBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: 'affine:surface',
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === 'affine:surface',
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (_, context) => {
|
||||
context.walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SurfaceBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(surfaceBlockPlainTextAdapterMatcher);
|
||||
|
||||
export const edgelessSurfaceBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: 'affine:surface',
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === 'affine:surface',
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, provider } = context;
|
||||
if (!provider) {
|
||||
context.walkerContext.skipAllChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const elementModelMatchers = Array.from(
|
||||
provider.getAll(ElementToPlainTextAdapterMatcherIdentifier).values()
|
||||
);
|
||||
const plainTextElementModelAdapter = new PlainTextElementModelAdapter(
|
||||
elementModelMatchers
|
||||
);
|
||||
if ('elements' in o.node.props) {
|
||||
const elements = o.node.props.elements as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
// Get all the node maps of mindMap elements
|
||||
const mindMapArray = Object.entries(elements)
|
||||
.filter(([_, element]) => element.type === 'mindmap')
|
||||
.map(([_, element]) => getMindMapNodeMap(element));
|
||||
walkerContext.setGlobalContext(
|
||||
'surface:mindMap:nodeMapArray',
|
||||
mindMapArray
|
||||
);
|
||||
|
||||
Object.entries(
|
||||
o.node.props.elements as Record<string, Record<string, unknown>>
|
||||
).forEach(([_, element]) => {
|
||||
const plainText = plainTextElementModelAdapter.fromElementModel(
|
||||
element,
|
||||
{ walkerContext, elements }
|
||||
);
|
||||
if (plainText) {
|
||||
context.textBuffer.content += plainText + '\n';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgelessSurfaceBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(edgelessSurfaceBlockPlainTextAdapterMatcher);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ASTWalkerContext } from '@blocksuite/store';
|
||||
|
||||
import type { ElementModelMap } from '../element-model/index.js';
|
||||
|
||||
export type ElementModelAdapterContext<TNode extends object = never> = {
|
||||
walkerContext: ASTWalkerContext<TNode>;
|
||||
elements: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ElementModelMatcher<TNode extends object = never> = {
|
||||
name: keyof ElementModelMap;
|
||||
match: (element: Record<string, unknown>) => boolean;
|
||||
toAST: (
|
||||
element: Record<string, unknown>,
|
||||
context: ElementModelAdapterContext<TNode>
|
||||
) => TNode | null;
|
||||
};
|
||||
|
||||
export abstract class ElementModelAdapter<
|
||||
AST = unknown,
|
||||
TNode extends object = never,
|
||||
> {
|
||||
/**
|
||||
* Convert element model to AST format
|
||||
*/
|
||||
abstract fromElementModel(
|
||||
element: Record<string, unknown>,
|
||||
context: ElementModelAdapterContext<TNode>
|
||||
): AST | null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
EdgelessTextBlockModel,
|
||||
EmbedSyncedDocModel,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
|
||||
import chunk from 'lodash-es/chunk';
|
||||
|
||||
const ALIGN_HEIGHT = 200;
|
||||
const ALIGN_PADDING = 20;
|
||||
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import type { BlockModel, BlockProps } from '@blocksuite/store';
|
||||
|
||||
import { EdgelessCRUDIdentifier } from '../extensions/crud-extension.js';
|
||||
import { updateXYWH } from '../utils/update-xywh.js';
|
||||
|
||||
/**
|
||||
* Automatically arrange elements according to fixed row and column rules
|
||||
*/
|
||||
export const autoArrangeElementsCommand: Command = (ctx, next) => {
|
||||
const { updateBlock } = ctx.std.store;
|
||||
const gfx = ctx.std.get(GfxControllerIdentifier);
|
||||
|
||||
const elements = gfx.selection.selectedElements;
|
||||
const { updateElement } = ctx.std.get(EdgelessCRUDIdentifier);
|
||||
if (elements && updateElement) {
|
||||
autoArrangeElements(elements, updateElement, updateBlock);
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Adjust the height of the selected element to a fixed value and arrange the elements
|
||||
*/
|
||||
export const autoResizeElementsCommand: Command = (ctx, next) => {
|
||||
const { updateBlock } = ctx.std.store;
|
||||
const gfx = ctx.std.get(GfxControllerIdentifier);
|
||||
|
||||
const elements = gfx.selection.selectedElements;
|
||||
const { updateElement } = ctx.std.get(EdgelessCRUDIdentifier);
|
||||
if (elements && updateElement) {
|
||||
autoResizeElements(elements, updateElement, updateBlock);
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
function splitElementsToChunks(models: GfxModel[]) {
|
||||
const sortByCenterX = (a: GfxModel, b: GfxModel) =>
|
||||
a.elementBound.center[0] - b.elementBound.center[0];
|
||||
const sortByCenterY = (a: GfxModel, b: GfxModel) =>
|
||||
a.elementBound.center[1] - b.elementBound.center[1];
|
||||
const elements = models.filter(ele => {
|
||||
if (
|
||||
ele instanceof ConnectorElementModel &&
|
||||
(ele.target.id || ele.source.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
elements.sort(sortByCenterY);
|
||||
const chunks = chunk(elements, 4);
|
||||
chunks.forEach(items => items.sort(sortByCenterX));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function autoArrangeElements(
|
||||
elements: GfxModel[],
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
const chunks = splitElementsToChunks(elements);
|
||||
// update element XY
|
||||
const startX: number = chunks[0][0].elementBound.x;
|
||||
let startY: number = chunks[0][0].elementBound.y;
|
||||
chunks.forEach(items => {
|
||||
let posX = startX;
|
||||
let maxHeight = 0;
|
||||
items.forEach(ele => {
|
||||
const { x: eleX, y: eleY } = ele.elementBound;
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const xOffset = bound.x - eleX;
|
||||
const yOffset = bound.y - eleY;
|
||||
bound.x = posX + xOffset;
|
||||
bound.y = startY + yOffset;
|
||||
updateXYWH(ele, bound, updateElement, updateBlock);
|
||||
if (ele.elementBound.h > maxHeight) {
|
||||
maxHeight = ele.elementBound.h;
|
||||
}
|
||||
posX += ele.elementBound.w + ALIGN_PADDING;
|
||||
});
|
||||
startY += maxHeight + ALIGN_PADDING;
|
||||
});
|
||||
}
|
||||
|
||||
function autoResizeElements(
|
||||
elements: GfxModel[],
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
// resize or scale to fixed height
|
||||
elements.forEach(ele => {
|
||||
if (
|
||||
ele instanceof ConnectorElementModel ||
|
||||
ele instanceof MindmapElementModel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ele instanceof NoteBlockModel) {
|
||||
const curScale = ele.props.edgeless.scale ?? 1;
|
||||
const nextScale = curScale * (ALIGN_HEIGHT / ele.elementBound.h);
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
bound.h = bound.h * (nextScale / curScale);
|
||||
bound.w = bound.w * (nextScale / curScale);
|
||||
updateElement(ele.id, {
|
||||
edgeless: {
|
||||
...ele.props.edgeless,
|
||||
scale: nextScale,
|
||||
},
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else if (
|
||||
ele instanceof EdgelessTextBlockModel ||
|
||||
ele instanceof EmbedSyncedDocModel
|
||||
) {
|
||||
const curScale = ele.props.scale ?? 1;
|
||||
const nextScale = curScale * (ALIGN_HEIGHT / ele.elementBound.h);
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
bound.h = bound.h * (nextScale / curScale);
|
||||
bound.w = bound.w * (nextScale / curScale);
|
||||
updateElement(ele.id, {
|
||||
scale: nextScale,
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else {
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const scale = ALIGN_HEIGHT / ele.elementBound.h;
|
||||
bound.h = scale * bound.h;
|
||||
bound.w = scale * bound.w;
|
||||
updateXYWH(ele, bound, updateElement, updateBlock);
|
||||
}
|
||||
});
|
||||
|
||||
// arrange
|
||||
autoArrangeElements(elements, updateElement, updateBlock);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
autoArrangeElementsCommand,
|
||||
autoResizeElementsCommand,
|
||||
} from './auto-align.js';
|
||||
export { reassociateConnectorsCommand } from './reassociate-connectors.js';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import type { SurfaceBlockModel } from '../surface-model';
|
||||
|
||||
/**
|
||||
* Re-associate bindings for block that have been converted.
|
||||
*
|
||||
* @param oldId - the old block id
|
||||
* @param newId - the new block id
|
||||
*/
|
||||
export const reassociateConnectorsCommand: Command<{
|
||||
oldId: string;
|
||||
newId: string;
|
||||
}> = (ctx, next) => {
|
||||
const { oldId, newId } = ctx;
|
||||
const gfx = ctx.std.get(GfxControllerIdentifier);
|
||||
if (!oldId || !newId || !gfx.surface) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = gfx.surface;
|
||||
const connectors = (surface as SurfaceBlockModel).getConnectors(oldId);
|
||||
for (const { id, source, target } of connectors) {
|
||||
if (source.id === oldId) {
|
||||
surface.updateElement(id, {
|
||||
source: {
|
||||
...source,
|
||||
id: newId,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.id === oldId) {
|
||||
surface.updateElement(id, {
|
||||
target: {
|
||||
...target,
|
||||
id: newId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export const ZOOM_MAX = 6.0;
|
||||
export const ZOOM_MIN = 0.1;
|
||||
export const ZOOM_STEP = 0.25;
|
||||
export const ZOOM_INITIAL = 1.0;
|
||||
export const ZOOM_WHEEL_STEP = 0.1;
|
||||
export const GRID_SIZE = 3000;
|
||||
export const GRID_GAP_MIN = 10;
|
||||
export const GRID_GAP_MAX = 50;
|
||||
export const DEFAULT_NOTE_OFFSET_X = 30;
|
||||
export const DEFAULT_NOTE_OFFSET_Y = 40;
|
||||
|
||||
// TODO: need to check the default central area ratio
|
||||
export const DEFAULT_CENTRAL_AREA_RATIO = 0.3;
|
||||
|
||||
export interface IModelCoord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const EXCLUDING_MOUSE_OUT_CLASS_LIST = [
|
||||
'affine-note-mask',
|
||||
'edgeless-block-portal-note',
|
||||
'affine-block-children-container',
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SurfaceBlockComponent } from './surface-block.js';
|
||||
import { SurfaceBlockVoidComponent } from './surface-block-void.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-surface-void', SurfaceBlockVoidComponent);
|
||||
customElements.define('affine-surface', SurfaceBlockComponent);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
GfxPrimitiveElementModel as SurfaceElementModel,
|
||||
GfxGroupLikeElementModel as SurfaceGroupLikeModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BrushElementModel,
|
||||
ConnectorElementModel,
|
||||
GroupElementModel,
|
||||
HighlighterElementModel,
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
import { SurfaceElementModel } from './base.js';
|
||||
|
||||
export const elementsCtorMap = {
|
||||
group: GroupElementModel,
|
||||
connector: ConnectorElementModel,
|
||||
shape: ShapeElementModel,
|
||||
brush: BrushElementModel,
|
||||
text: TextElementModel,
|
||||
mindmap: MindmapElementModel,
|
||||
highlighter: HighlighterElementModel,
|
||||
};
|
||||
|
||||
export {
|
||||
BrushElementModel,
|
||||
ConnectorElementModel,
|
||||
GroupElementModel,
|
||||
HighlighterElementModel,
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
SurfaceElementModel,
|
||||
TextElementModel,
|
||||
};
|
||||
|
||||
export enum CanvasElementType {
|
||||
BRUSH = 'brush',
|
||||
CONNECTOR = 'connector',
|
||||
GROUP = 'group',
|
||||
MINDMAP = 'mindmap',
|
||||
SHAPE = 'shape',
|
||||
TEXT = 'text',
|
||||
HIGHLIGHTER = 'highlighter',
|
||||
}
|
||||
|
||||
export type ElementModelMap = {
|
||||
['shape']: ShapeElementModel;
|
||||
['brush']: BrushElementModel;
|
||||
['connector']: ConnectorElementModel;
|
||||
['text']: TextElementModel;
|
||||
['group']: GroupElementModel;
|
||||
['mindmap']: MindmapElementModel;
|
||||
['highlighter']: HighlighterElementModel;
|
||||
};
|
||||
|
||||
export function isCanvasElementType(type: string): type is CanvasElementType {
|
||||
return type.toLocaleUpperCase() in CanvasElementType;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
|
||||
import { type BlockSnapshot, Extension, type Store } from '@blocksuite/store';
|
||||
|
||||
import { getSurfaceComponent } from '../utils/get-surface-block';
|
||||
import { EdgelessCRUDIdentifier } from './crud-extension';
|
||||
|
||||
export type ClipboardConfigCreationContext = {
|
||||
/**
|
||||
* element old id to new id
|
||||
*/
|
||||
oldToNewIdMap: Map<string, string>;
|
||||
/**
|
||||
* element old id to new layer index
|
||||
*/
|
||||
originalIndexes: Map<string, string>;
|
||||
|
||||
/**
|
||||
* frame old id to new presentation index
|
||||
*/
|
||||
newPresentationIndexes: Map<string, string>;
|
||||
};
|
||||
|
||||
export const EdgelessClipboardConfigIdentifier =
|
||||
createIdentifier<EdgelessClipboardConfig>('edgeless-clipboard-config');
|
||||
|
||||
export abstract class EdgelessClipboardConfig extends Extension {
|
||||
static key: string;
|
||||
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return getSurfaceComponent(this.std);
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
onBlockSnapshotPaste = async (
|
||||
snapshot: BlockSnapshot,
|
||||
doc: Store,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const block = await this.std.clipboard.pasteBlockSnapshot(
|
||||
snapshot,
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
return block?.id ?? null;
|
||||
};
|
||||
|
||||
abstract createBlock(
|
||||
snapshot: BlockSnapshot,
|
||||
context: ClipboardConfigCreationContext
|
||||
): string | null | Promise<string | null>;
|
||||
|
||||
static override setup(di: Container) {
|
||||
if (!this.key) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'Key is not defined in the EdgelessClipboardConfig'
|
||||
);
|
||||
}
|
||||
|
||||
di.add(
|
||||
this as unknown as { new (std: BlockStdScope): EdgelessClipboardConfig },
|
||||
[StdIdentifier]
|
||||
);
|
||||
|
||||
di.addImpl(EdgelessClipboardConfigIdentifier(this.key), provider =>
|
||||
provider.get(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SurfaceElementModelMap } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { type BlockModel, Extension } from '@blocksuite/store';
|
||||
|
||||
import type { SurfaceBlockModel } from '../surface-model';
|
||||
import { getLastPropsKey } from '../utils/get-last-props-key';
|
||||
import { isConnectable, isNoteBlock } from './query';
|
||||
|
||||
export const EdgelessCRUDIdentifier = createIdentifier<EdgelessCRUDExtension>(
|
||||
'AffineEdgelessCrudService'
|
||||
);
|
||||
|
||||
export class EdgelessCRUDExtension extends Extension {
|
||||
constructor(readonly std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.add(this, [StdIdentifier]);
|
||||
di.addImpl(EdgelessCRUDIdentifier, provider => provider.get(this));
|
||||
}
|
||||
|
||||
private get _gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private get _surface() {
|
||||
return this._gfx.surface as SurfaceBlockModel | null;
|
||||
}
|
||||
|
||||
deleteElements = (elements: GfxModel[]) => {
|
||||
const surface = this._surface;
|
||||
if (!surface) {
|
||||
console.error('surface is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
const set = new Set(elements);
|
||||
elements.forEach(element => {
|
||||
if (isConnectable(element)) {
|
||||
const connectors = surface.getConnectors(element.id);
|
||||
connectors.forEach(connector => set.add(connector));
|
||||
}
|
||||
});
|
||||
|
||||
set.forEach(element => {
|
||||
if (isNoteBlock(element)) {
|
||||
const children = gfx.doc.root?.children ?? [];
|
||||
if (children.length > 1) {
|
||||
gfx.doc.deleteBlock(element);
|
||||
}
|
||||
} else {
|
||||
gfx.deleteElement(element.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
addBlock = (
|
||||
flavour: string,
|
||||
props: Record<string, unknown>,
|
||||
parentId?: string | BlockModel,
|
||||
parentIndex?: number
|
||||
) => {
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
const key = getLastPropsKey(flavour, props);
|
||||
if (key) {
|
||||
props = this.std.get(EditPropsStore).applyLastProps(key, props);
|
||||
}
|
||||
|
||||
const nProps = {
|
||||
...props,
|
||||
index: gfx.layer.generateIndex(),
|
||||
};
|
||||
|
||||
return this.std.store.addBlock(
|
||||
flavour as never,
|
||||
nProps,
|
||||
parentId,
|
||||
parentIndex
|
||||
);
|
||||
};
|
||||
|
||||
addElement = <T extends Record<string, unknown>>(type: string, props: T) => {
|
||||
const surface = this._surface;
|
||||
if (!surface) {
|
||||
console.error('surface is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
const key = getLastPropsKey(type, props);
|
||||
if (key) {
|
||||
props = this.std.get(EditPropsStore).applyLastProps(key, props) as T;
|
||||
}
|
||||
|
||||
const nProps = {
|
||||
...props,
|
||||
type,
|
||||
index: props.index ?? gfx.layer.generateIndex(),
|
||||
};
|
||||
|
||||
return surface.addElement(nProps);
|
||||
};
|
||||
|
||||
updateElement = (id: string, props: Record<string, unknown>) => {
|
||||
const surface = this._surface;
|
||||
if (!surface) {
|
||||
console.error('surface is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this._surface.getElementById(id);
|
||||
if (element) {
|
||||
const key = getLastPropsKey(element.type, {
|
||||
...element.yMap.toJSON(),
|
||||
...props,
|
||||
});
|
||||
key && this.std.get(EditPropsStore).recordLastProps(key, props);
|
||||
this._surface.updateElement(id, props);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = this.std.store.getModelById(id);
|
||||
if (block) {
|
||||
const key = getLastPropsKey(block.flavour, {
|
||||
...block.yBlock.toJSON(),
|
||||
...props,
|
||||
});
|
||||
key && this.std.get(EditPropsStore).recordLastProps(key, props);
|
||||
this.std.store.updateBlock(block, props);
|
||||
}
|
||||
};
|
||||
|
||||
getElementById(id: string): GfxModel | null {
|
||||
const surface = this._surface;
|
||||
if (!surface) {
|
||||
return null;
|
||||
}
|
||||
const el = surface.getElementById(id) ?? this.std.store.getModelById(id);
|
||||
return el as GfxModel | null;
|
||||
}
|
||||
|
||||
getElementsByType<K extends keyof SurfaceElementModelMap>(
|
||||
type: K
|
||||
): SurfaceElementModelMap[K][] {
|
||||
if (!this._surface) {
|
||||
return [];
|
||||
}
|
||||
return this._surface.getElementsByType(type);
|
||||
}
|
||||
|
||||
removeElement(id: string | GfxModel) {
|
||||
id = typeof id === 'string' ? id : id.id;
|
||||
|
||||
const el = this.getElementById(id);
|
||||
if (isGfxGroupCompatibleModel(el)) {
|
||||
el.childIds.forEach(childId => {
|
||||
this.removeElement(childId);
|
||||
});
|
||||
}
|
||||
|
||||
if (el instanceof GfxBlockElementModel) {
|
||||
this.std.store.deleteBlock(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._surface?.hasElementById(id)) {
|
||||
this._surface.deleteElement(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
createIdentifier,
|
||||
type ServiceIdentifier,
|
||||
} from '@blocksuite/global/di';
|
||||
import type {
|
||||
GfxLocalElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { ElementRenderer } from '../renderer/elements';
|
||||
|
||||
export const ElementRendererIdentifier =
|
||||
createIdentifier<unknown>('element-renderer');
|
||||
|
||||
export const ElementRendererExtension = <
|
||||
T extends GfxPrimitiveElementModel | GfxLocalElementModel,
|
||||
>(
|
||||
id: string,
|
||||
renderer: ElementRenderer<T>
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<ElementRenderer<T>>;
|
||||
} => {
|
||||
const identifier = ElementRendererIdentifier<ElementRenderer<T>>(id);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => renderer);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,588 @@
|
||||
import { ImageBlockModel, type RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { FetchUtils } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
CANVAS_EXPORT_IGNORE_TAGS,
|
||||
DEFAULT_IMAGE_PROXY_ENDPOINT,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import type { Viewport } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
isInsidePageEditor,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { IBound } from '@blocksuite/global/gfx';
|
||||
import { deserializeXYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
type EditorHost,
|
||||
StdIdentifier,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
GfxPrimitiveElementModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType, Store } from '@blocksuite/store';
|
||||
|
||||
import type { CanvasRenderer } from '../../renderer/canvas-renderer.js';
|
||||
import type { SurfaceBlockComponent } from '../../surface-block.js';
|
||||
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
|
||||
import { FileExporter } from './file-exporter.js';
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-imports
|
||||
type Html2CanvasFunction = typeof import('html2canvas').default;
|
||||
|
||||
export type ExportOptions = {
|
||||
imageProxyEndpoint: string;
|
||||
};
|
||||
|
||||
export class ExportManager {
|
||||
private readonly _exportOptions: ExportOptions = {
|
||||
imageProxyEndpoint: DEFAULT_IMAGE_PROXY_ENDPOINT,
|
||||
};
|
||||
|
||||
private readonly _replaceRichTextWithSvgElement = (element: HTMLElement) => {
|
||||
const richList = Array.from(element.querySelectorAll('.inline-editor'));
|
||||
richList.forEach(rich => {
|
||||
const svgEle = this._elementToSvgElement(
|
||||
rich.cloneNode(true) as HTMLElement,
|
||||
rich.clientWidth,
|
||||
rich.clientHeight + 1
|
||||
);
|
||||
rich.parentElement?.append(svgEle);
|
||||
rich.remove();
|
||||
});
|
||||
};
|
||||
|
||||
replaceImgSrcWithSvg = async (element: HTMLElement) => {
|
||||
const imgList = Array.from(element.querySelectorAll('img'));
|
||||
// Create an array of promises
|
||||
const promises = imgList.map(img => {
|
||||
return FetchUtils.fetchImage(
|
||||
img.src,
|
||||
undefined,
|
||||
this._exportOptions.imageProxyEndpoint
|
||||
)
|
||||
.then(response => response && response.blob())
|
||||
.then(async blob => {
|
||||
if (!blob) return;
|
||||
// If the file type is SVG, set svg width and height
|
||||
if (blob.type === 'image/svg+xml') {
|
||||
// Parse the SVG
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(
|
||||
await blob.text(),
|
||||
'image/svg+xml'
|
||||
);
|
||||
const svgElement =
|
||||
svgDoc.documentElement as unknown as SVGSVGElement;
|
||||
|
||||
// Check if the SVG has width and height attributes
|
||||
if (
|
||||
!svgElement.hasAttribute('width') &&
|
||||
!svgElement.hasAttribute('height')
|
||||
) {
|
||||
// Get the viewBox
|
||||
const viewBox = svgElement.viewBox.baseVal;
|
||||
// Set the SVG width and height
|
||||
svgElement.setAttribute('width', `${viewBox.width}px`);
|
||||
svgElement.setAttribute('height', `${viewBox.height}px`);
|
||||
}
|
||||
|
||||
// Replace the img src with the modified SVG
|
||||
const serializer = new XMLSerializer();
|
||||
const newSvgStr = serializer.serializeToString(svgElement);
|
||||
img.src =
|
||||
'data:image/svg+xml;charset=utf-8,' +
|
||||
encodeURIComponent(newSvgStr);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for all promises to resolve
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
get doc(): Store {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get editorHost(): EditorHost {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
constructor(readonly std: BlockStdScope) {}
|
||||
|
||||
private _checkCanContinueToCanvas(pathName: string, editorMode: boolean) {
|
||||
if (
|
||||
location.pathname !== pathName ||
|
||||
isInsidePageEditor(this.editorHost) !== editorMode
|
||||
) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.EdgelessExportError,
|
||||
'Unable to export content to canvas'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _checkReady() {
|
||||
const pathname = location.pathname;
|
||||
const editorMode = isInsidePageEditor(this.editorHost);
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
let count = 0;
|
||||
const checkReactRender = setInterval(() => {
|
||||
try {
|
||||
this._checkCanContinueToCanvas(pathname, editorMode);
|
||||
} catch (e) {
|
||||
clearInterval(checkReactRender);
|
||||
reject(e);
|
||||
}
|
||||
const rootModel = this.doc.root;
|
||||
const rootComponent = this.doc.root
|
||||
? rootModel
|
||||
? this.editorHost.view.getBlock(rootModel.id)
|
||||
: null
|
||||
: null;
|
||||
const imageCard = rootComponent?.querySelector(
|
||||
'affine-image-fallback-card'
|
||||
);
|
||||
const isReady =
|
||||
!imageCard || imageCard.getAttribute('imageState') === '0';
|
||||
if (rootComponent && isReady) {
|
||||
clearInterval(checkReactRender);
|
||||
resolve(true);
|
||||
}
|
||||
count++;
|
||||
if (count > 10 * 60) {
|
||||
clearInterval(checkReactRender);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
private _createCanvas(bound: IBound, fillStyle: string) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
|
||||
canvas.width = (bound.w + 100) * dpr;
|
||||
canvas.height = (bound.h + 100) * dpr;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
private _disableMediaPrint() {
|
||||
document.querySelectorAll('.media-print').forEach(mediaPrint => {
|
||||
mediaPrint.classList.add('hide');
|
||||
});
|
||||
}
|
||||
|
||||
private async _docToCanvas(): Promise<HTMLCanvasElement | void> {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
if (!(html2canvas instanceof Function)) return;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const editorMode = isInsidePageEditor(this.editorHost);
|
||||
|
||||
const rootComponent = getRootByEditorHost(this.editorHost);
|
||||
if (!rootComponent) return;
|
||||
const viewportElement = rootComponent.viewportElement;
|
||||
if (!viewportElement) return;
|
||||
const pageContainer = viewportElement.querySelector(
|
||||
'.affine-page-root-block-container'
|
||||
);
|
||||
const rect = pageContainer?.getBoundingClientRect();
|
||||
const { viewport } = rootComponent;
|
||||
if (!viewport) return;
|
||||
const pageWidth = rect?.width;
|
||||
const pageLeft = rect?.left ?? 0;
|
||||
const viewportHeight = viewportElement?.scrollHeight;
|
||||
|
||||
const html2canvasOption = {
|
||||
ignoreElements: function (element: Element) {
|
||||
if (
|
||||
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
|
||||
element.classList.contains('dg')
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
(element.classList.contains('close') &&
|
||||
element.parentElement?.classList.contains(
|
||||
'meta-data-expanded-title'
|
||||
)) ||
|
||||
(element.classList.contains('expand') &&
|
||||
element.parentElement?.classList.contains('meta-data'))
|
||||
) {
|
||||
// the close and expand buttons in affine-doc-meta-data is not needed to be showed
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onclone: async (_documentClone: Document, element: HTMLElement) => {
|
||||
element.style.height = `${viewportHeight}px`;
|
||||
this._replaceRichTextWithSvgElement(element);
|
||||
await this.replaceImgSrcWithSvg(element);
|
||||
},
|
||||
backgroundColor: window.getComputedStyle(viewportElement).backgroundColor,
|
||||
x: pageLeft - viewport.left,
|
||||
width: pageWidth,
|
||||
height: viewportHeight,
|
||||
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
|
||||
proxy: this._exportOptions.imageProxyEndpoint,
|
||||
};
|
||||
|
||||
let data: HTMLCanvasElement;
|
||||
try {
|
||||
this._enableMediaPrint();
|
||||
data = await html2canvas(
|
||||
viewportElement as HTMLElement,
|
||||
html2canvasOption
|
||||
);
|
||||
} finally {
|
||||
this._disableMediaPrint();
|
||||
}
|
||||
this._checkCanContinueToCanvas(pathname, editorMode);
|
||||
return data;
|
||||
}
|
||||
|
||||
private _drawEdgelessBackground(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{
|
||||
size,
|
||||
backgroundColor,
|
||||
gridColor,
|
||||
}: {
|
||||
size: number;
|
||||
backgroundColor: string;
|
||||
gridColor: string;
|
||||
}
|
||||
) {
|
||||
const svgImg = `<svg width='${ctx.canvas.width}px' height='${ctx.canvas.height}px' xmlns='http://www.w3.org/2000/svg' style='background-size:${size}px ${size}px;background-color:${backgroundColor}; background-image: radial-gradient(${gridColor} 1px, ${backgroundColor} 1px)'></svg>`;
|
||||
const img = new Image();
|
||||
const cleanup = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
cleanup();
|
||||
ctx.drawImage(img, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = e => {
|
||||
cleanup();
|
||||
reject(e);
|
||||
};
|
||||
|
||||
img.src = `data:image/svg+xml,${encodeURIComponent(svgImg)}`;
|
||||
});
|
||||
}
|
||||
|
||||
private _elementToSvgElement(
|
||||
node: HTMLElement,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const xmlns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(xmlns, 'svg');
|
||||
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
|
||||
|
||||
svg.setAttribute('width', `${width}`);
|
||||
svg.setAttribute('height', `${height}`);
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
foreignObject.setAttribute('width', '100%');
|
||||
foreignObject.setAttribute('height', '100%');
|
||||
foreignObject.setAttribute('x', '0');
|
||||
foreignObject.setAttribute('y', '0');
|
||||
foreignObject.setAttribute('externalResourcesRequired', 'true');
|
||||
|
||||
svg.append(foreignObject);
|
||||
foreignObject.append(node);
|
||||
return svg;
|
||||
}
|
||||
|
||||
private _enableMediaPrint() {
|
||||
document.querySelectorAll('.media-print').forEach(mediaPrint => {
|
||||
mediaPrint.classList.remove('hide');
|
||||
});
|
||||
}
|
||||
|
||||
private async _html2canvas(
|
||||
htmlElement: HTMLElement,
|
||||
options: Parameters<Html2CanvasFunction>[1] = {}
|
||||
) {
|
||||
const html2canvas = (await import('html2canvas'))
|
||||
.default as unknown as Html2CanvasFunction;
|
||||
const html2canvasOption = {
|
||||
ignoreElements: function (element: Element) {
|
||||
if (
|
||||
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
|
||||
element.classList.contains('dg')
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onclone: async (documentClone: Document, element: HTMLElement) => {
|
||||
// html2canvas can't support transform feature
|
||||
element.style.setProperty('transform', 'none');
|
||||
const layer = element.classList.contains('.affine-edgeless-layer')
|
||||
? element
|
||||
: null;
|
||||
|
||||
if (layer instanceof HTMLElement) {
|
||||
layer.style.setProperty('transform', 'none');
|
||||
}
|
||||
|
||||
const boxShadowEles = documentClone.querySelectorAll(
|
||||
"[style*='box-shadow']"
|
||||
);
|
||||
boxShadowEles.forEach(function (element) {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.setProperty('box-shadow', 'none');
|
||||
}
|
||||
});
|
||||
|
||||
this._replaceRichTextWithSvgElement(element);
|
||||
await this.replaceImgSrcWithSvg(element);
|
||||
},
|
||||
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
|
||||
proxy: this._exportOptions.imageProxyEndpoint,
|
||||
};
|
||||
|
||||
let data: HTMLCanvasElement;
|
||||
try {
|
||||
this._enableMediaPrint();
|
||||
data = await html2canvas(
|
||||
htmlElement,
|
||||
Object.assign(html2canvasOption, options)
|
||||
);
|
||||
} finally {
|
||||
this._disableMediaPrint();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private async _toCanvas(): Promise<HTMLCanvasElement | void> {
|
||||
try {
|
||||
await this._checkReady();
|
||||
} catch (e: unknown) {
|
||||
console.error('Failed to export to canvas');
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInsidePageEditor(this.editorHost)) {
|
||||
return this._docToCanvas();
|
||||
} else {
|
||||
const rootModel = this.doc.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const gfx = this.editorHost.std.get(GfxControllerIdentifier);
|
||||
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
|
||||
if (!surfaceBlock) return;
|
||||
const bound = gfx.elementsBound;
|
||||
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: refactor of this part
|
||||
async edgelessToCanvas(
|
||||
surfaceRenderer: CanvasRenderer,
|
||||
bound: IBound,
|
||||
gfx: GfxController,
|
||||
blocks?: GfxBlockElementModel[],
|
||||
elements?: GfxPrimitiveElementModel[],
|
||||
edgelessBackground?: {
|
||||
zoom: number;
|
||||
}
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const rootModel = this.doc.root;
|
||||
if (!rootModel) return;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const editorMode = isInsidePageEditor(this.editorHost);
|
||||
const rootComponent = getRootByEditorHost(this.editorHost);
|
||||
if (!rootComponent) return;
|
||||
const viewportElement = rootComponent.viewportElement;
|
||||
if (!viewportElement) return;
|
||||
const containerComputedStyle = window.getComputedStyle(viewportElement);
|
||||
|
||||
const container = rootComponent.querySelector(
|
||||
'.affine-block-children-container'
|
||||
);
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const { ctx, canvas } = this._createCanvas(
|
||||
bound,
|
||||
window.getComputedStyle(container).backgroundColor
|
||||
);
|
||||
|
||||
if (edgelessBackground) {
|
||||
await this._drawEdgelessBackground(ctx, {
|
||||
backgroundColor: containerComputedStyle.getPropertyValue(
|
||||
'--affine-background-primary-color'
|
||||
),
|
||||
size: getBgGridGap(edgelessBackground.zoom),
|
||||
gridColor: containerComputedStyle.getPropertyValue(
|
||||
'--affine-edgeless-grid-color'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!blocks && !elements) {
|
||||
blocks = gfx.getElementsByBound(bound, { type: 'block' });
|
||||
elements = gfx.getElementsByBound(bound, { type: 'canvas' });
|
||||
} else {
|
||||
elements = elements ?? [];
|
||||
blocks = blocks ?? [];
|
||||
const blockSet = new Set<GfxBlockElementModel>();
|
||||
const elementSet = new Set<GfxPrimitiveElementModel>();
|
||||
const addFn = (item: GfxModel) => {
|
||||
if (item instanceof GfxBlockElementModel) {
|
||||
blockSet.add(item);
|
||||
} else if (item instanceof GfxPrimitiveElementModel) {
|
||||
elementSet.add(item);
|
||||
}
|
||||
};
|
||||
[...elements, ...blocks].forEach(item => {
|
||||
addFn(item);
|
||||
if (isGfxGroupCompatibleModel(item)) {
|
||||
item.descendantElements.forEach(descendant => {
|
||||
addFn(descendant);
|
||||
});
|
||||
}
|
||||
});
|
||||
elements = [...elementSet];
|
||||
blocks = [...blockSet];
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
if (matchModels(block, [ImageBlockModel])) {
|
||||
if (!block.props.sourceId) return;
|
||||
|
||||
const blob = await block.doc.blobSync.get(block.props.sourceId);
|
||||
if (!blob) return;
|
||||
|
||||
const blobToImage = (blob: Blob) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
const blockBound = xywhArrayToObject(block);
|
||||
ctx.drawImage(
|
||||
await blobToImage(blob),
|
||||
blockBound.x - bound.x,
|
||||
blockBound.y - bound.y,
|
||||
blockBound.w,
|
||||
blockBound.h
|
||||
);
|
||||
}
|
||||
const blockComponent = this.editorHost.view.getBlock(block.id);
|
||||
if (blockComponent) {
|
||||
const blockBound = xywhArrayToObject(block);
|
||||
const canvasData = await this._html2canvas(
|
||||
blockComponent as HTMLElement
|
||||
);
|
||||
ctx.drawImage(
|
||||
canvasData,
|
||||
blockBound.x - bound.x + 50,
|
||||
blockBound.y - bound.y + 50,
|
||||
blockBound.w,
|
||||
blockBound.h
|
||||
);
|
||||
}
|
||||
|
||||
this._checkCanContinueToCanvas(pathname, editorMode);
|
||||
}
|
||||
|
||||
const surfaceCanvas = surfaceRenderer.getCanvasByBound(bound, elements);
|
||||
ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async exportPdf() {
|
||||
const rootModel = this.doc.root;
|
||||
if (!rootModel) return;
|
||||
const canvasImage = await this._toCanvas();
|
||||
if (!canvasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const PDFLib = await import('pdf-lib');
|
||||
const pdfDoc = await PDFLib.PDFDocument.create();
|
||||
const page = pdfDoc.addPage([canvasImage.width, canvasImage.height]);
|
||||
const imageEmbed = await pdfDoc.embedPng(canvasImage.toDataURL('PNG'));
|
||||
const { width, height } = imageEmbed.scale(1);
|
||||
page.drawImage(imageEmbed, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true });
|
||||
|
||||
FileExporter.exportFile(
|
||||
(rootModel as RootBlockModel).props.title.toString() + '.pdf',
|
||||
pdfBase64
|
||||
);
|
||||
}
|
||||
|
||||
async exportPng() {
|
||||
const rootModel = this.doc.root;
|
||||
if (!rootModel) return;
|
||||
const canvasImage = await this._toCanvas();
|
||||
if (!canvasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
FileExporter.exportPng(
|
||||
(this.doc.root as RootBlockModel).props.title.toString(),
|
||||
canvasImage.toDataURL('image/png')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportManagerExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.add(ExportManager, [StdIdentifier]);
|
||||
},
|
||||
};
|
||||
|
||||
function xywhArrayToObject(element: GfxBlockElementModel) {
|
||||
const [x, y, w, h] = deserializeXYWH(element.xywh);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
type RootBlockComponent = BlockComponent & {
|
||||
viewportElement: HTMLElement;
|
||||
viewport: Viewport;
|
||||
};
|
||||
|
||||
function getRootByEditorHost(
|
||||
editorHost: EditorHost
|
||||
): RootBlockComponent | null {
|
||||
const model = editorHost.doc.root;
|
||||
if (!model) return null;
|
||||
const root = editorHost.view.getBlock(model.id);
|
||||
return root as RootBlockComponent | null;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* oxlint-disable no-control-regex */
|
||||
// Context: Lean towards breaking out any localizable content into constants so it's
|
||||
// easier to track content we may need to localize in the future. (i18n)
|
||||
const UNTITLED_PAGE_NAME = 'Untitled';
|
||||
|
||||
/** Tools for exporting files to device. For example, via browser download. */
|
||||
export const FileExporter = {
|
||||
/**
|
||||
* Create a download for the user's browser.
|
||||
*
|
||||
* @param filename
|
||||
* @param text
|
||||
* @param mimeType like `"text/plain"`, `"text/html"`, `"application/javascript"`, etc. See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types mdn docs List of MIME types}.
|
||||
*
|
||||
* @remarks
|
||||
* Only accepts data in utf-8 encoding (html files, javascript source, text files, etc).
|
||||
*
|
||||
* @example
|
||||
* const todoMDText = `# Todo items
|
||||
* [ ] Item 1
|
||||
* [ ] Item 2
|
||||
* `
|
||||
* FileExporter.exportFile("Todo list.md", todoMDText, "text/plain")
|
||||
*
|
||||
* @example
|
||||
* const stateJsonContent = JSON.stringify({ a: 1, b: 2, c: 3 })
|
||||
* FileExporter.exportFile("state.json", jsonContent, "application/json")
|
||||
*/
|
||||
exportFile(filename: string, dataURL: string) {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('href', dataURL);
|
||||
const safeFilename = getSafeFileName(filename);
|
||||
element.setAttribute('download', safeFilename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.append(element);
|
||||
|
||||
element.click();
|
||||
|
||||
element.remove();
|
||||
},
|
||||
exportPng(docTitle: string | undefined, dataURL: string) {
|
||||
const title = docTitle?.trim() || UNTITLED_PAGE_NAME;
|
||||
FileExporter.exportFile(title + '.png', dataURL);
|
||||
},
|
||||
};
|
||||
|
||||
function getSafeFileName(string: string) {
|
||||
const replacement = ' ';
|
||||
const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
|
||||
const windowsReservedNameRegex = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
||||
const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
|
||||
const reTrailingPeriods = /\.+$/;
|
||||
const allowedLength = 50;
|
||||
|
||||
function trimRepeated(string: string, target: string) {
|
||||
const escapeStringRegexp = target
|
||||
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
|
||||
.replace(/-/g, '\\x2d');
|
||||
const regex = new RegExp(`(?:${escapeStringRegexp}){2,}`, 'g');
|
||||
return string.replace(regex, target);
|
||||
}
|
||||
|
||||
string = string
|
||||
.normalize('NFD')
|
||||
.replace(filenameReservedRegex, replacement)
|
||||
.replace(reControlChars, replacement)
|
||||
.replace(reTrailingPeriods, '');
|
||||
|
||||
string = trimRepeated(string, replacement);
|
||||
string = windowsReservedNameRegex.test(string)
|
||||
? string + replacement
|
||||
: string;
|
||||
const extIndex = string.lastIndexOf('.');
|
||||
const filename = string.slice(0, extIndex).trim();
|
||||
const extension = string.slice(extIndex);
|
||||
string =
|
||||
filename.slice(0, Math.max(1, allowedLength - extension.length)) +
|
||||
extension;
|
||||
return string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ExportManager, ExportManagerExtension } from './export-manager.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './clipboard-config';
|
||||
export * from './crud-extension';
|
||||
export * from './element-renderer';
|
||||
export * from './export-manager';
|
||||
export * from './legacy-slot-extension';
|
||||
export * from './query';
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { Subject } from 'rxjs';
|
||||
export const EdgelessLegacySlotIdentifier = createIdentifier<{
|
||||
readonlyUpdated: Subject<boolean>;
|
||||
navigatorSettingUpdated: Subject<{
|
||||
hideToolbar?: boolean;
|
||||
blackBackground?: boolean;
|
||||
fillScreen?: boolean;
|
||||
}>;
|
||||
navigatorFrameChanged: Subject<FrameBlockModel>;
|
||||
fullScreenToggled: Subject<void>;
|
||||
|
||||
elementResizeStart: Subject<void>;
|
||||
elementResizeEnd: Subject<void>;
|
||||
toggleNoteSlicer: Subject<void>;
|
||||
|
||||
toolbarLocked: Subject<boolean>;
|
||||
}>('AffineEdgelessLegacySlotService');
|
||||
|
||||
export const EdgelessLegacySlotExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(EdgelessLegacySlotIdentifier, () => ({
|
||||
readonlyUpdated: new Subject<boolean>(),
|
||||
navigatorSettingUpdated: new Subject<{
|
||||
hideToolbar?: boolean;
|
||||
blackBackground?: boolean;
|
||||
fillScreen?: boolean;
|
||||
}>(),
|
||||
navigatorFrameChanged: new Subject<FrameBlockModel>(),
|
||||
fullScreenToggled: new Subject(),
|
||||
elementResizeStart: new Subject(),
|
||||
elementResizeEnd: new Subject(),
|
||||
toggleNoteSlicer: new Subject(),
|
||||
toolbarLocked: new Subject<boolean>(),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { Connectable } from '../managers/connector-manager';
|
||||
|
||||
export function isConnectable(
|
||||
element: GfxModel | null
|
||||
): element is Connectable {
|
||||
return !!element && element.connectable;
|
||||
}
|
||||
|
||||
export function isNoteBlock(
|
||||
element: BlockModel | GfxModel | null
|
||||
): element is NoteBlockModel {
|
||||
return !!element && 'flavour' in element && element.flavour === 'affine:note';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// oxlint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./effects.ts" />
|
||||
export * from './consts.js';
|
||||
export { GRID_GAP_MAX, GRID_GAP_MIN } from './consts.js';
|
||||
export {
|
||||
SurfaceElementModel,
|
||||
SurfaceGroupLikeModel,
|
||||
} from './element-model/base.js';
|
||||
export { CanvasElementType } from './element-model/index.js';
|
||||
import {
|
||||
isConnectorAndBindingsAllSelected,
|
||||
isConnectorWithLabel,
|
||||
} from './managers/connector-manager.js';
|
||||
export {
|
||||
calculateNearestLocation,
|
||||
ConnectionOverlay,
|
||||
ConnectorEndpointLocations,
|
||||
ConnectorEndpointLocationsOnTriangle,
|
||||
ConnectorPathGenerator,
|
||||
PathGenerator,
|
||||
} from './managers/connector-manager.js';
|
||||
export { CanvasRenderer } from './renderer/canvas-renderer.js';
|
||||
export type { ElementRenderer } from './renderer/elements/index.js';
|
||||
export * from './renderer/elements/type.js';
|
||||
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';
|
||||
export { ToolOverlay } from './renderer/tool-overlay.js';
|
||||
import {
|
||||
getFontFaces,
|
||||
getFontFacesByFontFamily,
|
||||
isSameFontFamily,
|
||||
wrapFontFamily,
|
||||
} from './utils/font.js';
|
||||
export * from './adapters/index.js';
|
||||
export * from './extensions';
|
||||
export type { SurfaceContext } from './surface-block.js';
|
||||
export { SurfaceBlockComponent } from './surface-block.js';
|
||||
export {
|
||||
SurfaceBlockModel,
|
||||
SurfaceBlockSchema,
|
||||
SurfaceBlockSchemaExtension,
|
||||
} from './surface-model.js';
|
||||
export {
|
||||
EdgelessSurfaceBlockSpec,
|
||||
PageSurfaceBlockSpec,
|
||||
} from './surface-spec.js';
|
||||
export { SurfaceBlockTransformer } from './surface-transformer.js';
|
||||
export {
|
||||
addNote,
|
||||
addNoteAtPoint,
|
||||
generateElementId,
|
||||
getBgGridGap,
|
||||
getLastPropsKey,
|
||||
getSurfaceBlock,
|
||||
getSurfaceComponent,
|
||||
normalizeWheelDeltaY,
|
||||
} from './utils';
|
||||
export { AStarRunner } from './utils/a-star.js';
|
||||
export { RoughCanvas } from './utils/rough/canvas.js';
|
||||
export type { Options } from './utils/rough/core';
|
||||
export { sortIndex } from './utils/sort';
|
||||
export { updateXYWH } from './utils/update-xywh.js';
|
||||
|
||||
export const ConnectorUtils = {
|
||||
isConnectorAndBindingsAllSelected,
|
||||
isConnectorWithLabel,
|
||||
};
|
||||
|
||||
export const TextUtils = {
|
||||
wrapFontFamily,
|
||||
getFontFaces,
|
||||
getFontFacesByFontFamily,
|
||||
isSameFontFamily,
|
||||
};
|
||||
|
||||
export * from './commands';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { IBound } from '@blocksuite/global/gfx';
|
||||
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type {
|
||||
GridManager,
|
||||
LayerManager,
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import last from 'lodash-es/last';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { SurfaceElementModel } from '../element-model/base.js';
|
||||
import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
|
||||
import { RoughCanvas } from '../utils/rough/canvas.js';
|
||||
import type { ElementRenderer } from './elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
getColorScheme: () => ColorScheme;
|
||||
getColorValue: (color: Color, fallback?: Color, real?: boolean) => string;
|
||||
getPropertyValue: (property: string) => string;
|
||||
selectedElements?: () => string[];
|
||||
};
|
||||
|
||||
type RendererOptions = {
|
||||
std: BlockStdScope;
|
||||
viewport: Viewport;
|
||||
layerManager: LayerManager;
|
||||
provider?: Partial<EnvProvider>;
|
||||
enableStackingCanvas?: boolean;
|
||||
onStackingCanvasCreated?: (canvas: HTMLCanvasElement) => void;
|
||||
gridManager: GridManager;
|
||||
surfaceModel: SurfaceBlockModel;
|
||||
};
|
||||
|
||||
export class CanvasRenderer {
|
||||
private _container!: HTMLElement;
|
||||
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
private readonly _overlays = new Set<Overlay>();
|
||||
|
||||
private _refreshRafId: number | null = null;
|
||||
|
||||
private _stackingCanvas: HTMLCanvasElement[] = [];
|
||||
|
||||
canvas: HTMLCanvasElement;
|
||||
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
std: BlockStdScope;
|
||||
|
||||
grid: GridManager;
|
||||
|
||||
layerManager: LayerManager;
|
||||
|
||||
provider: Partial<EnvProvider>;
|
||||
|
||||
stackingCanvasUpdated = new Subject<{
|
||||
canvases: HTMLCanvasElement[];
|
||||
added: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
}>();
|
||||
|
||||
viewport: Viewport;
|
||||
|
||||
get stackingCanvas() {
|
||||
return this._stackingCanvas;
|
||||
}
|
||||
|
||||
constructor(options: RendererOptions) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
this.canvas = canvas;
|
||||
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
this.std = options.std;
|
||||
this.viewport = options.viewport;
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
this._initViewport();
|
||||
|
||||
options.enableStackingCanvas = options.enableStackingCanvas ?? false;
|
||||
if (options.enableStackingCanvas) {
|
||||
this._initStackingCanvas(options.onStackingCanvasCreated);
|
||||
}
|
||||
|
||||
this._watchSurface(options.surfaceModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifying the actual size gives better results and more consistent behavior across browsers.
|
||||
*
|
||||
* Make sure the main canvas and the offscreen canvas or layer canvas are the same size.
|
||||
*
|
||||
* It is not recommended to set width and height to 100%.
|
||||
*/
|
||||
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
|
||||
const { width, height } = this.viewport;
|
||||
const actualWidth = Math.ceil(width * dpr);
|
||||
const actualHeight = Math.ceil(height * dpr);
|
||||
|
||||
return {
|
||||
filter({ width, height }: HTMLCanvasElement) {
|
||||
return width !== actualWidth || height !== actualHeight;
|
||||
},
|
||||
update(canvas: HTMLCanvasElement) {
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
canvas.width = actualWidth;
|
||||
canvas.height = actualHeight;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
||||
const layer = this.layerManager;
|
||||
const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => {
|
||||
this._stackingCanvas = canvases;
|
||||
|
||||
const sizeUpdater = this._canvasSizeUpdater();
|
||||
|
||||
canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update);
|
||||
};
|
||||
const updateStackingCanvas = () => {
|
||||
/**
|
||||
* we already have a main canvas, so the last layer should be skipped
|
||||
*/
|
||||
const canvasLayers = layer.getCanvasLayers().slice(0, -1);
|
||||
const canvases = [];
|
||||
const currentCanvases = this._stackingCanvas;
|
||||
const lastLayer = last(this.layerManager.layers);
|
||||
const maximumZIndex = lastLayer
|
||||
? lastLayer.zIndex + lastLayer.elements.length + 1
|
||||
: 1;
|
||||
|
||||
this.canvas.style.zIndex = maximumZIndex.toString();
|
||||
|
||||
for (let i = 0; i < canvasLayers.length; ++i) {
|
||||
const layer = canvasLayers[i];
|
||||
const created = i < currentCanvases.length;
|
||||
const canvas = created
|
||||
? currentCanvases[i]
|
||||
: document.createElement('canvas');
|
||||
|
||||
if (!created) {
|
||||
onCreated?.(canvas);
|
||||
}
|
||||
|
||||
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
|
||||
canvas.style.zIndex = layer.zIndex.toString();
|
||||
canvases.push(canvas);
|
||||
}
|
||||
|
||||
this._stackingCanvas = canvases;
|
||||
updateStackingCanvasSize(canvases);
|
||||
|
||||
if (currentCanvases.length !== canvases.length) {
|
||||
const diff = canvases.length - currentCanvases.length;
|
||||
const payload: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
added: HTMLCanvasElement[];
|
||||
} = {
|
||||
canvases,
|
||||
removed: [],
|
||||
added: [],
|
||||
};
|
||||
|
||||
if (diff > 0) {
|
||||
payload.added = canvases.slice(-diff);
|
||||
} else {
|
||||
payload.removed = currentCanvases.slice(diff);
|
||||
}
|
||||
|
||||
this.stackingCanvasUpdated.next(payload);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.layerManager.slots.layerUpdated.subscribe(() => {
|
||||
updateStackingCanvas();
|
||||
})
|
||||
);
|
||||
|
||||
updateStackingCanvas();
|
||||
}
|
||||
|
||||
private _initViewport() {
|
||||
let sizeUpdatedRafId: number | null = null;
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => {
|
||||
if (sizeUpdatedRafId) return;
|
||||
sizeUpdatedRafId = requestConnectedFrame(() => {
|
||||
sizeUpdatedRafId = null;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
this.refresh();
|
||||
}, this._container);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _render() {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
const { ctx } = this;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const scale = zoom * dpr;
|
||||
const matrix = new DOMMatrix().scaleSelf(scale);
|
||||
/**
|
||||
* if a layer does not have a corresponding canvas
|
||||
* its element will be add to this array and drawing on the
|
||||
* main canvas
|
||||
*/
|
||||
let fallbackElement: SurfaceElementModel[] = [];
|
||||
|
||||
this.layerManager.getCanvasLayers().forEach((layer, idx) => {
|
||||
if (!this._stackingCanvas[idx]) {
|
||||
fallbackElement = fallbackElement.concat(layer.elements);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this._stackingCanvas[idx];
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const rc = new RoughCanvas(ctx.canvas);
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements);
|
||||
});
|
||||
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
ctx.save();
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
this._renderByBound(
|
||||
ctx,
|
||||
matrix,
|
||||
new RoughCanvas(ctx.canvas),
|
||||
viewportBounds,
|
||||
fallbackElement,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private _renderByBound(
|
||||
ctx: CanvasRenderingContext2D | null,
|
||||
matrix: DOMMatrix,
|
||||
rc: RoughCanvas,
|
||||
bound: IBound,
|
||||
surfaceElements?: SurfaceElementModel[],
|
||||
overLay: boolean = false
|
||||
) {
|
||||
if (!ctx) return;
|
||||
|
||||
const elements =
|
||||
surfaceElements ??
|
||||
(this.grid.search(bound, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[]);
|
||||
|
||||
for (const element of elements) {
|
||||
const display = (element.display ?? true) && !element.hidden;
|
||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
||||
const renderFn = this.std.getOptional<ElementRenderer>(
|
||||
ElementRendererIdentifier(element.type)
|
||||
);
|
||||
|
||||
if (!renderFn) {
|
||||
console.warn(`Cannot find renderer for ${element.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.globalAlpha = element.opacity ?? 1;
|
||||
const dx = element.x - bound.x;
|
||||
const dy = element.y - bound.y;
|
||||
|
||||
renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
if (overLay) {
|
||||
for (const overlay of this._overlays) {
|
||||
ctx.save();
|
||||
ctx.translate(-bound.x, -bound.y);
|
||||
overlay.render(ctx, rc);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _resetSize() {
|
||||
const sizeUpdater = this._canvasSizeUpdater();
|
||||
|
||||
sizeUpdater.update(this.canvas);
|
||||
|
||||
this._stackingCanvas.forEach(sizeUpdater.update);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private _watchSurface(surfaceModel: SurfaceBlockModel) {
|
||||
this._disposables.add(
|
||||
surfaceModel.elementAdded.subscribe(() => this.refresh())
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.elementRemoved.subscribe(() => this.refresh())
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementAdded.subscribe(() => this.refresh())
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
surfaceModel.elementUpdated.subscribe(payload => {
|
||||
// ignore externalXYWH update cause it's updated by the renderer
|
||||
if (payload.props['externalXYWH']) return;
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay(overlay: Overlay) {
|
||||
overlay.setRenderer(this);
|
||||
this._overlays.add(overlay);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to attach main canvas, main canvas will always exist
|
||||
* @param container
|
||||
*/
|
||||
attach(container: HTMLElement) {
|
||||
this._container = container;
|
||||
container.append(this.canvas);
|
||||
|
||||
this._resetSize();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._overlays.forEach(overlay => overlay.dispose());
|
||||
this._overlays.clear();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
generateColorProperty(color: Color, fallback?: Color) {
|
||||
return (
|
||||
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
|
||||
);
|
||||
}
|
||||
|
||||
getCanvasByBound(
|
||||
bound: IBound = this.viewport.viewportBounds,
|
||||
surfaceElements?: SurfaceElementModel[],
|
||||
canvas?: HTMLCanvasElement,
|
||||
clearBeforeDrawing?: boolean,
|
||||
withZoom?: boolean
|
||||
): HTMLCanvasElement {
|
||||
canvas = canvas || document.createElement('canvas');
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
|
||||
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
|
||||
|
||||
canvas.style.width = `${bound.w}px`;
|
||||
canvas.style.height = `${bound.h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const matrix = new DOMMatrix().scaleSelf(
|
||||
withZoom ? dpr * this.viewport.zoom : dpr
|
||||
);
|
||||
const rc = new RoughCanvas(canvas);
|
||||
|
||||
if (clearBeforeDrawing) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
this._renderByBound(ctx, matrix, rc, bound, surfaceElements);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
getColorScheme() {
|
||||
return this.provider.getColorScheme?.() ?? ColorScheme.Light;
|
||||
}
|
||||
|
||||
getColorValue(color: Color, fallback?: Color, real?: boolean) {
|
||||
return (
|
||||
this.provider.getColorValue?.(color, fallback, real) ?? 'transparent'
|
||||
);
|
||||
}
|
||||
|
||||
getPropertyValue(property: string) {
|
||||
return this.provider.getPropertyValue?.(property) ?? '';
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if (this._refreshRafId !== null) return;
|
||||
|
||||
this._refreshRafId = requestConnectedFrame(() => {
|
||||
this._refreshRafId = null;
|
||||
this._render();
|
||||
}, this._container);
|
||||
}
|
||||
|
||||
removeOverlay(overlay: Overlay) {
|
||||
if (!this._overlays.has(overlay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.setRenderer(null);
|
||||
this._overlays.delete(overlay);
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IBound } from '@blocksuite/global/gfx';
|
||||
import type {
|
||||
GfxLocalElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import type { RoughCanvas } from '../../index.js';
|
||||
import type { CanvasRenderer } from '../canvas-renderer.js';
|
||||
|
||||
export type ElementRenderer<
|
||||
T extends
|
||||
| GfxPrimitiveElementModel
|
||||
| GfxLocalElementModel = GfxPrimitiveElementModel,
|
||||
> = (
|
||||
model: T,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
viewportBound: IBound
|
||||
) => void;
|
||||
|
||||
export const elementRendererExtensions = [];
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {
|
||||
ShapeElementModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
export type CanvasElementWithText = ShapeElementModel | TextElementModel;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import {
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { RoughCanvas } from '../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from './canvas-renderer.js';
|
||||
|
||||
/**
|
||||
* An overlay is a layer covered on top of elements,
|
||||
* can be used for rendering non-CRDT state indicators.
|
||||
*/
|
||||
export abstract class Overlay extends Extension {
|
||||
static overlayName: string = '';
|
||||
|
||||
protected _renderer: CanvasRenderer | null = null;
|
||||
|
||||
constructor(protected gfx: GfxController) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container): void {
|
||||
if (!this.overlayName) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
`The overlay constructor '${this.name}' should have a static 'overlayName' property.`
|
||||
);
|
||||
}
|
||||
|
||||
di.addImpl(OverlayIdentifier(this.overlayName), this, [
|
||||
GfxControllerIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
clear() {}
|
||||
|
||||
dispose() {}
|
||||
|
||||
refresh() {
|
||||
if (this._renderer) {
|
||||
this._renderer.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
abstract render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void;
|
||||
|
||||
setRenderer(renderer: CanvasRenderer | null) {
|
||||
this._renderer = renderer;
|
||||
}
|
||||
}
|
||||
|
||||
export const OverlayIdentifier = createIdentifier<Overlay>('Overlay');
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import type { GfxController } from '@blocksuite/std/gfx';
|
||||
|
||||
import type { RoughCanvas } from '../utils/rough/canvas';
|
||||
import { Overlay } from './overlay';
|
||||
|
||||
export class ToolOverlay extends Overlay {
|
||||
protected disposables = new DisposableGroup();
|
||||
|
||||
globalAlpha: number;
|
||||
|
||||
x: number;
|
||||
|
||||
y: number;
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.globalAlpha = 0;
|
||||
this.gfx = gfx;
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
// when viewport is updated, we should keep the overlay in the same position
|
||||
// to get last mouse position and convert it to model coordinates
|
||||
const pos = this.gfx.tool.lastMousePos$.value;
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(pos.x, pos.y);
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void {
|
||||
noop([ctx, rc]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BlockComponent } from '@blocksuite/std';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export class SurfaceBlockVoidComponent extends BlockComponent<SurfaceBlockModel> {
|
||||
override render() {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-surface-void': SurfaceBlockVoidComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Color } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { EditorHost, SurfaceSelection } from '@blocksuite/std';
|
||||
import { BlockComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier, type Viewport } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { css, html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import type { Subject } from 'rxjs';
|
||||
|
||||
import { ConnectorElementModel } from './element-model/index.js';
|
||||
import { CanvasRenderer } from './renderer/canvas-renderer.js';
|
||||
import { OverlayIdentifier } from './renderer/overlay.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export interface SurfaceContext {
|
||||
viewport: Viewport;
|
||||
host: EditorHost;
|
||||
selection: {
|
||||
selectedIds: string[];
|
||||
slots: {
|
||||
updated: Subject<SurfaceSelection[]>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
||||
static isConnector = (element: unknown): element is ConnectorElementModel => {
|
||||
return element instanceof ConnectorElementModel;
|
||||
};
|
||||
|
||||
static override styles = css`
|
||||
.affine-edgeless-surface-block-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-edgeless-surface-block-container canvas {
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
edgeless-block-portal-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.affine-block-children-container.edgeless {
|
||||
padding-left: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
/**
|
||||
* Fix: pointerEvent stops firing after a short time.
|
||||
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
|
||||
* up to the one that implements the gesture (in other words, the first containing scrolling element)
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
|
||||
*/
|
||||
touch-action: none;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
background-image: radial-gradient(
|
||||
var(--affine-edgeless-grid-color) 1px,
|
||||
var(--affine-background-primary-color) 1px
|
||||
);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.affine-edgeless-block-child {
|
||||
position: absolute;
|
||||
transform-origin: center;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--affine-white-10);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--affine-shadow-3);
|
||||
pointer-events: all;
|
||||
}
|
||||
`;
|
||||
|
||||
private _cachedViewport = new Bound();
|
||||
|
||||
private readonly _initThemeObserver = () => {
|
||||
const theme = this.std.get(ThemeProvider);
|
||||
this.disposables.add(theme.theme$.subscribe(() => this.requestUpdate()));
|
||||
};
|
||||
|
||||
private _lastTime = 0;
|
||||
|
||||
private _renderer!: CanvasRenderer;
|
||||
|
||||
fitToViewport = (bound: Bound) => {
|
||||
const { viewport } = this._gfx;
|
||||
bound = bound.expand(30);
|
||||
if (Date.now() - this._lastTime > 200)
|
||||
this._cachedViewport = viewport.viewportBounds;
|
||||
this._lastTime = Date.now();
|
||||
|
||||
if (this._cachedViewport.contains(bound)) return;
|
||||
|
||||
this._cachedViewport = this._cachedViewport.unite(bound);
|
||||
viewport.setViewportByBound(this._cachedViewport, [0, 0, 0, 0], true);
|
||||
};
|
||||
|
||||
refresh = () => {
|
||||
this._renderer?.refresh();
|
||||
};
|
||||
|
||||
private get _gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get renderer() {
|
||||
return this._renderer;
|
||||
}
|
||||
|
||||
private _initOverlays() {
|
||||
this.std.provider.getAll(OverlayIdentifier).forEach(overlay => {
|
||||
this._renderer.addOverlay(overlay);
|
||||
});
|
||||
|
||||
this._disposables.add(() => {
|
||||
this.std.provider.getAll(OverlayIdentifier).forEach(overlay => {
|
||||
this._renderer.removeOverlay(overlay);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _initRenderer() {
|
||||
const gfx = this._gfx;
|
||||
const themeService = this.std.get(ThemeProvider);
|
||||
|
||||
this._renderer = new CanvasRenderer({
|
||||
std: this.std,
|
||||
viewport: gfx.viewport,
|
||||
layerManager: gfx.layer,
|
||||
gridManager: gfx.grid,
|
||||
enableStackingCanvas: true,
|
||||
provider: {
|
||||
generateColorProperty: (color: Color, fallback?: Color) =>
|
||||
themeService.generateColorProperty(
|
||||
color,
|
||||
fallback,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
getColorValue: (color: Color, fallback?: Color, real?: boolean) =>
|
||||
themeService.getColorValue(
|
||||
color,
|
||||
fallback,
|
||||
real,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
getColorScheme: () => themeService.edgelessTheme,
|
||||
getPropertyValue: (property: string) =>
|
||||
themeService.getCssVariableColor(
|
||||
property,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
selectedElements: () => gfx.selection.selectedIds,
|
||||
},
|
||||
onStackingCanvasCreated(canvas) {
|
||||
canvas.className = 'indexable-canvas';
|
||||
},
|
||||
surfaceModel: this.model,
|
||||
});
|
||||
|
||||
this._disposables.add(() => {
|
||||
this._renderer.dispose();
|
||||
});
|
||||
this._disposables.add(
|
||||
this._renderer.stackingCanvasUpdated.subscribe(payload => {
|
||||
if (payload.added.length) {
|
||||
this._surfaceContainer.append(...payload.added);
|
||||
}
|
||||
|
||||
if (payload.removed.length) {
|
||||
payload.removed.forEach(canvas => {
|
||||
canvas.remove();
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
gfx.selection.slots.updated.subscribe(() => {
|
||||
this._renderer.refresh();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
|
||||
this._initThemeObserver();
|
||||
this._initRenderer();
|
||||
this._initOverlays();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._renderer.attach(this._surfaceContainer);
|
||||
this._renderer['_render']();
|
||||
this._surfaceContainer.append(...this._renderer.stackingCanvas);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="affine-edgeless-surface-block-container">
|
||||
<!-- attach canvas later in renderer -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.affine-edgeless-surface-block-container')
|
||||
private accessor _surfaceContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-surface': SurfaceBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
ConnectorElementModel,
|
||||
SurfaceElementModelMap,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { SurfaceBlockProps } from '@blocksuite/std/gfx';
|
||||
import { SurfaceBlockModel as BaseSurfaceModel } from '@blocksuite/std/gfx';
|
||||
import { BlockSchemaExtension, defineBlockSchema } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { elementsCtorMap } from './element-model/index.js';
|
||||
import { SurfaceBlockTransformer } from './surface-transformer.js';
|
||||
import { connectorWatcher } from './watchers/connector.js';
|
||||
import { groupRelationWatcher } from './watchers/group.js';
|
||||
|
||||
export const SurfaceBlockSchema = defineBlockSchema({
|
||||
flavour: 'affine:surface',
|
||||
props: (internalPrimitives): SurfaceBlockProps => ({
|
||||
elements: internalPrimitives.Boxed(new Y.Map()),
|
||||
}),
|
||||
metadata: {
|
||||
version: 5,
|
||||
role: 'hub',
|
||||
parent: ['@root'],
|
||||
children: [
|
||||
'affine:frame',
|
||||
'affine:image',
|
||||
'affine:bookmark',
|
||||
'affine:attachment',
|
||||
'affine:embed-*',
|
||||
'affine:edgeless-text',
|
||||
],
|
||||
},
|
||||
transformer: transformerConfigs =>
|
||||
new SurfaceBlockTransformer(transformerConfigs),
|
||||
toModel: () => new SurfaceBlockModel(),
|
||||
});
|
||||
|
||||
export const SurfaceBlockSchemaExtension =
|
||||
BlockSchemaExtension(SurfaceBlockSchema);
|
||||
|
||||
export type SurfaceMiddleware = (surface: SurfaceBlockModel) => () => void;
|
||||
|
||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
||||
|
||||
override _init() {
|
||||
this._extendElement(elementsCtorMap);
|
||||
super._init();
|
||||
[connectorWatcher(this), groupRelationWatcher(this)].forEach(disposable =>
|
||||
this._disposables.add(disposable)
|
||||
);
|
||||
}
|
||||
|
||||
getConnectors(id: string) {
|
||||
const connectors = this.getElementsByType(
|
||||
'connector'
|
||||
) as unknown[] as ConnectorElementModel[];
|
||||
|
||||
return connectors.filter(
|
||||
connector => connector.source?.id === id || connector.target?.id === id
|
||||
);
|
||||
}
|
||||
|
||||
override getElementsByType<K extends keyof SurfaceElementModelMap>(
|
||||
type: K
|
||||
): SurfaceElementModelMap[K][] {
|
||||
return super.getElementsByType(type) as SurfaceElementModelMap[K][];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import {
|
||||
EdgelessCRUDExtension,
|
||||
EdgelessLegacySlotExtension,
|
||||
} from './extensions';
|
||||
import { ExportManagerExtension } from './extensions/export-manager/export-manager';
|
||||
|
||||
const CommonSurfaceBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:surface'),
|
||||
EdgelessCRUDExtension,
|
||||
EdgelessLegacySlotExtension,
|
||||
ExportManagerExtension,
|
||||
];
|
||||
|
||||
export const PageSurfaceBlockSpec: ExtensionType[] = [
|
||||
...CommonSurfaceBlockSpec,
|
||||
BlockViewExtension('affine:surface', literal`affine-surface-void`),
|
||||
];
|
||||
|
||||
export const EdgelessSurfaceBlockSpec: ExtensionType[] = [
|
||||
...CommonSurfaceBlockSpec,
|
||||
BlockViewExtension('affine:surface', literal`affine-surface`),
|
||||
];
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { SurfaceBlockProps } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
SURFACE_TEXT_UNIQ_IDENTIFIER,
|
||||
SURFACE_YMAP_UNIQ_IDENTIFIER,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type {
|
||||
FromSnapshotPayload,
|
||||
SnapshotNode,
|
||||
ToSnapshotPayload,
|
||||
} from '@blocksuite/store';
|
||||
import { BaseBlockTransformer } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class SurfaceBlockTransformer extends BaseBlockTransformer<SurfaceBlockProps> {
|
||||
private _elementToJSON(element: Y.Map<unknown>) {
|
||||
const value: Record<string, unknown> = {};
|
||||
element.forEach((_value, _key) => {
|
||||
value[_key] = this._toJSON(_value);
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private _fromJSON(value: unknown): unknown {
|
||||
if (value instanceof Object) {
|
||||
if (Reflect.has(value, SURFACE_TEXT_UNIQ_IDENTIFIER)) {
|
||||
const yText = new Y.Text();
|
||||
yText.applyDelta(Reflect.get(value, 'delta'));
|
||||
return yText;
|
||||
} else if (Reflect.has(value, SURFACE_YMAP_UNIQ_IDENTIFIER)) {
|
||||
const yMap = new Y.Map();
|
||||
const json = Reflect.get(value, 'json') as Record<string, unknown>;
|
||||
Object.entries(json).forEach(([key, value]) => {
|
||||
yMap.set(key, value);
|
||||
});
|
||||
return yMap;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private _toJSON(value: unknown): unknown {
|
||||
if (value instanceof Y.Text) {
|
||||
return {
|
||||
[SURFACE_TEXT_UNIQ_IDENTIFIER]: true,
|
||||
delta: value.toDelta(),
|
||||
};
|
||||
} else if (value instanceof Y.Map) {
|
||||
return {
|
||||
[SURFACE_YMAP_UNIQ_IDENTIFIER]: true,
|
||||
json: value.toJSON(),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
elementFromJSON(element: Record<string, unknown>) {
|
||||
const yMap = new Y.Map();
|
||||
Object.entries(element).forEach(([key, value]) => {
|
||||
yMap.set(key, this._fromJSON(value));
|
||||
});
|
||||
|
||||
return yMap;
|
||||
}
|
||||
|
||||
override async fromSnapshot(
|
||||
payload: FromSnapshotPayload
|
||||
): Promise<SnapshotNode<SurfaceBlockProps>> {
|
||||
const snapshotRet = await super.fromSnapshot(payload);
|
||||
const elementsJSON = snapshotRet.props.elements as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const yMap = new Y.Map<Y.Map<unknown>>();
|
||||
|
||||
Object.entries(elementsJSON).forEach(([key, value]) => {
|
||||
const element = this.elementFromJSON(value as Record<string, unknown>);
|
||||
|
||||
yMap.set(key, element);
|
||||
});
|
||||
|
||||
const elements = this._internal.Boxed(yMap);
|
||||
|
||||
snapshotRet.props = {
|
||||
elements,
|
||||
};
|
||||
|
||||
return snapshotRet;
|
||||
}
|
||||
|
||||
override toSnapshot(payload: ToSnapshotPayload<SurfaceBlockProps>) {
|
||||
const snapshot = super.toSnapshot(payload);
|
||||
const elementsValue = payload.model.props.elements.getValue();
|
||||
const value: Record<string, unknown> = {};
|
||||
/**
|
||||
* When the selectedElements is defined, only the selected elements will be serialized.
|
||||
*/
|
||||
const selectedElements = this.transformerConfigs.get(
|
||||
'selectedElements'
|
||||
) as Set<string>;
|
||||
|
||||
if (elementsValue) {
|
||||
elementsValue.forEach((element, key) => {
|
||||
if (selectedElements?.has(key) || !selectedElements) {
|
||||
value[key] = this._elementToJSON(element as Y.Map<unknown>);
|
||||
}
|
||||
});
|
||||
}
|
||||
snapshot.props = {
|
||||
elements: value,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { almostEqual, type Bound, type IVec3 } from '@blocksuite/global/gfx';
|
||||
|
||||
import { Graph } from './graph.js';
|
||||
import { PriorityQueue } from './priority-queue.js';
|
||||
|
||||
function isNullish<T>(val: T | null | undefined): val is null | undefined {
|
||||
return val === null || val === undefined;
|
||||
}
|
||||
|
||||
function cost(point: IVec3, point2: IVec3) {
|
||||
return Math.abs(point[0] - point2[0]) + Math.abs(point[1] - point2[1]);
|
||||
}
|
||||
|
||||
function compare(a: [number, number, number], b: [number, number, number]) {
|
||||
if (a[2] + 0.01 < b[2]) return -1;
|
||||
else if (a[2] - 0.01 > b[2]) return 1;
|
||||
else if (a[0] < b[0]) return -1;
|
||||
else if (a[0] > b[0]) return 1;
|
||||
else if (a[1] > b[1]) return -1;
|
||||
else if (a[1] < b[1]) return 1;
|
||||
else return 0;
|
||||
}
|
||||
|
||||
function heuristic(a: IVec3, b: IVec3): number {
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
|
||||
}
|
||||
|
||||
function getDiagonalCount(a: IVec3, last: IVec3, last2: IVec3): number {
|
||||
if (almostEqual(a[0], last[0]) && almostEqual(a[0], last2[0])) return 0;
|
||||
if (almostEqual(a[1], last[1]) && almostEqual(a[1], last2[1])) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function pointAlmostEqual(a: IVec3, b: IVec3): boolean {
|
||||
return almostEqual(a[0], b[0], 0.02) && almostEqual(a[1], b[1], 0.02);
|
||||
}
|
||||
|
||||
export class AStarRunner {
|
||||
private readonly _cameFrom = new Map<
|
||||
IVec3,
|
||||
{ from: IVec3[]; indexs: number[] }
|
||||
>();
|
||||
|
||||
private _complete = false;
|
||||
|
||||
private readonly _costSoFar = new Map<IVec3, number[]>();
|
||||
|
||||
private _current: IVec3 | null = null;
|
||||
|
||||
private readonly _diagonalCount = new Map<IVec3, number[]>();
|
||||
|
||||
private _frontier!: PriorityQueue<
|
||||
IVec3,
|
||||
[diagonalCount: number, pointPriority: number, distCost: number]
|
||||
>;
|
||||
|
||||
private readonly _graph: Graph<IVec3>;
|
||||
|
||||
private readonly _pointPriority = new Map<IVec3, number[]>();
|
||||
|
||||
get path() {
|
||||
const result: IVec3[] = [];
|
||||
let current: null | IVec3 = this._complete
|
||||
? this._originalEp
|
||||
: this._current;
|
||||
const nextIndexs = [0];
|
||||
while (current) {
|
||||
result.unshift(current);
|
||||
const froms = this._cameFrom.get(current);
|
||||
if (!froms) return result;
|
||||
const index = nextIndexs.shift();
|
||||
if (isNullish(index)) {
|
||||
break;
|
||||
}
|
||||
nextIndexs.push(froms.indexs[index]);
|
||||
current = froms.from[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(
|
||||
points: IVec3[],
|
||||
private readonly _sp: IVec3,
|
||||
private readonly _ep: IVec3,
|
||||
private readonly _originalSp: IVec3,
|
||||
private _originalEp: IVec3,
|
||||
blocks: Bound[] = [],
|
||||
expandBlocks: Bound[] = []
|
||||
) {
|
||||
this._sp[2] = 0;
|
||||
this._ep[2] = 0;
|
||||
this._originalEp[2] = 0;
|
||||
this._graph = new Graph([...points], blocks, expandBlocks);
|
||||
this._init();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this._cameFrom.set(this._sp, { from: [this._originalSp], indexs: [-1] });
|
||||
this._cameFrom.set(this._originalSp, { from: [], indexs: [] });
|
||||
|
||||
this._costSoFar.set(this._sp, [0]);
|
||||
this._diagonalCount.set(this._sp, [0]);
|
||||
this._pointPriority.set(this._sp, [0]);
|
||||
this._frontier = new PriorityQueue<
|
||||
IVec3,
|
||||
[diagonalCount: number, pointPriority: number, distCost: number]
|
||||
>(compare);
|
||||
this._frontier.enqueue(this._sp, [0, 0, 0]);
|
||||
}
|
||||
|
||||
private _neighbors(cur: IVec3) {
|
||||
const neighbors = this._graph.neighbors(cur);
|
||||
const cameFroms = this._cameFrom.get(cur);
|
||||
|
||||
if (cameFroms) {
|
||||
cameFroms.from.forEach(from => {
|
||||
const index = neighbors.findIndex(n => pointAlmostEqual(n, from));
|
||||
if (index >= 0) {
|
||||
neighbors.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cur === this._ep) neighbors.push(this._originalEp);
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._cameFrom.clear();
|
||||
this._costSoFar.clear();
|
||||
this._diagonalCount.clear();
|
||||
this._pointPriority.clear();
|
||||
this._complete = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
run() {
|
||||
while (!this._complete) {
|
||||
this.step();
|
||||
}
|
||||
}
|
||||
|
||||
step() {
|
||||
if (this._complete) return;
|
||||
this._current = this._frontier.dequeue();
|
||||
const current = this._current;
|
||||
if (!current) {
|
||||
this._complete = true;
|
||||
return;
|
||||
}
|
||||
if (current === this._ep && pointAlmostEqual(this._ep, this._originalEp)) {
|
||||
this._originalEp = this._ep;
|
||||
}
|
||||
const neighbors = this._neighbors(current);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < neighbors.length; i++) {
|
||||
const next = neighbors[i];
|
||||
const curCosts = this._costSoFar.get(current);
|
||||
const curDiagoalCounts = this._diagonalCount.get(current);
|
||||
const curPointPrioritys = this._pointPriority.get(current);
|
||||
const cameFroms = this._cameFrom.get(current);
|
||||
if (!curCosts || !curDiagoalCounts || !curPointPrioritys || !cameFroms) {
|
||||
continue;
|
||||
}
|
||||
const newCosts = curCosts.map(co => co + cost(current, next));
|
||||
|
||||
const newDiagonalCounts = curDiagoalCounts.map(
|
||||
(count, index) =>
|
||||
count + getDiagonalCount(next, current, cameFroms.from[index])
|
||||
);
|
||||
if (isNullish(next[2])) {
|
||||
continue;
|
||||
}
|
||||
const newPointPrioritys = curPointPrioritys.map(
|
||||
pointPriority => pointPriority + next[2]
|
||||
);
|
||||
let index = -1;
|
||||
if (newCosts.length === 1) {
|
||||
index = 0;
|
||||
} else {
|
||||
const costsIndexs = findAllMinimalIndexs(
|
||||
newCosts,
|
||||
(a, b) => a + 0.01 < b,
|
||||
(a, b) => almostEqual(a, b, 0.02)
|
||||
);
|
||||
if (costsIndexs.length === 1) {
|
||||
index = costsIndexs[0];
|
||||
} else {
|
||||
const diagonalCounts = costsIndexs.map(i => newDiagonalCounts[i]);
|
||||
const diagonalCountsIndexs = findAllMinimalIndexs(
|
||||
diagonalCounts,
|
||||
(a, b) => a < b,
|
||||
(a, b) => a === b
|
||||
);
|
||||
if (diagonalCountsIndexs.length === 1) {
|
||||
index = costsIndexs[diagonalCountsIndexs[0]];
|
||||
} else {
|
||||
const pointPriorities = diagonalCountsIndexs.map(
|
||||
i => newPointPrioritys[costsIndexs[i]]
|
||||
);
|
||||
const pointPrioritiesIndexs = findAllMaximalIndexs(
|
||||
pointPriorities,
|
||||
(a, b) => a > b,
|
||||
(a, b) => a === b
|
||||
);
|
||||
index = pointPrioritiesIndexs[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldEnqueue = !this._costSoFar.has(next);
|
||||
const nextCosts = this._costSoFar.get(next) ?? [];
|
||||
const nextDiagonalCounts = this._diagonalCount.get(next) ?? [];
|
||||
const nextPointPriorities = this._pointPriority.get(next) ?? [];
|
||||
const nextCameFrom = this._cameFrom.get(next) ?? { from: [], indexs: [] };
|
||||
nextCosts.push(newCosts[index]);
|
||||
nextDiagonalCounts.push(newDiagonalCounts[index]);
|
||||
nextPointPriorities.push(newPointPrioritys[index]);
|
||||
nextCameFrom.from.push(current);
|
||||
nextCameFrom.indexs.push(index);
|
||||
|
||||
const newDiagonalCount = newDiagonalCounts[index];
|
||||
const newPointPriority = newPointPrioritys[index];
|
||||
const newCost = newCosts[index];
|
||||
|
||||
this._costSoFar.set(next, nextCosts);
|
||||
this._diagonalCount.set(next, nextDiagonalCounts);
|
||||
this._pointPriority.set(next, nextPointPriorities);
|
||||
this._cameFrom.set(next, nextCameFrom);
|
||||
const newPriority: [number, number, number] = [
|
||||
newDiagonalCount,
|
||||
newPointPriority,
|
||||
newCost + heuristic(next, this._ep),
|
||||
];
|
||||
if (shouldEnqueue) {
|
||||
this._frontier.enqueue(next, newPriority);
|
||||
} else {
|
||||
const index = this._frontier.heap.findIndex(
|
||||
item => item.value === next
|
||||
);
|
||||
const old = this._frontier.heap[index];
|
||||
if (old) {
|
||||
if (compare(newPriority, old.priority) < 0) {
|
||||
old.priority = newPriority;
|
||||
this._frontier.bubbleUp(index);
|
||||
}
|
||||
} else {
|
||||
this._frontier.enqueue(next, newPriority);
|
||||
}
|
||||
}
|
||||
if (
|
||||
pointAlmostEqual(current, this._ep) &&
|
||||
pointAlmostEqual(next, this._originalEp)
|
||||
) {
|
||||
this._originalEp = next;
|
||||
this._complete = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAllMinimalIndexs(
|
||||
data: number[],
|
||||
isLess: (a: number, b: number) => boolean,
|
||||
isEqual: (a: number, b: number) => boolean
|
||||
) {
|
||||
let min = Infinity;
|
||||
let indexs: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const cur = data[i];
|
||||
if (isLess(cur, min)) {
|
||||
min = cur;
|
||||
indexs = [i];
|
||||
} else if (isEqual(cur, min)) {
|
||||
indexs.push(i);
|
||||
}
|
||||
}
|
||||
return indexs;
|
||||
}
|
||||
|
||||
function findAllMaximalIndexs(
|
||||
data: number[],
|
||||
isGreat: (a: number, b: number) => boolean,
|
||||
isEqual: (a: number, b: number) => boolean
|
||||
) {
|
||||
let max = -Infinity;
|
||||
let indexs: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const cur = data[i];
|
||||
if (isGreat(cur, max)) {
|
||||
max = cur;
|
||||
indexs = [i];
|
||||
} else if (isEqual(cur, max)) {
|
||||
indexs.push(i);
|
||||
}
|
||||
}
|
||||
return indexs;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
NOTE_MIN_HEIGHT,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
|
||||
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
|
||||
import { type IPoint, type Point, serializeXYWH } from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import {
|
||||
type GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { DEFAULT_NOTE_OFFSET_X, DEFAULT_NOTE_OFFSET_Y } from '../consts';
|
||||
import { EdgelessCRUDIdentifier } from '../extensions/crud-extension';
|
||||
|
||||
export function addNoteAtPoint(
|
||||
std: BlockStdScope,
|
||||
/**
|
||||
* The point is in browser coordinate
|
||||
*/
|
||||
point: IPoint,
|
||||
options: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
parentId?: string;
|
||||
noteIndex?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
scale?: number;
|
||||
} = {}
|
||||
) {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
const {
|
||||
width = DEFAULT_NOTE_WIDTH,
|
||||
height = DEFAULT_NOTE_HEIGHT,
|
||||
offsetX = DEFAULT_NOTE_OFFSET_X,
|
||||
offsetY = DEFAULT_NOTE_OFFSET_Y,
|
||||
parentId = gfx.doc.root?.id,
|
||||
noteIndex,
|
||||
scale = 1,
|
||||
} = options;
|
||||
const [x, y] = gfx.viewport.toModelCoord(point.x, point.y);
|
||||
const blockId = crud.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(
|
||||
x - offsetX * scale,
|
||||
y - offsetY * scale,
|
||||
width,
|
||||
height
|
||||
),
|
||||
displayMode: NoteDisplayMode.EdgelessOnly,
|
||||
},
|
||||
parentId,
|
||||
noteIndex
|
||||
);
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:draw',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'note',
|
||||
});
|
||||
|
||||
return blockId;
|
||||
}
|
||||
|
||||
type NoteOptions = {
|
||||
childFlavour: NoteChildrenFlavour;
|
||||
childType: string | null;
|
||||
collapse: boolean;
|
||||
};
|
||||
|
||||
export function addNote(
|
||||
std: BlockStdScope,
|
||||
point: Point,
|
||||
options: NoteOptions,
|
||||
width = DEFAULT_NOTE_WIDTH,
|
||||
height = DEFAULT_NOTE_HEIGHT
|
||||
) {
|
||||
const noteId = addNoteAtPoint(std, point, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const doc = std.store;
|
||||
|
||||
const blockId = doc.addBlock(
|
||||
options.childFlavour,
|
||||
{ type: options.childType },
|
||||
noteId
|
||||
);
|
||||
if (options.collapse && height > NOTE_MIN_HEIGHT) {
|
||||
const note = doc.getModelById(noteId) as NoteBlockModel;
|
||||
doc.updateBlock(note, () => {
|
||||
note.props.edgeless.collapse = true;
|
||||
note.props.edgeless.collapsedHeight = height;
|
||||
});
|
||||
}
|
||||
gfx.tool.setTool(
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
'default'
|
||||
);
|
||||
|
||||
// Wait for edgelessTool updated
|
||||
requestAnimationFrame(() => {
|
||||
const blocks =
|
||||
(doc.root?.children.filter(
|
||||
child => child.flavour === 'affine:note'
|
||||
) as GfxBlockElementModel[]) ?? [];
|
||||
const element = blocks.find(b => b.id === noteId);
|
||||
if (element) {
|
||||
gfx.selection.set({
|
||||
elements: [element.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
// Waiting dom updated, `note mask` is removed
|
||||
if (blockId) {
|
||||
focusTextModel(gfx.std, blockId);
|
||||
} else {
|
||||
// Cannot reuse `handleNativeRangeClick` directly here,
|
||||
// since `retargetClick` will re-target to pervious editor
|
||||
handleNativeRangeAtPoint(point.x, point.y);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FontFamily } from '@blocksuite/affine-model';
|
||||
import { IS_FIREFOX } from '@blocksuite/global/env';
|
||||
|
||||
export function wrapFontFamily(fontFamily: FontFamily | string): string {
|
||||
return `"${fontFamily}"`;
|
||||
}
|
||||
|
||||
export const getFontFaces = IS_FIREFOX
|
||||
? () => {
|
||||
const keys = document.fonts.keys();
|
||||
const fonts = [];
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const item = keys.next();
|
||||
done = !!item.done;
|
||||
if (item.value) {
|
||||
fonts.push(item.value);
|
||||
}
|
||||
}
|
||||
return fonts;
|
||||
}
|
||||
: () => [...document.fonts.keys()];
|
||||
|
||||
export const isSameFontFamily = IS_FIREFOX
|
||||
? (fontFamily: FontFamily | string) => (fontFace: FontFace) =>
|
||||
fontFace.family === `"${fontFamily}"`
|
||||
: (fontFamily: FontFamily | string) => (fontFace: FontFace) =>
|
||||
fontFace.family === fontFamily;
|
||||
|
||||
export function getFontFacesByFontFamily(
|
||||
fontFamily: FontFamily | string
|
||||
): FontFace[] {
|
||||
return (
|
||||
getFontFaces()
|
||||
.filter(isSameFontFamily(fontFamily))
|
||||
// remove duplicate font faces
|
||||
.filter(
|
||||
(item, index, arr) =>
|
||||
arr.findIndex(
|
||||
fontFace =>
|
||||
fontFace.family === item.family &&
|
||||
fontFace.weight === item.weight &&
|
||||
fontFace.style === item.style
|
||||
) === index
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { clamp } from '@blocksuite/global/gfx';
|
||||
|
||||
import { GRID_GAP_MAX, GRID_GAP_MIN } from '../consts';
|
||||
|
||||
export function getBgGridGap(zoom: number) {
|
||||
const step = zoom < 0.5 ? 2 : 1 / (Math.floor(zoom) || 1);
|
||||
const gap = clamp(20 * step * zoom, GRID_GAP_MIN, GRID_GAP_MAX);
|
||||
|
||||
return Math.round(gap);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getShapeName, type ShapeProps } from '@blocksuite/affine-model';
|
||||
import type {
|
||||
LastProps,
|
||||
LastPropsKey,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { NodePropsSchema } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
const LastPropsSchema = NodePropsSchema;
|
||||
|
||||
export function getLastPropsKey(
|
||||
modelType: string,
|
||||
modelProps: Partial<LastProps[LastPropsKey]>
|
||||
): LastPropsKey | null {
|
||||
if (modelType === 'shape') {
|
||||
const { shapeType, radius } = modelProps as ShapeProps;
|
||||
const shapeName = getShapeName(shapeType, radius);
|
||||
return `${modelType}:${shapeName}`;
|
||||
}
|
||||
|
||||
if (isLastPropsKey(modelType)) {
|
||||
return modelType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLastPropsKey(key: string): key is LastPropsKey {
|
||||
return Object.keys(LastPropsSchema.shape).includes(key);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
|
||||
import type { SurfaceBlockComponent } from '../surface-block';
|
||||
import type { SurfaceBlockModel } from '../surface-model';
|
||||
|
||||
export function getSurfaceBlock(doc: Store) {
|
||||
const blocks = doc.getBlocksByFlavour('affine:surface');
|
||||
return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null;
|
||||
}
|
||||
|
||||
export function getSurfaceComponent(std: BlockStdScope) {
|
||||
const surface = getSurfaceBlock(std.store);
|
||||
if (!surface) return null;
|
||||
return std.view.getBlock(surface.id) as SurfaceBlockComponent | null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Bound, IVec, IVec3 } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
almostEqual,
|
||||
isOverlap as _isOverlap,
|
||||
linePolygonIntersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
function isOverlap(line: IVec[], line2: IVec[]) {
|
||||
if (
|
||||
[line[0][1], line[1][1], line2[0][1], line2[1][1]].every(y =>
|
||||
almostEqual(y, line[0][1], 0.02)
|
||||
)
|
||||
) {
|
||||
return _isOverlap(line, line2, 0);
|
||||
} else if (
|
||||
[line[0][0], line[1][0], line2[0][0], line2[1][0]].every(x =>
|
||||
almostEqual(x, line[0][0], 0.02)
|
||||
)
|
||||
) {
|
||||
return _isOverlap(line, line2, 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function arrayAlmostEqual(point: IVec | IVec3, point2: IVec | IVec3) {
|
||||
return almostEqual(point[0], point2[0]) && almostEqual(point[1], point2[1]);
|
||||
}
|
||||
|
||||
export class Graph<V extends IVec | IVec3 = IVec> {
|
||||
private readonly _xMap = new Map<number, V[]>();
|
||||
|
||||
private readonly _yMap = new Map<number, V[]>();
|
||||
|
||||
constructor(
|
||||
private readonly points: V[],
|
||||
private readonly blocks: Bound[] = [],
|
||||
private readonly expandedBlocks: Bound[] = [],
|
||||
private readonly excludedPoints: V[] = []
|
||||
) {
|
||||
const xMap = this._xMap;
|
||||
const yMap = this._yMap;
|
||||
this.points.forEach(point => {
|
||||
const [x, y] = point;
|
||||
if (!xMap.has(x)) xMap.set(x, []);
|
||||
if (!yMap.has(y)) yMap.set(y, []);
|
||||
xMap.get(x)?.push(point);
|
||||
yMap.get(y)?.push(point);
|
||||
});
|
||||
}
|
||||
|
||||
private _canSkipBlock(point: IVec | IVec3) {
|
||||
return this.excludedPoints.some(excludedPoint => {
|
||||
return arrayAlmostEqual(point, excludedPoint);
|
||||
});
|
||||
}
|
||||
|
||||
private _isBlock(sp: IVec | IVec3, ep: IVec | IVec3) {
|
||||
return (
|
||||
this.blocks.some(block => {
|
||||
const rst = linePolygonIntersects(sp as IVec, ep as IVec, block.points);
|
||||
return (
|
||||
rst?.length === 2 ||
|
||||
block.isPointInBound(sp as IVec, 0) ||
|
||||
block.isPointInBound(ep as IVec, 0) ||
|
||||
[
|
||||
block.leftLine,
|
||||
block.upperLine,
|
||||
block.rightLine,
|
||||
block.lowerLine,
|
||||
].some(line => {
|
||||
return isOverlap(line, [sp, ep] as IVec[]);
|
||||
})
|
||||
);
|
||||
}) ||
|
||||
this.expandedBlocks.some(block => {
|
||||
const result = linePolygonIntersects(
|
||||
sp as IVec,
|
||||
ep as IVec,
|
||||
block.expand(-0.5).points
|
||||
);
|
||||
return result?.length === 2;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
neighbors(curPoint: V): V[] {
|
||||
const [x, y] = curPoint;
|
||||
const neighbors = new Set<V>();
|
||||
const xPoints = this._xMap.get(x);
|
||||
const yPoints = this._yMap.get(y);
|
||||
if (xPoints) {
|
||||
let plusMin = Infinity;
|
||||
let minusMin = Infinity;
|
||||
let plusPoint: V | undefined;
|
||||
let minusPoint: V | undefined;
|
||||
xPoints.forEach(point => {
|
||||
if (arrayAlmostEqual(point, curPoint as unknown as IVec)) return;
|
||||
const dif = point[1] - curPoint[1];
|
||||
if (dif > 0 && dif < plusMin) {
|
||||
plusMin = dif;
|
||||
plusPoint = point;
|
||||
}
|
||||
if (dif < 0 && Math.abs(dif) < minusMin) {
|
||||
minusMin = Math.abs(dif);
|
||||
minusPoint = point;
|
||||
}
|
||||
});
|
||||
if (
|
||||
plusPoint &&
|
||||
(this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint))
|
||||
) {
|
||||
neighbors.add(plusPoint);
|
||||
}
|
||||
if (
|
||||
minusPoint &&
|
||||
(this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint))
|
||||
)
|
||||
neighbors.add(minusPoint);
|
||||
}
|
||||
if (yPoints) {
|
||||
let plusMin = Infinity;
|
||||
let minusMin = Infinity;
|
||||
let plusPoint: V | undefined;
|
||||
let minusPoint: V | undefined;
|
||||
yPoints.forEach(point => {
|
||||
if (arrayAlmostEqual(point, curPoint)) return;
|
||||
const dif = point[0] - curPoint[0];
|
||||
if (dif > 0 && dif < plusMin) {
|
||||
plusMin = dif;
|
||||
plusPoint = point;
|
||||
}
|
||||
if (dif < 0 && Math.abs(dif) < minusMin) {
|
||||
minusMin = Math.abs(dif);
|
||||
minusPoint = point;
|
||||
}
|
||||
});
|
||||
if (
|
||||
plusPoint &&
|
||||
(this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint))
|
||||
)
|
||||
neighbors.add(plusPoint);
|
||||
if (
|
||||
minusPoint &&
|
||||
(this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint))
|
||||
)
|
||||
neighbors.add(minusPoint);
|
||||
}
|
||||
|
||||
return Array.from(neighbors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { ZOOM_WHEEL_STEP } from '../consts.js';
|
||||
|
||||
export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
export function generateElementId() {
|
||||
return nanoid(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes wheel delta.
|
||||
*
|
||||
* See https://stackoverflow.com/a/13650579
|
||||
*
|
||||
* From https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx
|
||||
* MIT License
|
||||
*/
|
||||
export function normalizeWheelDeltaY(delta: number, zoom = 1) {
|
||||
const sign = Math.sign(delta);
|
||||
const abs = Math.abs(delta);
|
||||
const maxStep = ZOOM_WHEEL_STEP * 100;
|
||||
if (abs > maxStep) {
|
||||
delta = maxStep * sign;
|
||||
}
|
||||
let newZoom = zoom - delta / 100;
|
||||
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||
newZoom +=
|
||||
Math.log10(Math.max(1, zoom)) *
|
||||
-sign *
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, abs / 20);
|
||||
return newZoom;
|
||||
}
|
||||
|
||||
export { addNote, addNoteAtPoint } from './add-note';
|
||||
export { getBgGridGap } from './get-bg-grip-gap';
|
||||
export { getLastPropsKey } from './get-last-props-key';
|
||||
export * from './get-surface-block';
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Segment } from './parser.js';
|
||||
|
||||
// Translate relative commands to absolute commands
|
||||
export function absolutize(segments: Segment[]): Segment[] {
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
let subx = 0,
|
||||
suby = 0;
|
||||
const out: Segment[] = [];
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
out.push({ key: 'M', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
[subx, suby] = data;
|
||||
break;
|
||||
case 'm':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'M', data: [cx, cy] });
|
||||
subx = cx;
|
||||
suby = cy;
|
||||
break;
|
||||
case 'L':
|
||||
out.push({ key: 'L', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
break;
|
||||
case 'l':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'C':
|
||||
out.push({ key: 'C', data: [...data] });
|
||||
cx = data[4];
|
||||
cy = data[5];
|
||||
break;
|
||||
case 'c': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'C', data: newdata });
|
||||
cx = newdata[4];
|
||||
cy = newdata[5];
|
||||
break;
|
||||
}
|
||||
case 'Q':
|
||||
out.push({ key: 'Q', data: [...data] });
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
case 'q': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'Q', data: newdata });
|
||||
cx = newdata[2];
|
||||
cy = newdata[3];
|
||||
break;
|
||||
}
|
||||
case 'A':
|
||||
out.push({ key: 'A', data: [...data] });
|
||||
cx = data[5];
|
||||
cy = data[6];
|
||||
break;
|
||||
case 'a':
|
||||
cx += data[5];
|
||||
cy += data[6];
|
||||
out.push({
|
||||
key: 'A',
|
||||
data: [data[0], data[1], data[2], data[3], data[4], cx, cy],
|
||||
});
|
||||
break;
|
||||
case 'H':
|
||||
out.push({ key: 'H', data: [...data] });
|
||||
cx = data[0];
|
||||
break;
|
||||
case 'h':
|
||||
cx += data[0];
|
||||
out.push({ key: 'H', data: [cx] });
|
||||
break;
|
||||
case 'V':
|
||||
out.push({ key: 'V', data: [...data] });
|
||||
cy = data[0];
|
||||
break;
|
||||
case 'v':
|
||||
cy += data[0];
|
||||
out.push({ key: 'V', data: [cy] });
|
||||
break;
|
||||
case 'S':
|
||||
out.push({ key: 'S', data: [...data] });
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
case 's': {
|
||||
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
|
||||
out.push({ key: 'S', data: newdata });
|
||||
cx = newdata[2];
|
||||
cy = newdata[3];
|
||||
break;
|
||||
}
|
||||
case 'T':
|
||||
out.push({ key: 'T', data: [...data] });
|
||||
cx = data[0];
|
||||
cy = data[1];
|
||||
break;
|
||||
case 't':
|
||||
cx += data[0];
|
||||
cy += data[1];
|
||||
out.push({ key: 'T', data: [cx, cy] });
|
||||
break;
|
||||
case 'Z':
|
||||
case 'z':
|
||||
out.push({ key: 'Z', data: [] });
|
||||
cx = subx;
|
||||
cy = suby;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Segment } from './parser.js';
|
||||
|
||||
// Normalize path to include only M, L, C, and Z commands
|
||||
export function normalize(segments: Segment[]): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
|
||||
let lastType = '';
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
let subx = 0,
|
||||
suby = 0;
|
||||
let lcx = 0,
|
||||
lcy = 0;
|
||||
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
out.push({ key: 'M', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
[subx, suby] = data;
|
||||
break;
|
||||
case 'C':
|
||||
out.push({ key: 'C', data: [...data] });
|
||||
cx = data[4];
|
||||
cy = data[5];
|
||||
lcx = data[2];
|
||||
lcy = data[3];
|
||||
break;
|
||||
case 'L':
|
||||
out.push({ key: 'L', data: [...data] });
|
||||
[cx, cy] = data;
|
||||
break;
|
||||
case 'H':
|
||||
cx = data[0];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'V':
|
||||
cy = data[0];
|
||||
out.push({ key: 'L', data: [cx, cy] });
|
||||
break;
|
||||
case 'S': {
|
||||
let cx1 = 0,
|
||||
cy1 = 0;
|
||||
if (lastType === 'C' || lastType === 'S') {
|
||||
cx1 = cx + (cx - lcx);
|
||||
cy1 = cy + (cy - lcy);
|
||||
} else {
|
||||
cx1 = cx;
|
||||
cy1 = cy;
|
||||
}
|
||||
out.push({ key: 'C', data: [cx1, cy1, ...data] });
|
||||
lcx = data[0];
|
||||
lcy = data[1];
|
||||
cx = data[2];
|
||||
cy = data[3];
|
||||
break;
|
||||
}
|
||||
case 'T': {
|
||||
const [x, y] = data;
|
||||
let x1 = 0,
|
||||
y1 = 0;
|
||||
if (lastType === 'Q' || lastType === 'T') {
|
||||
x1 = cx + (cx - lcx);
|
||||
y1 = cy + (cy - lcy);
|
||||
} else {
|
||||
x1 = cx;
|
||||
y1 = cy;
|
||||
}
|
||||
const cx1 = cx + (2 * (x1 - cx)) / 3;
|
||||
const cy1 = cy + (2 * (y1 - cy)) / 3;
|
||||
const cx2 = x + (2 * (x1 - x)) / 3;
|
||||
const cy2 = y + (2 * (y1 - y)) / 3;
|
||||
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
|
||||
lcx = x1;
|
||||
lcy = y1;
|
||||
cx = x;
|
||||
cy = y;
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
const [x1, y1, x, y] = data;
|
||||
const cx1 = cx + (2 * (x1 - cx)) / 3;
|
||||
const cy1 = cy + (2 * (y1 - cy)) / 3;
|
||||
const cx2 = x + (2 * (x1 - x)) / 3;
|
||||
const cy2 = y + (2 * (y1 - y)) / 3;
|
||||
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
|
||||
lcx = x1;
|
||||
lcy = y1;
|
||||
cx = x;
|
||||
cy = y;
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
const r1 = Math.abs(data[0]);
|
||||
const r2 = Math.abs(data[1]);
|
||||
const angle = data[2];
|
||||
const largeArcFlag = data[3];
|
||||
const sweepFlag = data[4];
|
||||
const x = data[5];
|
||||
const y = data[6];
|
||||
if (r1 === 0 || r2 === 0) {
|
||||
out.push({ key: 'C', data: [cx, cy, x, y, x, y] });
|
||||
cx = x;
|
||||
cy = y;
|
||||
} else {
|
||||
if (cx !== x || cy !== y) {
|
||||
const curves: number[][] = arcToCubicCurves(
|
||||
cx,
|
||||
cy,
|
||||
x,
|
||||
y,
|
||||
r1,
|
||||
r2,
|
||||
angle,
|
||||
largeArcFlag,
|
||||
sweepFlag
|
||||
);
|
||||
curves.forEach(function (curve) {
|
||||
out.push({ key: 'C', data: curve });
|
||||
});
|
||||
cx = x;
|
||||
cy = y;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
out.push({ key: 'Z', data: [] });
|
||||
cx = subx;
|
||||
cy = suby;
|
||||
break;
|
||||
}
|
||||
lastType = key;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function degToRad(degrees: number): number {
|
||||
return (Math.PI * degrees) / 180;
|
||||
}
|
||||
|
||||
function rotate(x: number, y: number, angleRad: number): [number, number] {
|
||||
const X = x * Math.cos(angleRad) - y * Math.sin(angleRad);
|
||||
const Y = x * Math.sin(angleRad) + y * Math.cos(angleRad);
|
||||
return [X, Y];
|
||||
}
|
||||
|
||||
function arcToCubicCurves(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
r1: number,
|
||||
r2: number,
|
||||
angle: number,
|
||||
largeArcFlag: number,
|
||||
sweepFlag: number,
|
||||
recursive?: number[]
|
||||
): number[][] {
|
||||
const angleRad = degToRad(angle);
|
||||
let params: number[][] = [];
|
||||
|
||||
let f1 = 0,
|
||||
f2 = 0,
|
||||
cx = 0,
|
||||
cy = 0;
|
||||
if (recursive) {
|
||||
[f1, f2, cx, cy] = recursive;
|
||||
} else {
|
||||
[x1, y1] = rotate(x1, y1, -angleRad);
|
||||
[x2, y2] = rotate(x2, y2, -angleRad);
|
||||
|
||||
const x = (x1 - x2) / 2;
|
||||
const y = (y1 - y2) / 2;
|
||||
let h = (x * x) / (r1 * r1) + (y * y) / (r2 * r2);
|
||||
if (h > 1) {
|
||||
h = Math.sqrt(h);
|
||||
r1 = h * r1;
|
||||
r2 = h * r2;
|
||||
}
|
||||
|
||||
const sign = largeArcFlag === sweepFlag ? -1 : 1;
|
||||
|
||||
const r1Pow = r1 * r1;
|
||||
const r2Pow = r2 * r2;
|
||||
|
||||
const left = r1Pow * r2Pow - r1Pow * y * y - r2Pow * x * x;
|
||||
const right = r1Pow * y * y + r2Pow * x * x;
|
||||
|
||||
const k = sign * Math.sqrt(Math.abs(left / right));
|
||||
|
||||
cx = (k * r1 * y) / r2 + (x1 + x2) / 2;
|
||||
cy = (k * -r2 * x) / r1 + (y1 + y2) / 2;
|
||||
|
||||
f1 = Math.asin(parseFloat(((y1 - cy) / r2).toFixed(9)));
|
||||
f2 = Math.asin(parseFloat(((y2 - cy) / r2).toFixed(9)));
|
||||
|
||||
if (x1 < cx) {
|
||||
f1 = Math.PI - f1;
|
||||
}
|
||||
if (x2 < cx) {
|
||||
f2 = Math.PI - f2;
|
||||
}
|
||||
|
||||
if (f1 < 0) {
|
||||
f1 = Math.PI * 2 + f1;
|
||||
}
|
||||
if (f2 < 0) {
|
||||
f2 = Math.PI * 2 + f2;
|
||||
}
|
||||
|
||||
if (sweepFlag && f1 > f2) {
|
||||
f1 = f1 - Math.PI * 2;
|
||||
}
|
||||
if (!sweepFlag && f2 > f1) {
|
||||
f2 = f2 - Math.PI * 2;
|
||||
}
|
||||
}
|
||||
|
||||
let df = f2 - f1;
|
||||
|
||||
if (Math.abs(df) > (Math.PI * 120) / 180) {
|
||||
const f2old = f2;
|
||||
const x2old = x2;
|
||||
const y2old = y2;
|
||||
|
||||
if (sweepFlag && f2 > f1) {
|
||||
f2 = f1 + ((Math.PI * 120) / 180) * 1;
|
||||
} else {
|
||||
f2 = f1 + ((Math.PI * 120) / 180) * -1;
|
||||
}
|
||||
|
||||
x2 = cx + r1 * Math.cos(f2);
|
||||
y2 = cy + r2 * Math.sin(f2);
|
||||
params = arcToCubicCurves(
|
||||
x2,
|
||||
y2,
|
||||
x2old,
|
||||
y2old,
|
||||
r1,
|
||||
r2,
|
||||
angle,
|
||||
0,
|
||||
sweepFlag,
|
||||
[f2, f2old, cx, cy]
|
||||
);
|
||||
}
|
||||
|
||||
df = f2 - f1;
|
||||
|
||||
const c1 = Math.cos(f1);
|
||||
const s1 = Math.sin(f1);
|
||||
const c2 = Math.cos(f2);
|
||||
const s2 = Math.sin(f2);
|
||||
const t = Math.tan(df / 4);
|
||||
const hx = (4 / 3) * r1 * t;
|
||||
const hy = (4 / 3) * r2 * t;
|
||||
|
||||
const m1 = [x1, y1];
|
||||
const m2 = [x1 + hx * s1, y1 - hy * c1];
|
||||
const m3 = [x2 + hx * s2, y2 - hy * c2];
|
||||
const m4 = [x2, y2];
|
||||
|
||||
m2[0] = 2 * m1[0] - m2[0];
|
||||
m2[1] = 2 * m1[1] - m2[1];
|
||||
|
||||
if (recursive) {
|
||||
return [m2, m3, m4].concat(params);
|
||||
} else {
|
||||
params = [m2, m3, m4].concat(params);
|
||||
const curves = [];
|
||||
for (let i = 0; i < params.length; i += 3) {
|
||||
const r1 = rotate(params[i][0], params[i][1], angleRad);
|
||||
const r2 = rotate(params[i + 1][0], params[i + 1][1], angleRad);
|
||||
const r3 = rotate(params[i + 2][0], params[i + 2][1], angleRad);
|
||||
curves.push([r1[0], r1[1], r2[0], r2[1], r3[0], r3[1]]);
|
||||
}
|
||||
return curves;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
const COMMAND = 0;
|
||||
const NUMBER = 1;
|
||||
const EOD = 2;
|
||||
type TokenType = 0 | 1 | 2;
|
||||
|
||||
interface PathToken {
|
||||
type: TokenType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
key: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
const PARAMS: Record<string, number> = {
|
||||
A: 7,
|
||||
a: 7,
|
||||
C: 6,
|
||||
c: 6,
|
||||
H: 1,
|
||||
h: 1,
|
||||
L: 2,
|
||||
l: 2,
|
||||
M: 2,
|
||||
m: 2,
|
||||
Q: 4,
|
||||
q: 4,
|
||||
S: 4,
|
||||
s: 4,
|
||||
T: 2,
|
||||
t: 2,
|
||||
V: 1,
|
||||
v: 1,
|
||||
Z: 0,
|
||||
z: 0,
|
||||
};
|
||||
|
||||
function tokenize(d: string): PathToken[] {
|
||||
const tokens: PathToken[] = [];
|
||||
let match: RegExpMatchArray | null;
|
||||
|
||||
while (d !== '') {
|
||||
match = d.match(/^([ \t\r\n,]+)/);
|
||||
if (match) {
|
||||
d = d.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
match = d.match(/^([aAcChHlLmMqQsStTvVzZ])/);
|
||||
if (match) {
|
||||
tokens.push({ type: COMMAND, text: match[0] });
|
||||
d = d.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
match = d.match(
|
||||
/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/
|
||||
);
|
||||
if (match) {
|
||||
tokens.push({ type: NUMBER, text: `${parseFloat(match[0])}` });
|
||||
d = d.slice(match[0].length);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
tokens.push({ type: EOD, text: '' });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isType(token: PathToken, type: number) {
|
||||
return token.type === type;
|
||||
}
|
||||
|
||||
export function parsePath(d: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const tokens = tokenize(d);
|
||||
let mode = 'BOD';
|
||||
let index = 0;
|
||||
let token = tokens[index];
|
||||
while (!isType(token, EOD)) {
|
||||
let paramsCount = 0;
|
||||
const params: number[] = [];
|
||||
if (mode === 'BOD') {
|
||||
if (token.text === 'M' || token.text === 'm') {
|
||||
index++;
|
||||
paramsCount = PARAMS[token.text];
|
||||
mode = token.text;
|
||||
} else {
|
||||
return parsePath('M0,0' + d);
|
||||
}
|
||||
} else if (isType(token, NUMBER)) {
|
||||
paramsCount = PARAMS[mode];
|
||||
} else {
|
||||
index++;
|
||||
paramsCount = PARAMS[token.text];
|
||||
mode = token.text;
|
||||
}
|
||||
if (index + paramsCount < tokens.length) {
|
||||
for (let i = index; i < index + paramsCount; i++) {
|
||||
const numbeToken = tokens[i];
|
||||
if (isType(numbeToken, NUMBER)) {
|
||||
params[params.length] = +numbeToken.text;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Param not a number: ' + mode + ',' + numbeToken.text
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof PARAMS[mode] === 'number') {
|
||||
const segment: Segment = { key: mode, data: params };
|
||||
segments.push(segment);
|
||||
index += paramsCount;
|
||||
token = tokens[index];
|
||||
if (mode === 'M') mode = 'L';
|
||||
if (mode === 'm') mode = 'l';
|
||||
} else {
|
||||
throw new Error('Bad segment: ' + mode);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Path data ended short');
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function serialize(segments: Segment[]): string {
|
||||
const tokens: (string | number)[] = [];
|
||||
for (const { key, data } of segments) {
|
||||
tokens.push(key);
|
||||
switch (key) {
|
||||
case 'C':
|
||||
case 'c':
|
||||
tokens.push(
|
||||
data[0],
|
||||
`${data[1]},`,
|
||||
data[2],
|
||||
`${data[3]},`,
|
||||
data[4],
|
||||
data[5]
|
||||
);
|
||||
break;
|
||||
case 'S':
|
||||
case 's':
|
||||
case 'Q':
|
||||
case 'q':
|
||||
tokens.push(data[0], `${data[1]},`, data[2], data[3]);
|
||||
break;
|
||||
default:
|
||||
tokens.push(...data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tokens.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
function clone(p: Point): Point {
|
||||
return [...p] as Point;
|
||||
}
|
||||
|
||||
export function curveToBezier(
|
||||
pointsIn: readonly Point[],
|
||||
curveTightness = 0
|
||||
): Point[] {
|
||||
const len = pointsIn.length;
|
||||
if (len < 3) {
|
||||
throw new Error('A curve must have at least three points.');
|
||||
}
|
||||
const out: Point[] = [];
|
||||
if (len === 3) {
|
||||
out.push(
|
||||
clone(pointsIn[0]),
|
||||
clone(pointsIn[1]),
|
||||
clone(pointsIn[2]),
|
||||
clone(pointsIn[2])
|
||||
);
|
||||
} else {
|
||||
const points: Point[] = [];
|
||||
points.push(pointsIn[0], pointsIn[0]);
|
||||
for (let i = 1; i < pointsIn.length; i++) {
|
||||
points.push(pointsIn[i]);
|
||||
if (i === pointsIn.length - 1) {
|
||||
points.push(pointsIn[i]);
|
||||
}
|
||||
}
|
||||
const b: Point[] = [];
|
||||
const s = 1 - curveTightness;
|
||||
out.push(clone(points[0]));
|
||||
for (let i = 1; i + 2 < points.length; i++) {
|
||||
const cachedVertArray = points[i];
|
||||
b[0] = [cachedVertArray[0], cachedVertArray[1]];
|
||||
b[1] = [
|
||||
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
|
||||
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
|
||||
];
|
||||
b[2] = [
|
||||
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
|
||||
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
|
||||
];
|
||||
b[3] = [points[i + 1][0], points[i + 1][1]];
|
||||
out.push(b[1], b[2], b[3]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
// distance between 2 points
|
||||
function distance(p1: Point, p2: Point): number {
|
||||
return Math.sqrt(distanceSq(p1, p2));
|
||||
}
|
||||
|
||||
// distance between 2 points squared
|
||||
function distanceSq(p1: Point, p2: Point): number {
|
||||
return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2);
|
||||
}
|
||||
|
||||
// Sistance squared from a point p to the line segment vw
|
||||
function distanceToSegmentSq(p: Point, v: Point, w: Point): number {
|
||||
const l2 = distanceSq(v, w);
|
||||
if (l2 === 0) {
|
||||
return distanceSq(p, v);
|
||||
}
|
||||
let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return distanceSq(p, lerp(v, w, t));
|
||||
}
|
||||
|
||||
function lerp(a: Point, b: Point, t: number): Point {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
}
|
||||
|
||||
// Adapted from https://seant23.wordpress.com/2010/11/12/offset-bezier-curves/
|
||||
function flatness(points: readonly Point[], offset: number): number {
|
||||
const p1 = points[offset + 0];
|
||||
const p2 = points[offset + 1];
|
||||
const p3 = points[offset + 2];
|
||||
const p4 = points[offset + 3];
|
||||
|
||||
let ux = 3 * p2[0] - 2 * p1[0] - p4[0];
|
||||
ux *= ux;
|
||||
let uy = 3 * p2[1] - 2 * p1[1] - p4[1];
|
||||
uy *= uy;
|
||||
let vx = 3 * p3[0] - 2 * p4[0] - p1[0];
|
||||
vx *= vx;
|
||||
let vy = 3 * p3[1] - 2 * p4[1] - p1[1];
|
||||
vy *= vy;
|
||||
|
||||
if (ux < vx) {
|
||||
ux = vx;
|
||||
}
|
||||
|
||||
if (uy < vy) {
|
||||
uy = vy;
|
||||
}
|
||||
|
||||
return ux + uy;
|
||||
}
|
||||
|
||||
function getPointsOnBezierCurveWithSplitting(
|
||||
points: readonly Point[],
|
||||
offset: number,
|
||||
tolerance: number,
|
||||
newPoints?: Point[]
|
||||
): Point[] {
|
||||
const outPoints = newPoints || [];
|
||||
if (flatness(points, offset) < tolerance) {
|
||||
const p0 = points[offset + 0];
|
||||
if (outPoints.length) {
|
||||
const d = distance(outPoints[outPoints.length - 1], p0);
|
||||
if (d > 1) {
|
||||
outPoints.push(p0);
|
||||
}
|
||||
} else {
|
||||
outPoints.push(p0);
|
||||
}
|
||||
outPoints.push(points[offset + 3]);
|
||||
} else {
|
||||
// subdivide
|
||||
const t = 0.5;
|
||||
const p1 = points[offset + 0];
|
||||
const p2 = points[offset + 1];
|
||||
const p3 = points[offset + 2];
|
||||
const p4 = points[offset + 3];
|
||||
|
||||
const q1 = lerp(p1, p2, t);
|
||||
const q2 = lerp(p2, p3, t);
|
||||
const q3 = lerp(p3, p4, t);
|
||||
|
||||
const r1 = lerp(q1, q2, t);
|
||||
const r2 = lerp(q2, q3, t);
|
||||
|
||||
const red = lerp(r1, r2, t);
|
||||
|
||||
getPointsOnBezierCurveWithSplitting(
|
||||
[p1, q1, r1, red],
|
||||
0,
|
||||
tolerance,
|
||||
outPoints
|
||||
);
|
||||
getPointsOnBezierCurveWithSplitting(
|
||||
[red, r2, q3, p4],
|
||||
0,
|
||||
tolerance,
|
||||
outPoints
|
||||
);
|
||||
}
|
||||
return outPoints;
|
||||
}
|
||||
|
||||
export function simplify(points: readonly Point[], distance: number): Point[] {
|
||||
return simplifyPoints(points, 0, points.length, distance);
|
||||
}
|
||||
|
||||
// Ramer–Douglas–Peucker algorithm
|
||||
// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
|
||||
function simplifyPoints(
|
||||
points: readonly Point[],
|
||||
start: number,
|
||||
end: number,
|
||||
epsilon: number,
|
||||
newPoints?: Point[]
|
||||
): Point[] {
|
||||
const outPoints = newPoints || [];
|
||||
|
||||
// find the most distance point from the endpoints
|
||||
const s = points[start];
|
||||
const e = points[end - 1];
|
||||
let maxDistSq = 0;
|
||||
let maxNdx = 1;
|
||||
for (let i = start + 1; i < end - 1; ++i) {
|
||||
const distSq = distanceToSegmentSq(points[i], s, e);
|
||||
if (distSq > maxDistSq) {
|
||||
maxDistSq = distSq;
|
||||
maxNdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// if that point is too far, split
|
||||
if (Math.sqrt(maxDistSq) > epsilon) {
|
||||
simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints);
|
||||
simplifyPoints(points, maxNdx, end, epsilon, outPoints);
|
||||
} else {
|
||||
if (!outPoints.length) {
|
||||
outPoints.push(s);
|
||||
}
|
||||
outPoints.push(e);
|
||||
}
|
||||
|
||||
return outPoints;
|
||||
}
|
||||
|
||||
export function pointsOnBezierCurves(
|
||||
points: Point[],
|
||||
tolerance = 0.15,
|
||||
distance?: number
|
||||
): Point[] {
|
||||
const newPoints: Point[] = [];
|
||||
const numSegments = (points.length - 1) / 3;
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
const offset = i * 3;
|
||||
getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints);
|
||||
}
|
||||
if (distance && distance > 0) {
|
||||
return simplifyPoints(newPoints, 0, newPoints.length, distance);
|
||||
}
|
||||
return newPoints;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { absolutize } from '../path-data-parser/absolutize.js';
|
||||
import { normalize } from '../path-data-parser/normalize.js';
|
||||
import { parsePath } from '../path-data-parser/parser.js';
|
||||
import { pointsOnBezierCurves, simplify } from '../points-on-curve/index.js';
|
||||
import type { Point } from '../rough/geometry.js';
|
||||
|
||||
export function pointsOnPath(
|
||||
path: string,
|
||||
tolerance?: number,
|
||||
distance?: number
|
||||
): Point[][] {
|
||||
const segments = parsePath(path);
|
||||
const normalized = normalize(absolutize(segments));
|
||||
|
||||
const sets: Point[][] = [];
|
||||
let currentPoints: Point[] = [];
|
||||
let start: Point = [0, 0];
|
||||
let pendingCurve: Point[] = [];
|
||||
|
||||
const appendPendingCurve = () => {
|
||||
if (pendingCurve.length >= 4) {
|
||||
currentPoints.push(...pointsOnBezierCurves(pendingCurve, tolerance));
|
||||
}
|
||||
pendingCurve = [];
|
||||
};
|
||||
|
||||
const appendPendingPoints = () => {
|
||||
appendPendingCurve();
|
||||
if (currentPoints.length) {
|
||||
sets.push(currentPoints);
|
||||
currentPoints = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const { key, data } of normalized) {
|
||||
switch (key) {
|
||||
case 'M':
|
||||
appendPendingPoints();
|
||||
start = [data[0], data[1]];
|
||||
currentPoints.push(start);
|
||||
break;
|
||||
case 'L':
|
||||
appendPendingCurve();
|
||||
currentPoints.push([data[0], data[1]]);
|
||||
break;
|
||||
case 'C':
|
||||
if (!pendingCurve.length) {
|
||||
const lastPoint = currentPoints.length
|
||||
? currentPoints[currentPoints.length - 1]
|
||||
: start;
|
||||
pendingCurve.push([lastPoint[0], lastPoint[1]]);
|
||||
}
|
||||
pendingCurve.push([data[0], data[1]]);
|
||||
pendingCurve.push([data[2], data[3]]);
|
||||
pendingCurve.push([data[4], data[5]]);
|
||||
break;
|
||||
case 'Z':
|
||||
appendPendingCurve();
|
||||
currentPoints.push([start[0], start[1]]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
appendPendingPoints();
|
||||
|
||||
if (!distance) {
|
||||
return sets;
|
||||
}
|
||||
|
||||
const out: Point[][] = [];
|
||||
for (const set of sets) {
|
||||
const simplifiedSet = simplify(set, distance);
|
||||
if (simplifiedSet.length) {
|
||||
out.push(simplifiedSet);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type PriorityQueueNode<T, K> = {
|
||||
value: T;
|
||||
priority: K;
|
||||
};
|
||||
|
||||
export class PriorityQueue<T, K> {
|
||||
heap: PriorityQueueNode<T, K>[] = [];
|
||||
|
||||
constructor(private readonly _compare: (a: K, b: K) => number) {}
|
||||
|
||||
bubbleDown(): void {
|
||||
let index = 0;
|
||||
const length = this.heap.length;
|
||||
const element = this.heap[0];
|
||||
|
||||
for (;;) {
|
||||
const leftChildIndex = 2 * index + 1;
|
||||
const rightChildIndex = 2 * index + 2;
|
||||
let swap = -1;
|
||||
let leftChild: PriorityQueueNode<T, K>,
|
||||
rightChild: PriorityQueueNode<T, K>;
|
||||
|
||||
if (leftChildIndex < length) {
|
||||
leftChild = this.heap[leftChildIndex];
|
||||
if (this._compare(leftChild.priority, element.priority) < 0) {
|
||||
swap = leftChildIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (rightChildIndex < length) {
|
||||
leftChild = this.heap[leftChildIndex];
|
||||
rightChild = this.heap[rightChildIndex];
|
||||
if (
|
||||
(swap === null &&
|
||||
this._compare(rightChild.priority, element.priority) < 0) ||
|
||||
(swap !== null &&
|
||||
this._compare(rightChild.priority, leftChild.priority) < 0)
|
||||
) {
|
||||
swap = rightChildIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap === -1) break;
|
||||
|
||||
this.heap[index] = this.heap[swap];
|
||||
this.heap[swap] = element;
|
||||
index = swap;
|
||||
}
|
||||
}
|
||||
|
||||
bubbleUp(index: number = this.heap.length - 1): void {
|
||||
const element = this.heap[index];
|
||||
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2);
|
||||
const parent = this.heap[parentIndex];
|
||||
|
||||
if (this._compare(parent.priority, element.priority) <= 0) break;
|
||||
|
||||
this.heap[parentIndex] = element;
|
||||
this.heap[index] = parent;
|
||||
index = parentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
dequeue(): T | null {
|
||||
const min = this.heap[0];
|
||||
const end = this.heap.pop();
|
||||
if (this.heap.length > 0 && end) {
|
||||
this.heap[0] = end;
|
||||
this.bubbleDown();
|
||||
}
|
||||
return min?.value ?? null;
|
||||
}
|
||||
|
||||
empty(): boolean {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
enqueue(value: T, priority: K): void {
|
||||
const node = { value, priority };
|
||||
this.heap.push(node);
|
||||
this.bubbleUp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Preet Shihn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import type { Point } from './geometry.js';
|
||||
|
||||
export class RoughCanvas {
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
|
||||
private readonly gen: RoughGenerator;
|
||||
|
||||
get generator(): RoughGenerator {
|
||||
return this.gen;
|
||||
}
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, config?: Config) {
|
||||
this.canvas = canvas;
|
||||
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.gen = new RoughGenerator(config);
|
||||
}
|
||||
|
||||
private _drawToContext(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawing: OpSet,
|
||||
fixedDecimals?: number,
|
||||
rule: CanvasFillRule = 'nonzero'
|
||||
) {
|
||||
ctx.beginPath();
|
||||
for (const item of drawing.ops) {
|
||||
const data =
|
||||
typeof fixedDecimals === 'number' && fixedDecimals >= 0
|
||||
? item.data.map(d => +d.toFixed(fixedDecimals))
|
||||
: item.data;
|
||||
switch (item.op) {
|
||||
case 'move':
|
||||
ctx.moveTo(data[0], data[1]);
|
||||
break;
|
||||
case 'bcurveTo':
|
||||
ctx.bezierCurveTo(
|
||||
data[0],
|
||||
data[1],
|
||||
data[2],
|
||||
data[3],
|
||||
data[4],
|
||||
data[5]
|
||||
);
|
||||
break;
|
||||
case 'lineTo':
|
||||
ctx.lineTo(data[0], data[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (drawing.type === 'fillPath') {
|
||||
ctx.fill(rule);
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
private fillSketch(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawing: OpSet,
|
||||
o: ResolvedOptions
|
||||
) {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
ctx.save();
|
||||
if (o.fillLineDash) {
|
||||
ctx.setLineDash(o.fillLineDash);
|
||||
}
|
||||
if (o.fillLineDashOffset) {
|
||||
ctx.lineDashOffset = o.fillLineDashOffset;
|
||||
}
|
||||
ctx.strokeStyle = o.fill || '';
|
||||
ctx.lineWidth = fweight;
|
||||
this._drawToContext(ctx, drawing, o.fixedDecimalPlaceDigits);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.arc(x, y, width, height, start, stop, closed, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
circle(x: number, y: number, diameter: number, options?: Options): Drawable {
|
||||
const d = this.gen.circle(x, y, diameter, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.curve(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
draw(drawable: Drawable): void {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.getDefaultOptions();
|
||||
const ctx = this.ctx;
|
||||
const precision = drawable.options.fixedDecimalPlaceDigits;
|
||||
|
||||
for (const drawing of sets) {
|
||||
switch (drawing.type) {
|
||||
case 'path':
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.stroke === 'none' ? 'transparent' : o.stroke;
|
||||
ctx.lineWidth = o.strokeWidth;
|
||||
if (o.strokeLineDash) {
|
||||
ctx.setLineDash(o.strokeLineDash);
|
||||
}
|
||||
if (o.strokeLineDashOffset) {
|
||||
ctx.lineDashOffset = o.strokeLineDashOffset;
|
||||
}
|
||||
this._drawToContext(ctx, drawing, precision);
|
||||
ctx.restore();
|
||||
break;
|
||||
case 'fillPath': {
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.fill || '';
|
||||
const fillRule: CanvasFillRule =
|
||||
drawable.shape === 'curve' ||
|
||||
drawable.shape === 'polygon' ||
|
||||
drawable.shape === 'path'
|
||||
? 'evenodd'
|
||||
: 'nonzero';
|
||||
this._drawToContext(ctx, drawing, precision, fillRule);
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'fillSketch':
|
||||
this.fillSketch(ctx, drawing, o);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.ellipse(x, y, width, height, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
getDefaultOptions(): ResolvedOptions {
|
||||
return this.gen.defaultOptions;
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.line(x1, y1, x2, y2, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.linearPath(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): Drawable {
|
||||
const drawing = this.gen.path(d, options);
|
||||
this.draw(drawing);
|
||||
return drawing;
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): Drawable {
|
||||
const d = this.gen.polygon(points, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const d = this.gen.rectangle(x, y, width, height, options);
|
||||
this.draw(d);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Point } from './geometry.js';
|
||||
import type { Random } from './math.js';
|
||||
|
||||
export const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
export interface Config {
|
||||
options?: Options;
|
||||
}
|
||||
|
||||
export interface DrawingSurface {
|
||||
width: number | SVGAnimatedLength;
|
||||
height: number | SVGAnimatedLength;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
maxRandomnessOffset?: number;
|
||||
roughness?: number;
|
||||
bowing?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
curveFitting?: number;
|
||||
curveTightness?: number;
|
||||
curveStepCount?: number;
|
||||
fill?: string;
|
||||
fillStyle?: string;
|
||||
fillWeight?: number;
|
||||
hachureAngle?: number;
|
||||
hachureGap?: number;
|
||||
simplification?: number;
|
||||
dashOffset?: number;
|
||||
dashGap?: number;
|
||||
zigzagOffset?: number;
|
||||
seed?: number;
|
||||
strokeLineDash?: number[];
|
||||
strokeLineDashOffset?: number;
|
||||
fillLineDash?: number[];
|
||||
fillLineDashOffset?: number;
|
||||
disableMultiStroke?: boolean;
|
||||
disableMultiStrokeFill?: boolean;
|
||||
preserveVertices?: boolean;
|
||||
fixedDecimalPlaceDigits?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedOptions extends Options {
|
||||
maxRandomnessOffset: number;
|
||||
roughness: number;
|
||||
bowing: number;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
curveFitting: number;
|
||||
curveTightness: number;
|
||||
curveStepCount: number;
|
||||
fillStyle: string;
|
||||
fillWeight: number;
|
||||
hachureAngle: number;
|
||||
hachureGap: number;
|
||||
dashOffset: number;
|
||||
dashGap: number;
|
||||
zigzagOffset: number;
|
||||
seed: number;
|
||||
randomizer?: Random;
|
||||
disableMultiStroke: boolean;
|
||||
disableMultiStrokeFill: boolean;
|
||||
preserveVertices: boolean;
|
||||
}
|
||||
|
||||
export type OpType = 'move' | 'bcurveTo' | 'lineTo';
|
||||
export type OpSetType = 'path' | 'fillPath' | 'fillSketch';
|
||||
|
||||
export interface Op {
|
||||
op: OpType;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface OpSet {
|
||||
type: OpSetType;
|
||||
ops: Op[];
|
||||
size?: Point;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface Drawable {
|
||||
shape: string;
|
||||
options: ResolvedOptions;
|
||||
sets: OpSet[];
|
||||
}
|
||||
|
||||
export interface PathInfo {
|
||||
d: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
fill?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class DashedFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private dashedLine(lines: Line[], o: ResolvedOptions): Op[] {
|
||||
const offset =
|
||||
o.dashOffset < 0
|
||||
? o.hachureGap < 0
|
||||
? o.strokeWidth * 4
|
||||
: o.hachureGap
|
||||
: o.dashOffset;
|
||||
const gap =
|
||||
o.dashGap < 0
|
||||
? o.hachureGap < 0
|
||||
? o.strokeWidth * 4
|
||||
: o.hachureGap
|
||||
: o.dashGap;
|
||||
const ops: Op[] = [];
|
||||
lines.forEach(line => {
|
||||
const length = lineLength(line);
|
||||
const count = Math.floor(length / (offset + gap));
|
||||
const startOffset = (length + gap - count * (offset + gap)) / 2;
|
||||
let p1 = line[0];
|
||||
let p2 = line[1];
|
||||
if (p1[0] > p2[0]) {
|
||||
p1 = line[1];
|
||||
p2 = line[0];
|
||||
}
|
||||
const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0]));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const lstart = i * (offset + gap);
|
||||
const lend = lstart + offset;
|
||||
const start: Point = [
|
||||
p1[0] + lstart * Math.cos(alpha) + startOffset * Math.cos(alpha),
|
||||
p1[1] + lstart * Math.sin(alpha) + startOffset * Math.sin(alpha),
|
||||
];
|
||||
const end: Point = [
|
||||
p1[0] + lend * Math.cos(alpha) + startOffset * Math.cos(alpha),
|
||||
p1[1] + lend * Math.sin(alpha) + startOffset * Math.sin(alpha),
|
||||
];
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(start[0], start[1], end[0], end[1], o)
|
||||
);
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return { type: 'fillSketch', ops: this.dashedLine(lines, o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class DotFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private dotsOnLines(lines: Line[], o: ResolvedOptions): OpSet {
|
||||
const ops: Op[] = [];
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
const ro = gap / 4;
|
||||
for (const line of lines) {
|
||||
const length = lineLength(line);
|
||||
const dl = length / gap;
|
||||
const count = Math.ceil(dl) - 1;
|
||||
const offset = length - count * gap;
|
||||
const x = (line[0][0] + line[1][0]) / 2 - gap / 4;
|
||||
const minY = Math.min(line[0][1], line[1][1]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = minY + offset + i * gap;
|
||||
const cx = x - ro + Math.random() * 2 * ro;
|
||||
const cy = y - ro + Math.random() * 2 * ro;
|
||||
const el = this.helper.ellipse(cx, cy, fweight, fweight, o);
|
||||
ops.push(...el.ops);
|
||||
}
|
||||
}
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
o = Object.assign({}, o, { hachureAngle: 0 });
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return this.dotsOnLines(lines, o);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Point } from '../geometry.js';
|
||||
|
||||
export interface PatternFiller {
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet;
|
||||
}
|
||||
|
||||
export interface RenderHelper {
|
||||
randOffset(x: number, o: ResolvedOptions): number;
|
||||
randOffsetWithRange(min: number, max: number, o: ResolvedOptions): number;
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet;
|
||||
doubleLineOps(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): Op[];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ResolvedOptions } from '../core.js';
|
||||
import { DashedFiller } from './dashed-filler.js';
|
||||
import { DotFiller } from './dot-filler.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
import { HatchFiller } from './hatch-filler.js';
|
||||
import { ZigZagFiller } from './zigzag-filler.js';
|
||||
import { ZigZagLineFiller } from './zigzag-line-filler.js';
|
||||
|
||||
const fillers: Record<string, PatternFiller> = {};
|
||||
|
||||
export function getFiller(
|
||||
o: ResolvedOptions,
|
||||
helper: RenderHelper
|
||||
): PatternFiller {
|
||||
let fillerName = o.fillStyle || 'hachure';
|
||||
if (!fillers[fillerName]) {
|
||||
switch (fillerName) {
|
||||
case 'zigzag':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new ZigZagFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'cross-hatch':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new HatchFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'dots':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new DotFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'dashed':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new DashedFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'zigzag-line':
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new ZigZagLineFiller(helper);
|
||||
}
|
||||
break;
|
||||
case 'hachure':
|
||||
default:
|
||||
fillerName = 'hachure';
|
||||
if (!fillers[fillerName]) {
|
||||
fillers[fillerName] = new HachureFiller(helper);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fillers[fillerName];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class HachureFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
protected _fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
const ops = this.renderLines(lines, o);
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
return this._fillPolygons(polygonList, o);
|
||||
}
|
||||
|
||||
protected renderLines(lines: Line[], o: ResolvedOptions): Op[] {
|
||||
const ops: Op[] = [];
|
||||
for (const line of lines) {
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(
|
||||
line[0][0],
|
||||
line[0][1],
|
||||
line[1][0],
|
||||
line[1][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Point } from '../geometry.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
|
||||
export class HatchFiller extends HachureFiller {
|
||||
override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const set = this._fillPolygons(polygonList, o);
|
||||
const o2 = Object.assign({}, o, { hachureAngle: o.hachureAngle + 90 });
|
||||
const set2 = this._fillPolygons(polygonList, o2);
|
||||
set.ops = set.ops.concat(set2.ops);
|
||||
return set;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { rotateLines, rotatePoints } from '../geometry.js';
|
||||
|
||||
interface EdgeEntry {
|
||||
ymin: number;
|
||||
ymax: number;
|
||||
x: number;
|
||||
islope: number;
|
||||
}
|
||||
|
||||
interface ActiveEdgeEntry {
|
||||
s: number;
|
||||
edge: EdgeEntry;
|
||||
}
|
||||
|
||||
export function polygonHachureLines(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): Line[] {
|
||||
const angle = o.hachureAngle + 90;
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
|
||||
const rotationCenter: Point = [0, 0];
|
||||
if (angle) {
|
||||
for (const polygon of polygonList) {
|
||||
rotatePoints(polygon, rotationCenter, angle);
|
||||
}
|
||||
}
|
||||
const lines = straightHachureLines(polygonList, gap);
|
||||
if (angle) {
|
||||
for (const polygon of polygonList) {
|
||||
rotatePoints(polygon, rotationCenter, -angle);
|
||||
}
|
||||
rotateLines(lines, rotationCenter, -angle);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function straightHachureLines(polygonList: Point[][], gap: number): Line[] {
|
||||
const vertexArray: Point[][] = [];
|
||||
for (const polygon of polygonList) {
|
||||
const vertices = [...polygon];
|
||||
if (vertices[0].join(',') !== vertices[vertices.length - 1].join(',')) {
|
||||
vertices.push([vertices[0][0], vertices[0][1]]);
|
||||
}
|
||||
if (vertices.length > 2) {
|
||||
vertexArray.push(vertices);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: Line[] = [];
|
||||
gap = Math.max(gap, 0.1);
|
||||
|
||||
// Create sorted edges table
|
||||
const edges: EdgeEntry[] = [];
|
||||
|
||||
for (const vertices of vertexArray) {
|
||||
for (let i = 0; i < vertices.length - 1; i++) {
|
||||
const p1 = vertices[i];
|
||||
const p2 = vertices[i + 1];
|
||||
if (p1[1] !== p2[1]) {
|
||||
const ymin = Math.min(p1[1], p2[1]);
|
||||
edges.push({
|
||||
ymin,
|
||||
ymax: Math.max(p1[1], p2[1]),
|
||||
x: ymin === p1[1] ? p1[0] : p2[0],
|
||||
islope: (p2[0] - p1[0]) / (p2[1] - p1[1]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort((e1, e2) => {
|
||||
if (e1.ymin < e2.ymin) {
|
||||
return -1;
|
||||
}
|
||||
if (e1.ymin > e2.ymin) {
|
||||
return 1;
|
||||
}
|
||||
if (e1.x < e2.x) {
|
||||
return -1;
|
||||
}
|
||||
if (e1.x > e2.x) {
|
||||
return 1;
|
||||
}
|
||||
if (e1.ymax === e2.ymax) {
|
||||
return 0;
|
||||
}
|
||||
return (e1.ymax - e2.ymax) / Math.abs(e1.ymax - e2.ymax);
|
||||
});
|
||||
if (!edges.length) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Start scanning
|
||||
let activeEdges: ActiveEdgeEntry[] = [];
|
||||
let y = edges[0].ymin;
|
||||
while (activeEdges.length || edges.length) {
|
||||
if (edges.length) {
|
||||
let ix = -1;
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (edges[i].ymin > y) {
|
||||
break;
|
||||
}
|
||||
ix = i;
|
||||
}
|
||||
const removed = edges.splice(0, ix + 1);
|
||||
removed.forEach(edge => {
|
||||
activeEdges.push({ s: y, edge });
|
||||
});
|
||||
}
|
||||
activeEdges = activeEdges.filter(ae => {
|
||||
if (ae.edge.ymax <= y) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
activeEdges.sort((ae1, ae2) => {
|
||||
if (ae1.edge.x === ae2.edge.x) {
|
||||
return 0;
|
||||
}
|
||||
return (ae1.edge.x - ae2.edge.x) / Math.abs(ae1.edge.x - ae2.edge.x);
|
||||
});
|
||||
|
||||
// fill between the edges
|
||||
if (activeEdges.length > 1) {
|
||||
for (let i = 0; i < activeEdges.length; i = i + 2) {
|
||||
const nexti = i + 1;
|
||||
if (nexti >= activeEdges.length) {
|
||||
break;
|
||||
}
|
||||
const ce = activeEdges[i].edge;
|
||||
const ne = activeEdges[nexti].edge;
|
||||
lines.push([
|
||||
[Math.round(ce.x), y],
|
||||
[Math.round(ne.x), y],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
y += gap;
|
||||
activeEdges.forEach(ae => {
|
||||
ae.edge.x = ae.edge.x + gap * ae.edge.islope;
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import { HachureFiller } from './hachure-filler.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class ZigZagFiller extends HachureFiller {
|
||||
override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
let gap = o.hachureGap;
|
||||
if (gap < 0) {
|
||||
gap = o.strokeWidth * 4;
|
||||
}
|
||||
gap = Math.max(gap, 0.1);
|
||||
const o2 = Object.assign({}, o, { hachureGap: gap });
|
||||
const lines = polygonHachureLines(polygonList, o2);
|
||||
const zigZagAngle = (Math.PI / 180) * o.hachureAngle;
|
||||
const zigzagLines: Line[] = [];
|
||||
const dgx = gap * 0.5 * Math.cos(zigZagAngle);
|
||||
const dgy = gap * 0.5 * Math.sin(zigZagAngle);
|
||||
for (const [p1, p2] of lines) {
|
||||
if (lineLength([p1, p2])) {
|
||||
zigzagLines.push(
|
||||
[[p1[0] - dgx, p1[1] + dgy], [...p2]],
|
||||
[[p1[0] + dgx, p1[1] - dgy], [...p2]]
|
||||
);
|
||||
}
|
||||
}
|
||||
const ops = this.renderLines(zigzagLines, o);
|
||||
return { type: 'fillSketch', ops };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Op, OpSet, ResolvedOptions } from '../core.js';
|
||||
import type { Line, Point } from '../geometry.js';
|
||||
import { lineLength } from '../geometry.js';
|
||||
import type { PatternFiller, RenderHelper } from './filler-interface.js';
|
||||
import { polygonHachureLines } from './scan-line-hachure.js';
|
||||
|
||||
export class ZigZagLineFiller implements PatternFiller {
|
||||
private readonly helper: RenderHelper;
|
||||
|
||||
constructor(helper: RenderHelper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
private zigzagLines(lines: Line[], zo: number, o: ResolvedOptions): Op[] {
|
||||
const ops: Op[] = [];
|
||||
lines.forEach(line => {
|
||||
const length = lineLength(line);
|
||||
const count = Math.round(length / (2 * zo));
|
||||
let p1 = line[0];
|
||||
let p2 = line[1];
|
||||
if (p1[0] > p2[0]) {
|
||||
p1 = line[1];
|
||||
p2 = line[0];
|
||||
}
|
||||
const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0]));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const lstart = i * 2 * zo;
|
||||
const lend = (i + 1) * 2 * zo;
|
||||
const dz = Math.sqrt(2 * Math.pow(zo, 2));
|
||||
const start: Point = [
|
||||
p1[0] + lstart * Math.cos(alpha),
|
||||
p1[1] + lstart * Math.sin(alpha),
|
||||
];
|
||||
const end: Point = [
|
||||
p1[0] + lend * Math.cos(alpha),
|
||||
p1[1] + lend * Math.sin(alpha),
|
||||
];
|
||||
const middle: Point = [
|
||||
start[0] + dz * Math.cos(alpha + Math.PI / 4),
|
||||
start[1] + dz * Math.sin(alpha + Math.PI / 4),
|
||||
];
|
||||
ops.push(
|
||||
...this.helper.doubleLineOps(
|
||||
start[0],
|
||||
start[1],
|
||||
middle[0],
|
||||
middle[1],
|
||||
o
|
||||
),
|
||||
...this.helper.doubleLineOps(middle[0], middle[1], end[0], end[1], o)
|
||||
);
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet {
|
||||
const gap = o.hachureGap < 0 ? o.strokeWidth * 4 : o.hachureGap;
|
||||
const zo = o.zigzagOffset < 0 ? gap : o.zigzagOffset;
|
||||
o = Object.assign({}, o, { hachureGap: gap + zo });
|
||||
const lines = polygonHachureLines(polygonList, o);
|
||||
return { type: 'fillSketch', ops: this.zigzagLines(lines, zo, o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { curveToBezier } from '../points-on-curve/curve-to-bezier.js';
|
||||
import { pointsOnBezierCurves } from '../points-on-curve/index.js';
|
||||
import { pointsOnPath } from '../points-on-path/index.js';
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
PathInfo,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import type { Point } from './geometry.js';
|
||||
import { randomSeed } from './math.js';
|
||||
import {
|
||||
arc,
|
||||
curve,
|
||||
ellipseWithParams,
|
||||
generateEllipseParams,
|
||||
line,
|
||||
linearPath,
|
||||
patternFillArc,
|
||||
patternFillPolygons,
|
||||
rectangle,
|
||||
solidFillPolygon,
|
||||
svgPath,
|
||||
} from './renderer.js';
|
||||
|
||||
const NOS = 'none';
|
||||
|
||||
export class RoughGenerator {
|
||||
private readonly config: Config;
|
||||
|
||||
defaultOptions: ResolvedOptions = {
|
||||
maxRandomnessOffset: 2,
|
||||
roughness: 1,
|
||||
bowing: 1,
|
||||
stroke: '#000',
|
||||
strokeWidth: 1,
|
||||
curveTightness: 0,
|
||||
curveFitting: 0.95,
|
||||
curveStepCount: 9,
|
||||
fillStyle: 'hachure',
|
||||
fillWeight: -1,
|
||||
hachureAngle: -41,
|
||||
hachureGap: -1,
|
||||
dashOffset: -1,
|
||||
dashGap: -1,
|
||||
zigzagOffset: -1,
|
||||
seed: 0,
|
||||
disableMultiStroke: false,
|
||||
disableMultiStrokeFill: false,
|
||||
preserveVertices: false,
|
||||
};
|
||||
|
||||
constructor(config?: Config) {
|
||||
this.config = config || {};
|
||||
if (this.config.options) {
|
||||
this.defaultOptions = this._o(this.config.options);
|
||||
}
|
||||
}
|
||||
|
||||
static newSeed(): number {
|
||||
return randomSeed();
|
||||
}
|
||||
|
||||
private _d(shape: string, sets: OpSet[], options: ResolvedOptions): Drawable {
|
||||
return { shape, sets: sets || [], options: options || this.defaultOptions };
|
||||
}
|
||||
|
||||
private _o(options?: Options): ResolvedOptions {
|
||||
return options
|
||||
? Object.assign({}, this.defaultOptions, options)
|
||||
: this.defaultOptions;
|
||||
}
|
||||
|
||||
private fillSketch(drawing: OpSet, o: ResolvedOptions): PathInfo {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
return {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: o.fill || NOS,
|
||||
strokeWidth: fweight,
|
||||
fill: NOS,
|
||||
};
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths = [];
|
||||
const outline = arc(x, y, width, height, start, stop, closed, true, o);
|
||||
if (closed && o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
const fillOptions: ResolvedOptions = { ...o };
|
||||
fillOptions.disableMultiStroke = true;
|
||||
const shape = arc(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
stop,
|
||||
true,
|
||||
false,
|
||||
fillOptions
|
||||
);
|
||||
shape.type = 'fillPath';
|
||||
paths.push(shape);
|
||||
} else {
|
||||
paths.push(patternFillArc(x, y, width, height, start, stop, o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('arc', paths, o);
|
||||
}
|
||||
|
||||
circle(x: number, y: number, diameter: number, options?: Options): Drawable {
|
||||
const ret = this.ellipse(x, y, diameter, diameter, options);
|
||||
ret.shape = 'circle';
|
||||
return ret;
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const outline = curve(points, o);
|
||||
if (o.fill && o.fill !== NOS && points.length >= 3) {
|
||||
const bcurve = curveToBezier(points);
|
||||
const polyPoints = pointsOnBezierCurves(
|
||||
bcurve,
|
||||
10,
|
||||
(1 + o.roughness) / 2
|
||||
);
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([polyPoints], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([polyPoints], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('curve', paths, o);
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const ellipseParams = generateEllipseParams(width, height, o);
|
||||
const ellipseResponse = ellipseWithParams(x, y, o, ellipseParams);
|
||||
if (o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
const shape = ellipseWithParams(x, y, o, ellipseParams).opset;
|
||||
shape.type = 'fillPath';
|
||||
paths.push(shape);
|
||||
} else {
|
||||
paths.push(patternFillPolygons([ellipseResponse.estimatedPoints], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(ellipseResponse.opset);
|
||||
}
|
||||
return this._d('ellipse', paths, o);
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
return this._d('line', [line(x1, y1, x2, y2, o)], o);
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
return this._d('linearPath', [linearPath(points, false, o)], o);
|
||||
}
|
||||
|
||||
opsToPath(drawing: OpSet, fixedDecimals?: number): string {
|
||||
let path = '';
|
||||
for (const item of drawing.ops) {
|
||||
const data =
|
||||
typeof fixedDecimals === 'number' && fixedDecimals >= 0
|
||||
? item.data.map(d => +d.toFixed(fixedDecimals))
|
||||
: item.data;
|
||||
switch (item.op) {
|
||||
case 'move':
|
||||
path += `M${data[0]} ${data[1]} `;
|
||||
break;
|
||||
case 'bcurveTo':
|
||||
path += `C${data[0]} ${data[1]}, ${data[2]} ${data[3]}, ${data[4]} ${data[5]} `;
|
||||
break;
|
||||
case 'lineTo':
|
||||
path += `L${data[0]} ${data[1]} `;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return path.trim();
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
if (!d) {
|
||||
return this._d('path', paths, o);
|
||||
}
|
||||
d = (d || '')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/(-\s)/g, '-')
|
||||
.replace('/(ss)/g', ' ');
|
||||
|
||||
const hasFill = o.fill && o.fill !== 'transparent' && o.fill !== NOS;
|
||||
const hasStroke = o.stroke !== NOS;
|
||||
const simplified = !!(o.simplification && o.simplification < 1);
|
||||
const distance = simplified
|
||||
? 4 - 4 * o.simplification!
|
||||
: (1 + o.roughness) / 2;
|
||||
const sets = pointsOnPath(d, 1, distance);
|
||||
|
||||
if (hasFill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon(sets, o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons(sets, o));
|
||||
}
|
||||
}
|
||||
if (hasStroke) {
|
||||
if (simplified) {
|
||||
sets.forEach(set => {
|
||||
paths.push(linearPath(set, false, o));
|
||||
});
|
||||
} else {
|
||||
paths.push(svgPath(d, o));
|
||||
}
|
||||
}
|
||||
|
||||
return this._d('path', paths, o);
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths: OpSet[] = [];
|
||||
const outline = linearPath(points, true, o);
|
||||
if (o.fill) {
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([points], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([points], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('polygon', paths, o);
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): Drawable {
|
||||
const o = this._o(options);
|
||||
const paths = [];
|
||||
const outline = rectangle(x, y, width, height, o);
|
||||
if (o.fill) {
|
||||
const points: Point[] = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
];
|
||||
if (o.fillStyle === 'solid') {
|
||||
paths.push(solidFillPolygon([points], o));
|
||||
} else {
|
||||
paths.push(patternFillPolygons([points], o));
|
||||
}
|
||||
}
|
||||
if (o.stroke !== NOS) {
|
||||
paths.push(outline);
|
||||
}
|
||||
return this._d('rectangle', paths, o);
|
||||
}
|
||||
|
||||
toPaths(drawable: Drawable): PathInfo[] {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.defaultOptions;
|
||||
const paths: PathInfo[] = [];
|
||||
for (const drawing of sets) {
|
||||
let path: PathInfo | null = null;
|
||||
switch (drawing.type) {
|
||||
case 'path':
|
||||
path = {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: o.stroke,
|
||||
strokeWidth: o.strokeWidth,
|
||||
fill: NOS,
|
||||
};
|
||||
break;
|
||||
case 'fillPath':
|
||||
path = {
|
||||
d: this.opsToPath(drawing),
|
||||
stroke: NOS,
|
||||
strokeWidth: 0,
|
||||
fill: o.fill || NOS,
|
||||
};
|
||||
break;
|
||||
case 'fillSketch':
|
||||
path = this.fillSketch(drawing, o);
|
||||
break;
|
||||
}
|
||||
if (path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type Point = [number, number];
|
||||
export type Line = [Point, Point];
|
||||
|
||||
export interface Rectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function rotatePoints(
|
||||
points: Point[],
|
||||
center: Point,
|
||||
degrees: number
|
||||
): void {
|
||||
if (points && points.length) {
|
||||
const [cx, cy] = center;
|
||||
const angle = (Math.PI / 180) * degrees;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
points.forEach(p => {
|
||||
const [x, y] = p;
|
||||
p[0] = (x - cx) * cos - (y - cy) * sin + cx;
|
||||
p[1] = (x - cx) * sin + (y - cy) * cos + cy;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function rotateLines(
|
||||
lines: Line[],
|
||||
center: Point,
|
||||
degrees: number
|
||||
): void {
|
||||
const points: Point[] = [];
|
||||
lines.forEach(line => points.push(...line));
|
||||
rotatePoints(points, center, degrees);
|
||||
}
|
||||
|
||||
export function lineLength(line: Line): number {
|
||||
const p1 = line[0];
|
||||
const p2 = line[1];
|
||||
return Math.sqrt(Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function randomSeed(): number {
|
||||
return Math.floor(Math.random() * 2 ** 31);
|
||||
}
|
||||
|
||||
export class Random {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
if (this.seed) {
|
||||
return (
|
||||
((2 ** 31 - 1) & (this.seed = Math.imul(48271, this.seed))) / 2 ** 31
|
||||
);
|
||||
} else {
|
||||
return Math.random();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { absolutize } from '../path-data-parser/absolutize.js';
|
||||
import { normalize } from '../path-data-parser/normalize.js';
|
||||
import { parsePath } from '../path-data-parser/parser.js';
|
||||
import type { Op, OpSet, ResolvedOptions } from './core.js';
|
||||
import { getFiller } from './fillers/filler.js';
|
||||
import type { RenderHelper } from './fillers/filler-interface.js';
|
||||
import type { Point } from './geometry.js';
|
||||
import { Random } from './math.js';
|
||||
|
||||
interface EllipseParams {
|
||||
rx: number;
|
||||
ry: number;
|
||||
increment: number;
|
||||
}
|
||||
|
||||
const helper: RenderHelper = {
|
||||
randOffset,
|
||||
randOffsetWithRange,
|
||||
ellipse,
|
||||
doubleLineOps: doubleLineFillOps,
|
||||
};
|
||||
|
||||
export function line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
return { type: 'path', ops: _doubleLine(x1, y1, x2, y2, o) };
|
||||
}
|
||||
|
||||
export function linearPath(
|
||||
points: Point[],
|
||||
close: boolean,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const len = (points || []).length;
|
||||
if (len > 2) {
|
||||
const ops: Op[] = [];
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
points[i][0],
|
||||
points[i][1],
|
||||
points[i + 1][0],
|
||||
points[i + 1][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
if (close) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
points[len - 1][0],
|
||||
points[len - 1][1],
|
||||
points[0][0],
|
||||
points[0][1],
|
||||
o
|
||||
)
|
||||
);
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
} else if (len === 2) {
|
||||
return line(points[0][0], points[0][1], points[1][0], points[1][1], o);
|
||||
}
|
||||
return { type: 'path', ops: [] };
|
||||
}
|
||||
|
||||
export function polygon(points: Point[], o: ResolvedOptions): OpSet {
|
||||
return linearPath(points, true, o);
|
||||
}
|
||||
|
||||
export function rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const points: Point[] = [
|
||||
[x, y],
|
||||
[x + width, y],
|
||||
[x + width, y + height],
|
||||
[x, y + height],
|
||||
];
|
||||
return polygon(points, o);
|
||||
}
|
||||
|
||||
export function curve(points: Point[], o: ResolvedOptions): OpSet {
|
||||
let o1 = _curveWithOffset(points, 1 * (1 + o.roughness * 0.2), o);
|
||||
if (!o.disableMultiStroke) {
|
||||
const o2 = _curveWithOffset(
|
||||
points,
|
||||
1.5 * (1 + o.roughness * 0.22),
|
||||
cloneOptionsAlterSeed(o)
|
||||
);
|
||||
o1 = o1.concat(o2);
|
||||
}
|
||||
return { type: 'path', ops: o1 };
|
||||
}
|
||||
|
||||
export interface EllipseResult {
|
||||
opset: OpSet;
|
||||
estimatedPoints: Point[];
|
||||
}
|
||||
|
||||
export function ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const params = generateEllipseParams(width, height, o);
|
||||
return ellipseWithParams(x, y, o, params).opset;
|
||||
}
|
||||
|
||||
export function generateEllipseParams(
|
||||
width: number,
|
||||
height: number,
|
||||
o: ResolvedOptions
|
||||
): EllipseParams {
|
||||
const psq = Math.sqrt(
|
||||
Math.PI *
|
||||
2 *
|
||||
Math.sqrt((Math.pow(width / 2, 2) + Math.pow(height / 2, 2)) / 2)
|
||||
);
|
||||
const stepCount = Math.ceil(
|
||||
Math.max(o.curveStepCount, (o.curveStepCount / Math.sqrt(200)) * psq)
|
||||
);
|
||||
const increment = (Math.PI * 2) / stepCount;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
const curveFitRandomness = 1 - o.curveFitting;
|
||||
rx += _offsetOpt(rx * curveFitRandomness, o);
|
||||
ry += _offsetOpt(ry * curveFitRandomness, o);
|
||||
return { increment, rx, ry };
|
||||
}
|
||||
|
||||
export function ellipseWithParams(
|
||||
x: number,
|
||||
y: number,
|
||||
o: ResolvedOptions,
|
||||
ellipseParams: EllipseParams
|
||||
): EllipseResult {
|
||||
const [ap1, cp1] = _computeEllipsePoints(
|
||||
ellipseParams.increment,
|
||||
x,
|
||||
y,
|
||||
ellipseParams.rx,
|
||||
ellipseParams.ry,
|
||||
1,
|
||||
ellipseParams.increment * _offset(0.1, _offset(0.4, 1, o), o),
|
||||
o
|
||||
);
|
||||
let o1 = _curve(ap1, null, o);
|
||||
if (!o.disableMultiStroke && o.roughness !== 0) {
|
||||
const [ap2] = _computeEllipsePoints(
|
||||
ellipseParams.increment,
|
||||
x,
|
||||
y,
|
||||
ellipseParams.rx,
|
||||
ellipseParams.ry,
|
||||
1.5,
|
||||
0,
|
||||
o
|
||||
);
|
||||
const o2 = _curve(ap2, null, o);
|
||||
o1 = o1.concat(o2);
|
||||
}
|
||||
return {
|
||||
estimatedPoints: cp1,
|
||||
opset: { type: 'path', ops: o1 },
|
||||
};
|
||||
}
|
||||
|
||||
export function arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed: boolean,
|
||||
roughClosure: boolean,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const cx = x;
|
||||
const cy = y;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
rx += _offsetOpt(rx * 0.01, o);
|
||||
ry += _offsetOpt(ry * 0.01, o);
|
||||
let strt = start;
|
||||
let stp = stop;
|
||||
while (strt < 0) {
|
||||
strt += Math.PI * 2;
|
||||
stp += Math.PI * 2;
|
||||
}
|
||||
if (stp - strt > Math.PI * 2) {
|
||||
strt = 0;
|
||||
stp = Math.PI * 2;
|
||||
}
|
||||
const ellipseInc = (Math.PI * 2) / o.curveStepCount;
|
||||
const arcInc = Math.min(ellipseInc / 2, (stp - strt) / 2);
|
||||
const ops = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1, o);
|
||||
if (!o.disableMultiStroke) {
|
||||
const o2 = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1.5, o);
|
||||
ops.push(...o2);
|
||||
}
|
||||
if (closed) {
|
||||
if (roughClosure) {
|
||||
ops.push(
|
||||
..._doubleLine(
|
||||
cx,
|
||||
cy,
|
||||
cx + rx * Math.cos(strt),
|
||||
cy + ry * Math.sin(strt),
|
||||
o
|
||||
),
|
||||
..._doubleLine(
|
||||
cx,
|
||||
cy,
|
||||
cx + rx * Math.cos(stp),
|
||||
cy + ry * Math.sin(stp),
|
||||
o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
ops.push(
|
||||
{ op: 'lineTo', data: [cx, cy] },
|
||||
{
|
||||
op: 'lineTo',
|
||||
data: [cx + rx * Math.cos(strt), cy + ry * Math.sin(strt)],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
}
|
||||
|
||||
export function svgPath(path: string, o: ResolvedOptions): OpSet {
|
||||
const segments = normalize(absolutize(parsePath(path)));
|
||||
const ops: Op[] = [];
|
||||
let first: Point = [0, 0];
|
||||
let current: Point = [0, 0];
|
||||
for (const { key, data } of segments) {
|
||||
switch (key) {
|
||||
case 'M': {
|
||||
const ro = 1 * (o.maxRandomnessOffset || 0);
|
||||
const pv = o.preserveVertices;
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: data.map(d => d + (pv ? 0 : _offsetOpt(ro, o))),
|
||||
});
|
||||
current = [data[0], data[1]];
|
||||
first = [data[0], data[1]];
|
||||
break;
|
||||
}
|
||||
case 'L':
|
||||
ops.push(..._doubleLine(current[0], current[1], data[0], data[1], o));
|
||||
current = [data[0], data[1]];
|
||||
break;
|
||||
case 'C': {
|
||||
const [x1, y1, x2, y2, x, y] = data;
|
||||
ops.push(..._bezierTo(x1, y1, x2, y2, x, y, current, o));
|
||||
current = [x, y];
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
ops.push(..._doubleLine(current[0], current[1], first[0], first[1], o));
|
||||
current = [first[0], first[1]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { type: 'path', ops };
|
||||
}
|
||||
|
||||
// Fills
|
||||
|
||||
export function solidFillPolygon(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const ops: Op[] = [];
|
||||
for (const points of polygonList) {
|
||||
if (points.length) {
|
||||
const offset = o.maxRandomnessOffset || 0;
|
||||
const len = points.length;
|
||||
if (len > 2) {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
],
|
||||
});
|
||||
for (let i = 1; i < len; i++) {
|
||||
ops.push({
|
||||
op: 'lineTo',
|
||||
data: [
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'fillPath', ops };
|
||||
}
|
||||
|
||||
export function patternFillPolygons(
|
||||
polygonList: Point[][],
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
return getFiller(o, helper).fillPolygons(polygonList, o);
|
||||
}
|
||||
|
||||
export function patternFillArc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
o: ResolvedOptions
|
||||
): OpSet {
|
||||
const cx = x;
|
||||
const cy = y;
|
||||
let rx = Math.abs(width / 2);
|
||||
let ry = Math.abs(height / 2);
|
||||
rx += _offsetOpt(rx * 0.01, o);
|
||||
ry += _offsetOpt(ry * 0.01, o);
|
||||
let strt = start;
|
||||
let stp = stop;
|
||||
while (strt < 0) {
|
||||
strt += Math.PI * 2;
|
||||
stp += Math.PI * 2;
|
||||
}
|
||||
if (stp - strt > Math.PI * 2) {
|
||||
strt = 0;
|
||||
stp = Math.PI * 2;
|
||||
}
|
||||
const increment = (stp - strt) / o.curveStepCount;
|
||||
const points: Point[] = [];
|
||||
for (let angle = strt; angle <= stp; angle = angle + increment) {
|
||||
points.push([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
|
||||
}
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
points.push([cx, cy]);
|
||||
return patternFillPolygons([points], o);
|
||||
}
|
||||
|
||||
export function randOffset(x: number, o: ResolvedOptions): number {
|
||||
return _offsetOpt(x, o);
|
||||
}
|
||||
|
||||
export function randOffsetWithRange(
|
||||
min: number,
|
||||
max: number,
|
||||
o: ResolvedOptions
|
||||
): number {
|
||||
return _offset(min, max, o);
|
||||
}
|
||||
|
||||
export function doubleLineFillOps(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
return _doubleLine(x1, y1, x2, y2, o, true);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
function cloneOptionsAlterSeed(ops: ResolvedOptions): ResolvedOptions {
|
||||
const result: ResolvedOptions = { ...ops };
|
||||
result.randomizer = undefined;
|
||||
if (ops.seed) {
|
||||
result.seed = ops.seed + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function random(ops: ResolvedOptions): number {
|
||||
if (!ops.randomizer) {
|
||||
ops.randomizer = new Random(ops.seed || 0);
|
||||
}
|
||||
return ops.randomizer.next();
|
||||
}
|
||||
|
||||
function _offset(
|
||||
min: number,
|
||||
max: number,
|
||||
ops: ResolvedOptions,
|
||||
roughnessGain = 1
|
||||
): number {
|
||||
return ops.roughness * roughnessGain * (random(ops) * (max - min) + min);
|
||||
}
|
||||
|
||||
function _offsetOpt(
|
||||
x: number,
|
||||
ops: ResolvedOptions,
|
||||
roughnessGain = 1
|
||||
): number {
|
||||
return _offset(-x, x, ops, roughnessGain);
|
||||
}
|
||||
|
||||
function _doubleLine(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions,
|
||||
filling = false
|
||||
): Op[] {
|
||||
const singleStroke = filling
|
||||
? o.disableMultiStrokeFill
|
||||
: o.disableMultiStroke;
|
||||
const o1 = _line(x1, y1, x2, y2, o, true, false);
|
||||
if (singleStroke) {
|
||||
return o1;
|
||||
}
|
||||
const o2 = _line(x1, y1, x2, y2, o, true, true);
|
||||
return o1.concat(o2);
|
||||
}
|
||||
|
||||
function _line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
o: ResolvedOptions,
|
||||
move: boolean,
|
||||
overlay: boolean
|
||||
): Op[] {
|
||||
const lengthSq = Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2);
|
||||
const length = Math.sqrt(lengthSq);
|
||||
let roughnessGain = 1;
|
||||
if (length < 200) {
|
||||
roughnessGain = 1;
|
||||
} else if (length > 500) {
|
||||
roughnessGain = 0.4;
|
||||
} else {
|
||||
roughnessGain = -0.0016668 * length + 1.233334;
|
||||
}
|
||||
|
||||
let offset = o.maxRandomnessOffset || 0;
|
||||
if (offset * offset * 100 > lengthSq) {
|
||||
offset = length / 10;
|
||||
}
|
||||
const halfOffset = offset / 2;
|
||||
const divergePoint = 0.2 + random(o) * 0.2;
|
||||
let midDispX = (o.bowing * o.maxRandomnessOffset * (y2 - y1)) / 200;
|
||||
let midDispY = (o.bowing * o.maxRandomnessOffset * (x1 - x2)) / 200;
|
||||
midDispX = _offsetOpt(midDispX, o, roughnessGain);
|
||||
midDispY = _offsetOpt(midDispY, o, roughnessGain);
|
||||
const ops: Op[] = [];
|
||||
const randomHalf = () => _offsetOpt(halfOffset, o, roughnessGain);
|
||||
const randomFull = () => _offsetOpt(offset, o, roughnessGain);
|
||||
const preserveVertices = o.preserveVertices;
|
||||
if (move) {
|
||||
if (overlay) {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
x1 + (preserveVertices ? 0 : randomHalf()),
|
||||
y1 + (preserveVertices ? 0 : randomHalf()),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
x1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)),
|
||||
y1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (overlay) {
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
midDispX + x1 + (x2 - x1) * divergePoint + randomHalf(),
|
||||
midDispY + y1 + (y2 - y1) * divergePoint + randomHalf(),
|
||||
midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomHalf(),
|
||||
midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomHalf(),
|
||||
x2 + (preserveVertices ? 0 : randomHalf()),
|
||||
y2 + (preserveVertices ? 0 : randomHalf()),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
midDispX + x1 + (x2 - x1) * divergePoint + randomFull(),
|
||||
midDispY + y1 + (y2 - y1) * divergePoint + randomFull(),
|
||||
midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomFull(),
|
||||
midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomFull(),
|
||||
x2 + (preserveVertices ? 0 : randomFull()),
|
||||
y2 + (preserveVertices ? 0 : randomFull()),
|
||||
],
|
||||
});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function _curveWithOffset(
|
||||
points: Point[],
|
||||
offset: number,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const ps: Point[] = [];
|
||||
ps.push([
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
ps.push([
|
||||
points[0][0] + _offsetOpt(offset, o),
|
||||
points[0][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ps.push([
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
if (i === points.length - 1) {
|
||||
ps.push([
|
||||
points[i][0] + _offsetOpt(offset, o),
|
||||
points[i][1] + _offsetOpt(offset, o),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return _curve(ps, null, o);
|
||||
}
|
||||
|
||||
function _curve(
|
||||
points: Point[],
|
||||
closePoint: Point | null,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const len = points.length;
|
||||
const ops: Op[] = [];
|
||||
if (len > 3) {
|
||||
const b = [];
|
||||
const s = 1 - o.curveTightness;
|
||||
ops.push({ op: 'move', data: [points[1][0], points[1][1]] });
|
||||
for (let i = 1; i + 2 < len; i++) {
|
||||
const cachedVertArray = points[i];
|
||||
b[0] = [cachedVertArray[0], cachedVertArray[1]];
|
||||
b[1] = [
|
||||
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
|
||||
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
|
||||
];
|
||||
b[2] = [
|
||||
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
|
||||
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
|
||||
];
|
||||
b[3] = [points[i + 1][0], points[i + 1][1]];
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [b[1][0], b[1][1], b[2][0], b[2][1], b[3][0], b[3][1]],
|
||||
});
|
||||
}
|
||||
if (closePoint && closePoint.length === 2) {
|
||||
const ro = o.maxRandomnessOffset;
|
||||
ops.push({
|
||||
op: 'lineTo',
|
||||
data: [
|
||||
closePoint[0] + _offsetOpt(ro, o),
|
||||
closePoint[1] + _offsetOpt(ro, o),
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (len === 3) {
|
||||
ops.push({ op: 'move', data: [points[1][0], points[1][1]] });
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
points[1][0],
|
||||
points[1][1],
|
||||
points[2][0],
|
||||
points[2][1],
|
||||
points[2][0],
|
||||
points[2][1],
|
||||
],
|
||||
});
|
||||
} else if (len === 2) {
|
||||
ops.push(
|
||||
..._doubleLine(points[0][0], points[0][1], points[1][0], points[1][1], o)
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function _computeEllipsePoints(
|
||||
increment: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
offset: number,
|
||||
overlap: number,
|
||||
o: ResolvedOptions
|
||||
): Point[][] {
|
||||
const coreOnly = o.roughness === 0;
|
||||
const corePoints: Point[] = [];
|
||||
const allPoints: Point[] = [];
|
||||
|
||||
if (coreOnly) {
|
||||
increment = increment / 4;
|
||||
allPoints.push([
|
||||
cx + rx * Math.cos(-increment),
|
||||
cy + ry * Math.sin(-increment),
|
||||
]);
|
||||
for (let angle = 0; angle <= Math.PI * 2; angle = angle + increment) {
|
||||
const p: Point = [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)];
|
||||
corePoints.push(p);
|
||||
allPoints.push(p);
|
||||
}
|
||||
allPoints.push([cx + rx * Math.cos(0), cy + ry * Math.sin(0)]);
|
||||
allPoints.push([
|
||||
cx + rx * Math.cos(increment),
|
||||
cy + ry * Math.sin(increment),
|
||||
]);
|
||||
} else {
|
||||
const radOffset = _offsetOpt(0.5, o) - Math.PI / 2;
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment),
|
||||
_offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment),
|
||||
]);
|
||||
const endAngle = Math.PI * 2 + radOffset - 0.01;
|
||||
for (let angle = radOffset; angle < endAngle; angle = angle + increment) {
|
||||
const p: Point = [
|
||||
_offsetOpt(offset, o) + cx + rx * Math.cos(angle),
|
||||
_offsetOpt(offset, o) + cy + ry * Math.sin(angle),
|
||||
];
|
||||
corePoints.push(p);
|
||||
allPoints.push(p);
|
||||
}
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) +
|
||||
cx +
|
||||
rx * Math.cos(radOffset + Math.PI * 2 + overlap * 0.5),
|
||||
_offsetOpt(offset, o) +
|
||||
cy +
|
||||
ry * Math.sin(radOffset + Math.PI * 2 + overlap * 0.5),
|
||||
]);
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) + cx + 0.98 * rx * Math.cos(radOffset + overlap),
|
||||
_offsetOpt(offset, o) + cy + 0.98 * ry * Math.sin(radOffset + overlap),
|
||||
]);
|
||||
allPoints.push([
|
||||
_offsetOpt(offset, o) +
|
||||
cx +
|
||||
0.9 * rx * Math.cos(radOffset + overlap * 0.5),
|
||||
_offsetOpt(offset, o) +
|
||||
cy +
|
||||
0.9 * ry * Math.sin(radOffset + overlap * 0.5),
|
||||
]);
|
||||
}
|
||||
|
||||
return [allPoints, corePoints];
|
||||
}
|
||||
|
||||
function _arc(
|
||||
increment: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
strt: number,
|
||||
stp: number,
|
||||
offset: number,
|
||||
o: ResolvedOptions
|
||||
) {
|
||||
const radOffset = strt + _offsetOpt(0.1, o);
|
||||
const points: Point[] = [];
|
||||
points.push([
|
||||
_offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment),
|
||||
_offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment),
|
||||
]);
|
||||
for (let angle = radOffset; angle <= stp; angle = angle + increment) {
|
||||
points.push([
|
||||
_offsetOpt(offset, o) + cx + rx * Math.cos(angle),
|
||||
_offsetOpt(offset, o) + cy + ry * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]);
|
||||
return _curve(points, null, o);
|
||||
}
|
||||
|
||||
function _bezierTo(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
x: number,
|
||||
y: number,
|
||||
current: Point,
|
||||
o: ResolvedOptions
|
||||
): Op[] {
|
||||
const ops: Op[] = [];
|
||||
const ros = [o.maxRandomnessOffset || 1, (o.maxRandomnessOffset || 1) + 0.3];
|
||||
let f: Point = [0, 0];
|
||||
const iterations = o.disableMultiStroke ? 1 : 2;
|
||||
const preserveVertices = o.preserveVertices;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (i === 0) {
|
||||
ops.push({ op: 'move', data: [current[0], current[1]] });
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'move',
|
||||
data: [
|
||||
current[0] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)),
|
||||
current[1] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)),
|
||||
],
|
||||
});
|
||||
}
|
||||
f = preserveVertices
|
||||
? [x, y]
|
||||
: [x + _offsetOpt(ros[i], o), y + _offsetOpt(ros[i], o)];
|
||||
ops.push({
|
||||
op: 'bcurveTo',
|
||||
data: [
|
||||
x1 + _offsetOpt(ros[i], o),
|
||||
y1 + _offsetOpt(ros[i], o),
|
||||
x2 + _offsetOpt(ros[i], o),
|
||||
y2 + _offsetOpt(ros[i], o),
|
||||
f[0],
|
||||
f[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RoughCanvas } from './canvas.js';
|
||||
import type { Config } from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import { RoughSVG } from './svg.js';
|
||||
|
||||
export default {
|
||||
canvas(canvas: HTMLCanvasElement, config?: Config): RoughCanvas {
|
||||
return new RoughCanvas(canvas, config);
|
||||
},
|
||||
|
||||
svg(svg: SVGSVGElement, config?: Config): RoughSVG {
|
||||
return new RoughSVG(svg, config);
|
||||
},
|
||||
|
||||
generator(config?: Config): RoughGenerator {
|
||||
return new RoughGenerator(config);
|
||||
},
|
||||
|
||||
newSeed(): number {
|
||||
return RoughGenerator.newSeed();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Config,
|
||||
Drawable,
|
||||
OpSet,
|
||||
Options,
|
||||
ResolvedOptions,
|
||||
} from './core.js';
|
||||
import { SVGNS } from './core.js';
|
||||
import { RoughGenerator } from './generator.js';
|
||||
import type { Point } from './geometry.js';
|
||||
|
||||
export class RoughSVG {
|
||||
private readonly gen: RoughGenerator;
|
||||
|
||||
private readonly svg: SVGSVGElement;
|
||||
|
||||
get generator(): RoughGenerator {
|
||||
return this.gen;
|
||||
}
|
||||
|
||||
constructor(svg: SVGSVGElement, config?: Config) {
|
||||
this.svg = svg;
|
||||
this.gen = new RoughGenerator(config);
|
||||
}
|
||||
|
||||
private fillSketch(
|
||||
doc: Document,
|
||||
drawing: OpSet,
|
||||
o: ResolvedOptions
|
||||
): SVGPathElement {
|
||||
let fweight = o.fillWeight;
|
||||
if (fweight < 0) {
|
||||
fweight = o.strokeWidth / 2;
|
||||
}
|
||||
const path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, o.fixedDecimalPlaceDigits));
|
||||
path.setAttribute('stroke', o.fill || '');
|
||||
path.setAttribute('stroke-width', fweight + '');
|
||||
path.setAttribute('fill', 'none');
|
||||
if (o.fillLineDash) {
|
||||
path.setAttribute('stroke-dasharray', o.fillLineDash.join(' ').trim());
|
||||
}
|
||||
if (o.fillLineDashOffset) {
|
||||
path.setAttribute('stroke-dashoffset', `${o.fillLineDashOffset}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
arc(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
start: number,
|
||||
stop: number,
|
||||
closed = false,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.arc(x, y, width, height, start, stop, closed, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
circle(
|
||||
x: number,
|
||||
y: number,
|
||||
diameter: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.circle(x, y, diameter, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
curve(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.curve(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
draw(drawable: Drawable): SVGGElement {
|
||||
const sets = drawable.sets || [];
|
||||
const o = drawable.options || this.getDefaultOptions();
|
||||
const doc = this.svg.ownerDocument || window.document;
|
||||
const g = doc.createElementNS(SVGNS, 'g');
|
||||
const precision = drawable.options.fixedDecimalPlaceDigits;
|
||||
for (const drawing of sets) {
|
||||
let path = null;
|
||||
switch (drawing.type) {
|
||||
case 'path': {
|
||||
path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, precision));
|
||||
path.setAttribute('stroke', o.stroke);
|
||||
path.setAttribute('stroke-width', o.strokeWidth + '');
|
||||
path.setAttribute('fill', 'none');
|
||||
if (o.strokeLineDash) {
|
||||
path.setAttribute(
|
||||
'stroke-dasharray',
|
||||
o.strokeLineDash.join(' ').trim()
|
||||
);
|
||||
}
|
||||
if (o.strokeLineDashOffset) {
|
||||
path.setAttribute('stroke-dashoffset', `${o.strokeLineDashOffset}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fillPath': {
|
||||
path = doc.createElementNS(SVGNS, 'path');
|
||||
path.setAttribute('d', this.opsToPath(drawing, precision));
|
||||
path.setAttribute('stroke', 'none');
|
||||
path.setAttribute('stroke-width', '0');
|
||||
path.setAttribute('fill', o.fill || '');
|
||||
if (drawable.shape === 'curve' || drawable.shape === 'polygon') {
|
||||
path.setAttribute('fill-rule', 'evenodd');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fillSketch': {
|
||||
path = this.fillSketch(doc, drawing, o);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (path) {
|
||||
g.append(path);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.ellipse(x, y, width, height, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
getDefaultOptions(): ResolvedOptions {
|
||||
return this.gen.defaultOptions;
|
||||
}
|
||||
|
||||
line(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.line(x1, y1, x2, y2, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
linearPath(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.linearPath(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
opsToPath(drawing: OpSet, fixedDecimalPlaceDigits?: number): string {
|
||||
return this.gen.opsToPath(drawing, fixedDecimalPlaceDigits);
|
||||
}
|
||||
|
||||
path(d: string, options?: Options): SVGGElement {
|
||||
const drawing = this.gen.path(d, options);
|
||||
return this.draw(drawing);
|
||||
}
|
||||
|
||||
polygon(points: Point[], options?: Options): SVGGElement {
|
||||
const d = this.gen.polygon(points, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
|
||||
rectangle(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Options
|
||||
): SVGGElement {
|
||||
const d = this.gen.rectangle(x, y, width, height, options);
|
||||
return this.draw(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export function loadingSort<T extends { id: string; deps: string[] }>(
|
||||
elements: T[]
|
||||
) {
|
||||
const graph = new Map<string, string[]>();
|
||||
const outDegree = new Map<string, number>();
|
||||
const sortedOrder = [];
|
||||
const map = new Map<string, T>();
|
||||
|
||||
elements.forEach(element => {
|
||||
outDegree.set(element.id, 0);
|
||||
map.set(element.id, element);
|
||||
});
|
||||
|
||||
elements.forEach(element => {
|
||||
element.deps.forEach(depId => {
|
||||
if (outDegree.has(depId)) {
|
||||
graph.has(depId)
|
||||
? graph.get(depId)!.push(element.id)
|
||||
: graph.set(depId, [element.id]);
|
||||
outDegree.set(element.id, outDegree.get(element.id)! + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
for (const [id, degree] of outDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
sortedOrder.push(node);
|
||||
|
||||
const deps = graph.get(node) || [];
|
||||
deps.forEach(depId => {
|
||||
if (outDegree.has(depId)) {
|
||||
outDegree.set(depId, outDegree.get(depId)! - 1);
|
||||
|
||||
if (outDegree.get(depId) === 0) {
|
||||
queue.push(depId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sortedOrder.map(id => map.get(id)!);
|
||||
}
|
||||
|
||||
export function sortIndex(
|
||||
a: { id: string; index: string },
|
||||
b: { id: string; index: string },
|
||||
groupIndexMap: Map<string, { id: string; index: string }>
|
||||
) {
|
||||
const aGroupIndex = groupIndexMap.get(a.id);
|
||||
const bGroupIndex = groupIndexMap.get(b.id);
|
||||
|
||||
if (aGroupIndex && bGroupIndex) {
|
||||
return aGroupIndex.id === bGroupIndex.id
|
||||
? a.index === b.index
|
||||
? 0
|
||||
: a.index > b.index
|
||||
? 1
|
||||
: -1
|
||||
: aGroupIndex.index > bGroupIndex.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
if (aGroupIndex) {
|
||||
return aGroupIndex.id === b.id
|
||||
? 1
|
||||
: aGroupIndex.index === b.index
|
||||
? 0
|
||||
: aGroupIndex.index > b.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
if (bGroupIndex) {
|
||||
return a.id === bGroupIndex.id
|
||||
? -1
|
||||
: a.index === bGroupIndex.index
|
||||
? 0
|
||||
: a.index > bGroupIndex.index
|
||||
? 1
|
||||
: -1;
|
||||
}
|
||||
|
||||
return a.index === b.index ? 0 : a.index > b.index ? 1 : -1;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
NOTE_MIN_HEIGHT,
|
||||
NOTE_MIN_WIDTH,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound, clamp } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
type GfxModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel, BlockProps } from '@blocksuite/store';
|
||||
|
||||
function updatChildElementsXYWH(
|
||||
container: GfxGroupCompatibleInterface,
|
||||
targetBound: Bound,
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
const containerBound = Bound.deserialize(container.xywh);
|
||||
const scaleX = targetBound.w / containerBound.w;
|
||||
const scaleY = targetBound.h / containerBound.h;
|
||||
container.childElements.forEach(child => {
|
||||
const childBound = Bound.deserialize(child.xywh);
|
||||
childBound.x = targetBound.x + scaleX * (childBound.x - containerBound.x);
|
||||
childBound.y = targetBound.y + scaleY * (childBound.y - containerBound.y);
|
||||
childBound.w = scaleX * childBound.w;
|
||||
childBound.h = scaleY * childBound.h;
|
||||
updateXYWH(child, childBound, updateElement, updateBlock);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateXYWH(
|
||||
ele: GfxModel,
|
||||
bound: Bound,
|
||||
updateElement: (id: string, props: Record<string, unknown>) => void,
|
||||
updateBlock: (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => void
|
||||
) {
|
||||
if (ele instanceof ConnectorElementModel) {
|
||||
ele.moveTo(bound);
|
||||
} else if (ele instanceof NoteBlockModel) {
|
||||
const scale = ele.props.edgeless.scale ?? 1;
|
||||
bound.w = clamp(bound.w, NOTE_MIN_WIDTH * scale, Infinity);
|
||||
bound.h = clamp(bound.h, NOTE_MIN_HEIGHT * scale, Infinity);
|
||||
if (bound.h >= NOTE_MIN_HEIGHT * scale) {
|
||||
updateBlock.call(ele.doc, ele, () => {
|
||||
ele.props.edgeless.collapse = true;
|
||||
ele.props.edgeless.collapsedHeight = bound.h / scale;
|
||||
});
|
||||
}
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else if (ele instanceof MindmapElementModel) {
|
||||
const rootId = ele.tree.id;
|
||||
const rootEle = ele.childElements.find(child => child.id === rootId);
|
||||
if (rootEle) {
|
||||
const rootBound = Bound.deserialize(rootEle.xywh);
|
||||
rootBound.x += bound.x - ele.x;
|
||||
rootBound.y += bound.y - ele.y;
|
||||
updateXYWH(rootEle, rootBound, updateElement, updateBlock);
|
||||
}
|
||||
ele.layout();
|
||||
} else if (isGfxGroupCompatibleModel(ele)) {
|
||||
updatChildElementsXYWH(ele, bound, updateElement, updateBlock);
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else {
|
||||
updateElement(ele.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
import { ConnectorPathGenerator } from '../managers/connector-manager.js';
|
||||
import type { SurfaceBlockModel, SurfaceMiddleware } from '../surface-model.js';
|
||||
|
||||
export const connectorWatcher: SurfaceMiddleware = (
|
||||
surface: SurfaceBlockModel
|
||||
) => {
|
||||
const hasElementById = (id: string) =>
|
||||
surface.hasElementById(id) || surface.doc.hasBlock(id);
|
||||
const elementGetter = (id: string) =>
|
||||
surface.getElementById(id) ?? (surface.doc.getModelById(id) as GfxModel);
|
||||
const updateConnectorPath = (connector: ConnectorElementModel) => {
|
||||
if (
|
||||
((connector.source?.id && hasElementById(connector.source.id)) ||
|
||||
(!connector.source?.id && connector.source?.position)) &&
|
||||
((connector.target?.id && hasElementById(connector.target.id)) ||
|
||||
(!connector.target?.id && connector.target?.position))
|
||||
) {
|
||||
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
|
||||
}
|
||||
};
|
||||
const pendingList = new Set<ConnectorElementModel>();
|
||||
let pendingFlag = false;
|
||||
const addToUpdateList = (connector: ConnectorElementModel) => {
|
||||
pendingList.add(connector);
|
||||
|
||||
if (!pendingFlag) {
|
||||
pendingFlag = true;
|
||||
queueMicrotask(() => {
|
||||
pendingList.forEach(updateConnectorPath);
|
||||
pendingList.clear();
|
||||
pendingFlag = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disposables = [
|
||||
surface.elementAdded.subscribe(({ id }) => {
|
||||
const element = elementGetter(id);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
if ('type' in element && element.type === 'connector') {
|
||||
addToUpdateList(element as ConnectorElementModel);
|
||||
} else {
|
||||
surface.getConnectors(id).forEach(addToUpdateList);
|
||||
}
|
||||
}),
|
||||
surface.elementUpdated.subscribe(({ id, props }) => {
|
||||
const element = elementGetter(id);
|
||||
|
||||
if (props['xywh'] || props['rotate']) {
|
||||
surface.getConnectors(id).forEach(addToUpdateList);
|
||||
}
|
||||
|
||||
if (
|
||||
'type' in element &&
|
||||
element.type === 'connector' &&
|
||||
(props['mode'] !== undefined ||
|
||||
props['target'] ||
|
||||
props['source'] ||
|
||||
(props['xywh'] && !(element as ConnectorElementModel).updatingPath))
|
||||
) {
|
||||
addToUpdateList(element as ConnectorElementModel);
|
||||
}
|
||||
}),
|
||||
surface.doc.slots.blockUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.type === 'add' ||
|
||||
(payload.type === 'update' && payload.props.key === 'xywh')
|
||||
) {
|
||||
surface.getConnectors(payload.id).forEach(addToUpdateList);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
surface
|
||||
.getElementsByType('connector')
|
||||
.forEach(connector =>
|
||||
updateConnectorPath(connector as ConnectorElementModel)
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SurfaceGroupLikeModel } from '../element-model/base.js';
|
||||
import type { SurfaceBlockModel, SurfaceMiddleware } from '../surface-model.js';
|
||||
|
||||
export const groupRelationWatcher: SurfaceMiddleware = (
|
||||
surface: SurfaceBlockModel
|
||||
) => {
|
||||
const disposables = [
|
||||
surface.elementUpdated.subscribe(({ id, props, local }) => {
|
||||
if (!local) return;
|
||||
|
||||
const element = surface.getElementById(id)!;
|
||||
|
||||
// remove the group if it has no children
|
||||
if (
|
||||
element instanceof SurfaceGroupLikeModel &&
|
||||
props['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
surface.deleteElement(id);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user