chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions
@@ -0,0 +1,295 @@
import type { Bound, IVec3 } from '@blocksuite/global/gfx';
import { almostEqual } from '@blocksuite/global/gfx';
import { Graph } from './graph.js';
import { PriorityQueue } from './priority-queue.js';
function cost(point: IVec3, point2: IVec3) {
return Math.abs(point[0] - point2[0]) + Math.abs(point[1] - point2[1]);
}
function compare(a: [number, number, number], b: [number, number, number]) {
if (a[2] + 0.01 < b[2]) return -1;
else if (a[2] - 0.01 > b[2]) return 1;
else if (a[0] < b[0]) return -1;
else if (a[0] > b[0]) return 1;
else if (a[1] > b[1]) return -1;
else if (a[1] < b[1]) return 1;
else return 0;
}
function heuristic(a: IVec3, b: IVec3): number {
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
}
function getDiagonalCount(a: IVec3, last: IVec3, last2: IVec3): number {
if (almostEqual(a[0], last[0]) && almostEqual(a[0], last2[0])) return 0;
if (almostEqual(a[1], last[1]) && almostEqual(a[1], last2[1])) return 0;
return 1;
}
function pointAlmostEqual(a: IVec3, b: IVec3): boolean {
return almostEqual(a[0], b[0], 0.02) && almostEqual(a[1], b[1], 0.02);
}
export class AStarRunner {
private 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 (index !== undefined && index !== null) {
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) {
return [];
}
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 (!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 { BlockStdScope } from '@blocksuite/block-std';
import {
type GfxBlockElementModel,
GfxControllerIdentifier,
} from '@blocksuite/block-std/gfx';
import { type IPoint, type Point, serializeXYWH } from '@blocksuite/global/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.getBlockById(noteId) as NoteBlockModel;
doc.updateBlock(note, () => {
note.edgeless.collapse = true;
note.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 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,8 @@
import type { Store } from '@blocksuite/store';
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;
}
@@ -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,40 @@
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 { getSurfaceBlock } from './get-surface-block';
export * from './mindmap/style-svg.js';
@@ -0,0 +1,201 @@
import {
LayoutType,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
} from '@blocksuite/affine-model';
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
export const NODE_VERTICAL_SPACING = 45;
export const NODE_HORIZONTAL_SPACING = 110;
export const NODE_FIRST_LEVEL_HORIZONTAL_SPACING = 200;
type TreeSize = {
/**
* The parent of the tree
*/
parent: TreeSize | null;
/**
* The root node of the tree
*/
root: MindmapNode;
/**
* The size of the tree, including its descendants
*/
bound: Bound;
/**
* The size of the children of the root
*/
children: TreeSize[];
};
const calculateNodeSize = (
root: MindmapNode,
parent: TreeSize | null = null,
rootChildren?: MindmapNode[]
): TreeSize => {
const bound = root.element.elementBound;
const children: TreeSize[] = [];
const firstLevel = parent === null;
rootChildren = rootChildren ?? root.children;
const treeSize: TreeSize = {
parent,
root,
bound,
children,
};
if (rootChildren?.length && !root.detail.collapsed) {
const childrenBound = rootChildren.reduce(
(pre, node) => {
const childSize = calculateNodeSize(node, treeSize);
children.push(childSize);
pre.w = Math.max(pre.w, childSize.bound.w);
pre.h +=
pre.h > 0
? NODE_VERTICAL_SPACING + childSize.bound.h
: childSize.bound.h;
return pre;
},
new Bound(0, 0, 0, 0)
);
bound.w +=
childrenBound.w +
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING);
bound.h = Math.max(bound.h, childrenBound.h);
}
return treeSize;
};
const layoutTree = (
tree: TreeSize,
layoutType: LayoutType.LEFT | LayoutType.RIGHT,
mindmap: MindmapElementModel,
path: number[] = [0]
) => {
const firstLevel = path.length === 1;
const treeHeight = tree.bound.h;
const currentX =
layoutType === LayoutType.RIGHT
? tree.root.element.x +
tree.root.element.w +
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING)
: tree.root.element.x -
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING);
let currentY = tree.root.element.y + (tree.root.element.h - treeHeight) / 2;
if (tree.root.element.h >= treeHeight && tree.children.length) {
const onlyChild = tree.children[0];
currentY += (tree.root.element.h - onlyChild.root.element.h) / 2;
}
if (!tree.root.detail.collapsed) {
tree.children.forEach((subtree, idx) => {
const subtreeRootEl = subtree.root.element;
const subtreeHeight = subtree.bound.h;
const xywh = `[${
layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w
},${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH;
const currentNodePath = [...path, idx];
if (subtreeRootEl.xywh !== xywh) {
subtreeRootEl.xywh = xywh;
}
layoutTree(subtree, layoutType, mindmap, currentNodePath);
currentY += subtreeHeight + NODE_VERTICAL_SPACING;
});
}
};
const layoutRight = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
layoutTree(rootTree, LayoutType.RIGHT, mindmap, path);
};
const layoutLeft = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
layoutTree(rootTree, LayoutType.LEFT, mindmap, path);
};
const layoutBalance = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
const leftTree: MindmapNode[] = (root as MindmapRoot).left;
const rightTree: MindmapNode[] = (root as MindmapRoot).right;
{
const leftTreeSize = calculateNodeSize(root, null, leftTree);
const mockRoot = {
parent: null,
root: rootTree.root,
bound: leftTreeSize.bound,
children: leftTreeSize.children,
};
layoutTree(mockRoot, LayoutType.LEFT, mindmap, path);
}
{
const rightTreeSize = calculateNodeSize(root, null, rightTree);
const mockRoot = {
parent: null,
root: rootTree.root,
bound: rightTreeSize.bound,
children: rightTreeSize.children,
};
layoutTree(mockRoot, LayoutType.RIGHT, mindmap, [0]);
}
};
export const layout = (
root: MindmapNode,
mindmap: MindmapElementModel,
layoutDir: LayoutType | null,
path: number[]
) => {
layoutDir = layoutDir ?? mindmap.layoutType;
switch (layoutDir) {
case LayoutType.RIGHT:
return layoutRight(root, mindmap, path);
case LayoutType.LEFT:
return layoutLeft(root, mindmap, path);
case LayoutType.BALANCE:
return layoutBalance(root, mindmap, path);
}
};
@@ -0,0 +1,680 @@
import { html } from 'lit';
export const MindmapStyleOne = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="64" height="48" rx="4" />
<path d="M23.976 24L43.4022 24" stroke="#E660A4" stroke-width="1.4" />
<path
d="M23.976 24.0001L26.5099 24.0001C30.4749 24.0001 33.6891 20.7858 33.6891 16.8208V16.8208C33.6891 12.8559 36.9034 9.6416 40.8684 9.6416L43.4022 9.6416"
stroke="#6E52DF"
stroke-width="1.4"
/>
<path
d="M23.976 24L26.5099 24C30.4749 24 33.6891 27.2143 33.6891 31.1792V31.1792C33.6891 35.1442 36.9034 38.3585 40.8684 38.3585L43.4022 38.3585"
stroke="#FF8C38"
stroke-width="1.4"
/>
<g>
<rect
x="42.7023"
y="33.4353"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FF8C38"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="42.7023"
y="19.0769"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#E660A4"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="42.7023"
y="4.71846"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#6E52DF"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="5.53921"
y="17.3879"
width="18.2923"
height="13.2246"
rx="2.77776"
stroke="#84CFFF"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
</svg> `;
export const MindmapStyleTwo = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.9761 24L43.4023 24"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<path
d="M23.9761 24.0001L33.5892 24.0001C33.6444 24.0001 33.6892 23.9553 33.6892 23.9001L33.6892 9.7416C33.6892 9.68637 33.7339 9.6416 33.7892 9.6416L43.4023 9.6416"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<path
d="M23.9761 24L33.5892 24C33.6444 24 33.6892 24.0448 33.6892 24.1L33.6892 38.2585C33.6892 38.3137 33.7339 38.3585 33.7892 38.3585L43.4023 38.3585"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<g filter="url(#filter0_dd_7851_11431)">
<rect
x="43.4023"
y="34.1353"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter1_dd_7851_11431)">
<rect
x="43.4023"
y="19.7769"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter2_dd_7851_11431)">
<rect
x="43.4023"
y="5.41846"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter3_dd_7851_11431)">
<rect
x="6.23926"
y="18.0879"
width="16.8923"
height="11.8246"
rx="0.1"
fill="#FFC46B"
/>
</g>
<defs>
<filter
id="filter0_dd_7851_11431"
x="42.4023"
y="33.1353"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter1_dd_7851_11431"
x="42.4023"
y="18.7769"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter2_dd_7851_11431"
x="42.4023"
y="4.41846"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter3_dd_7851_11431"
x="5.23926"
y="17.0879"
width="19.8923"
height="14.8247"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
</defs>
</svg> `;
export const MindmapStyleThree = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.976 24L43.4022 24" stroke="#FFD338" stroke-width="1.4" />
<path
d="M23.976 24.0001L26.5099 24.0001C30.4749 24.0001 33.6891 20.7858 33.6891 16.8208V16.8208C33.6891 12.8559 36.9034 9.6416 40.8684 9.6416L43.4022 9.6416"
stroke="#FFD338"
stroke-width="1.4"
/>
<path
d="M23.976 24L26.5099 24C30.4749 24 33.6891 27.2143 33.6891 31.1792V31.1792C33.6891 35.1442 36.9034 38.3585 40.8684 38.3585L43.4022 38.3585"
stroke="#FFD338"
stroke-width="1.4"
/>
<g filter="url(#filter0_d_7851_6843)">
<rect
x="43.4023"
y="34.1353"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="33.4353"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter1_d_7851_6843)">
<rect
x="43.4023"
y="19.7769"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="19.0769"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter2_d_7851_6843)">
<rect
x="43.4023"
y="5.41846"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="4.71846"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter3_d_7851_6843)">
<rect
x="6.23921"
y="18.0879"
width="16.8923"
height="11.8246"
rx="2.07776"
fill="#FFD338"
/>
<rect
x="5.53921"
y="17.3879"
width="18.2923"
height="13.2246"
rx="2.77776"
stroke="white"
stroke-width="1.4"
/>
</g>
<defs>
<filter
id="filter0_d_7851_6843"
x="39.4684"
y="30.2015"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter1_d_7851_6843"
x="39.4684"
y="15.8431"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter2_d_7851_6843"
x="39.4684"
y="1.4847"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter3_d_7851_6843"
x="2.30537"
y="14.1541"
width="24.76"
height="19.6922"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
</defs>
</svg> `;
export const MindmapStyleFour = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.9761 24L43.4023 24"
stroke="#E660A4"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M23.9761 24.0001L26.5099 24.0001C30.4749 24.0001 33.6892 20.7858 33.6892 16.8208V16.8208C33.6892 12.8559 36.9034 9.6416 40.8684 9.6416L43.4023 9.6416"
stroke="#6E52DF"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M23.9761 24L26.5099 24C30.4749 24 33.6892 27.2143 33.6892 31.1792V31.1792C33.6892 35.1442 36.9034 38.3585 40.8684 38.3585L43.4023 38.3585"
stroke="#FF8C38"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M7 26C9.85124 21.8236 11.8347 18.5607 11.8347 20.649C11.8347 22.7372 10.9669 24.695 11.2149 25.3475C11.4628 26 12.9504 24.0423 13.9421 22.8677C14.9339 21.6931 15.9256 21.0405 15.8017 22.8677C15.6777 24.6949 16.2975 25.739 17.2893 24.8254C18.281 23.9118 19.2727 23.5203 20.0165 23.7813C20.6116 23.9901 21.5868 24.5644 22 24.8254"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 11C46.4711 7.51964 48.1901 4.80062 48.1901 6.54079C48.1901 8.28097 47.438 9.91246 47.6529 10.4562C47.8678 11 49.157 9.36862 50.0165 8.38977C50.876 7.41092 51.7355 6.86712 51.6281 8.38977C51.5207 9.91243 52.0579 10.7825 52.9174 10.0212C53.7769 9.25986 54.6364 8.93358 55.281 9.1511C55.7967 9.32512 56.6419 9.80367 57 10.0212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 25.5C46.4711 22.0196 48.1901 19.3006 48.1901 21.0408C48.1901 22.781 47.438 24.4125 47.6529 24.9562C47.8678 25.5 49.157 23.8686 50.0165 22.8898C50.876 21.9109 51.7355 21.3671 51.6281 22.8898C51.5207 24.4124 52.0579 25.2825 52.9174 24.5212C53.7769 23.7599 54.6364 23.4336 55.281 23.6511C55.7967 23.8251 56.6419 24.3037 57 24.5212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 40C46.4711 36.5196 48.1901 33.8006 48.1901 35.5408C48.1901 37.281 47.438 38.9125 47.6529 39.4562C47.8678 40 49.157 38.3686 50.0165 37.3898C50.876 36.4109 51.7355 35.8671 51.6281 37.3898C51.5207 38.9124 52.0579 39.7825 52.9174 39.0212C53.7769 38.2599 54.6364 37.9336 55.281 38.1511C55.7967 38.3251 56.6419 38.8037 57 39.0212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg> `;
@@ -0,0 +1,692 @@
import {
applyNodeStyle,
LayoutType,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
type MindmapStyle,
type NodeDetail,
type NodeType,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import {
generateKeyBetween,
type SurfaceBlockModel,
} from '@blocksuite/block-std/gfx';
import type { IVec } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import isEqual from 'lodash-es/isEqual';
import last from 'lodash-es/last';
import * as Y from 'yjs';
import { fitContent } from '../../renderer/elements/shape/utils.js';
import { layout } from './layout.js';
export function getHoveredArea(
target: ShapeElementModel,
position: [number, number],
layoutDir: LayoutType
): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' {
const { x, y, w, h } = target;
const center =
layoutDir === LayoutType.BALANCE
? [x + w / 2, y + h / 2]
: layoutDir === LayoutType.LEFT
? [x + (w / 3) * 1, y + h / 2]
: [x + (w / 3) * 2, y + h / 2];
return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`;
}
/**
* Hide the connector between the target node and its parent
*/
export function hideNodeConnector(
mindmap: MindmapElementModel,
/**
* The mind map node which's connector will be hide
*/
target: MindmapNode
) {
const parent = mindmap.getParentNode(target.id);
if (!parent) {
return;
}
const connectorId = `#${parent.id}-${target.id}`;
const connector = mindmap.connectors.get(connectorId);
if (!connector) {
return;
}
connector.opacity = 0;
return () => {
connector.opacity = 1;
};
}
/**
* Move the node to the new parent within the same mind map
* @param mindmap
* @param node the node should already exist in the mind map
* @param parent
* @param targetIndex
* @param layout
* @returns
*/
function moveNodePosition(
mindmap: MindmapElementModel,
node: MindmapNode,
parent: string | MindmapNode,
targetIndex: number,
layout?: LayoutType
) {
parent = mindmap.nodeMap.get(
typeof parent === 'string' ? parent : parent.id
)!;
if (!parent || !mindmap.nodeMap.has(node.id)) {
return;
}
assertType<MindmapNode>(parent);
if (layout === LayoutType.BALANCE || parent !== mindmap.tree) {
layout = undefined;
}
const siblings = parent.children.filter(child => child !== node);
targetIndex = Math.min(targetIndex, parent.children.length);
siblings.splice(targetIndex, 0, node);
// calculate the index
// the sibling node may be the same node, so we need to filter it out
const preSibling = siblings[targetIndex - 1];
const afterSibling = siblings[targetIndex + 1];
const index =
preSibling || afterSibling
? generateKeyBetween(
preSibling?.detail.index ?? null,
afterSibling?.detail.index ?? null
)
: (node.detail.index ?? undefined);
mindmap.surface.doc.transact(() => {
const val: NodeDetail = {
...node.detail,
index,
parent: parent.id,
};
mindmap.children.set(node.id, val);
});
if (parent.detail.collapsed) {
mindmap.toggleCollapse(parent);
}
mindmap.layout();
return mindmap.nodeMap.get(node.id);
}
export function applyStyle(
mindmap: MindmapElementModel,
shouldFitContent: boolean = false
) {
mindmap.surface.doc.transact(() => {
const style = mindmap.styleGetter;
if (!style) return;
applyNodeStyle(mindmap.tree, style.root);
if (shouldFitContent) {
fitContent(mindmap.tree.element as ShapeElementModel);
}
const walk = (node: MindmapNode, path: number[]) => {
node.children.forEach((child, idx) => {
const currentPath = [...path, idx];
const nodeStyle = style.getNodeStyle(child, currentPath);
applyNodeStyle(child, nodeStyle.node);
if (shouldFitContent) {
fitContent(child.element as ShapeElementModel);
}
walk(child, currentPath);
});
};
walk(mindmap.tree, [0]);
});
}
/**
*
* @param mindmap the mind map to add the node to
* @param parent the parent node or the parent node id
* @param node the node must be an detached node
* @param targetIndex the index to insert the node at
* @returns
*/
export function addNode(
mindmap: MindmapElementModel,
parent: string | MindmapNode,
node: MindmapNode,
targetIndex?: number
) {
const parentNode = mindmap.getNode(
typeof parent === 'string' ? parent : parent.id
);
if (!parentNode) {
return;
}
const children = parentNode.children.slice();
targetIndex =
targetIndex !== undefined
? Math.min(targetIndex, children.length)
: children.length;
children.splice(targetIndex, 0, node);
const before = children[targetIndex - 1] ?? null;
const after = children[targetIndex + 1] ?? null;
const index =
before || after
? generateKeyBetween(
before?.detail.index ?? null,
after?.detail.index ?? null
)
: node.detail.index;
mindmap.surface.doc.transact(() => {
mindmap.children.set(node.id, {
...node.detail,
index,
parent: parentNode.id,
});
const recursiveAddChild = (node: MindmapNode) => {
node.children?.forEach(child => {
mindmap.children.set(child.id, {
...child.detail,
parent: node.id,
});
recursiveAddChild(child);
});
};
recursiveAddChild(node);
});
if (parentNode.detail.collapsed) {
mindmap.toggleCollapse(parentNode);
}
mindmap.layout();
}
export function addTree(
mindmap: MindmapElementModel,
parent: string | MindmapNode,
tree: NodeType | MindmapNode,
/**
* `sibling` indicates where to insert a subtree among peer elements.
* If it's a string, it represents a peer element's ID;
* if it's a number, it represents its index.
* The subtree will be inserted before the sibling element.
*/
sibling?: string | number
) {
parent = typeof parent === 'string' ? parent : parent.id;
if (!mindmap.nodeMap.has(parent) || !parent) {
return null;
}
assertType<string>(parent);
const traverse = (
node: NodeType | MindmapNode,
parent: string,
sibling?: string | number
) => {
let nodeId: string;
if ('text' in node) {
nodeId = mindmap.addNode(parent, sibling, 'before', {
text: node.text,
});
} else {
mindmap.children.set(node.id, {
...node.detail,
parent,
});
nodeId = node.id;
}
node.children?.forEach(child => traverse(child, nodeId));
return nodeId;
};
if (!('text' in tree)) {
// Modify the children ymap directly hence need transaction
mindmap.surface.doc.transact(() => {
traverse(tree, parent, sibling);
});
applyStyle(mindmap);
mindmap.layout();
return mindmap.nodeMap.get(tree.id);
} else {
const nodeId = traverse(tree, parent, sibling);
mindmap.layout();
return mindmap.nodeMap.get(nodeId);
}
}
/**
* Detach a mindmap node or subtree. It is similar to `removeChild` but
* it does not delete the node.
*
* So the node can be used to create a new mind map or merge into other mind map
* @param mindmap the mind map that the subtree belongs to
* @param subtree the subtree to detach
*/
export function detachMindmap(
mindmap: MindmapElementModel,
subtree: string | MindmapNode
) {
subtree =
typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree;
assertType<MindmapNode>(subtree);
if (!subtree) return;
const traverse = (subtree: MindmapNode) => {
mindmap.children.delete(subtree.id);
// cut the reference inside the ymap
subtree.detail = {
...subtree.detail,
};
subtree.children.forEach(child => traverse(child));
};
mindmap.surface.doc.transact(() => {
traverse(subtree);
});
mindmap.layout();
delete subtree.detail.parent;
return subtree;
}
export function handleLayout(
mindmap: MindmapElementModel,
tree?: MindmapNode | MindmapRoot,
shouldApplyStyle = true,
layoutType?: LayoutType
) {
if (!tree || !tree.element) return;
if (shouldApplyStyle) {
applyStyle(mindmap, true);
}
mindmap.surface.doc.transact(() => {
const path = mindmap.getPath(tree.id);
layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path);
});
}
export function createFromTree(
tree: MindmapNode,
style: MindmapStyle,
layoutType: LayoutType,
surface: SurfaceBlockModel
) {
const children = new Y.Map();
const traverse = (subtree: MindmapNode, parent?: string) => {
const value: NodeDetail = {
...subtree.detail,
parent,
};
if (!parent) {
delete value.parent;
}
children.set(subtree.id, value);
subtree.children.forEach(child => traverse(child, subtree.id));
};
traverse(tree);
const mindmapId = surface.addElement({
type: 'mindmap',
children,
layoutType,
style,
});
const mindmap = surface.getElementById(mindmapId) as MindmapElementModel;
handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType);
return mindmap;
}
/**
* Move a subtree from one mind map to another
* @param from the mind map that the `subtree` belongs to
* @param subtree the subtree to move
* @param to the mind map to move the `subtree` to
* @param parent the new parent node to attach the `subtree` to
* @param index the index to insert the `subtree` at
*/
export function moveNode(
from: MindmapElementModel,
subtree: MindmapNode,
to: MindmapElementModel,
parent: MindmapNode | string,
index: number
) {
if (from === to) {
return moveNodePosition(from, subtree, parent, index);
}
if (!detachMindmap(from, subtree)) return;
return addNode(to, parent, subtree, index);
}
export function findTargetNode(
mindmap: MindmapElementModel,
position: IVec
): MindmapNode | null {
const find = (node: MindmapNode): MindmapNode | null => {
if (!node.responseArea) {
return null;
}
const layoutDir = mindmap.getLayoutDir(node);
if (
layoutDir === LayoutType.BALANCE ||
(layoutDir === LayoutType.RIGHT &&
position[0] > node.element.x + node.element.w) ||
(layoutDir === LayoutType.LEFT && position[0] < node.element.x)
) {
for (const child of node.children) {
const result = find(child);
if (result) {
return result;
}
}
}
return node.responseArea.containsPoint(position) ? node : null;
};
return find(mindmap.tree);
}
function determineInsertPosition(
mindmap: MindmapElementModel,
mindmapNode: MindmapNode,
position: IVec
):
| {
type: 'child';
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
}
| {
type: 'sibling';
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
position: 'prev' | 'next';
}
| null {
if (
!mindmapNode.responseArea ||
!mindmapNode.responseArea.containsPoint(position)
) {
return null;
}
const layoutDir = mindmap.getLayoutDir(mindmapNode);
const elementBound = mindmapNode.element.elementBound;
const targetLayout: LayoutType.LEFT | LayoutType.RIGHT =
layoutDir === LayoutType.BALANCE
? position[0] > elementBound.x + elementBound.w / 2
? LayoutType.RIGHT
: LayoutType.LEFT
: layoutDir;
if (
elementBound.containsPoint(position) ||
(layoutDir === LayoutType.RIGHT
? position[0] > elementBound.x + elementBound.w
: position[0] < elementBound.x)
) {
return {
type: 'child',
layoutDir: targetLayout,
};
}
if (
mindmap.layoutType === LayoutType.BALANCE &&
mindmap.getPath(mindmapNode.id).length === 2
) {
return {
type: 'sibling',
layoutDir: targetLayout,
position:
targetLayout === LayoutType.LEFT
? position[1] > elementBound.y + elementBound.h / 2
? 'prev'
: 'next'
: position[1] > elementBound.y + elementBound.h / 2
? 'next'
: 'prev',
};
}
return {
type: 'sibling',
layoutDir: targetLayout,
position:
position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev',
};
}
function showMergeIndicator(
targetMindMap: MindmapElementModel,
target: MindmapNode,
sourceMindMap: MindmapElementModel,
source: MindmapNode,
insertPosition:
| {
type: 'sibling';
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
position: 'prev' | 'next';
}
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> },
callback: (option: {
targetMindMap: MindmapElementModel;
target: MindmapNode;
sourceMindMap: MindmapElementModel;
source: MindmapNode;
newParent: MindmapNode;
insertPosition:
| {
type: 'sibling';
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
position: 'prev' | 'next';
}
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
path: number[];
}) => () => void
) {
const newParent =
insertPosition.type === 'child'
? target
: targetMindMap.getParentNode(target.id)!;
if (!newParent) {
return null;
}
const path = targetMindMap.getPath(newParent);
const curPath = sourceMindMap.getPath(source.id);
if (insertPosition.type === 'sibling') {
const curPath = targetMindMap.getPath(target.id);
const parent = targetMindMap.getParentNode(target.id);
if (!parent) {
return null;
}
const idx = parent.children
.filter(child => child.id !== source.id)
.indexOf(target);
path.push(
idx === -1
? Math.max(
0,
last(curPath)! + (insertPosition.position === 'next' ? 1 : 0)
)
: Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0))
);
} else {
path.push(target.children.length);
}
// hide original connector
const abortPreview = callback({
targetMindMap,
target: target,
sourceMindMap,
source,
newParent,
insertPosition,
path,
});
const abort = () => {
abortPreview?.();
};
const merge = () => {
abort();
if (targetMindMap === sourceMindMap && isEqual(path, curPath)) {
return;
}
moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!);
};
return {
abort,
merge,
};
}
/**
* Try to move a node to another mind map.
* It will show a merge indicator if the node can be merged to the target mind map.
* @param targetMindMap
* @param target
* @param sourceMindMap
* @param source
* @param position
* @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map.
*/
export function tryMoveNode(
targetMindMap: MindmapElementModel,
target: MindmapNode,
sourceMindMap: MindmapElementModel,
source: MindmapNode,
position: IVec,
callback: (option: {
targetMindMap: MindmapElementModel;
target: MindmapNode;
sourceMindMap: MindmapElementModel;
source: MindmapNode;
newParent: MindmapNode;
insertPosition:
| {
type: 'sibling';
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
position: 'prev' | 'next';
}
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
path: number[];
}) => () => void
) {
const insertInfo = determineInsertPosition(targetMindMap, target, position);
if (!insertInfo) {
return null;
}
return showMergeIndicator(
targetMindMap,
target,
sourceMindMap,
source,
insertInfo,
callback
);
}
/**
* Check if the mind map contains the target node.
* @param mindmap Mind map to check
* @param targetNode Node to check
* @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map.
* @returns
*/
export function containsNode(
mindmap: MindmapElementModel,
targetNode: MindmapNode,
searchParent?: MindmapNode
) {
searchParent = searchParent ?? mindmap.tree;
const find = (checkAgainstNode: MindmapNode) => {
if (checkAgainstNode.id === targetNode.id) {
return true;
}
for (const child of checkAgainstNode.children) {
if (find(child)) {
return true;
}
}
return false;
};
return find(searchParent);
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Preet Shihn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,117 @@
import type { Segment } from './parser.js';
// Translate relative commands to absolute commands
export function absolutize(segments: Segment[]): Segment[] {
let cx = 0,
cy = 0;
let subx = 0,
suby = 0;
const out: Segment[] = [];
for (const { key, data } of segments) {
switch (key) {
case 'M':
out.push({ key: 'M', data: [...data] });
[cx, cy] = data;
[subx, suby] = data;
break;
case 'm':
cx += data[0];
cy += data[1];
out.push({ key: 'M', data: [cx, cy] });
subx = cx;
suby = cy;
break;
case 'L':
out.push({ key: 'L', data: [...data] });
[cx, cy] = data;
break;
case 'l':
cx += data[0];
cy += data[1];
out.push({ key: 'L', data: [cx, cy] });
break;
case 'C':
out.push({ key: 'C', data: [...data] });
cx = data[4];
cy = data[5];
break;
case 'c': {
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
out.push({ key: 'C', data: newdata });
cx = newdata[4];
cy = newdata[5];
break;
}
case 'Q':
out.push({ key: 'Q', data: [...data] });
cx = data[2];
cy = data[3];
break;
case 'q': {
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
out.push({ key: 'Q', data: newdata });
cx = newdata[2];
cy = newdata[3];
break;
}
case 'A':
out.push({ key: 'A', data: [...data] });
cx = data[5];
cy = data[6];
break;
case 'a':
cx += data[5];
cy += data[6];
out.push({
key: 'A',
data: [data[0], data[1], data[2], data[3], data[4], cx, cy],
});
break;
case 'H':
out.push({ key: 'H', data: [...data] });
cx = data[0];
break;
case 'h':
cx += data[0];
out.push({ key: 'H', data: [cx] });
break;
case 'V':
out.push({ key: 'V', data: [...data] });
cy = data[0];
break;
case 'v':
cy += data[0];
out.push({ key: 'V', data: [cy] });
break;
case 'S':
out.push({ key: 'S', data: [...data] });
cx = data[2];
cy = data[3];
break;
case 's': {
const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx));
out.push({ key: 'S', data: newdata });
cx = newdata[2];
cy = newdata[3];
break;
}
case 'T':
out.push({ key: 'T', data: [...data] });
cx = data[0];
cy = data[1];
break;
case 't':
cx += data[0];
cy += data[1];
out.push({ key: 'T', data: [cx, cy] });
break;
case 'Z':
case 'z':
out.push({ key: 'Z', data: [] });
cx = subx;
cy = suby;
break;
}
}
return out;
}
@@ -0,0 +1,280 @@
import type { Segment } from './parser.js';
// Normalize path to include only M, L, C, and Z commands
export function normalize(segments: Segment[]): Segment[] {
const out: Segment[] = [];
let lastType = '';
let cx = 0,
cy = 0;
let subx = 0,
suby = 0;
let lcx = 0,
lcy = 0;
for (const { key, data } of segments) {
switch (key) {
case 'M':
out.push({ key: 'M', data: [...data] });
[cx, cy] = data;
[subx, suby] = data;
break;
case 'C':
out.push({ key: 'C', data: [...data] });
cx = data[4];
cy = data[5];
lcx = data[2];
lcy = data[3];
break;
case 'L':
out.push({ key: 'L', data: [...data] });
[cx, cy] = data;
break;
case 'H':
cx = data[0];
out.push({ key: 'L', data: [cx, cy] });
break;
case 'V':
cy = data[0];
out.push({ key: 'L', data: [cx, cy] });
break;
case 'S': {
let cx1 = 0,
cy1 = 0;
if (lastType === 'C' || lastType === 'S') {
cx1 = cx + (cx - lcx);
cy1 = cy + (cy - lcy);
} else {
cx1 = cx;
cy1 = cy;
}
out.push({ key: 'C', data: [cx1, cy1, ...data] });
lcx = data[0];
lcy = data[1];
cx = data[2];
cy = data[3];
break;
}
case 'T': {
const [x, y] = data;
let x1 = 0,
y1 = 0;
if (lastType === 'Q' || lastType === 'T') {
x1 = cx + (cx - lcx);
y1 = cy + (cy - lcy);
} else {
x1 = cx;
y1 = cy;
}
const cx1 = cx + (2 * (x1 - cx)) / 3;
const cy1 = cy + (2 * (y1 - cy)) / 3;
const cx2 = x + (2 * (x1 - x)) / 3;
const cy2 = y + (2 * (y1 - y)) / 3;
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
lcx = x1;
lcy = y1;
cx = x;
cy = y;
break;
}
case 'Q': {
const [x1, y1, x, y] = data;
const cx1 = cx + (2 * (x1 - cx)) / 3;
const cy1 = cy + (2 * (y1 - cy)) / 3;
const cx2 = x + (2 * (x1 - x)) / 3;
const cy2 = y + (2 * (y1 - y)) / 3;
out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] });
lcx = x1;
lcy = y1;
cx = x;
cy = y;
break;
}
case 'A': {
const r1 = Math.abs(data[0]);
const r2 = Math.abs(data[1]);
const angle = data[2];
const largeArcFlag = data[3];
const sweepFlag = data[4];
const x = data[5];
const y = data[6];
if (r1 === 0 || r2 === 0) {
out.push({ key: 'C', data: [cx, cy, x, y, x, y] });
cx = x;
cy = y;
} else {
if (cx !== x || cy !== y) {
const curves: number[][] = arcToCubicCurves(
cx,
cy,
x,
y,
r1,
r2,
angle,
largeArcFlag,
sweepFlag
);
curves.forEach(function (curve) {
out.push({ key: 'C', data: curve });
});
cx = x;
cy = y;
}
}
break;
}
case 'Z':
out.push({ key: 'Z', data: [] });
cx = subx;
cy = suby;
break;
}
lastType = key;
}
return out;
}
function degToRad(degrees: number): number {
return (Math.PI * degrees) / 180;
}
function rotate(x: number, y: number, angleRad: number): [number, number] {
const X = x * Math.cos(angleRad) - y * Math.sin(angleRad);
const Y = x * Math.sin(angleRad) + y * Math.cos(angleRad);
return [X, Y];
}
function arcToCubicCurves(
x1: number,
y1: number,
x2: number,
y2: number,
r1: number,
r2: number,
angle: number,
largeArcFlag: number,
sweepFlag: number,
recursive?: number[]
): number[][] {
const angleRad = degToRad(angle);
let params: number[][] = [];
let f1 = 0,
f2 = 0,
cx = 0,
cy = 0;
if (recursive) {
[f1, f2, cx, cy] = recursive;
} else {
[x1, y1] = rotate(x1, y1, -angleRad);
[x2, y2] = rotate(x2, y2, -angleRad);
const x = (x1 - x2) / 2;
const y = (y1 - y2) / 2;
let h = (x * x) / (r1 * r1) + (y * y) / (r2 * r2);
if (h > 1) {
h = Math.sqrt(h);
r1 = h * r1;
r2 = h * r2;
}
const sign = largeArcFlag === sweepFlag ? -1 : 1;
const r1Pow = r1 * r1;
const r2Pow = r2 * r2;
const left = r1Pow * r2Pow - r1Pow * y * y - r2Pow * x * x;
const right = r1Pow * y * y + r2Pow * x * x;
const k = sign * Math.sqrt(Math.abs(left / right));
cx = (k * r1 * y) / r2 + (x1 + x2) / 2;
cy = (k * -r2 * x) / r1 + (y1 + y2) / 2;
f1 = Math.asin(parseFloat(((y1 - cy) / r2).toFixed(9)));
f2 = Math.asin(parseFloat(((y2 - cy) / r2).toFixed(9)));
if (x1 < cx) {
f1 = Math.PI - f1;
}
if (x2 < cx) {
f2 = Math.PI - f2;
}
if (f1 < 0) {
f1 = Math.PI * 2 + f1;
}
if (f2 < 0) {
f2 = Math.PI * 2 + f2;
}
if (sweepFlag && f1 > f2) {
f1 = f1 - Math.PI * 2;
}
if (!sweepFlag && f2 > f1) {
f2 = f2 - Math.PI * 2;
}
}
let df = f2 - f1;
if (Math.abs(df) > (Math.PI * 120) / 180) {
const f2old = f2;
const x2old = x2;
const y2old = y2;
if (sweepFlag && f2 > f1) {
f2 = f1 + ((Math.PI * 120) / 180) * 1;
} else {
f2 = f1 + ((Math.PI * 120) / 180) * -1;
}
x2 = cx + r1 * Math.cos(f2);
y2 = cy + r2 * Math.sin(f2);
params = arcToCubicCurves(
x2,
y2,
x2old,
y2old,
r1,
r2,
angle,
0,
sweepFlag,
[f2, f2old, cx, cy]
);
}
df = f2 - f1;
const c1 = Math.cos(f1);
const s1 = Math.sin(f1);
const c2 = Math.cos(f2);
const s2 = Math.sin(f2);
const t = Math.tan(df / 4);
const hx = (4 / 3) * r1 * t;
const hy = (4 / 3) * r2 * t;
const m1 = [x1, y1];
const m2 = [x1 + hx * s1, y1 - hy * c1];
const m3 = [x2 + hx * s2, y2 - hy * c2];
const m4 = [x2, y2];
m2[0] = 2 * m1[0] - m2[0];
m2[1] = 2 * m1[1] - m2[1];
if (recursive) {
return [m2, m3, m4].concat(params);
} else {
params = [m2, m3, m4].concat(params);
const curves = [];
for (let i = 0; i < params.length; i += 3) {
const r1 = rotate(params[i][0], params[i][1], angleRad);
const r2 = rotate(params[i + 1][0], params[i + 1][1], angleRad);
const r3 = rotate(params[i + 2][0], params[i + 2][1], angleRad);
curves.push([r1[0], r1[1], r2[0], r2[1], r3[0], r3[1]]);
}
return curves;
}
}
@@ -0,0 +1,153 @@
const COMMAND = 0;
const NUMBER = 1;
const EOD = 2;
type TokenType = 0 | 1 | 2;
interface PathToken {
type: TokenType;
text: string;
}
export interface Segment {
key: string;
data: number[];
}
const PARAMS: Record<string, number> = {
A: 7,
a: 7,
C: 6,
c: 6,
H: 1,
h: 1,
L: 2,
l: 2,
M: 2,
m: 2,
Q: 4,
q: 4,
S: 4,
s: 4,
T: 2,
t: 2,
V: 1,
v: 1,
Z: 0,
z: 0,
};
function tokenize(d: string): PathToken[] {
const tokens: PathToken[] = [];
let match: RegExpMatchArray | null;
while (d !== '') {
match = d.match(/^([ \t\r\n,]+)/);
if (match) {
d = d.slice(match[0].length);
continue;
}
match = d.match(/^([aAcChHlLmMqQsStTvVzZ])/);
if (match) {
tokens.push({ type: COMMAND, text: match[0] });
d = d.slice(match[0].length);
continue;
}
match = d.match(
/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/
);
if (match) {
tokens.push({ type: NUMBER, text: `${parseFloat(match[0])}` });
d = d.slice(match[0].length);
} else {
return [];
}
}
tokens.push({ type: EOD, text: '' });
return tokens;
}
function isType(token: PathToken, type: number) {
return token.type === type;
}
export function parsePath(d: string): Segment[] {
const segments: Segment[] = [];
const tokens = tokenize(d);
let mode = 'BOD';
let index = 0;
let token = tokens[index];
while (!isType(token, EOD)) {
let paramsCount = 0;
const params: number[] = [];
if (mode === 'BOD') {
if (token.text === 'M' || token.text === 'm') {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
} else {
return parsePath('M0,0' + d);
}
} else if (isType(token, NUMBER)) {
paramsCount = PARAMS[mode];
} else {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
}
if (index + paramsCount < tokens.length) {
for (let i = index; i < index + paramsCount; i++) {
const numbeToken = tokens[i];
if (isType(numbeToken, NUMBER)) {
params[params.length] = +numbeToken.text;
} else {
throw new Error(
'Param not a number: ' + mode + ',' + numbeToken.text
);
}
}
if (typeof PARAMS[mode] === 'number') {
const segment: Segment = { key: mode, data: params };
segments.push(segment);
index += paramsCount;
token = tokens[index];
if (mode === 'M') mode = 'L';
if (mode === 'm') mode = 'l';
} else {
throw new Error('Bad segment: ' + mode);
}
} else {
throw new Error('Path data ended short');
}
}
return segments;
}
export function serialize(segments: Segment[]): string {
const tokens: (string | number)[] = [];
for (const { key, data } of segments) {
tokens.push(key);
switch (key) {
case 'C':
case 'c':
tokens.push(
data[0],
`${data[1]},`,
data[2],
`${data[3]},`,
data[4],
data[5]
);
break;
case 'S':
case 's':
case 'Q':
case 'q':
tokens.push(data[0], `${data[1]},`, data[2], data[3]);
break;
default:
tokens.push(...data);
break;
}
}
return tokens.join(' ');
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Preet Shihn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,51 @@
import type { Point } from '../rough/geometry.js';
function clone(p: Point): Point {
return [...p] as Point;
}
export function curveToBezier(
pointsIn: readonly Point[],
curveTightness = 0
): Point[] {
const len = pointsIn.length;
if (len < 3) {
throw new Error('A curve must have at least three points.');
}
const out: Point[] = [];
if (len === 3) {
out.push(
clone(pointsIn[0]),
clone(pointsIn[1]),
clone(pointsIn[2]),
clone(pointsIn[2])
);
} else {
const points: Point[] = [];
points.push(pointsIn[0], pointsIn[0]);
for (let i = 1; i < pointsIn.length; i++) {
points.push(pointsIn[i]);
if (i === pointsIn.length - 1) {
points.push(pointsIn[i]);
}
}
const b: Point[] = [];
const s = 1 - curveTightness;
out.push(clone(points[0]));
for (let i = 1; i + 2 < points.length; i++) {
const cachedVertArray = points[i];
b[0] = [cachedVertArray[0], cachedVertArray[1]];
b[1] = [
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
];
b[2] = [
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
];
b[3] = [points[i + 1][0], points[i + 1][1]];
out.push(b[1], b[2], b[3]);
}
}
return out;
}
@@ -0,0 +1,163 @@
import type { Point } from '../rough/geometry.js';
// distance between 2 points
function distance(p1: Point, p2: Point): number {
return Math.sqrt(distanceSq(p1, p2));
}
// distance between 2 points squared
function distanceSq(p1: Point, p2: Point): number {
return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2);
}
// Sistance squared from a point p to the line segment vw
function distanceToSegmentSq(p: Point, v: Point, w: Point): number {
const l2 = distanceSq(v, w);
if (l2 === 0) {
return distanceSq(p, v);
}
let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2;
t = Math.max(0, Math.min(1, t));
return distanceSq(p, lerp(v, w, t));
}
function lerp(a: Point, b: Point, t: number): Point {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
// Adapted from https://seant23.wordpress.com/2010/11/12/offset-bezier-curves/
function flatness(points: readonly Point[], offset: number): number {
const p1 = points[offset + 0];
const p2 = points[offset + 1];
const p3 = points[offset + 2];
const p4 = points[offset + 3];
let ux = 3 * p2[0] - 2 * p1[0] - p4[0];
ux *= ux;
let uy = 3 * p2[1] - 2 * p1[1] - p4[1];
uy *= uy;
let vx = 3 * p3[0] - 2 * p4[0] - p1[0];
vx *= vx;
let vy = 3 * p3[1] - 2 * p4[1] - p1[1];
vy *= vy;
if (ux < vx) {
ux = vx;
}
if (uy < vy) {
uy = vy;
}
return ux + uy;
}
function getPointsOnBezierCurveWithSplitting(
points: readonly Point[],
offset: number,
tolerance: number,
newPoints?: Point[]
): Point[] {
const outPoints = newPoints || [];
if (flatness(points, offset) < tolerance) {
const p0 = points[offset + 0];
if (outPoints.length) {
const d = distance(outPoints[outPoints.length - 1], p0);
if (d > 1) {
outPoints.push(p0);
}
} else {
outPoints.push(p0);
}
outPoints.push(points[offset + 3]);
} else {
// subdivide
const t = 0.5;
const p1 = points[offset + 0];
const p2 = points[offset + 1];
const p3 = points[offset + 2];
const p4 = points[offset + 3];
const q1 = lerp(p1, p2, t);
const q2 = lerp(p2, p3, t);
const q3 = lerp(p3, p4, t);
const r1 = lerp(q1, q2, t);
const r2 = lerp(q2, q3, t);
const red = lerp(r1, r2, t);
getPointsOnBezierCurveWithSplitting(
[p1, q1, r1, red],
0,
tolerance,
outPoints
);
getPointsOnBezierCurveWithSplitting(
[red, r2, q3, p4],
0,
tolerance,
outPoints
);
}
return outPoints;
}
export function simplify(points: readonly Point[], distance: number): Point[] {
return simplifyPoints(points, 0, points.length, distance);
}
// RamerDouglasPeucker algorithm
// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
function simplifyPoints(
points: readonly Point[],
start: number,
end: number,
epsilon: number,
newPoints?: Point[]
): Point[] {
const outPoints = newPoints || [];
// find the most distance point from the endpoints
const s = points[start];
const e = points[end - 1];
let maxDistSq = 0;
let maxNdx = 1;
for (let i = start + 1; i < end - 1; ++i) {
const distSq = distanceToSegmentSq(points[i], s, e);
if (distSq > maxDistSq) {
maxDistSq = distSq;
maxNdx = i;
}
}
// if that point is too far, split
if (Math.sqrt(maxDistSq) > epsilon) {
simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints);
simplifyPoints(points, maxNdx, end, epsilon, outPoints);
} else {
if (!outPoints.length) {
outPoints.push(s);
}
outPoints.push(e);
}
return outPoints;
}
export function pointsOnBezierCurves(
points: Point[],
tolerance = 0.15,
distance?: number
): Point[] {
const newPoints: Point[] = [];
const numSegments = (points.length - 1) / 3;
for (let i = 0; i < numSegments; i++) {
const offset = i * 3;
getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints);
}
if (distance && distance > 0) {
return simplifyPoints(newPoints, 0, newPoints.length, distance);
}
return newPoints;
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Preet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,77 @@
import { absolutize } from '../path-data-parser/absolutize.js';
import { normalize } from '../path-data-parser/normalize.js';
import { parsePath } from '../path-data-parser/parser.js';
import { pointsOnBezierCurves, simplify } from '../points-on-curve/index.js';
import type { Point } from '../rough/geometry.js';
export function pointsOnPath(
path: string,
tolerance?: number,
distance?: number
): Point[][] {
const segments = parsePath(path);
const normalized = normalize(absolutize(segments));
const sets: Point[][] = [];
let currentPoints: Point[] = [];
let start: Point = [0, 0];
let pendingCurve: Point[] = [];
const appendPendingCurve = () => {
if (pendingCurve.length >= 4) {
currentPoints.push(...pointsOnBezierCurves(pendingCurve, tolerance));
}
pendingCurve = [];
};
const appendPendingPoints = () => {
appendPendingCurve();
if (currentPoints.length) {
sets.push(currentPoints);
currentPoints = [];
}
};
for (const { key, data } of normalized) {
switch (key) {
case 'M':
appendPendingPoints();
start = [data[0], data[1]];
currentPoints.push(start);
break;
case 'L':
appendPendingCurve();
currentPoints.push([data[0], data[1]]);
break;
case 'C':
if (!pendingCurve.length) {
const lastPoint = currentPoints.length
? currentPoints[currentPoints.length - 1]
: start;
pendingCurve.push([lastPoint[0], lastPoint[1]]);
}
pendingCurve.push([data[0], data[1]]);
pendingCurve.push([data[2], data[3]]);
pendingCurve.push([data[4], data[5]]);
break;
case 'Z':
appendPendingCurve();
currentPoints.push([start[0], start[1]]);
break;
}
}
appendPendingPoints();
if (!distance) {
return sets;
}
const out: Point[][] = [];
for (const set of sets) {
const simplifiedSet = simplify(set, distance);
if (simplifiedSet.length) {
out.push(simplifiedSet);
}
}
return out;
}
@@ -0,0 +1,85 @@
type PriorityQueueNode<T, K> = {
value: T;
priority: K;
};
export class PriorityQueue<T, K> {
heap: PriorityQueueNode<T, K>[] = [];
constructor(private 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 {
type GfxGroupCompatibleInterface,
type GfxModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/block-std/gfx';
import { Bound, clamp } from '@blocksuite/global/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.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(ele, () => {
ele.edgeless.collapse = true;
ele.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(),
});
}
}