Skip to content

Commit

Permalink
feat: support copy as SVG
Browse files Browse the repository at this point in the history
  • Loading branch information
F-star committed May 16, 2024
1 parent ce62dd6 commit d2c6e8e
Show file tree
Hide file tree
Showing 18 changed files with 392 additions and 46 deletions.
13 changes: 13 additions & 0 deletions packages/common/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,16 @@ export const getClosestValInSortedArr = (
export const isWindows =
navigator.platform.toLowerCase().includes('win') ||
navigator.userAgent.includes('Windows');

export const escapeHtml = (str: string) => {
if (typeof str == 'string') {
return str.replace(/<|&|>/g, (matches) => {
return {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
}[matches]!;
});
}
return '';
};
12 changes: 12 additions & 0 deletions packages/core/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { arrMap, noop, omit } from '@suika/common';
import { AddGraphCmd } from './commands/add_graphs';
import { type Editor } from './editor';
import { Graph } from './graphs';
import { toSVG } from './to_svg';
import { type IEditorPaperData } from './type';

/**
Expand Down Expand Up @@ -66,6 +67,17 @@ export class ClipboardManager {
});
}

copyAsSVG() {
const graphs = this.editor.selectedElements.getItems();
if (graphs.length === 0) {
return;
}
const svgStr = toSVG(graphs);
navigator.clipboard.writeText(svgStr).then(() => {
console.log('SVG copied');
});
}

/**
* paste at special coords
*/
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/graphs/ellipse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseRGBAStr } from '@suika/common';
import { type IPoint } from '@suika/geo';
import { Matrix } from 'pixi.js';

import { DOUBLE_PI } from '../constant';
Expand Down Expand Up @@ -98,4 +99,25 @@ export class Ellipse extends Graph<EllipseAttrs> {
ctx.stroke();
ctx.closePath();
}

