chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -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
);
@@ -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,
},
],
};
},
};
@@ -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,
},
],
};
},
};
@@ -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,
},
],
};
},
};
@@ -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,
];
@@ -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;
},
};
@@ -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,
},
],
};
},
};
@@ -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);
@@ -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 };
},
};
@@ -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 };
},
};
@@ -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 };
},
};
@@ -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,
];
@@ -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 };
},
};
@@ -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 };
},
};
@@ -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);
}
// RamerDouglasPeucker 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