mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 18:09:58 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import type { IVec, IVec3 } from '@blocksuite/global/utils';
|
||||
import { almostEqual } from '@blocksuite/global/utils';
|
||||
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);
|
||||
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/utils';
|
||||
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/utils';
|
||||
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,158 @@
|
||||
import {
|
||||
almostEqual,
|
||||
assertExists,
|
||||
isPointOnLineSegment,
|
||||
type IVec,
|
||||
lineEllipseIntersects,
|
||||
lineIntersects,
|
||||
linePolygonIntersects,
|
||||
linePolylineIntersects,
|
||||
pointAlmostEqual,
|
||||
polygonGetPointTangent,
|
||||
rotatePoints,
|
||||
toDegree,
|
||||
toRadian,
|
||||
} from '@blocksuite/global/utils';
|
||||
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],
|
||||
];
|
||||
assertExists(rst);
|
||||
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],
|
||||
]
|
||||
);
|
||||
assertExists(rst);
|
||||
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,20 @@
|
||||
import { SurfaceBlockHtmlAdapterExtension } from './html-adapter/html.js';
|
||||
import {
|
||||
EdgelessSurfaceBlockMarkdownAdapterExtension,
|
||||
SurfaceBlockMarkdownAdapterExtension,
|
||||
} from './markdown/markdown.js';
|
||||
import {
|
||||
EdgelessSurfaceBlockPlainTextAdapterExtension,
|
||||
SurfaceBlockPlainTextAdapterExtension,
|
||||
} from './plain-text/plain-text.js';
|
||||
|
||||
export const SurfaceBlockAdapterExtensions = [
|
||||
SurfaceBlockPlainTextAdapterExtension,
|
||||
SurfaceBlockMarkdownAdapterExtension,
|
||||
SurfaceBlockHtmlAdapterExtension,
|
||||
];
|
||||
|
||||
export const EdgelessSurfaceBlockAdapterExtensions = [
|
||||
EdgelessSurfaceBlockPlainTextAdapterExtension,
|
||||
EdgelessSurfaceBlockMarkdownAdapterExtension,
|
||||
];
|
||||
@@ -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
|
||||
);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const brushToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'brush',
|
||||
match: elementModel => elementModel.type === 'brush',
|
||||
toAST: () => {
|
||||
const content = `Brush Stroke`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { getConnectorText } from '../../../utils/text.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const connectorToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'connector',
|
||||
match: elementModel => elementModel.type === 'connector',
|
||||
toAST: elementModel => {
|
||||
const text = getConnectorText(elementModel);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `Connector, with text label "${text}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { getGroupTitle } from '../../../utils/text.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const groupToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `Group, with title "${title}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { brushToMarkdownAdapterMatcher } from './brush.js';
|
||||
import { connectorToMarkdownAdapterMatcher } from './connector.js';
|
||||
import { groupToMarkdownAdapterMatcher } from './group.js';
|
||||
import { mindmapToMarkdownAdapterMatcher } from './mindmap.js';
|
||||
import { shapeToMarkdownAdapterMatcher } from './shape.js';
|
||||
import { textToMarkdownAdapterMatcher } from './text.js';
|
||||
|
||||
export const elementToMarkdownAdapterMatchers = [
|
||||
groupToMarkdownAdapterMatcher,
|
||||
shapeToMarkdownAdapterMatcher,
|
||||
connectorToMarkdownAdapterMatcher,
|
||||
brushToMarkdownAdapterMatcher,
|
||||
textToMarkdownAdapterMatcher,
|
||||
mindmapToMarkdownAdapterMatcher,
|
||||
];
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type { MindMapTreeNode } from '../../../types/mindmap.js';
|
||||
import { buildMindMapTree } from '../../../utils/mindmap.js';
|
||||
import { getShapeText } from '../../../utils/text.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const mindmapToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'mindmap',
|
||||
match: elementModel => elementModel.type === 'mindmap',
|
||||
toAST: (elementModel, context) => {
|
||||
if (elementModel.type !== 'mindmap') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mindMapTree = buildMindMapTree(elementModel);
|
||||
if (!mindMapTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { walkerContext, elements } = context;
|
||||
const traverseMindMapTree = (node: MindMapTreeNode) => {
|
||||
const shapeElement = elements[node.id as string];
|
||||
const shapeText = getShapeText(shapeElement);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'listItem',
|
||||
spread: false,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: shapeText,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
node.children.forEach(child => {
|
||||
traverseMindMapTree(child);
|
||||
walkerContext.closeNode();
|
||||
});
|
||||
};
|
||||
|
||||
// First create a list node for the mind map tree
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'list',
|
||||
ordered: false,
|
||||
spread: false,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
traverseMindMapTree(mindMapTree);
|
||||
walkerContext.closeNode().closeNode();
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { MindMapTreeNode } from '../../../types/mindmap.js';
|
||||
import { getShapeText, getShapeType } from '../../../utils/text.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const shapeToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'shape',
|
||||
match: elementModel => elementModel.type === 'shape',
|
||||
toAST: (elementModel, context) => {
|
||||
let content = '';
|
||||
const { walkerContext } = context;
|
||||
const mindMapNodeMaps = walkerContext.getGlobalContext(
|
||||
'surface:mindMap:nodeMapArray'
|
||||
) as Array<Map<string, MindMapTreeNode>>;
|
||||
if (mindMapNodeMaps && mindMapNodeMaps.length > 0) {
|
||||
// Check if the elementModel is a mindMap node
|
||||
// If it is, we should return { content: '' } directly
|
||||
// And get the content when we handle the whole mindMap
|
||||
const isMindMapNode = mindMapNodeMaps.some(nodeMap =>
|
||||
nodeMap.has(elementModel.id as string)
|
||||
);
|
||||
if (isMindMapNode) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If it is not, we should return the text and shapeType
|
||||
const text = getShapeText(elementModel);
|
||||
const type = getShapeType(elementModel);
|
||||
if (!text && !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shapeType = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
content = `${shapeType}, with text label "${text}"`;
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { getTextElementText } from '../../../utils/text.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from '../type.js';
|
||||
|
||||
export const textToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher =
|
||||
{
|
||||
name: 'text',
|
||||
match: elementModel => elementModel.type === 'text',
|
||||
toAST: elementModel => {
|
||||
const content = getTextElementText(elementModel);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MarkdownAST } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import {
|
||||
ElementModelAdapter,
|
||||
type ElementModelAdapterContext,
|
||||
} from '../../type.js';
|
||||
import { elementToMarkdownAdapterMatchers } from './elements/index.js';
|
||||
import type { ElementModelToMarkdownAdapterMatcher } from './type.js';
|
||||
|
||||
export class MarkdownElementModelAdapter extends ElementModelAdapter<
|
||||
MarkdownAST,
|
||||
MarkdownAST
|
||||
> {
|
||||
constructor(
|
||||
readonly elementModelMatchers: ElementModelToMarkdownAdapterMatcher[] = elementToMarkdownAdapterMatchers
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { MarkdownAST } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import type { ElementModelMatcher } from '../../type.js';
|
||||
|
||||
export type ElementModelToMarkdownAdapterMatcher =
|
||||
ElementModelMatcher<MarkdownAST>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { getMindMapNodeMap } from '../utils/mindmap.js';
|
||||
import { MarkdownElementModelAdapter } from './element-adapter/index.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 } = context;
|
||||
const markdownElementModelAdapter = new MarkdownElementModelAdapter();
|
||||
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);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const brushToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'brush',
|
||||
match: elementModel => elementModel.type === 'brush',
|
||||
toAST: () => {
|
||||
const content = `Brush Stroke`;
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { getConnectorText } from '../../../utils/text.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const connectorToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'connector',
|
||||
match: elementModel => elementModel.type === 'connector',
|
||||
toAST: elementModel => {
|
||||
const text = getConnectorText(elementModel);
|
||||
const content = `Connector, with text label "${text}"`;
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { getGroupTitle } from '../../../utils/text.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const groupToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'group',
|
||||
match: elementModel => elementModel.type === 'group',
|
||||
toAST: elementModel => {
|
||||
const title = getGroupTitle(elementModel);
|
||||
const content = `Group, with title "${title}"`;
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { brushToPlainTextAdapterMatcher } from './brush.js';
|
||||
import { connectorToPlainTextAdapterMatcher } from './connector.js';
|
||||
import { groupToPlainTextAdapterMatcher } from './group.js';
|
||||
import { mindmapToPlainTextAdapterMatcher } from './mindmap.js';
|
||||
import { shapeToPlainTextAdapterMatcher } from './shape.js';
|
||||
import { textToPlainTextAdapterMatcher } from './text.js';
|
||||
|
||||
export const elementModelToPlainTextAdapterMatchers = [
|
||||
groupToPlainTextAdapterMatcher,
|
||||
shapeToPlainTextAdapterMatcher,
|
||||
connectorToPlainTextAdapterMatcher,
|
||||
brushToPlainTextAdapterMatcher,
|
||||
textToPlainTextAdapterMatcher,
|
||||
mindmapToPlainTextAdapterMatcher,
|
||||
];
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { getMindMapTreeText } from '../../../utils/text.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const mindmapToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'mindmap',
|
||||
match: elementModel => elementModel.type === 'mindmap',
|
||||
toAST: (elementModel, context) => {
|
||||
const mindMapContent = getMindMapTreeText(elementModel, context.elements);
|
||||
return { content: mindMapContent };
|
||||
},
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { MindMapTreeNode } from '../../../types/mindmap.js';
|
||||
import { getShapeText, getShapeType } from '../../../utils/text.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const shapeToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'shape',
|
||||
match: elementModel => elementModel.type === 'shape',
|
||||
toAST: (elementModel, context) => {
|
||||
let content = '';
|
||||
const { walkerContext } = context;
|
||||
const mindMapNodeMaps = walkerContext.getGlobalContext(
|
||||
'surface:mindMap:nodeMapArray'
|
||||
) as Array<Map<string, MindMapTreeNode>>;
|
||||
if (mindMapNodeMaps && mindMapNodeMaps.length > 0) {
|
||||
// Check if the elementModel is a mindMap node
|
||||
// If it is, we should return { content: '' } directly
|
||||
// And get the content when we handle the whole mindMap
|
||||
const isMindMapNode = mindMapNodeMaps.some(nodeMap =>
|
||||
nodeMap.has(elementModel.id as string)
|
||||
);
|
||||
if (isMindMapNode) {
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
|
||||
// If it is not, we should return the text and shapeType
|
||||
const text = getShapeText(elementModel);
|
||||
const type = getShapeType(elementModel);
|
||||
const shapeType = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
content = `${shapeType}, with text label "${text}"`;
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { getTextElementText } from '../../../utils/text.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from '../type.js';
|
||||
|
||||
export const textToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher =
|
||||
{
|
||||
name: 'text',
|
||||
match: elementModel => elementModel.type === 'text',
|
||||
toAST: elementModel => {
|
||||
const content = getTextElementText(elementModel);
|
||||
return { content };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TextBuffer } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import {
|
||||
ElementModelAdapter,
|
||||
type ElementModelAdapterContext,
|
||||
} from '../../type.js';
|
||||
import { elementModelToPlainTextAdapterMatchers } from './elements/index.js';
|
||||
import type { ElementModelToPlainTextAdapterMatcher } from './type.js';
|
||||
|
||||
export class PlainTextElementModelAdapter extends ElementModelAdapter<
|
||||
string,
|
||||
TextBuffer
|
||||
> {
|
||||
constructor(
|
||||
readonly elementModelMatchers: ElementModelToPlainTextAdapterMatcher[] = elementModelToPlainTextAdapterMatchers
|
||||
) {
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TextBuffer } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import type { ElementModelMatcher } from '../../type.js';
|
||||
|
||||
export type ElementModelToPlainTextAdapterMatcher =
|
||||
ElementModelMatcher<TextBuffer>;
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { getMindMapNodeMap } from '../utils/mindmap.js';
|
||||
import { PlainTextElementModelAdapter } from './element-adapter/index.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 } = context;
|
||||
const plainTextElementModelAdapter = new PlainTextElementModelAdapter();
|
||||
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,25 @@
|
||||
export interface MindMapTreeNode {
|
||||
id: string;
|
||||
index: string;
|
||||
children: MindMapTreeNode[];
|
||||
}
|
||||
|
||||
export interface MindMapNode {
|
||||
index: string;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export type MindMapJson = Record<string, MindMapNode>;
|
||||
|
||||
export interface MindMapElement {
|
||||
index: string;
|
||||
seed: number;
|
||||
children: {
|
||||
'affine:surface:ymap': boolean;
|
||||
json: MindMapJson;
|
||||
};
|
||||
layoutType: number;
|
||||
style: number;
|
||||
type: 'mindmap';
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
MindMapElement,
|
||||
MindMapJson,
|
||||
MindMapTreeNode,
|
||||
} from '../types/mindmap.js';
|
||||
|
||||
function isMindMapElement(element: unknown): element is MindMapElement {
|
||||
return (
|
||||
typeof element === 'object' &&
|
||||
element !== null &&
|
||||
'type' in element &&
|
||||
(element as MindMapElement).type === 'mindmap' &&
|
||||
'children' in element &&
|
||||
typeof (element as MindMapElement).children === 'object' &&
|
||||
'json' in (element as MindMapElement).children
|
||||
);
|
||||
}
|
||||
|
||||
export function getMindMapChildrenJson(
|
||||
element: Record<string, unknown>
|
||||
): MindMapJson | null {
|
||||
if (!isMindMapElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.children.json;
|
||||
}
|
||||
|
||||
export function getMindMapNodeMap(
|
||||
element: Record<string, unknown>
|
||||
): Map<string, MindMapTreeNode> {
|
||||
const nodeMap = new Map<string, MindMapTreeNode>();
|
||||
const childrenJson = getMindMapChildrenJson(element);
|
||||
if (!childrenJson) {
|
||||
return nodeMap;
|
||||
}
|
||||
|
||||
for (const [id, info] of Object.entries(childrenJson)) {
|
||||
nodeMap.set(id, {
|
||||
id,
|
||||
index: info.index,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return nodeMap;
|
||||
}
|
||||
|
||||
export function buildMindMapTree(element: Record<string, unknown>) {
|
||||
let root: MindMapTreeNode | null = null;
|
||||
|
||||
// First traverse to get node map
|
||||
const nodeMap = getMindMapNodeMap(element);
|
||||
const childrenJson = getMindMapChildrenJson(element);
|
||||
if (!childrenJson) {
|
||||
return root;
|
||||
}
|
||||
|
||||
// Second traverse to build tree
|
||||
for (const [id, info] of Object.entries(childrenJson)) {
|
||||
const node = nodeMap.get(id)!;
|
||||
|
||||
if (info.parent) {
|
||||
const parentNode = nodeMap.get(info.parent);
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node);
|
||||
}
|
||||
} else {
|
||||
root = node;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { DeltaInsert } from '@blocksuite/inline/types';
|
||||
|
||||
import type { MindMapTreeNode } from '../types/mindmap.js';
|
||||
import { buildMindMapTree } from './mindmap.js';
|
||||
|
||||
export function getShapeType(elementModel: Record<string, unknown>): string {
|
||||
let shapeType = '';
|
||||
if (elementModel.type !== 'shape') {
|
||||
return shapeType;
|
||||
}
|
||||
|
||||
if (
|
||||
'shapeType' in elementModel &&
|
||||
typeof elementModel.shapeType === 'string'
|
||||
) {
|
||||
shapeType = elementModel.shapeType;
|
||||
}
|
||||
return shapeType;
|
||||
}
|
||||
|
||||
export function getShapeText(elementModel: Record<string, unknown>): string {
|
||||
let text = '';
|
||||
if (elementModel.type !== 'shape') {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (
|
||||
'text' in elementModel &&
|
||||
typeof elementModel.text === 'object' &&
|
||||
elementModel.text
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.text) {
|
||||
delta = elementModel.text.delta as DeltaInsert[];
|
||||
}
|
||||
text = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function getConnectorText(
|
||||
elementModel: Record<string, unknown>
|
||||
): string {
|
||||
let text = '';
|
||||
if (elementModel.type !== 'connector') {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (
|
||||
'text' in elementModel &&
|
||||
typeof elementModel.text === 'object' &&
|
||||
elementModel.text
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.text) {
|
||||
delta = elementModel.text.delta as DeltaInsert[];
|
||||
}
|
||||
text = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function getGroupTitle(elementModel: Record<string, unknown>): string {
|
||||
let title = '';
|
||||
if (elementModel.type !== 'group') {
|
||||
return title;
|
||||
}
|
||||
|
||||
if (
|
||||
'title' in elementModel &&
|
||||
typeof elementModel.title === 'object' &&
|
||||
elementModel.title
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.title) {
|
||||
delta = elementModel.title.delta as DeltaInsert[];
|
||||
}
|
||||
title = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
export function getTextElementText(
|
||||
elementModel: Record<string, unknown>
|
||||
): string {
|
||||
let text = '';
|
||||
if (elementModel.type !== 'text') {
|
||||
return text;
|
||||
}
|
||||
if (
|
||||
'text' in elementModel &&
|
||||
typeof elementModel.text === 'object' &&
|
||||
elementModel.text
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.text) {
|
||||
delta = elementModel.text.delta as DeltaInsert[];
|
||||
}
|
||||
text = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* traverse the mindMapTree and construct the content string
|
||||
* like:
|
||||
* - Root
|
||||
* - Child 1
|
||||
* - Child 1.1
|
||||
* - Child 1.2
|
||||
* - Child 2
|
||||
* - Child 2.1
|
||||
* - Child 2.2
|
||||
* - Child 3
|
||||
* - Child 3.1
|
||||
* - Child 3.2
|
||||
* @param elementModel - the mindmap element model
|
||||
* @param elements - the elements map
|
||||
* @returns the mindmap tree text
|
||||
*/
|
||||
export function getMindMapTreeText(
|
||||
elementModel: Record<string, unknown>,
|
||||
elements: Record<string, Record<string, unknown>>,
|
||||
options: {
|
||||
prefix: string;
|
||||
repeat: number;
|
||||
} = {
|
||||
prefix: ' ',
|
||||
repeat: 2,
|
||||
}
|
||||
): string {
|
||||
let mindMapContent = '';
|
||||
if (elementModel.type !== 'mindmap') {
|
||||
return mindMapContent;
|
||||
}
|
||||
|
||||
const mindMapTree = buildMindMapTree(elementModel);
|
||||
if (!mindMapTree) {
|
||||
return mindMapContent;
|
||||
}
|
||||
|
||||
let layer = 0;
|
||||
const traverseMindMapTree = (
|
||||
node: MindMapTreeNode,
|
||||
prefix: string,
|
||||
repeat: number
|
||||
) => {
|
||||
const shapeElement = elements[node.id as string];
|
||||
const shapeText = getShapeText(shapeElement);
|
||||
if (shapeElement) {
|
||||
mindMapContent += `${prefix.repeat(layer * repeat)}- ${shapeText}\n`;
|
||||
}
|
||||
node.children.forEach(child => {
|
||||
layer++;
|
||||
traverseMindMapTree(child, prefix, repeat);
|
||||
layer--;
|
||||
});
|
||||
};
|
||||
traverseMindMapTree(mindMapTree, options.prefix, options.repeat);
|
||||
|
||||
return mindMapContent;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
EdgelessTextBlockModel,
|
||||
EmbedSyncedDocModel,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import chunk from 'lodash.chunk';
|
||||
|
||||
const ALIGN_HEIGHT = 200;
|
||||
const ALIGN_PADDING = 20;
|
||||
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
import type { BlockModel, BlockProps } from '@blocksuite/store';
|
||||
|
||||
import { updateXYWH } from '../utils/update-xywh.js';
|
||||
|
||||
/**
|
||||
* Automatically arrange elements according to fixed row and column rules
|
||||
*/
|
||||
export const autoArrangeElementsCommand: Command<never, never, {}> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { updateBlock } = ctx.std.doc;
|
||||
const rootService = ctx.std.getService('affine:page');
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const elements = rootService?.selection.selectedElements;
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const updateElement = rootService?.updateElement;
|
||||
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<never, never, {}> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { updateBlock } = ctx.std.doc;
|
||||
const rootService = ctx.std.getService('affine:page');
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const elements = rootService?.selection.selectedElements;
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const updateElement = rootService?.updateElement;
|
||||
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.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.edgeless,
|
||||
scale: nextScale,
|
||||
},
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
} else if (
|
||||
ele instanceof EdgelessTextBlockModel ||
|
||||
ele instanceof EmbedSyncedDocModel
|
||||
) {
|
||||
const curScale = ele.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,13 @@
|
||||
import type { BlockCommands } from '@blocksuite/block-std';
|
||||
|
||||
import {
|
||||
autoArrangeElementsCommand,
|
||||
autoResizeElementsCommand,
|
||||
} from './auto-align.js';
|
||||
import { reassociateConnectorsCommand } from './reassociate-connectors.js';
|
||||
|
||||
export const commands: BlockCommands = {
|
||||
reassociateConnectors: reassociateConnectorsCommand,
|
||||
autoArrangeElements: autoArrangeElementsCommand,
|
||||
autoResizeElements: autoResizeElementsCommand,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
/**
|
||||
* 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<
|
||||
never,
|
||||
never,
|
||||
{ oldId: string; newId: string }
|
||||
> = (ctx, next) => {
|
||||
const { oldId, newId } = ctx;
|
||||
const service = ctx.std.getService('affine:surface');
|
||||
if (!oldId || !newId || !service) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = service.surface;
|
||||
const connectors = surface.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,16 @@
|
||||
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;
|
||||
|
||||
// TODO: need to check the default central area ratio
|
||||
export const DEFAULT_CENTRAL_AREA_RATIO = 0.3;
|
||||
|
||||
export interface IModelCoord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
autoArrangeElementsCommand,
|
||||
autoResizeElementsCommand,
|
||||
} from './commands/auto-align.js';
|
||||
import type { reassociateConnectorsCommand } from './commands/reassociate-connectors.js';
|
||||
import { SurfaceBlockComponent } from './surface-block.js';
|
||||
import { SurfaceBlockVoidComponent } from './surface-block-void.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
import type { SurfaceBlockService } from './surface-service.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-surface-void', SurfaceBlockVoidComponent);
|
||||
customElements.define('affine-surface', SurfaceBlockComponent);
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface BlockServices {
|
||||
'affine:surface': SurfaceBlockService;
|
||||
}
|
||||
interface BlockModels {
|
||||
'affine:surface': SurfaceBlockModel;
|
||||
}
|
||||
interface Commands {
|
||||
reassociateConnectors: typeof reassociateConnectorsCommand;
|
||||
autoArrangeElements: typeof autoArrangeElementsCommand;
|
||||
autoResizeElements: typeof autoResizeElementsCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
GfxPrimitiveElementModel as SurfaceElementModel,
|
||||
GfxGroupLikeElementModel as SurfaceGroupLikeModel,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
BrushElementModel,
|
||||
ConnectorElementModel,
|
||||
GroupElementModel,
|
||||
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,
|
||||
};
|
||||
|
||||
export {
|
||||
BrushElementModel,
|
||||
ConnectorElementModel,
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
SurfaceElementModel,
|
||||
TextElementModel,
|
||||
};
|
||||
|
||||
export enum CanvasElementType {
|
||||
BRUSH = 'brush',
|
||||
CONNECTOR = 'connector',
|
||||
GROUP = 'group',
|
||||
MINDMAP = 'mindmap',
|
||||
SHAPE = 'shape',
|
||||
TEXT = 'text',
|
||||
}
|
||||
|
||||
export type ElementModelMap = {
|
||||
['shape']: ShapeElementModel;
|
||||
['brush']: BrushElementModel;
|
||||
['connector']: ConnectorElementModel;
|
||||
['text']: TextElementModel;
|
||||
['group']: GroupElementModel;
|
||||
['mindmap']: MindmapElementModel;
|
||||
};
|
||||
|
||||
export function isCanvasElementType(type: string): type is CanvasElementType {
|
||||
return type.toLocaleUpperCase() in CanvasElementType;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
||||
/// <reference path="./effects.ts" />
|
||||
export { type IModelCoord, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } 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 * from './renderer/elements/group/consts.js';
|
||||
export type { ElementRenderer } from './renderer/elements/index.js';
|
||||
export {
|
||||
elementRenderers,
|
||||
normalizeShapeBound,
|
||||
} from './renderer/elements/index.js';
|
||||
export { fitContent } from './renderer/elements/shape/utils.js';
|
||||
export * from './renderer/elements/type.js';
|
||||
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';
|
||||
import {
|
||||
getCursorByCoord,
|
||||
getLineHeight,
|
||||
isFontStyleSupported,
|
||||
isFontWeightSupported,
|
||||
normalizeTextBound,
|
||||
splitIntoLines,
|
||||
} from './renderer/elements/text/utils.js';
|
||||
import {
|
||||
getFontFaces,
|
||||
getFontFacesByFontFamily,
|
||||
isSameFontFamily,
|
||||
wrapFontFamily,
|
||||
} from './utils/font.js';
|
||||
export type { SurfaceContext } from './surface-block.js';
|
||||
export { SurfaceBlockComponent } from './surface-block.js';
|
||||
export { SurfaceBlockModel, SurfaceBlockSchema } from './surface-model.js';
|
||||
export type { SurfaceBlockService } from './surface-service.js';
|
||||
export {
|
||||
EdgelessSurfaceBlockSpec,
|
||||
PageSurfaceBlockSpec,
|
||||
} from './surface-spec.js';
|
||||
export { SurfaceBlockTransformer } from './surface-transformer.js';
|
||||
export { AStarRunner } from './utils/a-star.js';
|
||||
export {
|
||||
NODE_FIRST_LEVEL_HORIZONTAL_SPACING,
|
||||
NODE_HORIZONTAL_SPACING,
|
||||
NODE_VERTICAL_SPACING,
|
||||
} from './utils/mindmap/layout.js';
|
||||
export { RoughCanvas } from './utils/rough/canvas.js';
|
||||
|
||||
import {
|
||||
almostEqual,
|
||||
clamp,
|
||||
getPointFromBoundsWithRotation,
|
||||
getStroke,
|
||||
getSvgPathFromStroke,
|
||||
intersects,
|
||||
isOverlap,
|
||||
isPointIn,
|
||||
lineIntersects,
|
||||
linePolygonIntersects,
|
||||
normalizeDegAngle,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
polygonPointDistance,
|
||||
polyLineNearestPoint,
|
||||
rotatePoints,
|
||||
sign,
|
||||
toDegree,
|
||||
toRadian,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
|
||||
import { generateElementId, normalizeWheelDeltaY } from './utils/index.js';
|
||||
import {
|
||||
addTree,
|
||||
containsNode,
|
||||
createFromTree,
|
||||
detachMindmap,
|
||||
findTargetNode,
|
||||
hideNodeConnector,
|
||||
moveNode,
|
||||
tryMoveNode,
|
||||
} from './utils/mindmap/utils.js';
|
||||
export type { Options } from './utils/rough/core.js';
|
||||
export { sortIndex } from './utils/sort.js';
|
||||
export { updateXYWH } from './utils/update-xywh.js';
|
||||
|
||||
export const ConnectorUtils = {
|
||||
isConnectorAndBindingsAllSelected,
|
||||
isConnectorWithLabel,
|
||||
};
|
||||
|
||||
export const TextUtils = {
|
||||
splitIntoLines,
|
||||
normalizeTextBound,
|
||||
getLineHeight,
|
||||
getCursorByCoord,
|
||||
isFontWeightSupported,
|
||||
isFontStyleSupported,
|
||||
wrapFontFamily,
|
||||
getFontFaces,
|
||||
getFontFacesByFontFamily,
|
||||
isSameFontFamily,
|
||||
};
|
||||
|
||||
export const CommonUtils = {
|
||||
almostEqual,
|
||||
clamp,
|
||||
generateElementId,
|
||||
generateKeyBetween,
|
||||
getPointFromBoundsWithRotation,
|
||||
getStroke,
|
||||
getSvgPathFromStroke,
|
||||
intersects,
|
||||
isOverlap,
|
||||
isPointIn,
|
||||
lineIntersects,
|
||||
linePolygonIntersects,
|
||||
normalizeDegAngle,
|
||||
normalizeWheelDeltaY,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
polygonPointDistance,
|
||||
polyLineNearestPoint,
|
||||
rotatePoints,
|
||||
sign,
|
||||
toDegree,
|
||||
toRadian,
|
||||
};
|
||||
|
||||
export const MindmapUtils = {
|
||||
addTree,
|
||||
createFromTree,
|
||||
detachMindmap,
|
||||
moveNode,
|
||||
findTargetNode,
|
||||
tryMoveNode,
|
||||
hideNodeConnector,
|
||||
containsNode,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import type {
|
||||
GridManager,
|
||||
LayerManager,
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { IBound } from '@blocksuite/global/utils';
|
||||
import {
|
||||
DisposableGroup,
|
||||
getBoundWithRotation,
|
||||
intersects,
|
||||
last,
|
||||
Slot,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import type { SurfaceElementModel } from '../element-model/base.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: string) => string;
|
||||
getColorScheme: () => ColorScheme;
|
||||
getColorValue: (color: Color, fallback?: string, real?: boolean) => string;
|
||||
getPropertyValue: (property: string) => string;
|
||||
selectedElements?: () => string[];
|
||||
};
|
||||
|
||||
type RendererOptions = {
|
||||
viewport: Viewport;
|
||||
layerManager: LayerManager;
|
||||
provider?: Partial<EnvProvider>;
|
||||
enableStackingCanvas?: boolean;
|
||||
onStackingCanvasCreated?: (canvas: HTMLCanvasElement) => void;
|
||||
elementRenderers: Record<string, ElementRenderer>;
|
||||
gridManager: GridManager;
|
||||
surfaceModel: SurfaceBlockModel;
|
||||
};
|
||||
|
||||
export class CanvasRenderer {
|
||||
private _container!: HTMLElement;
|
||||
|
||||
private _disposables = new DisposableGroup();
|
||||
|
||||
private _overlays = new Set<Overlay>();
|
||||
|
||||
private _refreshRafId: number | null = null;
|
||||
|
||||
private _stackingCanvas: HTMLCanvasElement[] = [];
|
||||
|
||||
canvas: HTMLCanvasElement;
|
||||
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
elementRenderers: Record<string, ElementRenderer>;
|
||||
|
||||
grid: GridManager;
|
||||
|
||||
layerManager: LayerManager;
|
||||
|
||||
provider: Partial<EnvProvider>;
|
||||
|
||||
stackingCanvasUpdated = new Slot<{
|
||||
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.viewport = options.viewport;
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
this.elementRenderers = options.elementRenderers;
|
||||
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.emit(payload);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.layerManager.slots.layerUpdated.on(() => {
|
||||
updateStackingCanvas();
|
||||
})
|
||||
);
|
||||
|
||||
updateStackingCanvas();
|
||||
}
|
||||
|
||||
private _initViewport() {
|
||||
let sizeUpdatedRafId: number | null = null;
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.on(() => {
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.sizeUpdated.on(() => {
|
||||
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.elementRenderers[
|
||||
element.type as keyof typeof this.elementRenderers
|
||||
];
|
||||
|
||||
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) {
|
||||
const slots = [
|
||||
'elementAdded',
|
||||
'elementRemoved',
|
||||
'localElementAdded',
|
||||
'localElementDeleted',
|
||||
'localElementUpdated',
|
||||
] as const;
|
||||
|
||||
slots.forEach(slotName => {
|
||||
this._disposables.add(surfaceModel[slotName].on(() => this.refresh()));
|
||||
});
|
||||
|
||||
this._disposables.add(
|
||||
surfaceModel.elementUpdated.on(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: string) {
|
||||
return (
|
||||
this.provider.generateColorProperty?.(color, fallback) ??
|
||||
(fallback.startsWith('--') ? `var(${fallback})` : fallback)
|
||||
);
|
||||
}
|
||||
|
||||
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?: string, 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,25 @@
|
||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
|
||||
export function brush(
|
||||
model: BrushElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer
|
||||
) {
|
||||
const { rotate } = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
const color = renderer.getColorValue(model.color, '#000000', true);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
|
||||
ctx.fill(new Path2D(model.commands));
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
getBezierParameters,
|
||||
type PointLocation,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/inline';
|
||||
|
||||
import { isConnectorWithLabel } from '../../../managers/connector-manager.js';
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
type TextDelta,
|
||||
wrapTextDeltas,
|
||||
} from '../text/utils.js';
|
||||
import {
|
||||
DEFAULT_ARROW_SIZE,
|
||||
getArrowOptions,
|
||||
renderArrow,
|
||||
renderCircle,
|
||||
renderDiamond,
|
||||
renderTriangle,
|
||||
} from './utils.js';
|
||||
|
||||
export function connector(
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas
|
||||
) {
|
||||
const {
|
||||
mode,
|
||||
path: points,
|
||||
strokeStyle,
|
||||
frontEndpointStyle,
|
||||
rearEndpointStyle,
|
||||
strokeWidth,
|
||||
} = model;
|
||||
|
||||
// points might not be build yet in some senarios
|
||||
// eg. undo/redo, copy/paste
|
||||
if (!points.length || points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
const hasLabel = isConnectorWithLabel(model);
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
if (hasLabel) {
|
||||
ctx.save();
|
||||
|
||||
const { deserializedXYWH, labelXYWH } = model as ConnectorElementModel;
|
||||
const [x, y, w, h] = deserializedXYWH;
|
||||
const [lx, ly, lw, lh] = labelXYWH!;
|
||||
const offset = DEFAULT_ARROW_SIZE * strokeWidth;
|
||||
|
||||
dx = lx - x;
|
||||
dy = ly - y;
|
||||
|
||||
const path = new Path2D();
|
||||
path.rect(-offset / 2, -offset / 2, w + offset, h + offset);
|
||||
path.rect(dx - 3 - 0.5, dy - 3 - 0.5, lw + 6 + 1, lh + 6 + 1);
|
||||
ctx.clip(path, 'evenodd');
|
||||
}
|
||||
|
||||
const strokeColor = renderer.getColorValue(model.stroke, '#000000', true);
|
||||
|
||||
renderPoints(
|
||||
model,
|
||||
ctx,
|
||||
rc,
|
||||
points,
|
||||
strokeStyle === 'dash',
|
||||
mode === ConnectorMode.Curve,
|
||||
strokeColor
|
||||
);
|
||||
renderEndpoint(
|
||||
model,
|
||||
points,
|
||||
ctx,
|
||||
rc,
|
||||
'Front',
|
||||
frontEndpointStyle,
|
||||
strokeColor
|
||||
);
|
||||
renderEndpoint(
|
||||
model,
|
||||
points,
|
||||
ctx,
|
||||
rc,
|
||||
'Rear',
|
||||
rearEndpointStyle,
|
||||
strokeColor
|
||||
);
|
||||
|
||||
if (hasLabel) {
|
||||
ctx.restore();
|
||||
|
||||
renderLabel(
|
||||
model as ConnectorElementModel,
|
||||
ctx,
|
||||
matrix.translate(dx, dy),
|
||||
renderer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPoints(
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
points: PointLocation[],
|
||||
dash: boolean,
|
||||
curve: boolean,
|
||||
stroke: string
|
||||
) {
|
||||
const { seed, strokeWidth, roughness, rough } = model;
|
||||
|
||||
if (rough) {
|
||||
const options = {
|
||||
seed,
|
||||
roughness,
|
||||
stroke,
|
||||
strokeLineDash: dash ? [12, 12] : undefined,
|
||||
strokeWidth,
|
||||
};
|
||||
if (curve) {
|
||||
const b = getBezierParameters(points);
|
||||
rc.path(
|
||||
`M${b[0][0]},${b[0][1]} C${b[1][0]},${b[1][1]} ${b[2][0]},${b[2][1]} ${b[3][0]},${b[3][1]}`,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
rc.linearPath(points as unknown as [number, number][], options);
|
||||
}
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
dash && ctx.setLineDash([12, 12]);
|
||||
ctx.beginPath();
|
||||
if (curve) {
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(point[0], point[1]);
|
||||
} else {
|
||||
const last = points[index - 1];
|
||||
ctx.bezierCurveTo(
|
||||
last.absOut[0],
|
||||
last.absOut[1],
|
||||
point.absIn[0],
|
||||
point.absIn[1],
|
||||
point[0],
|
||||
point[1]
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(point[0], point[1]);
|
||||
} else {
|
||||
ctx.lineTo(point[0], point[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function renderEndpoint(
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
location: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
end: 'Front' | 'Rear',
|
||||
style: PointStyle,
|
||||
stroke: string
|
||||
) {
|
||||
const arrowOptions = getArrowOptions(end, model, stroke);
|
||||
|
||||
switch (style) {
|
||||
case 'Arrow':
|
||||
renderArrow(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Triangle':
|
||||
renderTriangle(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Circle':
|
||||
renderCircle(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
case 'Diamond':
|
||||
renderDiamond(location, ctx, rc, arrowOptions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
model: ConnectorElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer
|
||||
) {
|
||||
const {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelStyle: {
|
||||
color,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
},
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = model;
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
const [, , w, h] = labelXYWH!;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const deltas = wrapTextDeltas(text!, font, w);
|
||||
const lines = deltaInsertsToChunks(deltas);
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const textHeight = (lines.length - 1) * lineHeight * 0.5;
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
ctx.font = font;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = renderer.getColorValue(color, '#000000', true);
|
||||
|
||||
let textMaxWidth = textAlign === 'center' ? 0 : getMaxTextWidth(lines, font);
|
||||
if (hasMaxWidth && maxWidth > 0) {
|
||||
textMaxWidth = Math.min(textMaxWidth, textMaxWidth);
|
||||
}
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
const rtl = isRTL(str);
|
||||
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
|
||||
if (shouldTemporarilyAttach) {
|
||||
// to correctly render RTL text mixed with LTR, we have to append it
|
||||
// to the DOM
|
||||
document.body.append(ctx.canvas);
|
||||
}
|
||||
|
||||
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
||||
|
||||
const x =
|
||||
textMaxWidth *
|
||||
(textAlign === 'center'
|
||||
? 1
|
||||
: textAlign === 'right'
|
||||
? rtl
|
||||
? -0.5
|
||||
: 0.5
|
||||
: rtl
|
||||
? 0.5
|
||||
: -0.5);
|
||||
ctx.fillText(str, x + cx, index * lineHeight - textHeight + cy);
|
||||
|
||||
if (shouldTemporarilyAttach) {
|
||||
ctx.canvas.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxTextWidth(lines: TextDelta[][], font: string) {
|
||||
return Math.max(
|
||||
...lines.flatMap(line =>
|
||||
line.map(delta => getTextWidth(delta.insert, font))
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
type LocalConnectorElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
BezierCurveParameters,
|
||||
IVec,
|
||||
PointLocation,
|
||||
} from '@blocksuite/global/utils';
|
||||
import {
|
||||
getBezierParameters,
|
||||
getBezierTangent,
|
||||
Vec,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
|
||||
type ConnectorEnd = 'Front' | 'Rear';
|
||||
|
||||
export const DEFAULT_ARROW_SIZE = 15;
|
||||
|
||||
export function getArrowPoints(
|
||||
points: PointLocation[],
|
||||
size = 10,
|
||||
mode: ConnectorMode,
|
||||
bezierParameters: BezierCurveParameters,
|
||||
endPoint: ConnectorEnd = 'Rear',
|
||||
radians: number = Math.PI / 4
|
||||
) {
|
||||
const anchorPoint = getPointWithTangent(
|
||||
points,
|
||||
mode,
|
||||
endPoint,
|
||||
bezierParameters
|
||||
);
|
||||
const unit = Vec.mul(anchorPoint.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
return {
|
||||
points: [
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle + radians), size), anchorPoint),
|
||||
anchorPoint,
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle - radians), size), anchorPoint),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getCircleCenterPoint(
|
||||
points: PointLocation[],
|
||||
radius = 5,
|
||||
mode: ConnectorMode,
|
||||
bezierParameters: BezierCurveParameters,
|
||||
endPoint: ConnectorEnd = 'Rear'
|
||||
) {
|
||||
const anchorPoint = getPointWithTangent(
|
||||
points,
|
||||
mode,
|
||||
endPoint,
|
||||
bezierParameters
|
||||
);
|
||||
|
||||
const unit = Vec.mul(anchorPoint.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
return Vec.add(Vec.mul(Vec.rot(unit, angle), radius), anchorPoint);
|
||||
}
|
||||
|
||||
export function getPointWithTangent(
|
||||
points: PointLocation[],
|
||||
mode: ConnectorMode,
|
||||
endPoint: ConnectorEnd,
|
||||
bezierParameters: BezierCurveParameters
|
||||
) {
|
||||
const anchorIndex = endPoint === 'Rear' ? points.length - 1 : 0;
|
||||
const pointToAnchorIndex =
|
||||
endPoint === 'Rear' ? anchorIndex - 1 : anchorIndex + 1;
|
||||
const anchorPoint = points[anchorIndex];
|
||||
const pointToAnchor = points[pointToAnchorIndex];
|
||||
|
||||
const clone = anchorPoint.clone();
|
||||
let tangent;
|
||||
if (mode !== ConnectorMode.Curve) {
|
||||
tangent =
|
||||
endPoint === 'Rear'
|
||||
? Vec.tangent(anchorPoint, pointToAnchor)
|
||||
: Vec.tangent(pointToAnchor, anchorPoint);
|
||||
} else {
|
||||
tangent =
|
||||
endPoint === 'Rear'
|
||||
? getBezierTangent(bezierParameters, 1)
|
||||
: getBezierTangent(bezierParameters, 0);
|
||||
}
|
||||
clone.tangent = tangent ?? [0, 0];
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
export function getDiamondPoints(
|
||||
point: PointLocation,
|
||||
size = 10,
|
||||
endPoint: ConnectorEnd = 'Rear'
|
||||
) {
|
||||
const unit = Vec.mul(point.tangent, -1);
|
||||
const angle = endPoint === 'Front' ? Math.PI : 0;
|
||||
|
||||
const diamondPoints = [
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle + Math.PI * 0.25), size), point),
|
||||
point,
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle - Math.PI * 0.25), size), point),
|
||||
Vec.add(Vec.mul(Vec.rot(unit, angle), size * Math.sqrt(2)), point),
|
||||
];
|
||||
|
||||
return {
|
||||
points: diamondPoints,
|
||||
};
|
||||
}
|
||||
|
||||
export type ArrowOptions = ReturnType<typeof getArrowOptions>;
|
||||
|
||||
export function getArrowOptions(
|
||||
end: ConnectorEnd,
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
strokeColor: string
|
||||
) {
|
||||
const { seed, mode, rough, roughness, strokeWidth, path } = model;
|
||||
|
||||
return {
|
||||
end,
|
||||
seed,
|
||||
mode,
|
||||
rough,
|
||||
roughness,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
fillColor: strokeColor,
|
||||
fillStyle: 'solid',
|
||||
bezierParameters: getBezierParameters(path),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRcOptions(options: ArrowOptions) {
|
||||
const { seed, roughness, strokeWidth, strokeColor, fillColor } = options;
|
||||
return {
|
||||
seed,
|
||||
roughness,
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
fill: fillColor,
|
||||
fillStyle: 'solid',
|
||||
};
|
||||
}
|
||||
|
||||
export function renderRoundedPolygon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: IVec[],
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
fill: boolean = true
|
||||
) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i === 0) {
|
||||
ctx.moveTo(points[i][0], points[i][1]);
|
||||
} else {
|
||||
ctx.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function renderArrow(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const radians = Math.PI / 4;
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
const { points: arrowPoints } = getArrowPoints(
|
||||
points,
|
||||
size,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end,
|
||||
radians
|
||||
);
|
||||
|
||||
if (rough) {
|
||||
rc.linearPath(arrowPoints as [number, number][], getRcOptions(options));
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, arrowPoints, strokeColor, strokeWidth, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTriangle(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const radians = Math.PI / 6;
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
const { points: trianglePoints } = getArrowPoints(
|
||||
points,
|
||||
size,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end,
|
||||
radians
|
||||
);
|
||||
|
||||
if (rough) {
|
||||
rc.polygon(
|
||||
[
|
||||
[trianglePoints[0][0], trianglePoints[0][1]],
|
||||
[trianglePoints[1][0], trianglePoints[1][1]],
|
||||
[trianglePoints[2][0], trianglePoints[2][1]],
|
||||
],
|
||||
getRcOptions(options)
|
||||
);
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, trianglePoints, strokeColor, strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderDiamond(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const { mode, end, rough, bezierParameters, strokeColor, strokeWidth } =
|
||||
options;
|
||||
const anchorPoint = getPointWithTangent(points, mode, end, bezierParameters);
|
||||
const size = 10 * (strokeWidth / 2);
|
||||
const { points: diamondPoints } = getDiamondPoints(anchorPoint, size, end);
|
||||
|
||||
if (rough) {
|
||||
rc.polygon(
|
||||
[
|
||||
[diamondPoints[0][0], diamondPoints[0][1]],
|
||||
[diamondPoints[1][0], diamondPoints[1][1]],
|
||||
[diamondPoints[2][0], diamondPoints[2][1]],
|
||||
[diamondPoints[3][0], diamondPoints[3][1]],
|
||||
],
|
||||
getRcOptions(options)
|
||||
);
|
||||
} else {
|
||||
renderRoundedPolygon(ctx, diamondPoints, strokeColor, strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCircle(
|
||||
points: PointLocation[],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rc: RoughCanvas,
|
||||
options: ArrowOptions
|
||||
) {
|
||||
const {
|
||||
bezierParameters,
|
||||
mode,
|
||||
end,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
rough,
|
||||
} = options;
|
||||
const radius = 5 * (strokeWidth / 2);
|
||||
const centerPoint = getCircleCenterPoint(
|
||||
points,
|
||||
radius,
|
||||
mode,
|
||||
bezierParameters,
|
||||
end
|
||||
);
|
||||
const cx = centerPoint[0];
|
||||
const cy = centerPoint[1];
|
||||
|
||||
if (rough) {
|
||||
// radius + 2 when render rough circle to avoid connector line cross the circle and make it looks bad
|
||||
rc.circle(cx, cy, radius + 2, getRcOptions(options));
|
||||
} else {
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, radius, radius, 0, 0, 2 * Math.PI);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FontFamily } from '@blocksuite/affine-model';
|
||||
|
||||
export const GROUP_TITLE_FONT = FontFamily.Inter;
|
||||
export const GROUP_TITLE_FONT_SIZE = 12;
|
||||
export const GROUP_TITLE_PADDING = [2, 0];
|
||||
export const GROUP_TITLE_OFFSET = 4;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import { titleRenderParams } from './utils.js';
|
||||
|
||||
export function group(
|
||||
model: GroupElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer
|
||||
) {
|
||||
const { xywh } = model;
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderParams = titleRenderParams(model, renderer.viewport.zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle(model, ctx, renderer, renderParams);
|
||||
} else {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
ctx.lineWidth = 2 / renderer.viewport.zoom;
|
||||
ctx.strokeStyle = '#8FD1FF';
|
||||
ctx.strokeRect(0, 0, bound.w, bound.h);
|
||||
|
||||
if (model.showTitle) renderTitle(model, ctx, renderer, renderParams);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTitle(
|
||||
model: GroupElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
renderer: CanvasRenderer,
|
||||
renderParams: ReturnType<typeof titleRenderParams>
|
||||
) {
|
||||
const { text, lineHeight, font, padding, offset, titleBound } = renderParams;
|
||||
|
||||
model.externalXYWH = titleBound.serialize();
|
||||
|
||||
ctx.translate(0, -offset);
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = renderer.getPropertyValue('--affine-blue');
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, padding[0], -lineHeight / 2 - padding[1]);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { FontWeight } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getLineWidth,
|
||||
truncateTextByWidth,
|
||||
} from '../text/utils.js';
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts.js';
|
||||
|
||||
export function titleRenderParams(group: GroupElementModel, zoom: number) {
|
||||
let text = group.title.toString().trim();
|
||||
const font = getGroupTitleFont(zoom);
|
||||
const lineWidth = getLineWidth(text, font);
|
||||
const lineHeight = getLineHeight(
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE / zoom,
|
||||
'normal'
|
||||
);
|
||||
const bound = group.elementBound;
|
||||
const padding = [
|
||||
GROUP_TITLE_PADDING[0] / zoom,
|
||||
GROUP_TITLE_PADDING[1] / zoom,
|
||||
];
|
||||
const offset = GROUP_TITLE_OFFSET / zoom;
|
||||
|
||||
let titleWidth = lineWidth + padding[0] * 2;
|
||||
const titleHeight = lineHeight + padding[1] * 2;
|
||||
|
||||
if (titleWidth > bound.w) {
|
||||
text = truncateTextByWidth(text, font, bound.w - 10);
|
||||
text = text.slice(0, text.length - 1) + '..';
|
||||
titleWidth = bound.w;
|
||||
}
|
||||
|
||||
return {
|
||||
font,
|
||||
bound,
|
||||
text,
|
||||
titleWidth,
|
||||
titleHeight,
|
||||
offset,
|
||||
lineHeight,
|
||||
padding,
|
||||
titleBound: new Bound(
|
||||
bound.x,
|
||||
bound.y - titleHeight - offset,
|
||||
titleWidth,
|
||||
titleHeight
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function titleBound(group: GroupElementModel, zoom: number) {
|
||||
const { titleWidth, titleHeight, bound } = titleRenderParams(group, zoom);
|
||||
|
||||
return new Bound(bound.x, bound.y - titleHeight, titleWidth, titleHeight);
|
||||
}
|
||||
|
||||
function getGroupTitleFont(zoom: number) {
|
||||
const fontSize = GROUP_TITLE_FONT_SIZE / zoom;
|
||||
const font = getFontString({
|
||||
fontSize,
|
||||
fontFamily: GROUP_TITLE_FONT,
|
||||
fontWeight: FontWeight.Regular,
|
||||
fontStyle: 'normal',
|
||||
});
|
||||
|
||||
return font;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IBound } from '@blocksuite/global/utils';
|
||||
|
||||
import type { RoughCanvas, SurfaceElementModel } from '../../index.js';
|
||||
import type { CanvasRenderer } from '../canvas-renderer.js';
|
||||
import { brush } from './brush/index.js';
|
||||
import { connector } from './connector/index.js';
|
||||
import { group } from './group/index.js';
|
||||
import { mindmap } from './mindmap.js';
|
||||
import { shape } from './shape/index.js';
|
||||
import { text } from './text/index.js';
|
||||
export { normalizeShapeBound } from './shape/utils.js';
|
||||
|
||||
export type ElementRenderer<
|
||||
T extends BlockSuite.SurfaceElementModel = SurfaceElementModel,
|
||||
> = (
|
||||
model: T,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
viewportBound: IBound
|
||||
) => void;
|
||||
|
||||
export const elementRenderers = {
|
||||
brush,
|
||||
connector,
|
||||
group,
|
||||
shape,
|
||||
text,
|
||||
mindmap,
|
||||
} as Record<string, ElementRenderer<any>>;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
MindmapElementModel,
|
||||
MindmapNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import type { IBound } from '@blocksuite/global/utils';
|
||||
|
||||
import { ConnectorPathGenerator } from '../../managers/connector-manager.js';
|
||||
import type { RoughCanvas } from '../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../canvas-renderer.js';
|
||||
import { connector as renderConnector } from './connector/index.js';
|
||||
|
||||
export function mindmap(
|
||||
model: MindmapElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
bound: IBound
|
||||
) {
|
||||
const dx = model.x - bound.x;
|
||||
const dy = model.y - bound.y;
|
||||
|
||||
matrix = matrix.translate(-dx, -dy);
|
||||
|
||||
const mindmapOpacity = model.opacity;
|
||||
|
||||
const traverse = (node: MindmapNode) => {
|
||||
const connectors = model.getConnectors(node);
|
||||
if (!connectors) return;
|
||||
connectors.reverse().forEach(result => {
|
||||
const { connector, outdated } = result;
|
||||
const elementGetter = (id: string) =>
|
||||
model.surface.getElementById(id) ??
|
||||
(model.surface.doc.getBlockById(id) as GfxModel);
|
||||
|
||||
if (outdated) {
|
||||
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
|
||||
}
|
||||
|
||||
const dx = connector.x - bound.x;
|
||||
const dy = connector.y - bound.y;
|
||||
const origin = ctx.globalAlpha;
|
||||
const shouldSetGlobalAlpha =
|
||||
origin !== connector.opacity * mindmapOpacity;
|
||||
|
||||
if (shouldSetGlobalAlpha) {
|
||||
ctx.globalAlpha = connector.opacity * mindmapOpacity;
|
||||
}
|
||||
|
||||
renderConnector(connector, ctx, matrix.translate(dx, dy), renderer, rc);
|
||||
|
||||
if (shouldSetGlobalAlpha) {
|
||||
ctx.globalAlpha = origin;
|
||||
}
|
||||
});
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
return;
|
||||
} else {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
model.tree && traverse(model.tree);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import { type Colors, drawGeneralShape } from './utils.js';
|
||||
|
||||
export function diamond(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
colors: Colors
|
||||
) {
|
||||
const {
|
||||
seed,
|
||||
strokeWidth,
|
||||
filled,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
rotate,
|
||||
shapeStyle,
|
||||
} = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const renderOffset = Math.max(strokeWidth, 0) / 2;
|
||||
const renderWidth = w - renderOffset * 2;
|
||||
const renderHeight = h - renderOffset * 2;
|
||||
const cx = renderWidth / 2;
|
||||
const cy = renderHeight / 2;
|
||||
|
||||
const { fillColor, strokeColor } = colors;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix
|
||||
.translateSelf(renderOffset, renderOffset)
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
if (shapeStyle === 'General') {
|
||||
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
|
||||
} else {
|
||||
rc.polygon(
|
||||
[
|
||||
[renderWidth / 2, 0],
|
||||
[renderWidth, renderHeight / 2],
|
||||
[renderWidth / 2, renderHeight],
|
||||
[0, renderHeight / 2],
|
||||
],
|
||||
{
|
||||
seed,
|
||||
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
|
||||
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
|
||||
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
|
||||
strokeWidth,
|
||||
fill: filled ? fillColor : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import { type Colors, drawGeneralShape } from './utils.js';
|
||||
|
||||
export function ellipse(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
colors: Colors
|
||||
) {
|
||||
const {
|
||||
seed,
|
||||
strokeWidth,
|
||||
filled,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
rotate,
|
||||
shapeStyle,
|
||||
} = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const renderOffset = Math.max(strokeWidth, 0) / 2;
|
||||
const renderWidth = Math.max(1, w - renderOffset * 2);
|
||||
const renderHeight = Math.max(1, h - renderOffset * 2);
|
||||
const cx = renderWidth / 2;
|
||||
const cy = renderHeight / 2;
|
||||
|
||||
const { fillColor, strokeColor } = colors;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix
|
||||
.translateSelf(renderOffset, renderOffset)
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
if (shapeStyle === 'General') {
|
||||
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
|
||||
} else {
|
||||
rc.ellipse(cx, cy, renderWidth, renderHeight, {
|
||||
seed,
|
||||
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
|
||||
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
|
||||
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
|
||||
strokeWidth,
|
||||
fill: filled ? fillColor : undefined,
|
||||
curveFitting: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_SHAPE_FILL_COLOR,
|
||||
DEFAULT_SHAPE_STROKE_COLOR,
|
||||
DEFAULT_SHAPE_TEXT_COLOR,
|
||||
TextAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { IBound } from '@blocksuite/global/utils';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/inline';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import {
|
||||
getFontMetrics,
|
||||
getFontString,
|
||||
getLineWidth,
|
||||
isRTL,
|
||||
measureTextInDOM,
|
||||
wrapTextDeltas,
|
||||
} from '../text/utils.js';
|
||||
import { diamond } from './diamond.js';
|
||||
import { ellipse } from './ellipse.js';
|
||||
import { rect } from './rect.js';
|
||||
import { triangle } from './triangle.js';
|
||||
import { type Colors, horizontalOffset, verticalOffset } from './utils.js';
|
||||
|
||||
const shapeRenderers: Record<
|
||||
ShapeType,
|
||||
(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
colors: Colors
|
||||
) => void
|
||||
> = {
|
||||
diamond,
|
||||
rect,
|
||||
triangle,
|
||||
ellipse,
|
||||
};
|
||||
|
||||
export function shape(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas
|
||||
) {
|
||||
const color = renderer.getColorValue(
|
||||
model.color,
|
||||
DEFAULT_SHAPE_TEXT_COLOR,
|
||||
true
|
||||
);
|
||||
const fillColor = renderer.getColorValue(
|
||||
model.fillColor,
|
||||
DEFAULT_SHAPE_FILL_COLOR,
|
||||
true
|
||||
);
|
||||
const strokeColor = renderer.getColorValue(
|
||||
model.strokeColor,
|
||||
DEFAULT_SHAPE_STROKE_COLOR,
|
||||
true
|
||||
);
|
||||
const colors = { color, fillColor, strokeColor };
|
||||
|
||||
shapeRenderers[model.shapeType](model, ctx, matrix, renderer, rc, colors);
|
||||
|
||||
if (model.textDisplay) {
|
||||
renderText(model, ctx, colors);
|
||||
}
|
||||
}
|
||||
|
||||
function renderText(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ color }: Colors
|
||||
) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
w,
|
||||
h,
|
||||
textVerticalAlign,
|
||||
padding,
|
||||
} = model;
|
||||
if (!text) return;
|
||||
|
||||
const [verticalPadding, horPadding] = padding;
|
||||
const font = getFontString(model);
|
||||
const { lineGap, lineHeight } = measureTextInDOM(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight
|
||||
);
|
||||
const metrics = getFontMetrics(fontFamily, fontSize, fontWeight);
|
||||
const lines =
|
||||
typeof text === 'string'
|
||||
? [text.split('\n').map(line => ({ insert: line }))]
|
||||
: deltaInsertsToChunks(wrapTextDeltas(text, font, w - horPadding * 2));
|
||||
const horOffset = horizontalOffset(model.w, model.textAlign, horPadding);
|
||||
const vertOffset =
|
||||
verticalOffset(
|
||||
lines,
|
||||
lineHeight + lineGap,
|
||||
h,
|
||||
textVerticalAlign,
|
||||
verticalPadding
|
||||
) +
|
||||
metrics.fontBoundingBoxAscent +
|
||||
lineGap / 2;
|
||||
let maxLineWidth = 0;
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
for (const [lineIndex, line] of lines.entries()) {
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
const rtl = isRTL(str);
|
||||
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
|
||||
if (shouldTemporarilyAttach) {
|
||||
// to correctly render RTL text mixed with LTR, we have to append it
|
||||
// to the DOM
|
||||
document.body.append(ctx.canvas);
|
||||
}
|
||||
|
||||
if (ctx.canvas.dir !== (rtl ? 'rtl' : 'ltr')) {
|
||||
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
||||
}
|
||||
|
||||
ctx.fillText(
|
||||
str,
|
||||
// 0.5 is the dom editor padding to make the text align with the DOM text
|
||||
horOffset + 0.5,
|
||||
lineIndex * lineHeight + vertOffset
|
||||
);
|
||||
|
||||
maxLineWidth = Math.max(maxLineWidth, getLineWidth(str, font));
|
||||
|
||||
if (shouldTemporarilyAttach) {
|
||||
ctx.canvas.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offsetX =
|
||||
model.textAlign === TextAlign.Center
|
||||
? (w - maxLineWidth) / 2
|
||||
: model.textAlign === TextAlign.Left
|
||||
? horOffset
|
||||
: horOffset - maxLineWidth;
|
||||
const offsetY = vertOffset - lineHeight + verticalPadding / 2;
|
||||
|
||||
const bound = new Bound(
|
||||
x + offsetX,
|
||||
y + offsetY,
|
||||
maxLineWidth,
|
||||
lineHeight * lines.length
|
||||
) as IBound;
|
||||
|
||||
bound.rotate = model.rotate ?? 0;
|
||||
model.textBound = bound;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import { type Colors, drawGeneralShape } from './utils.js';
|
||||
|
||||
/**
|
||||
* "magic number" for bezier approximations of arcs (http://itc.ktu.lt/itc354/Riskus354.pdf)
|
||||
*/
|
||||
const K_RECT = 1 - 0.5522847498;
|
||||
|
||||
export function rect(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
colors: Colors
|
||||
) {
|
||||
const {
|
||||
filled,
|
||||
radius,
|
||||
rotate,
|
||||
roughness,
|
||||
seed,
|
||||
shapeStyle,
|
||||
strokeStyle,
|
||||
strokeWidth,
|
||||
} = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const renderOffset = Math.max(strokeWidth, 0) / 2;
|
||||
const renderWidth = w - renderOffset * 2;
|
||||
const renderHeight = h - renderOffset * 2;
|
||||
const r =
|
||||
radius < 1 ? Math.min(renderWidth * radius, renderHeight * radius) : radius;
|
||||
const cx = renderWidth / 2;
|
||||
const cy = renderHeight / 2;
|
||||
|
||||
const { fillColor, strokeColor } = colors;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix
|
||||
.translateSelf(renderOffset, renderOffset)
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
if (shapeStyle === 'General') {
|
||||
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
|
||||
} else {
|
||||
rc.path(
|
||||
`
|
||||
M ${r} 0
|
||||
L ${renderWidth - r} 0
|
||||
C ${renderWidth - K_RECT * r} 0 ${renderWidth} ${
|
||||
K_RECT * r
|
||||
} ${renderWidth} ${r}
|
||||
L ${renderWidth} ${renderHeight - r}
|
||||
C ${renderWidth} ${renderHeight - K_RECT * r} ${
|
||||
renderWidth - K_RECT * r
|
||||
} ${renderHeight} ${renderWidth - r} ${renderHeight}
|
||||
L ${r} ${renderHeight}
|
||||
C ${K_RECT * r} ${renderHeight} 0 ${renderHeight - K_RECT * r} 0 ${
|
||||
renderHeight - r
|
||||
}
|
||||
L 0 ${r}
|
||||
C 0 ${K_RECT * r} ${K_RECT * r} 0 ${r} 0
|
||||
Z
|
||||
`,
|
||||
{
|
||||
seed,
|
||||
roughness,
|
||||
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
|
||||
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
|
||||
strokeWidth,
|
||||
fill: filled ? fillColor : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ctx.setTransform(
|
||||
ctx
|
||||
.getTransform()
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(-rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
.translateSelf(-renderOffset, -renderOffset)
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import { type Colors, drawGeneralShape } from './utils.js';
|
||||
|
||||
export function triangle(
|
||||
model: ShapeElementModel | LocalShapeElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer,
|
||||
rc: RoughCanvas,
|
||||
colors: Colors
|
||||
) {
|
||||
const {
|
||||
seed,
|
||||
strokeWidth,
|
||||
filled,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
rotate,
|
||||
shapeStyle,
|
||||
} = model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const renderOffset = Math.max(strokeWidth, 0) / 2;
|
||||
const renderWidth = w - renderOffset * 2;
|
||||
const renderHeight = h - renderOffset * 2;
|
||||
const cx = renderWidth / 2;
|
||||
const cy = renderHeight / 2;
|
||||
|
||||
const { fillColor, strokeColor } = colors;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix
|
||||
.translateSelf(renderOffset, renderOffset)
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(rotate)
|
||||
.translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
if (shapeStyle === 'General') {
|
||||
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
|
||||
} else {
|
||||
rc.polygon(
|
||||
[
|
||||
[renderWidth / 2, 0],
|
||||
[renderWidth, renderHeight],
|
||||
[0, renderHeight],
|
||||
],
|
||||
{
|
||||
seed,
|
||||
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
|
||||
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
|
||||
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
|
||||
strokeWidth,
|
||||
fill: filled ? fillColor : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
LocalShapeElementModel,
|
||||
ShapeElementModel,
|
||||
TextAlign,
|
||||
TextVerticalAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Bound, SerializedXYWH } from '@blocksuite/global/utils';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/inline';
|
||||
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getLineWidth,
|
||||
getTextWidth,
|
||||
measureTextInDOM,
|
||||
type TextDelta,
|
||||
wrapText,
|
||||
wrapTextDeltas,
|
||||
} from '../text/utils.js';
|
||||
|
||||
export type Colors = {
|
||||
color: string;
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
};
|
||||
|
||||
export function drawGeneralShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
shapeModel: ShapeElementModel | LocalShapeElementModel,
|
||||
renderer: CanvasRenderer,
|
||||
filled: boolean,
|
||||
fillColor: string,
|
||||
strokeColor: string
|
||||
) {
|
||||
const sizeOffset = Math.max(shapeModel.strokeWidth, 0);
|
||||
const w = Math.max(shapeModel.w - sizeOffset, 0);
|
||||
const h = Math.max(shapeModel.h - sizeOffset, 0);
|
||||
|
||||
switch (shapeModel.shapeType) {
|
||||
case 'rect':
|
||||
drawRect(ctx, 0, 0, w, h, shapeModel.radius ?? 0);
|
||||
break;
|
||||
case 'diamond':
|
||||
drawDiamond(ctx, 0, 0, w, h);
|
||||
break;
|
||||
case 'ellipse':
|
||||
drawEllipse(ctx, 0, 0, w, h);
|
||||
break;
|
||||
case 'triangle':
|
||||
drawTriangle(ctx, 0, 0, w, h);
|
||||
}
|
||||
|
||||
ctx.lineWidth = shapeModel.strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.fillStyle = filled ? fillColor : 'transparent';
|
||||
|
||||
switch (shapeModel.strokeStyle) {
|
||||
case 'none':
|
||||
ctx.strokeStyle = 'transparent';
|
||||
break;
|
||||
case 'dash':
|
||||
ctx.setLineDash([12, 12]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (shapeModel.shadow) {
|
||||
const { blur, offsetX, offsetY, color } = shapeModel.shadow;
|
||||
const scale = ctx.getTransform().a;
|
||||
|
||||
const enableShadowBlur = shapeModel.surface.doc.awarenessStore.getFlag(
|
||||
'enable_shape_shadow_blur'
|
||||
);
|
||||
|
||||
// hard shadow, or soft shadow if `enable_shape_shadow_blur` is true
|
||||
// see comment of `shape.shadow` in `ShapeElementModel`
|
||||
if (blur === 0 || enableShadowBlur) {
|
||||
ctx.shadowBlur = blur * scale;
|
||||
ctx.shadowOffsetX = offsetX * scale;
|
||||
ctx.shadowOffsetY = offsetY * scale;
|
||||
}
|
||||
|
||||
ctx.shadowColor = renderer.getPropertyValue(color);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
|
||||
if (shapeModel.shadow) {
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number
|
||||
) {
|
||||
const r =
|
||||
radius < 1
|
||||
? Math.max(Math.min(width * radius, height * radius), 0)
|
||||
: radius;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + width - r, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + r, r);
|
||||
ctx.lineTo(x + width, y + height - r);
|
||||
ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
|
||||
ctx.lineTo(x + r, y + height);
|
||||
ctx.arcTo(x, y + height, x, y + height - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawDiamond(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(width / 2, y);
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.lineTo(width / 2, height);
|
||||
ctx.lineTo(x, height / 2);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawEllipse(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_x: number,
|
||||
_y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, width / 2, height / 2, 0, 0, 2 * Math.PI);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawTriangle(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(width / 2, y);
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export function horizontalOffset(
|
||||
width: number,
|
||||
textAlign: TextAlign,
|
||||
horiPadding: number
|
||||
) {
|
||||
return textAlign === 'center'
|
||||
? width / 2
|
||||
: textAlign === 'right'
|
||||
? width - horiPadding
|
||||
: horiPadding;
|
||||
}
|
||||
|
||||
export function verticalOffset(
|
||||
lines: TextDelta[][],
|
||||
lineHeight: number,
|
||||
height: number,
|
||||
textVerticalAlign: TextVerticalAlign,
|
||||
verticalPadding: number
|
||||
) {
|
||||
return textVerticalAlign === 'center'
|
||||
? Math.max((height - lineHeight * lines.length) / 2, verticalPadding)
|
||||
: textVerticalAlign === 'top'
|
||||
? verticalPadding
|
||||
: height - lineHeight * lines.length - verticalPadding;
|
||||
}
|
||||
export function normalizeShapeBound(
|
||||
shape: ShapeElementModel,
|
||||
bound: Bound
|
||||
): Bound {
|
||||
if (!shape.text) return bound;
|
||||
|
||||
const [verticalPadding, horiPadding] = shape.padding;
|
||||
const yText = shape.text;
|
||||
const { fontFamily, fontSize, fontStyle, fontWeight } = shape;
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
const widestCharWidth =
|
||||
[...yText.toString()]
|
||||
.map(char => getTextWidth(char, font))
|
||||
.sort((a, b) => a - b)
|
||||
.pop() ?? getTextWidth('W', font);
|
||||
|
||||
if (bound.w < widestCharWidth + horiPadding * 2) {
|
||||
bound.w = widestCharWidth + horiPadding * 2;
|
||||
}
|
||||
const deltas: TextDelta[] = (yText.toDelta() as TextDelta[]).flatMap(
|
||||
delta => ({
|
||||
insert: wrapText(delta.insert, font, bound.w - horiPadding * 2),
|
||||
attributes: delta.attributes,
|
||||
})
|
||||
) as TextDelta[];
|
||||
const lines = deltaInsertsToChunks(deltas);
|
||||
|
||||
if (bound.h < lineHeight * lines.length + verticalPadding * 2) {
|
||||
bound.h = lineHeight * lines.length + verticalPadding * 2;
|
||||
}
|
||||
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function fitContent(shape: ShapeElementModel) {
|
||||
const font = getFontString(shape);
|
||||
|
||||
if (!shape.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [verticalPadding, horiPadding] = shape.padding;
|
||||
const lines = deltaInsertsToChunks(
|
||||
wrapTextDeltas(shape.text, font, shape.maxWidth || Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
const { lineHeight, lineGap } = measureTextInDOM(
|
||||
shape.fontFamily,
|
||||
shape.fontSize,
|
||||
shape.fontWeight
|
||||
);
|
||||
let maxWidth = 0;
|
||||
let height = 0;
|
||||
|
||||
lines.forEach(line => {
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
|
||||
maxWidth = Math.max(maxWidth, getLineWidth(str, font));
|
||||
}
|
||||
height += lineHeight + lineGap;
|
||||
});
|
||||
|
||||
height = Math.max(lineHeight + lineGap, height);
|
||||
|
||||
maxWidth += horiPadding * 2;
|
||||
height += verticalPadding * 2;
|
||||
|
||||
const newXYWH = `[${shape.x},${shape.y},${maxWidth},${height}]`;
|
||||
|
||||
if (shape.xywh !== newXYWH) {
|
||||
shape.xywh = newXYWH as SerializedXYWH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/inline';
|
||||
|
||||
import type { CanvasRenderer } from '../../canvas-renderer.js';
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
wrapTextDeltas,
|
||||
} from './utils.js';
|
||||
|
||||
export function text(
|
||||
model: TextElementModel,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
matrix: DOMMatrix,
|
||||
renderer: CanvasRenderer
|
||||
) {
|
||||
const { fontSize, fontWeight, fontStyle, fontFamily, textAlign, rotate } =
|
||||
model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
// const deltas: ITextDelta[] = yText.toDelta() as ITextDelta[];
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
const deltas = wrapTextDeltas(model.text, font, w);
|
||||
const lines = deltaInsertsToChunks(deltas);
|
||||
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const horizontalOffset =
|
||||
textAlign === 'center' ? w / 2 : textAlign === 'right' ? w : 0;
|
||||
|
||||
const color = renderer.getColorValue(model.color, '#000000', true);
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = 'ideographic';
|
||||
|
||||
for (const [lineIndex, line] of lines.entries()) {
|
||||
let beforeTextWidth = 0;
|
||||
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
const rtl = isRTL(str);
|
||||
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
|
||||
if (shouldTemporarilyAttach) {
|
||||
// to correctly render RTL text mixed with LTR, we have to append it
|
||||
// to the DOM
|
||||
document.body.append(ctx.canvas);
|
||||
}
|
||||
|
||||
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
||||
|
||||
// 0.5 comes from v-line padding
|
||||
const offset =
|
||||
textAlign === 'center' ? 0 : textAlign === 'right' ? -0.5 : 0.5;
|
||||
ctx.fillText(
|
||||
str,
|
||||
horizontalOffset + beforeTextWidth + offset,
|
||||
(lineIndex + 1) * lineHeightPx
|
||||
);
|
||||
|
||||
beforeTextWidth += getTextWidth(str, font);
|
||||
|
||||
if (shouldTemporarilyAttach) {
|
||||
ctx.canvas.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import type {
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Bound } from '@blocksuite/global/utils';
|
||||
import {
|
||||
getPointsFromBoundWithRotation,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/inline';
|
||||
import type { Y } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
getFontFacesByFontFamily,
|
||||
wrapFontFamily,
|
||||
} from '../../../utils/font.js';
|
||||
|
||||
export type TextDelta = {
|
||||
insert: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getMeasureCtx = (function initMeasureContext() {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
return () => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
ctx = canvas.getContext('2d')!;
|
||||
}
|
||||
|
||||
return ctx!;
|
||||
};
|
||||
})();
|
||||
|
||||
const textMeasureCache = new Map<
|
||||
string,
|
||||
{
|
||||
lineHeight: number;
|
||||
lineGap: number;
|
||||
fontSize: number;
|
||||
}
|
||||
>();
|
||||
|
||||
export function measureTextInDOM(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
) {
|
||||
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
|
||||
|
||||
if (textMeasureCache.has(cacheKey)) {
|
||||
const {
|
||||
fontSize: cacheFontSize,
|
||||
lineGap,
|
||||
lineHeight,
|
||||
} = textMeasureCache.get(cacheKey)!;
|
||||
|
||||
return {
|
||||
lineHeight: lineHeight * (fontSize / cacheFontSize),
|
||||
lineGap: lineGap * (fontSize / cacheFontSize),
|
||||
};
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
const span = document.createElement('span');
|
||||
|
||||
div.append(span);
|
||||
|
||||
span.innerText = 'x';
|
||||
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '0px';
|
||||
div.style.left = '0px';
|
||||
div.style.visibility = 'hidden';
|
||||
div.style.fontFamily = wrapFontFamily(fontFamily);
|
||||
div.style.fontWeight = fontWeight;
|
||||
div.style.fontSize = `${fontSize}px`;
|
||||
|
||||
div.style.pointerEvents = 'none';
|
||||
|
||||
document.body.append(div);
|
||||
|
||||
const lineHeight = span.getBoundingClientRect().height;
|
||||
const height = div.getBoundingClientRect().height;
|
||||
const result = {
|
||||
lineHeight,
|
||||
lineGap: height - lineHeight,
|
||||
};
|
||||
|
||||
div.remove();
|
||||
|
||||
textMeasureCache.set(cacheKey, {
|
||||
...result,
|
||||
fontSize,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
}: {
|
||||
fontStyle: string;
|
||||
fontWeight: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}): string {
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
return `${fontStyle} ${fontWeight} ${fontSize}px/${lineHeight}px ${wrapFontFamily(
|
||||
fontFamily
|
||||
)}, sans-serif`.trim();
|
||||
}
|
||||
|
||||
export function getLineHeight(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
): number {
|
||||
const { lineHeight } = measureTextInDOM(fontFamily, fontSize, fontWeight);
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
type TextMetricsLike = Writeable<TextMetrics>;
|
||||
|
||||
const metricsCache = new Map<
|
||||
string,
|
||||
{
|
||||
fontSize: number;
|
||||
metrics: TextMetrics;
|
||||
}
|
||||
>();
|
||||
export function getFontMetrics(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
) {
|
||||
const ctx = getMeasureCtx();
|
||||
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
|
||||
|
||||
if (metricsCache.has(cacheKey)) {
|
||||
const { fontSize: cacheFontSize, metrics } = metricsCache.get(cacheKey)!;
|
||||
|
||||
return Object.keys(Object.getPrototypeOf(metrics)).reduce((acc, key) => {
|
||||
acc[key as keyof TextMetrics] =
|
||||
metrics[key as keyof TextMetrics] * (fontSize / cacheFontSize);
|
||||
return acc;
|
||||
}, {} as TextMetricsLike);
|
||||
}
|
||||
|
||||
const font = `${fontWeight} ${fontSize}px ${wrapFontFamily(fontFamily)}`;
|
||||
ctx.font = font;
|
||||
const metrics = ctx.measureText('x');
|
||||
|
||||
// check if font does not fallback
|
||||
if (ctx.font === font) {
|
||||
metricsCache.set(cacheKey, {
|
||||
fontSize,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const RS_LTR_CHARS =
|
||||
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' +
|
||||
'\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF';
|
||||
const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
|
||||
export function isRTL(text: string) {
|
||||
return RE_RTL_CHECK.test(text);
|
||||
}
|
||||
|
||||
export function splitIntoLines(text: string): string[] {
|
||||
return normalizeText(text).split('\n');
|
||||
}
|
||||
|
||||
export function getLineWidth(text: string, font: string): number {
|
||||
const ctx = getMeasureCtx();
|
||||
if (font !== ctx.font) ctx.font = font;
|
||||
const width = ctx.measureText(text).width;
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
export function getTextWidth(text: string, font: string): number {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach(line => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
}
|
||||
|
||||
export function wrapTextDeltas(text: Y.Text, font: string, w: number) {
|
||||
const deltas: TextDelta[] = (text.toDelta() as TextDelta[]).flatMap(
|
||||
delta => ({
|
||||
insert: wrapText(delta.insert, font, w),
|
||||
attributes: delta.attributes,
|
||||
})
|
||||
) as TextDelta[];
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
export const truncateTextByWidth = (
|
||||
text: string,
|
||||
font: string,
|
||||
width: number
|
||||
) => {
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
for (; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
totalWidth += charWidth.calculate(char, font);
|
||||
if (totalWidth > width) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return text.slice(0, i);
|
||||
};
|
||||
|
||||
export function getTextCursorPosition(
|
||||
model: TextElementModel,
|
||||
coord: { x: number; y: number }
|
||||
) {
|
||||
const leftTop = getPointsFromBoundWithRotation(model)[0];
|
||||
const mousePos = rotatePoints(
|
||||
[[coord.x, coord.y]],
|
||||
leftTop,
|
||||
-model.rotate
|
||||
)[0];
|
||||
|
||||
return [
|
||||
Math.floor(
|
||||
(mousePos[1] - leftTop[1]) /
|
||||
getLineHeight(model.fontFamily, model.fontSize, model.fontWeight)
|
||||
),
|
||||
mousePos[0] - leftTop[0],
|
||||
];
|
||||
}
|
||||
|
||||
export function getCursorByCoord(
|
||||
model: TextElementModel,
|
||||
coord: { x: number; y: number }
|
||||
) {
|
||||
const [lineIndex, offsetX] = getTextCursorPosition(model, coord);
|
||||
|
||||
const font = getFontString(model);
|
||||
const deltas = wrapTextDeltas(model.text, font, model.w);
|
||||
const lines = deltaInsertsToChunks(deltas).map(line =>
|
||||
line.map(iTextDelta => iTextDelta.insert).join('')
|
||||
);
|
||||
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) {
|
||||
return model.text.length;
|
||||
}
|
||||
|
||||
const string = lines[lineIndex];
|
||||
|
||||
let index = lines.slice(0, lineIndex).join('').length - 1;
|
||||
let currentStringWidth = 0;
|
||||
let charIndex = 0;
|
||||
while (currentStringWidth < offsetX) {
|
||||
index += 1;
|
||||
if (charIndex === string.length) {
|
||||
break;
|
||||
}
|
||||
currentStringWidth += charWidth.calculate(string[charIndex], font);
|
||||
charIndex += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
hasMaxWidth,
|
||||
maxWidth,
|
||||
}: {
|
||||
yText: Y.Text;
|
||||
fontStyle: FontStyle;
|
||||
fontWeight: FontWeight;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
hasMaxWidth?: boolean;
|
||||
maxWidth?: number;
|
||||
},
|
||||
bound: Bound,
|
||||
dragging: boolean = false
|
||||
): Bound {
|
||||
if (!yText) return bound;
|
||||
|
||||
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
|
||||
let lines: TextDelta[][] = [];
|
||||
const deltas: TextDelta[] = yText.toDelta() as TextDelta[];
|
||||
const text = yText.toString();
|
||||
const widestCharWidth =
|
||||
[...text]
|
||||
.map(char => getTextWidth(char, font))
|
||||
.sort((a, b) => a - b)
|
||||
.pop() ?? getTextWidth('W', font);
|
||||
|
||||
if (bound.w < widestCharWidth) {
|
||||
bound.w = widestCharWidth;
|
||||
}
|
||||
|
||||
const width = bound.w;
|
||||
const insertDeltas = deltas.flatMap(delta => ({
|
||||
insert: wrapText(delta.insert, font, width),
|
||||
attributes: delta.attributes,
|
||||
})) as TextDelta[];
|
||||
lines = deltaInsertsToChunks(insertDeltas);
|
||||
if (!dragging) {
|
||||
lines = deltaInsertsToChunks(deltas);
|
||||
const widestLineWidth = Math.max(
|
||||
...text.split('\n').map(line => getTextWidth(line, font))
|
||||
);
|
||||
bound.w = widestLineWidth;
|
||||
if (hasMaxWidth && maxWidth && maxWidth > 0) {
|
||||
bound.w = Math.min(bound.w, maxWidth);
|
||||
}
|
||||
}
|
||||
bound.h = lineHeightPx * lines.length;
|
||||
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function isFontWeightSupported(
|
||||
fontFamily: FontFamily | string,
|
||||
weight: FontWeight
|
||||
) {
|
||||
const fontFaces = getFontFacesByFontFamily(fontFamily);
|
||||
const fontFace = fontFaces.find(fontFace => fontFace.weight === weight);
|
||||
return !!fontFace;
|
||||
}
|
||||
|
||||
export function isFontStyleSupported(
|
||||
fontFamily: FontFamily | string,
|
||||
style: FontStyle
|
||||
) {
|
||||
const fontFaces = getFontFacesByFontFamily(fontFamily);
|
||||
const fontFace = fontFaces.find(fontFace => fontFace.style === style);
|
||||
return !!fontFace;
|
||||
}
|
||||
|
||||
export function normalizeText(text: string): string {
|
||||
return (
|
||||
text
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, ' ')
|
||||
// normalize newlines
|
||||
.replace(/\r?\n|\r/g, '\n')
|
||||
);
|
||||
}
|
||||
|
||||
export const getTextHeight = (text: string, lineHeight: number) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return lineHeight * lineCount;
|
||||
};
|
||||
|
||||
export function parseTokens(text: string): string[] {
|
||||
// Splitting words containing "-" as those are treated as separate words
|
||||
// by css wrapping algorithm eg non-profit => non-, profit
|
||||
const words = text.split('-');
|
||||
if (words.length > 1) {
|
||||
// non-proft org => ['non-', 'profit org']
|
||||
words.forEach((word, index) => {
|
||||
if (index !== words.length - 1) {
|
||||
words[index] = word += '-';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joining the words with space and splitting them again with space to get the
|
||||
// final list of tokens
|
||||
// ['non-', 'profit org'] =>,'non- profit org' => ['non-','profit','org']
|
||||
return words.join(' ').split(' ');
|
||||
}
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: Record<string, Array<number>> = {};
|
||||
|
||||
const calculate = (char: string, font: string) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: string) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export function wrapText(text: string, font: string, maxWidth: number): string {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split('\n');
|
||||
const spaceWidth = getLineWidth(' ', font);
|
||||
|
||||
let currentLine = '';
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = '';
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
originalLines.forEach(originalLine => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
// Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
|
||||
const words = parseTokens(originalLine);
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line unless the line ends with hyphen to sync
|
||||
// with css word-wrap
|
||||
} else if (!currentLine.endsWith('-')) {
|
||||
currentLine += ' ';
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
|
||||
// if word ends with "-" then we don't need to add space
|
||||
// to sync with css word-wrap
|
||||
const shouldAppendSpace = !word.endsWith('-');
|
||||
currentLine += word;
|
||||
|
||||
if (shouldAppendSpace) {
|
||||
currentLine += ' ';
|
||||
}
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
if (shouldAppendSpace) {
|
||||
lines.push(currentLine.slice(0, -1));
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === ' ') {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {
|
||||
ShapeElementModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
|
||||
export type CanvasElementWithText = ShapeElementModel | TextElementModel;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Extension } from '@blocksuite/block-std';
|
||||
import {
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
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,20 @@
|
||||
import { BlockComponent } from '@blocksuite/block-std';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
import type { SurfaceBlockService } from './surface-service.js';
|
||||
|
||||
export class SurfaceBlockVoidComponent extends BlockComponent<
|
||||
SurfaceBlockModel,
|
||||
SurfaceBlockService
|
||||
> {
|
||||
override render() {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-surface-void': SurfaceBlockVoidComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { Color } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { EditorHost, SurfaceSelection } from '@blocksuite/block-std';
|
||||
import { BlockComponent, RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type Viewport,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { Slot } from '@blocksuite/global/utils';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
import { ConnectorElementModel } from './element-model/index.js';
|
||||
import { CanvasRenderer } from './renderer/canvas-renderer.js';
|
||||
import type { ElementRenderer } from './renderer/elements/index.js';
|
||||
import { OverlayIdentifier } from './renderer/overlay.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
import type { SurfaceBlockService } from './surface-service.js';
|
||||
|
||||
export interface SurfaceContext {
|
||||
viewport: Viewport;
|
||||
host: EditorHost;
|
||||
elementRenderers: Record<string, ElementRenderer>;
|
||||
selection: {
|
||||
selectedIds: string[];
|
||||
slots: {
|
||||
updated: Slot<SurfaceSelection[]>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class SurfaceBlockComponent extends BlockComponent<
|
||||
SurfaceBlockModel,
|
||||
SurfaceBlockService
|
||||
> {
|
||||
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 _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 _edgelessService() {
|
||||
return this.std.getService('affine:page') as unknown as SurfaceContext;
|
||||
}
|
||||
|
||||
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({
|
||||
viewport: gfx.viewport,
|
||||
layerManager: gfx.layer,
|
||||
gridManager: gfx.grid,
|
||||
enableStackingCanvas: true,
|
||||
provider: {
|
||||
generateColorProperty: (color: Color, fallback: string) =>
|
||||
themeService.generateColorProperty(
|
||||
color,
|
||||
fallback,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
getColorValue: (color: Color, fallback?: string, real?: boolean) =>
|
||||
themeService.getColorValue(
|
||||
color,
|
||||
fallback,
|
||||
real,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
getColorScheme: () => themeService.edgelessTheme,
|
||||
getPropertyValue: (property: string) =>
|
||||
themeService.getCssVariableColor(
|
||||
property,
|
||||
themeService.edgelessTheme
|
||||
),
|
||||
selectedElements: () => this._edgelessService.selection.selectedIds,
|
||||
},
|
||||
onStackingCanvasCreated(canvas) {
|
||||
canvas.className = 'indexable-canvas';
|
||||
},
|
||||
elementRenderers: this._edgelessService.elementRenderers,
|
||||
surfaceModel: this.model,
|
||||
});
|
||||
|
||||
this._disposables.add(() => {
|
||||
this._renderer.dispose();
|
||||
});
|
||||
this._disposables.add(
|
||||
this._renderer.stackingCanvasUpdated.on(payload => {
|
||||
if (payload.added.length) {
|
||||
this._surfaceContainer.append(...payload.added);
|
||||
}
|
||||
|
||||
if (payload.removed.length) {
|
||||
payload.removed.forEach(canvas => {
|
||||
canvas.remove();
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this._edgelessService.selection.slots.updated.on(() => {
|
||||
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._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,64 @@
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import type { SurfaceBlockProps } from '@blocksuite/block-std/gfx';
|
||||
import { SurfaceBlockModel as BaseSurfaceModel } from '@blocksuite/block-std/gfx';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import { defineBlockSchema, DocCollection } from '@blocksuite/store';
|
||||
|
||||
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 DocCollection.Y.Map()),
|
||||
}),
|
||||
metadata: {
|
||||
version: 5,
|
||||
role: 'hub',
|
||||
parent: ['affine:page'],
|
||||
children: [
|
||||
'affine:frame',
|
||||
'affine:image',
|
||||
'affine:bookmark',
|
||||
'affine:attachment',
|
||||
'affine:embed-*',
|
||||
'affine:edgeless-text',
|
||||
],
|
||||
},
|
||||
transformer: () => new SurfaceBlockTransformer(),
|
||||
toModel: () => new SurfaceBlockModel(),
|
||||
});
|
||||
|
||||
export type SurfaceMiddleware = (surface: SurfaceBlockModel) => () => void;
|
||||
|
||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||
private _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 BlockSuite.SurfaceElementModelMap>(
|
||||
type: K
|
||||
): BlockSuite.SurfaceElementModelMap[K][] {
|
||||
return super.getElementsByType(
|
||||
type
|
||||
) as BlockSuite.SurfaceElementModelMap[K][];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
|
||||
import { type SurfaceBlockModel, SurfaceBlockSchema } from './surface-model.js';
|
||||
|
||||
export class SurfaceBlockService extends BlockService {
|
||||
static override readonly flavour = SurfaceBlockSchema.model.flavour;
|
||||
|
||||
surface!: SurfaceBlockModel;
|
||||
|
||||
get layer() {
|
||||
return this.std.get(GfxControllerIdentifier).layer;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
super.mounted();
|
||||
|
||||
this.surface = this.doc.getBlockByFlavour(
|
||||
'affine:surface'
|
||||
)[0] as SurfaceBlockModel;
|
||||
|
||||
if (!this.surface) {
|
||||
const disposable = this.doc.slots.blockUpdated.on(payload => {
|
||||
if (payload.flavour === 'affine:surface') {
|
||||
disposable.dispose();
|
||||
const surface = this.doc.getBlockById(
|
||||
payload.id
|
||||
) as SurfaceBlockModel | null;
|
||||
if (!surface) return;
|
||||
this.surface = surface;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
CommandExtension,
|
||||
type ExtensionType,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import {
|
||||
EdgelessSurfaceBlockAdapterExtensions,
|
||||
SurfaceBlockAdapterExtensions,
|
||||
} from './adapters/extension.js';
|
||||
import { commands } from './commands/index.js';
|
||||
import { SurfaceBlockService } from './surface-service.js';
|
||||
import { MindMapView } from './view/mindmap.js';
|
||||
|
||||
const CommonSurfaceBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:surface'),
|
||||
SurfaceBlockService,
|
||||
CommandExtension(commands),
|
||||
HighlightSelectionExtension,
|
||||
MindMapView,
|
||||
];
|
||||
|
||||
export const PageSurfaceBlockSpec: ExtensionType[] = [
|
||||
...CommonSurfaceBlockSpec,
|
||||
...SurfaceBlockAdapterExtensions,
|
||||
BlockViewExtension('affine:surface', literal`affine-surface-void`),
|
||||
];
|
||||
|
||||
export const EdgelessSurfaceBlockSpec: ExtensionType[] = [
|
||||
...CommonSurfaceBlockSpec,
|
||||
...EdgelessSurfaceBlockAdapterExtensions,
|
||||
BlockViewExtension('affine:surface', literal`affine-surface`),
|
||||
];
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { SurfaceBlockProps } from '@blocksuite/block-std/gfx';
|
||||
import type {
|
||||
FromSnapshotPayload,
|
||||
SnapshotNode,
|
||||
ToSnapshotPayload,
|
||||
Y,
|
||||
} from '@blocksuite/store';
|
||||
import { BaseBlockTransformer, DocCollection } from '@blocksuite/store';
|
||||
|
||||
const SURFACE_TEXT_UNIQ_IDENTIFIER = 'affine:surface:text';
|
||||
// Used for group children field
|
||||
const SURFACE_YMAP_UNIQ_IDENTIFIER = 'affine:surface:ymap';
|
||||
|
||||
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 DocCollection.Y.Text();
|
||||
yText.applyDelta(Reflect.get(value, 'delta'));
|
||||
return yText;
|
||||
} else if (Reflect.has(value, SURFACE_YMAP_UNIQ_IDENTIFIER)) {
|
||||
const yMap = new DocCollection.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 DocCollection.Y.Text) {
|
||||
return {
|
||||
[SURFACE_TEXT_UNIQ_IDENTIFIER]: true,
|
||||
delta: value.toDelta(),
|
||||
};
|
||||
} else if (value instanceof DocCollection.Y.Map) {
|
||||
return {
|
||||
[SURFACE_YMAP_UNIQ_IDENTIFIER]: true,
|
||||
json: value.toJSON(),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
elementFromJSON(element: Record<string, unknown>) {
|
||||
const yMap = new DocCollection.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 DocCollection.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.elements.getValue();
|
||||
const value: Record<string, unknown> = {};
|
||||
if (elementsValue) {
|
||||
elementsValue.forEach((element, key) => {
|
||||
value[key] = this._elementToJSON(element as Y.Map<unknown>);
|
||||
});
|
||||
}
|
||||
snapshot.props = {
|
||||
elements: value,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { Bound, IVec3 } from '@blocksuite/global/utils';
|
||||
import { almostEqual, assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
import { Graph } from './graph.js';
|
||||
import { PriorityQueue } from './priority-queue.js';
|
||||
|
||||
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 _cameFrom = new Map<IVec3, { from: IVec3[]; indexs: number[] }>();
|
||||
|
||||
private _complete = false;
|
||||
|
||||
private _costSoFar = new Map<IVec3, number[]>();
|
||||
|
||||
private _current: IVec3 | null = null;
|
||||
|
||||
private _diagonalCount = new Map<IVec3, number[]>();
|
||||
|
||||
private _frontier!: PriorityQueue<
|
||||
IVec3,
|
||||
[diagonalCount: number, pointPriority: number, distCost: number]
|
||||
>;
|
||||
|
||||
private _graph: Graph<IVec3>;
|
||||
|
||||
private _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();
|
||||
assertExists(index);
|
||||
nextIndexs.push(froms.indexs[index]);
|
||||
current = froms.from[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(
|
||||
points: IVec3[],
|
||||
private _sp: IVec3,
|
||||
private _ep: IVec3,
|
||||
private _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);
|
||||
assertExists(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);
|
||||
assertExists(curCosts);
|
||||
assertExists(curDiagoalCounts);
|
||||
assertExists(curPointPrioritys);
|
||||
assertExists(cameFroms);
|
||||
const newCosts = curCosts.map(co => co + cost(current, next));
|
||||
|
||||
const newDiagonalCounts = curDiagoalCounts.map(
|
||||
(count, index) =>
|
||||
count + getDiagonalCount(next, current, cameFroms.from[index])
|
||||
);
|
||||
assertExists(next[2]);
|
||||
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,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,151 @@
|
||||
import type { Bound, IVec, IVec3 } from '@blocksuite/global/utils';
|
||||
import {
|
||||
almostEqual,
|
||||
isOverlap as _isOverlap,
|
||||
linePolygonIntersects,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
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 _xMap = new Map<number, V[]>();
|
||||
|
||||
private _yMap = new Map<number, V[]>();
|
||||
|
||||
constructor(
|
||||
private points: V[],
|
||||
private blocks: Bound[] = [],
|
||||
private expandedBlocks: Bound[] = [],
|
||||
private 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,34 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
LayoutType,
|
||||
type MindmapElementModel,
|
||||
type MindmapNode,
|
||||
type MindmapRoot,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { SerializedXYWH } from '@blocksuite/global/utils';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
|
||||
export const NODE_VERTICAL_SPACING = 45;
|
||||
export const NODE_HORIZONTAL_SPACING = 110;
|
||||
export const NODE_FIRST_LEVEL_HORIZONTAL_SPACING = 200;
|
||||
|
||||
type TreeSize = {
|
||||
/**
|
||||
* The parent of the tree
|
||||
*/
|
||||
parent: TreeSize | null;
|
||||
|
||||
/**
|
||||
* The root node of the tree
|
||||
*/
|
||||
root: MindmapNode;
|
||||
|
||||
/**
|
||||
* The size of the tree, including its descendants
|
||||
*/
|
||||
bound: Bound;
|
||||
|
||||
/**
|
||||
* The size of the children of the root
|
||||
*/
|
||||
children: TreeSize[];
|
||||
};
|
||||
|
||||
const calculateNodeSize = (
|
||||
root: MindmapNode,
|
||||
parent: TreeSize | null = null,
|
||||
rootChildren?: MindmapNode[]
|
||||
): TreeSize => {
|
||||
const bound = root.element.elementBound;
|
||||
const children: TreeSize[] = [];
|
||||
const firstLevel = parent === null;
|
||||
|
||||
rootChildren = rootChildren ?? root.children;
|
||||
|
||||
const treeSize: TreeSize = {
|
||||
parent,
|
||||
root,
|
||||
bound,
|
||||
children,
|
||||
};
|
||||
|
||||
if (rootChildren?.length && !root.detail.collapsed) {
|
||||
const childrenBound = rootChildren.reduce(
|
||||
(pre, node) => {
|
||||
const childSize = calculateNodeSize(node, treeSize);
|
||||
|
||||
children.push(childSize);
|
||||
|
||||
pre.w = Math.max(pre.w, childSize.bound.w);
|
||||
pre.h +=
|
||||
pre.h > 0
|
||||
? NODE_VERTICAL_SPACING + childSize.bound.h
|
||||
: childSize.bound.h;
|
||||
|
||||
return pre;
|
||||
},
|
||||
new Bound(0, 0, 0, 0)
|
||||
);
|
||||
|
||||
bound.w +=
|
||||
childrenBound.w +
|
||||
(firstLevel
|
||||
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
|
||||
: NODE_HORIZONTAL_SPACING);
|
||||
bound.h = Math.max(bound.h, childrenBound.h);
|
||||
}
|
||||
|
||||
return treeSize;
|
||||
};
|
||||
|
||||
const layoutTree = (
|
||||
tree: TreeSize,
|
||||
layoutType: LayoutType.LEFT | LayoutType.RIGHT,
|
||||
mindmap: MindmapElementModel,
|
||||
path: number[] = [0]
|
||||
) => {
|
||||
const firstLevel = path.length === 1;
|
||||
const treeHeight = tree.bound.h;
|
||||
const currentX =
|
||||
layoutType === LayoutType.RIGHT
|
||||
? tree.root.element.x +
|
||||
tree.root.element.w +
|
||||
(firstLevel
|
||||
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
|
||||
: NODE_HORIZONTAL_SPACING)
|
||||
: tree.root.element.x -
|
||||
(firstLevel
|
||||
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
|
||||
: NODE_HORIZONTAL_SPACING);
|
||||
let currentY = tree.root.element.y + (tree.root.element.h - treeHeight) / 2;
|
||||
|
||||
if (tree.root.element.h >= treeHeight && tree.children.length) {
|
||||
const onlyChild = tree.children[0];
|
||||
|
||||
currentY += (tree.root.element.h - onlyChild.root.element.h) / 2;
|
||||
}
|
||||
|
||||
if (!tree.root.detail.collapsed) {
|
||||
tree.children.forEach((subtree, idx) => {
|
||||
const subtreeRootEl = subtree.root.element;
|
||||
const subtreeHeight = subtree.bound.h;
|
||||
const xywh = `[${
|
||||
layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w
|
||||
},${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH;
|
||||
|
||||
const currentNodePath = [...path, idx];
|
||||
|
||||
if (subtreeRootEl.xywh !== xywh) {
|
||||
subtreeRootEl.xywh = xywh;
|
||||
}
|
||||
|
||||
layoutTree(subtree, layoutType, mindmap, currentNodePath);
|
||||
|
||||
currentY += subtreeHeight + NODE_VERTICAL_SPACING;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const layoutRight = (
|
||||
root: MindmapNode,
|
||||
mindmap: MindmapElementModel,
|
||||
path = [0]
|
||||
) => {
|
||||
const rootTree = calculateNodeSize(root, null);
|
||||
|
||||
layoutTree(rootTree, LayoutType.RIGHT, mindmap, path);
|
||||
};
|
||||
|
||||
const layoutLeft = (
|
||||
root: MindmapNode,
|
||||
mindmap: MindmapElementModel,
|
||||
path = [0]
|
||||
) => {
|
||||
const rootTree = calculateNodeSize(root, null);
|
||||
|
||||
layoutTree(rootTree, LayoutType.LEFT, mindmap, path);
|
||||
};
|
||||
|
||||
const layoutBalance = (
|
||||
root: MindmapNode,
|
||||
mindmap: MindmapElementModel,
|
||||
path = [0]
|
||||
) => {
|
||||
const rootTree = calculateNodeSize(root, null);
|
||||
const leftTree: MindmapNode[] = (root as MindmapRoot).left;
|
||||
const rightTree: MindmapNode[] = (root as MindmapRoot).right;
|
||||
|
||||
{
|
||||
const leftTreeSize = calculateNodeSize(root, null, leftTree);
|
||||
const mockRoot = {
|
||||
parent: null,
|
||||
root: rootTree.root,
|
||||
bound: leftTreeSize.bound,
|
||||
children: leftTreeSize.children,
|
||||
};
|
||||
|
||||
layoutTree(mockRoot, LayoutType.LEFT, mindmap, path);
|
||||
}
|
||||
|
||||
{
|
||||
const rightTreeSize = calculateNodeSize(root, null, rightTree);
|
||||
const mockRoot = {
|
||||
parent: null,
|
||||
root: rootTree.root,
|
||||
bound: rightTreeSize.bound,
|
||||
children: rightTreeSize.children,
|
||||
};
|
||||
|
||||
layoutTree(mockRoot, LayoutType.RIGHT, mindmap, [0]);
|
||||
}
|
||||
};
|
||||
|
||||
export const layout = (
|
||||
root: MindmapNode,
|
||||
mindmap: MindmapElementModel,
|
||||
layoutDir: LayoutType | null,
|
||||
path: number[]
|
||||
) => {
|
||||
layoutDir = layoutDir ?? mindmap.layoutType;
|
||||
|
||||
switch (layoutDir) {
|
||||
case LayoutType.RIGHT:
|
||||
return layoutRight(root, mindmap, path);
|
||||
case LayoutType.LEFT:
|
||||
return layoutLeft(root, mindmap, path);
|
||||
case LayoutType.BALANCE:
|
||||
return layoutBalance(root, mindmap, path);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,689 @@
|
||||
import {
|
||||
applyNodeStyle,
|
||||
LayoutType,
|
||||
type MindmapElementModel,
|
||||
type MindmapNode,
|
||||
type MindmapRoot,
|
||||
type MindmapStyle,
|
||||
type NodeDetail,
|
||||
type NodeType,
|
||||
type ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
generateKeyBetween,
|
||||
type SurfaceBlockModel,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { assertType, isEqual, type IVec, last } from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
|
||||
import { fitContent } from '../../renderer/elements/shape/utils.js';
|
||||
import { layout } from './layout.js';
|
||||
|
||||
export function getHoveredArea(
|
||||
target: ShapeElementModel,
|
||||
position: [number, number],
|
||||
layoutDir: LayoutType
|
||||
): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' {
|
||||
const { x, y, w, h } = target;
|
||||
const center =
|
||||
layoutDir === LayoutType.BALANCE
|
||||
? [x + w / 2, y + h / 2]
|
||||
: layoutDir === LayoutType.LEFT
|
||||
? [x + (w / 3) * 1, y + h / 2]
|
||||
: [x + (w / 3) * 2, y + h / 2];
|
||||
|
||||
return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the connector between the target node and its parent
|
||||
*/
|
||||
export function hideNodeConnector(
|
||||
mindmap: MindmapElementModel,
|
||||
/**
|
||||
* The mind map node which's connector will be hide
|
||||
*/
|
||||
target: MindmapNode
|
||||
) {
|
||||
const parent = mindmap.getParentNode(target.id);
|
||||
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectorId = `#${parent.id}-${target.id}`;
|
||||
const connector = mindmap.connectors.get(connectorId);
|
||||
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
|
||||
connector.opacity = 0;
|
||||
|
||||
return () => {
|
||||
connector.opacity = 1;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the node to the new parent within the same mind map
|
||||
* @param mindmap
|
||||
* @param node the node should already exist in the mind map
|
||||
* @param parent
|
||||
* @param targetIndex
|
||||
* @param layout
|
||||
* @returns
|
||||
*/
|
||||
function moveNodePosition(
|
||||
mindmap: MindmapElementModel,
|
||||
node: MindmapNode,
|
||||
parent: string | MindmapNode,
|
||||
targetIndex: number,
|
||||
layout?: LayoutType
|
||||
) {
|
||||
parent = mindmap.nodeMap.get(
|
||||
typeof parent === 'string' ? parent : parent.id
|
||||
)!;
|
||||
|
||||
if (!parent || !mindmap.nodeMap.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertType<MindmapNode>(parent);
|
||||
|
||||
if (layout === LayoutType.BALANCE || parent !== mindmap.tree) {
|
||||
layout = undefined;
|
||||
}
|
||||
|
||||
const siblings = parent.children.filter(child => child !== node);
|
||||
|
||||
targetIndex = Math.min(targetIndex, parent.children.length);
|
||||
|
||||
siblings.splice(targetIndex, 0, node);
|
||||
|
||||
// calculate the index
|
||||
// the sibling node may be the same node, so we need to filter it out
|
||||
const preSibling = siblings[targetIndex - 1];
|
||||
const afterSibling = siblings[targetIndex + 1];
|
||||
const index =
|
||||
preSibling || afterSibling
|
||||
? generateKeyBetween(
|
||||
preSibling?.detail.index ?? null,
|
||||
afterSibling?.detail.index ?? null
|
||||
)
|
||||
: (node.detail.index ?? undefined);
|
||||
|
||||
mindmap.surface.doc.transact(() => {
|
||||
const val: NodeDetail = {
|
||||
...node.detail,
|
||||
index,
|
||||
parent: parent.id,
|
||||
};
|
||||
|
||||
mindmap.children.set(node.id, val);
|
||||
});
|
||||
|
||||
if (parent.detail.collapsed) {
|
||||
mindmap.toggleCollapse(parent);
|
||||
}
|
||||
|
||||
mindmap.layout();
|
||||
|
||||
return mindmap.nodeMap.get(node.id);
|
||||
}
|
||||
|
||||
export function applyStyle(
|
||||
mindmap: MindmapElementModel,
|
||||
shouldFitContent: boolean = false
|
||||
) {
|
||||
mindmap.surface.doc.transact(() => {
|
||||
const style = mindmap.styleGetter;
|
||||
|
||||
if (!style) return;
|
||||
|
||||
applyNodeStyle(mindmap.tree, style.root);
|
||||
if (shouldFitContent) {
|
||||
fitContent(mindmap.tree.element as ShapeElementModel);
|
||||
}
|
||||
|
||||
const walk = (node: MindmapNode, path: number[]) => {
|
||||
node.children.forEach((child, idx) => {
|
||||
const currentPath = [...path, idx];
|
||||
const nodeStyle = style.getNodeStyle(child, currentPath);
|
||||
|
||||
applyNodeStyle(child, nodeStyle.node);
|
||||
if (shouldFitContent) {
|
||||
fitContent(child.element as ShapeElementModel);
|
||||
}
|
||||
|
||||
walk(child, currentPath);
|
||||
});
|
||||
};
|
||||
|
||||
walk(mindmap.tree, [0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param mindmap the mind map to add the node to
|
||||
* @param parent the parent node or the parent node id
|
||||
* @param node the node must be an detached node
|
||||
* @param targetIndex the index to insert the node at
|
||||
* @returns
|
||||
*/
|
||||
export function addNode(
|
||||
mindmap: MindmapElementModel,
|
||||
parent: string | MindmapNode,
|
||||
node: MindmapNode,
|
||||
targetIndex?: number
|
||||
) {
|
||||
const parentNode = mindmap.getNode(
|
||||
typeof parent === 'string' ? parent : parent.id
|
||||
);
|
||||
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = parentNode.children.slice();
|
||||
|
||||
targetIndex =
|
||||
targetIndex !== undefined
|
||||
? Math.min(targetIndex, children.length)
|
||||
: children.length;
|
||||
|
||||
children.splice(targetIndex, 0, node);
|
||||
|
||||
const before = children[targetIndex - 1] ?? null;
|
||||
const after = children[targetIndex + 1] ?? null;
|
||||
const index =
|
||||
before || after
|
||||
? generateKeyBetween(
|
||||
before?.detail.index ?? null,
|
||||
after?.detail.index ?? null
|
||||
)
|
||||
: node.detail.index;
|
||||
|
||||
mindmap.surface.doc.transact(() => {
|
||||
mindmap.children.set(node.id, {
|
||||
...node.detail,
|
||||
index,
|
||||
parent: parentNode.id,
|
||||
});
|
||||
|
||||
const recursiveAddChild = (node: MindmapNode) => {
|
||||
node.children?.forEach(child => {
|
||||
mindmap.children.set(child.id, {
|
||||
...child.detail,
|
||||
parent: node.id,
|
||||
});
|
||||
|
||||
recursiveAddChild(child);
|
||||
});
|
||||
};
|
||||
|
||||
recursiveAddChild(node);
|
||||
});
|
||||
|
||||
if (parentNode.detail.collapsed) {
|
||||
mindmap.toggleCollapse(parentNode);
|
||||
}
|
||||
|
||||
mindmap.layout();
|
||||
}
|
||||
|
||||
export function addTree(
|
||||
mindmap: MindmapElementModel,
|
||||
parent: string | MindmapNode,
|
||||
tree: NodeType | MindmapNode,
|
||||
/**
|
||||
* `sibling` indicates where to insert a subtree among peer elements.
|
||||
* If it's a string, it represents a peer element's ID;
|
||||
* if it's a number, it represents its index.
|
||||
* The subtree will be inserted before the sibling element.
|
||||
*/
|
||||
sibling?: string | number
|
||||
) {
|
||||
parent = typeof parent === 'string' ? parent : parent.id;
|
||||
|
||||
if (!mindmap.nodeMap.has(parent) || !parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assertType<string>(parent);
|
||||
|
||||
const traverse = (
|
||||
node: NodeType | MindmapNode,
|
||||
parent: string,
|
||||
sibling?: string | number
|
||||
) => {
|
||||
let nodeId: string;
|
||||
if ('text' in node) {
|
||||
nodeId = mindmap.addNode(parent, sibling, 'before', {
|
||||
text: node.text,
|
||||
});
|
||||
} else {
|
||||
mindmap.children.set(node.id, {
|
||||
...node.detail,
|
||||
parent,
|
||||
});
|
||||
nodeId = node.id;
|
||||
}
|
||||
|
||||
node.children?.forEach(child => traverse(child, nodeId));
|
||||
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
if (!('text' in tree)) {
|
||||
// Modify the children ymap directly hence need transaction
|
||||
mindmap.surface.doc.transact(() => {
|
||||
traverse(tree, parent, sibling);
|
||||
});
|
||||
|
||||
applyStyle(mindmap);
|
||||
mindmap.layout();
|
||||
|
||||
return mindmap.nodeMap.get(tree.id);
|
||||
} else {
|
||||
const nodeId = traverse(tree, parent, sibling);
|
||||
|
||||
mindmap.layout();
|
||||
|
||||
return mindmap.nodeMap.get(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a mindmap node or subtree. It is similar to `removeChild` but
|
||||
* it does not delete the node.
|
||||
*
|
||||
* So the node can be used to create a new mind map or merge into other mind map
|
||||
* @param mindmap the mind map that the subtree belongs to
|
||||
* @param subtree the subtree to detach
|
||||
*/
|
||||
export function detachMindmap(
|
||||
mindmap: MindmapElementModel,
|
||||
subtree: string | MindmapNode
|
||||
) {
|
||||
subtree =
|
||||
typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree;
|
||||
|
||||
assertType<MindmapNode>(subtree);
|
||||
|
||||
if (!subtree) return;
|
||||
|
||||
const traverse = (subtree: MindmapNode) => {
|
||||
mindmap.children.delete(subtree.id);
|
||||
|
||||
// cut the reference inside the ymap
|
||||
subtree.detail = {
|
||||
...subtree.detail,
|
||||
};
|
||||
|
||||
subtree.children.forEach(child => traverse(child));
|
||||
};
|
||||
|
||||
mindmap.surface.doc.transact(() => {
|
||||
traverse(subtree);
|
||||
});
|
||||
|
||||
mindmap.layout();
|
||||
|
||||
delete subtree.detail.parent;
|
||||
|
||||
return subtree;
|
||||
}
|
||||
|
||||
export function handleLayout(
|
||||
mindmap: MindmapElementModel,
|
||||
tree?: MindmapNode | MindmapRoot,
|
||||
shouldApplyStyle = true,
|
||||
layoutType?: LayoutType
|
||||
) {
|
||||
if (!tree || !tree.element) return;
|
||||
|
||||
if (shouldApplyStyle) {
|
||||
applyStyle(mindmap, true);
|
||||
}
|
||||
|
||||
mindmap.surface.doc.transact(() => {
|
||||
const path = mindmap.getPath(tree.id);
|
||||
layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path);
|
||||
});
|
||||
}
|
||||
|
||||
export function createFromTree(
|
||||
tree: MindmapNode,
|
||||
style: MindmapStyle,
|
||||
layoutType: LayoutType,
|
||||
surface: SurfaceBlockModel
|
||||
) {
|
||||
const children = new DocCollection.Y.Map();
|
||||
const traverse = (subtree: MindmapNode, parent?: string) => {
|
||||
const value: NodeDetail = {
|
||||
...subtree.detail,
|
||||
parent,
|
||||
};
|
||||
|
||||
if (!parent) {
|
||||
delete value.parent;
|
||||
}
|
||||
|
||||
children.set(subtree.id, value);
|
||||
|
||||
subtree.children.forEach(child => traverse(child, subtree.id));
|
||||
};
|
||||
|
||||
traverse(tree);
|
||||
|
||||
const mindmapId = surface.addElement({
|
||||
type: 'mindmap',
|
||||
children,
|
||||
layoutType,
|
||||
style,
|
||||
});
|
||||
const mindmap = surface.getElementById(mindmapId) as MindmapElementModel;
|
||||
handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType);
|
||||
|
||||
return mindmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a subtree from one mind map to another
|
||||
* @param from the mind map that the `subtree` belongs to
|
||||
* @param subtree the subtree to move
|
||||
* @param to the mind map to move the `subtree` to
|
||||
* @param parent the new parent node to attach the `subtree` to
|
||||
* @param index the index to insert the `subtree` at
|
||||
*/
|
||||
export function moveNode(
|
||||
from: MindmapElementModel,
|
||||
subtree: MindmapNode,
|
||||
to: MindmapElementModel,
|
||||
parent: MindmapNode | string,
|
||||
index: number
|
||||
) {
|
||||
if (from === to) {
|
||||
return moveNodePosition(from, subtree, parent, index);
|
||||
}
|
||||
|
||||
if (!detachMindmap(from, subtree)) return;
|
||||
|
||||
return addNode(to, parent, subtree, index);
|
||||
}
|
||||
|
||||
export function findTargetNode(
|
||||
mindmap: MindmapElementModel,
|
||||
position: IVec
|
||||
): MindmapNode | null {
|
||||
const find = (node: MindmapNode): MindmapNode | null => {
|
||||
if (!node.responseArea) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutDir = mindmap.getLayoutDir(node);
|
||||
|
||||
if (
|
||||
layoutDir === LayoutType.BALANCE ||
|
||||
(layoutDir === LayoutType.RIGHT &&
|
||||
position[0] > node.element.x + node.element.w) ||
|
||||
(layoutDir === LayoutType.LEFT && position[0] < node.element.x)
|
||||
) {
|
||||
for (const child of node.children) {
|
||||
const result = find(child);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node.responseArea.containsPoint(position) ? node : null;
|
||||
};
|
||||
|
||||
return find(mindmap.tree);
|
||||
}
|
||||
|
||||
function determineInsertPosition(
|
||||
mindmap: MindmapElementModel,
|
||||
mindmapNode: MindmapNode,
|
||||
position: IVec
|
||||
):
|
||||
| {
|
||||
type: 'child';
|
||||
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
|
||||
}
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| null {
|
||||
if (
|
||||
!mindmapNode.responseArea ||
|
||||
!mindmapNode.responseArea.containsPoint(position)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutDir = mindmap.getLayoutDir(mindmapNode);
|
||||
const elementBound = mindmapNode.element.elementBound;
|
||||
const targetLayout: LayoutType.LEFT | LayoutType.RIGHT =
|
||||
layoutDir === LayoutType.BALANCE
|
||||
? position[0] > elementBound.x + elementBound.w / 2
|
||||
? LayoutType.RIGHT
|
||||
: LayoutType.LEFT
|
||||
: layoutDir;
|
||||
|
||||
if (
|
||||
elementBound.containsPoint(position) ||
|
||||
(layoutDir === LayoutType.RIGHT
|
||||
? position[0] > elementBound.x + elementBound.w
|
||||
: position[0] < elementBound.x)
|
||||
) {
|
||||
return {
|
||||
type: 'child',
|
||||
layoutDir: targetLayout,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
mindmap.layoutType === LayoutType.BALANCE &&
|
||||
mindmap.getPath(mindmapNode.id).length === 2
|
||||
) {
|
||||
return {
|
||||
type: 'sibling',
|
||||
layoutDir: targetLayout,
|
||||
position:
|
||||
targetLayout === LayoutType.LEFT
|
||||
? position[1] > elementBound.y + elementBound.h / 2
|
||||
? 'prev'
|
||||
: 'next'
|
||||
: position[1] > elementBound.y + elementBound.h / 2
|
||||
? 'next'
|
||||
: 'prev',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'sibling',
|
||||
layoutDir: targetLayout,
|
||||
position:
|
||||
position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev',
|
||||
};
|
||||
}
|
||||
|
||||
function showMergeIndicator(
|
||||
targetMindMap: MindmapElementModel,
|
||||
target: MindmapNode,
|
||||
sourceMindMap: MindmapElementModel,
|
||||
source: MindmapNode,
|
||||
insertPosition:
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> },
|
||||
callback: (option: {
|
||||
targetMindMap: MindmapElementModel;
|
||||
target: MindmapNode;
|
||||
sourceMindMap: MindmapElementModel;
|
||||
source: MindmapNode;
|
||||
newParent: MindmapNode;
|
||||
insertPosition:
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
|
||||
path: number[];
|
||||
}) => () => void
|
||||
) {
|
||||
const newParent =
|
||||
insertPosition.type === 'child'
|
||||
? target
|
||||
: targetMindMap.getParentNode(target.id)!;
|
||||
|
||||
if (!newParent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = targetMindMap.getPath(newParent);
|
||||
const curPath = sourceMindMap.getPath(source.id);
|
||||
|
||||
if (insertPosition.type === 'sibling') {
|
||||
const curPath = targetMindMap.getPath(target.id);
|
||||
const parent = targetMindMap.getParentNode(target.id);
|
||||
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idx = parent.children
|
||||
.filter(child => child.id !== source.id)
|
||||
.indexOf(target);
|
||||
|
||||
path.push(
|
||||
idx === -1
|
||||
? Math.max(
|
||||
0,
|
||||
last(curPath)! + (insertPosition.position === 'next' ? 1 : 0)
|
||||
)
|
||||
: Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0))
|
||||
);
|
||||
} else {
|
||||
path.push(target.children.length);
|
||||
}
|
||||
|
||||
// hide original connector
|
||||
const abortPreview = callback({
|
||||
targetMindMap,
|
||||
target: target,
|
||||
sourceMindMap,
|
||||
source,
|
||||
newParent,
|
||||
insertPosition,
|
||||
path,
|
||||
});
|
||||
|
||||
const abort = () => {
|
||||
abortPreview?.();
|
||||
};
|
||||
|
||||
const merge = () => {
|
||||
abort();
|
||||
|
||||
if (targetMindMap === sourceMindMap && isEqual(path, curPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!);
|
||||
};
|
||||
|
||||
return {
|
||||
abort,
|
||||
merge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to move a node to another mind map.
|
||||
* It will show a merge indicator if the node can be merged to the target mind map.
|
||||
* @param targetMindMap
|
||||
* @param target
|
||||
* @param sourceMindMap
|
||||
* @param source
|
||||
* @param position
|
||||
* @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map.
|
||||
*/
|
||||
export function tryMoveNode(
|
||||
targetMindMap: MindmapElementModel,
|
||||
target: MindmapNode,
|
||||
sourceMindMap: MindmapElementModel,
|
||||
source: MindmapNode,
|
||||
position: IVec,
|
||||
callback: (option: {
|
||||
targetMindMap: MindmapElementModel;
|
||||
target: MindmapNode;
|
||||
sourceMindMap: MindmapElementModel;
|
||||
source: MindmapNode;
|
||||
newParent: MindmapNode;
|
||||
insertPosition:
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
|
||||
path: number[];
|
||||
}) => () => void
|
||||
) {
|
||||
const insertInfo = determineInsertPosition(targetMindMap, target, position);
|
||||
|
||||
if (!insertInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showMergeIndicator(
|
||||
targetMindMap,
|
||||
target,
|
||||
sourceMindMap,
|
||||
source,
|
||||
insertInfo,
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the mind map contains the target node.
|
||||
* @param mindmap Mind map to check
|
||||
* @param targetNode Node to check
|
||||
* @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map.
|
||||
* @returns
|
||||
*/
|
||||
export function containsNode(
|
||||
mindmap: MindmapElementModel,
|
||||
targetNode: MindmapNode,
|
||||
searchParent?: MindmapNode
|
||||
) {
|
||||
searchParent = searchParent ?? mindmap.tree;
|
||||
|
||||
const find = (checkAgainstNode: MindmapNode) => {
|
||||
if (checkAgainstNode.id === targetNode.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of checkAgainstNode.children) {
|
||||
if (find(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return find(searchParent);
|
||||
}
|
||||
@@ -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 _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 canvas: HTMLCanvasElement;
|
||||
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
private 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 declare type OpType = 'move' | 'bcurveTo' | 'lineTo';
|
||||
export declare 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 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 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 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 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 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 gen: RoughGenerator;
|
||||
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user