override getSVGTagHead(offset?: IPoint) {
const tf = [...this.attrs.transform];
if (offset) {
tf[4] += offset.x;
tf[5] += offset.y;
}

const cx = this.attrs.width / 2;
const cy = this.attrs.height / 2;

if (this.attrs.width === this.attrs.height) {
return `<circle cx="${cx}" cy="${cy}" r="${cx}" transform="matrix(${tf.join(
' ',
)})"`;
} else {
return `<ellipse cx="${cx}" cy="${cy}" rx="${cx}" ry="${cy}" transform="matrix(${tf.join(
' ',
)})"`;
}
}
}
125 changes: 122 additions & 3 deletions packages/core/src/graphs/graph/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
genId,
objectNameGenerator,
omit,
parseRGBToHex,
} from '@suika/common';
import {
boxToRect,
Expand All @@ -29,8 +30,13 @@ import { Matrix } from 'pixi.js';
import { HALF_PI } from '../../constant';
import { type ControlHandle } from '../../control_handle_manager';
import { type ImgManager } from '../../Img_manager';
import { DEFAULT_IMAGE, type PaintImage } from '../../paint';
import { GraphType, type IObject, type Optional } from '../../type';
import { DEFAULT_IMAGE, type PaintImage, PaintType } from '../../paint';
import {
GraphType,
type IFillStrokeSVGAttrs,
type IObject,
type Optional,
} from '../../type';
import { drawRoundRectPath } from '../../utils';
import { type GraphAttrs, type IGraphOpts } from './graph_attrs';

Expand Down Expand Up @@ -79,7 +85,8 @@ export class Graph<ATTRS extends GraphAttrs = GraphAttrs> {
attrs.y !== undefined ||
attrs.width !== undefined ||
attrs.height !== undefined ||
attrs.transform !== undefined
attrs.transform !== undefined ||
'strokeWidth' in attrs
);
}
updateAttrs(
Expand All @@ -93,6 +100,14 @@ export class Graph<ATTRS extends GraphAttrs = GraphAttrs> {
this._cacheBbox = null;
this._cacheBboxWithStroke = null;
}

if (
'strokeWidth' in partialAttrs &&
partialAttrs.strokeWidth === undefined
) {
delete this.attrs.strokeWidth;
}

if (!partialAttrs.transform) {
if (partialAttrs.x !== undefined) {
this.attrs.transform[4] = partialAttrs.x;
Expand Down Expand Up @@ -555,4 +570,108 @@ export class Graph<ATTRS extends GraphAttrs = GraphAttrs> {
},
];
}

toSVGSegment(offset?: IPoint) {
const tagHead = this.getSVGTagHead(offset);
if (!tagHead) {
console.warn(
`please implement getSVGTagHead method of "${this.type}" type`,
);
return '';
}

// TODO: precision config
const fillAndStrokeAttrs: IFillStrokeSVGAttrs[] = [];

const { fillPaints, strokePaints } = this.getFillAndStrokesToSVG();
// TODO: do not to SVG if paints is empty
if (fillPaints.length <= 1 && strokePaints.length <= 1) {
const fillPaint = fillPaints[0];
if (fillPaint) {
const rect: IFillStrokeSVGAttrs = {};
if (fillPaint.type === PaintType.Solid) {
rect.fill = '#' + parseRGBToHex(fillPaint.attrs);
const opacity = fillPaint.attrs.a;
if (opacity !== 1) {
rect['fill-opacity'] = opacity;
}
}
fillAndStrokeAttrs.push(rect);
// TODO: solve image
}
const strokePaint = strokePaints[0];
if (strokePaint) {
const rect: IFillStrokeSVGAttrs = {};
if (strokePaint.type === PaintType.Solid) {
rect.stroke = '#' + parseRGBToHex(strokePaint.attrs);
const opacity = strokePaint.attrs.a;
if (opacity !== 1) {
rect['stroke-opacity'] = opacity;
}
}
fillAndStrokeAttrs.push(rect);
}
} else {
for (const fillPaint of fillPaints) {
if (fillPaint) {
if (fillPaint.type === PaintType.Solid) {
const rect: IFillStrokeSVGAttrs = {
fill: '#' + parseRGBToHex(fillPaint.attrs),
};
const opacity = fillPaint.attrs.a;
if (opacity !== 1) {
rect['fill-opacity'] = opacity;
}
fillAndStrokeAttrs.push(rect);
}
}
}
for (const strokePaint of strokePaints) {
if (strokePaint) {
if (strokePaint.type === PaintType.Solid) {
const rect: IFillStrokeSVGAttrs = {
stroke: '#' + parseRGBToHex(strokePaint.attrs),
};
const opacity = strokePaint.attrs.a;
if (opacity !== 1) {
rect['stroke-opacity'] = opacity;
}
fillAndStrokeAttrs.push(rect);
}
}
}
}

const strokeWidth = this.attrs.strokeWidth ?? 0;
const strokeWidthStr =
strokeWidth > 1 ? ` stroke-width="${strokeWidth}"` : '';

let content = '';
const tagTail = this.getSVGTagTail();
for (const attrs of fillAndStrokeAttrs) {
let fillAndStrokeStr = '';
let key: keyof typeof attrs;
for (key in attrs) {
fillAndStrokeStr += ` ${key}="${attrs[key]}"`;
}
content += tagHead + fillAndStrokeStr + strokeWidthStr + tagTail;
}

return content;
}

protected getSVGTagHead(_offset?: IPoint) {
return '';
}

protected getSVGTagTail() {
return '/>\n';
}

protected getFillAndStrokesToSVG() {
return {
fillPaints: this.attrs.fill ?? [],
strokePaints: this.attrs.stroke ?? [],
};
}
}
22 changes: 22 additions & 0 deletions packages/core/src/graphs/line.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseRGBAStr } from '@suika/common';
import { type IPoint } from '@suika/geo';

import { PaintType } from '../paint';
import { GraphType, type Optional } from '../type';
Expand Down Expand Up @@ -64,4 +65,25 @@ export class Line extends Graph<LineAttrs> {
ctx.stroke();
ctx.closePath();
}

protected override getSVGTagHead(offset?: IPoint) {
const tf = [...this.attrs.transform];
if (offset) {
tf[4] += offset.x;
tf[5] += offset.y;
}

const size = this.getTransformedSize();

return `<line x1="0" y1="0" x2="${size.width}" y2="${
size.height
}" transform="matrix(${tf.join(' ')})"`;
}

protected override getFillAndStrokesToSVG() {
return {
fillPaints: [],
strokePaints: this.attrs.stroke ?? [],
};
}
}
41 changes: 41 additions & 0 deletions packages/core/src/graphs/path/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,45 @@ export class Path extends Graph<PathAttrs> {
}
return pathItem.segs.length;
}

