refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

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