override getSVGTagHead(offset?: IPoint) {
const tf = [...this.attrs.transform];
if (offset) {
tf[4] += offset.x;
tf[5] += offset.y;
}

let d = '';

// TODO: optimize, it's duplicated with _realDraw method
for (const pathItem of this.attrs.pathData) {
const firstSeg = pathItem.segs[0];
if (!firstSeg) continue;

d += `M${firstSeg.point.x} ${firstSeg.point.y}`;

const segs = pathItem.segs;
for (let i = 1; i <= segs.length; i++) {
if (i === segs.length && !pathItem.closed) {
continue;
}
const currSeg = segs[i % segs.length];
const prevSeg = segs[i - 1];
const pointX = currSeg.point.x;
const pointY = currSeg.point.y;
const handle1 = Path.getHandleOut(prevSeg);
const handle2 = Path.getHandleIn(currSeg);
if (!handle1 && !handle2) {
d += `L${pointX} ${pointY}`;
} else {
d += `C${handle1.x} ${handle1.y} ${handle2.x} ${handle2.y} ${pointX} ${pointY}`;
}
}
if (pathItem.closed) {
d += 'Z';
}
}

return `<path d="${d}" transform="matrix(${tf.join(' ')})"`;
}
}
30 changes: 11 additions & 19 deletions packages/core/src/graphs/rect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseHexToRGBA, parseRGBAStr } from '@suika/common';
import { boxToRect, type IPoint, isPointInRoundRect } from '@suika/geo';
import { type IPoint, isPointInRoundRect } from '@suika/geo';
import { Matrix } from 'pixi.js';

import { ControlHandle } from '../control_handle_manager';
Expand Down Expand Up @@ -331,26 +331,18 @@ export class Rect extends Graph<RectAttrs> {
];
}

/**
* parse to svg string
* for debug
* wip
*/
toSVG() {
const container = boxToRect(this.getBboxWithStroke());
const center = this.getCenter();
const offsetX = container.width / 2 - center.x;
const offsetY = container.height / 2 - center.y;
override getSVGTagHead(offset?: IPoint) {
const tf = [...this.attrs.transform];
tf[4] += offsetX;
tf[5] += offsetY;
const matrixStr = tf.join(' ');
if (offset) {
tf[4] += offset.x;
tf[5] += offset.y;
}

const cornerRadius = this.attrs.cornerRadius ?? 0;
const cornerRadiusStr = cornerRadius > 1 ? ` rx="${cornerRadius}"` : '';

const svgHead = `<svg width="${container.width}" height="${container.height}" viewBox="0 0 ${container.width} ${container.height}" fill="none" xmlns="http://www.w3.org/2000/svg">`;
const content = `<rect width="${this.attrs.width}" height="${
return `<rect width="${this.attrs.width}" height="${
this.attrs.height
}" transform="matrix(${matrixStr})" fill="#D9D9D9" stroke="black" stroke-width="${this.getStrokeWidth()}"></rect>`;
const svgTail = `</svg>`;
return svgHead + '\n' + content + '\n' + svgTail;
}" transform="matrix(${tf.join(' ')})"${cornerRadiusStr}`;
}
}
20 changes: 19 additions & 1 deletion packages/core/src/graphs/regular_polygon.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { parseHexToRGBA, parseRGBAStr } from '@suika/common';
import { getRegularPolygon, isPointInConvexPolygon } from '@suika/geo';
import {
getRegularPolygon,
type IPoint,
isPointInConvexPolygon,
} from '@suika/geo';
import { Matrix, type Optional } from 'pixi.js';

import { type ImgManager } from '../Img_manager';
Expand Down Expand Up @@ -147,4 +151,18 @@ export class RegularPolygon extends Graph<RegularPolygonAttrs> {
point,
);
}

override getSVGTagHead(offset?: IPoint) {
const tf = [...this.attrs.transform];
if (offset) {
tf[4] += offset.x;
tf[5] += offset.y;
}

const points = getRegularPolygon(this.getSize(), this.attrs.count);

return `<polygon points="${points
.map((p) => `${p.x},${p.y}`)
.join(' ')}" transform="matrix(${tf.join(' ')})"`;
}
}
Loading

0 comments on commit d2c6e8e

Please sign in to comment.