From 200aedc48af376221cbddc785483a19f98cfa89e Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Wed, 18 Sep 2024 20:38:47 +0900 Subject: [PATCH 01/21] feat: Introduce shape clipping - Let group shapes have clip-rule. --- src/composables/shapeComposite.ts | 5 ++ src/composables/shapeRenderer.ts | 82 ++++++++++++++++++++++++------- src/models/index.ts | 3 ++ src/shapes/core.ts | 2 + src/shapes/group.ts | 6 ++- src/shapes/index.ts | 9 ++++ src/shapes/simplePolygon.ts | 13 +++++ src/utils/tree.ts | 17 +++++-- 8 files changed, 113 insertions(+), 24 deletions(-) diff --git a/src/composables/shapeComposite.ts b/src/composables/shapeComposite.ts index a024f752..66d91287 100644 --- a/src/composables/shapeComposite.ts +++ b/src/composables/shapeComposite.ts @@ -93,6 +93,10 @@ export function newShapeComposite(option: Option) { shapeModule.renderShape(getStruct, ctx, shape, mergedShapeContext, imageStore); } + function clip(shape: Shape): Path2D | undefined { + return shapeModule.clipShape(getStruct, shape, mergedShapeContext); + } + function createSVGElementInfo(shape: Shape, imageStore?: ImageStore): SVGElementInfo | undefined { return shapeModule.createSVGElementInfo(getStruct, shape, mergedShapeContext, imageStore); } @@ -261,6 +265,7 @@ export function newShapeComposite(option: Option) { getAllTransformTargets, render, + clip, createSVGElementInfo, findShapeAt, isPointOn, diff --git a/src/composables/shapeRenderer.ts b/src/composables/shapeRenderer.ts index 8d310127..cfd57a19 100644 --- a/src/composables/shapeRenderer.ts +++ b/src/composables/shapeRenderer.ts @@ -1,7 +1,10 @@ +import { Shape } from "../models"; import { DocOutput } from "../models/document"; import { getShapeTextBounds } from "../shapes"; +import { isGroupShape } from "../shapes/group"; +import { splitList } from "../utils/commons"; import { getDocCompositionInfo, hasDocNoContent, renderDocByComposition } from "../utils/textEditor"; -import { walkTree } from "../utils/tree"; +import { TreeNode } from "../utils/tree"; import { ImageStore } from "./imageStore"; import { ShapeComposite } from "./shapeComposite"; @@ -19,26 +22,67 @@ export function newShapeRenderer(option: Option) { const ignoreDocIdSet = new Set(option.ignoreDocIds ?? []); function render(ctx: CanvasRenderingContext2D) { - walkTree(mergedShapeTree, (node) => { - const shape = mergedShapeMap[node.id]; - option.shapeComposite.render(ctx, shape, option.imageStore); - - const doc = docMap[shape.id]; - if (doc && !ignoreDocIdSet.has(shape.id) && !hasDocNoContent(doc)) { - ctx.save(); - const bounds = getShapeTextBounds(option.shapeComposite.getShapeStruct, shape); - ctx.transform(...bounds.affine); - - const infoCache = option.shapeComposite.getDocCompositeCache(shape.id, doc); - const info = infoCache ?? getDocCompositionInfo(doc, ctx, bounds.range.width, bounds.range.height); - if (!infoCache) { - option.shapeComposite.setDocCompositeCache(shape.id, info, doc); - } - - renderDocByComposition(ctx, info.composition, info.lines, option.scale); - ctx.restore(); + renderShapeTree(ctx, mergedShapeTree); + } + + function renderShapeTree(ctx: CanvasRenderingContext2D, treeNodes: TreeNode[]) { + treeNodes.forEach((n) => renderShapeTreeStep(ctx, n)); + } + + function renderShapeTreeStep(ctx: CanvasRenderingContext2D, node: TreeNode) { + const shape = mergedShapeMap[node.id]; + renderShapeAndDoc(ctx, shape); + if (node.children.length === 0) return; + + const isParentGroup = isGroupShape(shape); + const [others, clips] = splitList(node.children, (c) => { + return !isParentGroup || !mergedShapeMap[c.id].clipping; + }); + + if (!isParentGroup || clips.length === 0) { + others.forEach((c) => renderShapeTreeStep(ctx, c)); + return; + } + + ctx.save(); + const region = new Path2D(); + let clipped = false; + clips.forEach((c) => { + const childShape = mergedShapeMap[c.id]; + const subRegion = option.shapeComposite.clip(childShape); + if (subRegion) { + region.addPath(subRegion); + clipped = true; } }); + if (clipped) { + ctx.clip(region, shape.clipRule); + } + others.forEach((c) => renderShapeTreeStep(ctx, c)); + ctx.restore(); + } + + function renderShapeAndDoc(ctx: CanvasRenderingContext2D, shape: Shape) { + option.shapeComposite.render(ctx, shape, option.imageStore); + renderDoc(ctx, shape); + } + + function renderDoc(ctx: CanvasRenderingContext2D, shape: Shape) { + const doc = docMap[shape.id]; + if (doc && !ignoreDocIdSet.has(shape.id) && !hasDocNoContent(doc)) { + ctx.save(); + const bounds = getShapeTextBounds(option.shapeComposite.getShapeStruct, shape); + ctx.transform(...bounds.affine); + + const infoCache = option.shapeComposite.getDocCompositeCache(shape.id, doc); + const info = infoCache ?? getDocCompositionInfo(doc, ctx, bounds.range.width, bounds.range.height); + if (!infoCache) { + option.shapeComposite.setDocCompositeCache(shape.id, info, doc); + } + + renderDocByComposition(ctx, info.composition, info.lines, option.scale); + ctx.restore(); + } } return { render }; diff --git a/src/models/index.ts b/src/models/index.ts index 8d21477c..03ed5f87 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -27,8 +27,11 @@ export interface Shape extends Entity { gcV?: GroupConstraint; gcH?: GroupConstraint; locked?: boolean; + clipping?: boolean; } +export type ClipRule = "nonzero" | "evenodd"; + export interface Size { width: number; height: number; diff --git a/src/shapes/core.ts b/src/shapes/core.ts index 7cd7b739..7c2d958f 100644 --- a/src/shapes/core.ts +++ b/src/shapes/core.ts @@ -41,6 +41,7 @@ export interface ShapeStruct { * "shapeMap" and "treeNode" are used for such purpose. */ render: (ctx: CanvasRenderingContext2D, shape: T, shapeContext?: ShapeContext, imageStore?: ImageStore) => void; + clip?: (shape: T, shapeContext?: ShapeContext) => Path2D; createSVGElementInfo?: (shape: T, shapeContext?: ShapeContext, imageStore?: ImageStore) => SVGElementInfo | undefined; getWrapperRect: (shape: T, shapeContext?: ShapeContext, includeBounds?: boolean) => IRectangle; getLocalRectPolygon: (shape: T, shapeContext?: ShapeContext) => IVec2[]; @@ -124,6 +125,7 @@ export function createBaseShape(arg: Partial = {}): Shape { gcV: arg.gcV, gcH: arg.gcH, locked: arg.locked, + clipping: arg.clipping, }; } diff --git a/src/shapes/group.ts b/src/shapes/group.ts index 91e827e2..fe18b68a 100644 --- a/src/shapes/group.ts +++ b/src/shapes/group.ts @@ -3,7 +3,10 @@ import { Shape } from "../models"; import { getRectPoints, getRotateFn, getWrapperRect } from "../utils/geometry"; import { IVec2, applyAffine, getOuterRectangle, getRadian, getRectCenter } from "okageo"; -export type GroupShape = Shape & { type: "group" }; +export type GroupShape = Shape & { + type: "group"; + clipRule?: "nonzero" | "evenodd"; // undefined means "nonzero" +}; /** * This shape doesn't have own stable bounds. @@ -16,6 +19,7 @@ export const struct: ShapeStruct = { return { ...createBaseShape(arg), type: "group", + clipRule: arg.clipRule, }; }, // TODO: Bounds can be rendered with fill and stroke style. diff --git a/src/shapes/index.ts b/src/shapes/index.ts index 8635b375..44bec3e0 100644 --- a/src/shapes/index.ts +++ b/src/shapes/index.ts @@ -34,6 +34,15 @@ export function renderShape( struct.render(ctx, shape, shapeContext, imageStore); } +export function clipShape( + getStruct: GetShapeStruct, + shape: T, + shapeContext: ShapeContext, +): Path2D | undefined { + const struct = getStruct(shape.type); + return struct.clip?.(shape, shapeContext); +} + export function createSVGElementInfo( getStruct: GetShapeStruct, shape: T, diff --git a/src/shapes/simplePolygon.ts b/src/shapes/simplePolygon.ts index 0aea99d2..06294a5d 100644 --- a/src/shapes/simplePolygon.ts +++ b/src/shapes/simplePolygon.ts @@ -30,6 +30,7 @@ import { getMarkersOnPolygon, getRectPoints, getRotateFn, + getRotatedRectAffine, getRotatedWrapperRect, getRotationAffine, isPointOnRectangle, @@ -70,6 +71,7 @@ export function getStructForSimplePolygon( ): Pick< ShapeStruct, | "render" + | "clip" | "createSVGElementInfo" | "getWrapperRect" | "getLocalRectPolygon" @@ -100,6 +102,17 @@ export function getStructForSimplePolygon( } }); }, + clip(shape) { + const rect = { x: shape.p.x, y: shape.p.y, width: shape.width, height: shape.height }; + const { path, curves } = getPath(shape); + + const region = new Path2D(); + const localRegion = new Path2D(); + applyCurvePath(localRegion, path, curves, true); + const m = getRotatedRectAffine(rect, shape.rotation); + region.addPath(localRegion, { a: m[0], b: m[1], c: m[2], d: m[3], e: m[4], f: m[5] }); + return region; + }, createSVGElementInfo(shape) { const transform = getShapeTransform(shape); const { path, curves } = getPath(shape); diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 9424bafb..efff916e 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -68,16 +68,25 @@ function getChildNodes(parentMap: { [id: string]: T[] }, ); } +type TreeWalkOptions = { + onDown?: (parent: TreeNode) => void; + onUp?: (parent: TreeNode) => void; +}; + /** * Depth first ordered */ -export function walkTree(treeNodes: TreeNode[], fn: (node: TreeNode, i: number) => void) { - treeNodes.forEach((n, i) => walkTreeStep(n, fn, i)); +export function walkTree(treeNodes: TreeNode[], fn: (node: TreeNode, i: number) => void, options?: TreeWalkOptions) { + treeNodes.forEach((n, i) => walkTreeStep(n, fn, i, options)); } -function walkTreeStep(node: TreeNode, fn: (node: TreeNode, i: number) => void, i: number) { +function walkTreeStep(node: TreeNode, fn: (node: TreeNode, i: number) => void, i: number, options?: TreeWalkOptions) { fn(node, i); - node.children.forEach((c, j) => walkTreeStep(c, fn, j)); + if (node.children.length > 0) { + options?.onDown?.(node); + node.children.forEach((c, j) => walkTreeStep(c, fn, j, options)); + options?.onUp?.(node); + } } export function walkTreeWithValue(treeNodes: TreeNode[], fn: (node: TreeNode, i: number, t: T) => T, t: T) { From a657f05c03e81bf91d05582b733142477eed07dd Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Wed, 18 Sep 2024 21:12:02 +0900 Subject: [PATCH 02/21] feat: Take care of clipPath on SVG export - Group shapes need to make `` element to apply clipping --- src/composables/shapeSVGRenderer.ts | 62 ++++++++++++++++++++++++----- src/shapes/group.ts | 3 ++ src/shapes/simplePolygon.ts | 1 + 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/composables/shapeSVGRenderer.ts b/src/composables/shapeSVGRenderer.ts index ddd3987d..fcf42eeb 100644 --- a/src/composables/shapeSVGRenderer.ts +++ b/src/composables/shapeSVGRenderer.ts @@ -6,9 +6,11 @@ import { blobToBase64 } from "../utils/fileAccess"; import { createTemplateShapeEmbedElement } from "../shapes/utils/shapeTemplateUtil"; import { createSVGElement, createSVGSVGElement, renderTransform } from "../utils/svgElements"; import { getDocCompositionInfo, hasDocNoContent, renderSVGDocByComposition } from "../utils/textEditor"; -import { walkTree } from "../utils/tree"; +import { TreeNode } from "../utils/tree"; import { ImageStore } from "./imageStore"; import { ShapeComposite } from "./shapeComposite"; +import { isGroupShape } from "../shapes/group"; +import { splitList } from "../utils/commons"; interface Option { shapeComposite: ShapeComposite; @@ -23,15 +25,7 @@ export function newShapeSVGRenderer(option: Option) { async function render(ctx: CanvasRenderingContext2D): Promise { const root = createSVGSVGElement(); - - walkTree(mergedShapeTree, (node) => { - const shape = mergedShapeMap[node.id]; - const doc = docMap[shape.id]; - const elm = createShapeElement(option, ctx, shape, doc); - if (elm) { - root.appendChild(elm); - } - }); + renderShapeTree(root, ctx, mergedShapeTree); // Gather asset files used in the SVG. const assetDataMap = new Map(); @@ -81,6 +75,54 @@ export function newShapeSVGRenderer(option: Option) { return root; } + function renderShapeTree(root: SVGElement, ctx: CanvasRenderingContext2D, treeNodes: TreeNode[]) { + treeNodes.forEach((n) => renderShapeTreeStep(root, ctx, n)); + } + + function renderShapeTreeStep(root: SVGElement, ctx: CanvasRenderingContext2D, node: TreeNode) { + const shape = mergedShapeMap[node.id]; + const elm = renderShapeAndDoc(ctx, shape); + if (elm) { + root.appendChild(elm); + } + if (node.children.length === 0) return; + + const parentElm = elm ?? root; + const isParentGroup = isGroupShape(shape); + const [others, clips] = splitList(node.children, (c) => { + return !isParentGroup || !mergedShapeMap[c.id].clipping; + }); + + if (!elm || !isParentGroup || clips.length === 0) { + others.forEach((c) => renderShapeTreeStep(parentElm, ctx, c)); + return; + } + + const clipPathId = `clip-${shape.id}`; + const clipPath = createSVGElement("clipPath", { id: clipPathId, "clip-rule": shape.clipRule ?? "nonzero" }); + let clipped = false; + clips.forEach((c) => { + const childShape = mergedShapeMap[c.id]; + const childElm = createShapeElement(option, ctx, childShape); + if (childElm) { + clipPath.appendChild(childElm); + clipped = true; + } + }); + if (clipped) { + root.appendChild(clipPath); + elm.setAttribute("clip-path", `url(#${clipPathId})`); + } + + others.forEach((c) => renderShapeTreeStep(parentElm, ctx, c)); + ctx.restore(); + } + + function renderShapeAndDoc(ctx: CanvasRenderingContext2D, shape: Shape): SVGElement | undefined { + const doc = docMap[shape.id]; + return createShapeElement(option, ctx, shape, doc); + } + async function renderWithMeta(ctx: CanvasRenderingContext2D): Promise { const root = await render(ctx); diff --git a/src/shapes/group.ts b/src/shapes/group.ts index fe18b68a..0efebd8d 100644 --- a/src/shapes/group.ts +++ b/src/shapes/group.ts @@ -24,6 +24,9 @@ export const struct: ShapeStruct = { }, // TODO: Bounds can be rendered with fill and stroke style. render() {}, + createSVGElementInfo() { + return { tag: "g" }; + }, getWrapperRect(shape, shapeContext) { const children = shapeContext?.treeNodeMap[shape.id].children; if (!children || children.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; diff --git a/src/shapes/simplePolygon.ts b/src/shapes/simplePolygon.ts index 06294a5d..4726510e 100644 --- a/src/shapes/simplePolygon.ts +++ b/src/shapes/simplePolygon.ts @@ -73,6 +73,7 @@ export function getStructForSimplePolygon( | "render" | "clip" | "createSVGElementInfo" + | "createClipSVGElementInfo" | "getWrapperRect" | "getLocalRectPolygon" | "isPointOn" From 60f3b177d08526046671c9832db651471226470f Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Wed, 18 Sep 2024 23:16:02 +0900 Subject: [PATCH 03/21] refactor: Polish method names --- src/shapes/core.ts | 2 +- src/shapes/index.spec.ts | 11 +++++++++++ src/shapes/index.ts | 13 +++++++------ src/shapes/rectangle.spec.ts | 1 + src/shapes/rectangle.ts | 17 +++++++++++++---- src/shapes/simplePolygon.ts | 5 ++--- 6 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/shapes/core.ts b/src/shapes/core.ts index 7c2d958f..330d6358 100644 --- a/src/shapes/core.ts +++ b/src/shapes/core.ts @@ -41,7 +41,7 @@ export interface ShapeStruct { * "shapeMap" and "treeNode" are used for such purpose. */ render: (ctx: CanvasRenderingContext2D, shape: T, shapeContext?: ShapeContext, imageStore?: ImageStore) => void; - clip?: (shape: T, shapeContext?: ShapeContext) => Path2D; + getClipPath?: (shape: T, shapeContext?: ShapeContext) => Path2D; createSVGElementInfo?: (shape: T, shapeContext?: ShapeContext, imageStore?: ImageStore) => SVGElementInfo | undefined; getWrapperRect: (shape: T, shapeContext?: ShapeContext, includeBounds?: boolean) => IRectangle; getLocalRectPolygon: (shape: T, shapeContext?: ShapeContext) => IVec2[]; diff --git a/src/shapes/index.spec.ts b/src/shapes/index.spec.ts index da4f778c..db67f64e 100644 --- a/src/shapes/index.spec.ts +++ b/src/shapes/index.spec.ts @@ -1,5 +1,6 @@ import { expect, describe, test, vi } from "vitest"; import { + canClip, canHaveText, canHaveTextPadding, createShape, @@ -46,6 +47,7 @@ describe("renderShape", () => { const ctx = { beginPath: vi.fn(), closePath: vi.fn(), + moveTo: vi.fn(), lineTo: vi.fn(), fill: vi.fn(), stroke: vi.fn(), @@ -62,6 +64,15 @@ describe("renderShape", () => { }); }); +describe("canClip", () => { + test("should true when a shape can clip other shapes", () => { + const rect = createShape(getCommonStruct, "rectangle", {}); + const line = createShape(getCommonStruct, "line", {}); + expect(canClip(getCommonStruct, rect)).toBe(true); + expect(canClip(getCommonStruct, line)).toBe(false); + }); +}); + describe("getWrapperRect", () => { test("should return rectangle", () => { const shape = createShape(getCommonStruct, "rectangle", { id: "test", width: 10, height: 20 }); diff --git a/src/shapes/index.ts b/src/shapes/index.ts index 44bec3e0..644a1cc5 100644 --- a/src/shapes/index.ts +++ b/src/shapes/index.ts @@ -34,13 +34,14 @@ export function renderShape( struct.render(ctx, shape, shapeContext, imageStore); } -export function clipShape( - getStruct: GetShapeStruct, - shape: T, - shapeContext: ShapeContext, -): Path2D | undefined { +export function clipShape(getStruct: GetShapeStruct, shape: Shape, shapeContext: ShapeContext): Path2D | undefined { + const struct = getStruct(shape.type); + return struct.getClipPath?.(shape, shapeContext); +} + +export function canClip(getStruct: GetShapeStruct, shape: Shape): boolean { const struct = getStruct(shape.type); - return struct.clip?.(shape, shapeContext); + return !!struct.getClipPath; } export function createSVGElementInfo( diff --git a/src/shapes/rectangle.spec.ts b/src/shapes/rectangle.spec.ts index dae39635..59dc20d2 100644 --- a/src/shapes/rectangle.spec.ts +++ b/src/shapes/rectangle.spec.ts @@ -15,6 +15,7 @@ describe("struct", () => { const ctx = { beginPath: vi.fn(), closePath: vi.fn(), + moveTo: vi.fn(), lineTo: vi.fn(), fill: vi.fn(), stroke: vi.fn(), diff --git a/src/shapes/rectangle.ts b/src/shapes/rectangle.ts index 4f59b44e..af6b61d5 100644 --- a/src/shapes/rectangle.ts +++ b/src/shapes/rectangle.ts @@ -33,6 +33,7 @@ import { import { createBoxPadding, getPaddingRect } from "../utils/boxPadding"; import { renderTransform } from "../utils/svgElements"; import { RectPolygonShape } from "./rectPolygon"; +import { applyPath } from "../utils/renderer"; export type RectangleShape = RectPolygonShape & CommonStyle & TextContainer; @@ -54,10 +55,7 @@ export const struct: ShapeStruct = { const rectPolygon = getLocalRectPolygon(shape); ctx.beginPath(); - rectPolygon.forEach((p) => { - ctx.lineTo(p.x, p.y); - }); - ctx.closePath(); + applyPath(ctx, rectPolygon, true); if (!shape.fill.disabled) { applyFillStyle(ctx, shape.fill); ctx.fill(); @@ -67,6 +65,17 @@ export const struct: ShapeStruct = { ctx.stroke(); } }, + getClipPath(shape) { + const rect = { x: shape.p.x, y: shape.p.y, width: shape.width, height: shape.height }; + const rectPolygon = getLocalRectPolygon(shape); + + const region = new Path2D(); + const localRegion = new Path2D(); + applyPath(localRegion, rectPolygon, true); + const m = getRotatedRectAffine(rect, shape.rotation); + region.addPath(localRegion, { a: m[0], b: m[1], c: m[2], d: m[3], e: m[4], f: m[5] }); + return region; + }, createSVGElementInfo(shape) { const rect = { x: shape.p.x, y: shape.p.y, width: shape.width, height: shape.height }; const affine = getRotatedRectAffine(rect, shape.rotation); diff --git a/src/shapes/simplePolygon.ts b/src/shapes/simplePolygon.ts index 4726510e..81366444 100644 --- a/src/shapes/simplePolygon.ts +++ b/src/shapes/simplePolygon.ts @@ -71,9 +71,8 @@ export function getStructForSimplePolygon( ): Pick< ShapeStruct, | "render" - | "clip" + | "getClipPath" | "createSVGElementInfo" - | "createClipSVGElementInfo" | "getWrapperRect" | "getLocalRectPolygon" | "isPointOn" @@ -103,7 +102,7 @@ export function getStructForSimplePolygon( } }); }, - clip(shape) { + getClipPath(shape) { const rect = { x: shape.p.x, y: shape.p.y, width: shape.width, height: shape.height }; const { path, curves } = getPath(shape); From bc22f1dde8b67c4d381b0a6cc85890be09021317 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Wed, 18 Sep 2024 23:32:11 +0900 Subject: [PATCH 04/21] fix: Invalid clipping path for rectangles --- src/shapes/rectangle.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/shapes/rectangle.ts b/src/shapes/rectangle.ts index af6b61d5..b1e975c2 100644 --- a/src/shapes/rectangle.ts +++ b/src/shapes/rectangle.ts @@ -66,14 +66,10 @@ export const struct: ShapeStruct = { } }, getClipPath(shape) { - const rect = { x: shape.p.x, y: shape.p.y, width: shape.width, height: shape.height }; const rectPolygon = getLocalRectPolygon(shape); const region = new Path2D(); - const localRegion = new Path2D(); - applyPath(localRegion, rectPolygon, true); - const m = getRotatedRectAffine(rect, shape.rotation); - region.addPath(localRegion, { a: m[0], b: m[1], c: m[2], d: m[3], e: m[4], f: m[5] }); + applyPath(region, rectPolygon, true); return region; }, createSVGElementInfo(shape) { From 7e34bbd21cb9e5f19bf5abee389ec370e2350e32 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 19 Sep 2024 21:33:02 +0900 Subject: [PATCH 05/21] feat: Add some fields for clipping to the inspector panel --- src/assets/icons/clip_rule_evenodd.svg | 2 + src/assets/icons/clip_rule_nonzero.svg | 2 + .../shapeInspectorPanel/ClipInspector.tsx | 24 +++++++ .../shapeInspectorPanel/ClipRuleInspector.tsx | 62 +++++++++++++++++++ .../GroupConstraintInspector.tsx | 2 +- .../ShapeInspectorPanel.tsx | 26 ++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/assets/icons/clip_rule_evenodd.svg create mode 100644 src/assets/icons/clip_rule_nonzero.svg create mode 100644 src/components/shapeInspectorPanel/ClipInspector.tsx create mode 100644 src/components/shapeInspectorPanel/ClipRuleInspector.tsx diff --git a/src/assets/icons/clip_rule_evenodd.svg b/src/assets/icons/clip_rule_evenodd.svg new file mode 100644 index 00000000..4fc48259 --- /dev/null +++ b/src/assets/icons/clip_rule_evenodd.svg @@ -0,0 +1,2 @@ + + diff --git a/src/assets/icons/clip_rule_nonzero.svg b/src/assets/icons/clip_rule_nonzero.svg new file mode 100644 index 00000000..118fce5e --- /dev/null +++ b/src/assets/icons/clip_rule_nonzero.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/components/shapeInspectorPanel/ClipInspector.tsx b/src/components/shapeInspectorPanel/ClipInspector.tsx new file mode 100644 index 00000000..679b6b03 --- /dev/null +++ b/src/components/shapeInspectorPanel/ClipInspector.tsx @@ -0,0 +1,24 @@ +import { useCallback } from "react"; +import { Shape } from "../../models"; +import { ToggleInput } from "../atoms/inputs/ToggleInput"; +import { InlineField } from "../atoms/InlineField"; + +interface Props { + targetShape: Shape; + updateTargetShape: (patch: Partial) => void; +} + +export const ClipInspector: React.FC = ({ targetShape, updateTargetShape }) => { + const handleChange = useCallback( + (val: boolean) => { + updateTargetShape({ clipping: val }); + }, + [updateTargetShape], + ); + + return ( + + + + ); +}; diff --git a/src/components/shapeInspectorPanel/ClipRuleInspector.tsx b/src/components/shapeInspectorPanel/ClipRuleInspector.tsx new file mode 100644 index 00000000..dbd4c027 --- /dev/null +++ b/src/components/shapeInspectorPanel/ClipRuleInspector.tsx @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { ClipRule } from "../../models"; +import { BlockField } from "../atoms/BlockField"; +import iconNonzero from "../../assets/icons/clip_rule_nonzero.svg"; +import iconEvenodd from "../../assets/icons/clip_rule_evenodd.svg"; +import { GroupShape } from "../../shapes/group"; + +const rules: { value: ClipRule; icon: string }[] = [ + { value: "nonzero", icon: iconNonzero }, + { value: "evenodd", icon: iconEvenodd }, +]; + +interface Props { + targetShape: GroupShape; + updateTargetShape: (patch: Partial) => void; +} + +export const ClipRuleInspector: React.FC = ({ targetShape, updateTargetShape }) => { + const handleChange = useCallback( + (val: ClipRule) => { + updateTargetShape({ clipRule: val }); + }, + [updateTargetShape], + ); + + return ( + +
+ {rules.map((rule) => ( + + ))} +
+
+ ); +}; + +interface ItemButtonProps { + value: ClipRule; + icon: string; + selectedValue?: ClipRule; + onClick?: (val: ClipRule) => void; +} + +const ItemButton: React.FC = ({ value, icon, selectedValue, onClick }) => { + const handleClick = useCallback(() => { + onClick?.(value); + }, [value, onClick]); + + const highlightClassName = value === (selectedValue ?? 0) ? " border-cyan-400" : " border-white"; + + return ( + + ); +}; diff --git a/src/components/shapeInspectorPanel/GroupConstraintInspector.tsx b/src/components/shapeInspectorPanel/GroupConstraintInspector.tsx index 880d4cae..311add7a 100644 --- a/src/components/shapeInspectorPanel/GroupConstraintInspector.tsx +++ b/src/components/shapeInspectorPanel/GroupConstraintInspector.tsx @@ -74,7 +74,7 @@ interface ItemButtonProps { onClick?: (val: GroupConstraint) => void; } -export const ItemButton: React.FC = ({ value, icon, vertical, selectedValue, onClick }) => { +const ItemButton: React.FC = ({ value, icon, vertical, selectedValue, onClick }) => { const handleClick = useCallback(() => { onClick?.(value); }, [value, onClick]); diff --git a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx index 88d64db2..b2c8523a 100644 --- a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx +++ b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx @@ -9,6 +9,10 @@ import { LineShapeInspector } from "./LineShapeInspector"; import { LineShape, isLineShape } from "../../shapes/line"; import { GroupConstraintInspector } from "./GroupConstraintInspector"; import { MultipleShapesInspector } from "./MultipleShapesInspector"; +import { ClipInspector } from "./ClipInspector"; +import { canClip } from "../../shapes"; +import { GroupShape, isGroupShape } from "../../shapes/group"; +import { ClipRuleInspector } from "./ClipRuleInspector"; export const ShapeInspectorPanel: React.FC = () => { const targetShape = useSelectedShape(); @@ -110,6 +114,22 @@ const ShapeInspectorPanelWithShape: React.FC [targetShapes, getShapeComposite, patchShapes], ); + const updateGroupShapesBySamePatch = useCallback( + (patch: Partial) => { + const shapeComposite = getShapeComposite(); + + const layoutPatch = getPatchByLayouts(shapeComposite, { + update: targetShapes.reduce<{ [id: string]: Partial }>((p, s) => { + if (!isGroupShape(s)) return p; + p[s.id] = patch; + return p; + }, {}), + }); + patchShapes(layoutPatch); + }, + [targetShapes, getShapeComposite, patchShapes], + ); + return (
{targetShapes.length >= 2 ? ( @@ -143,6 +163,12 @@ const ShapeInspectorPanelWithShape: React.FC )} + {canClip(getShapeComposite().getShapeStruct, targetShape) ? ( + + ) : undefined} + {isGroupShape(targetShape) ? ( + + ) : undefined} ); }; diff --git a/src/components/shapeInspectorPanel/ClipRuleInspector.tsx b/src/components/shapeInspectorPanel/ClipRuleInspector.tsx deleted file mode 100644 index dcaed8ac..00000000 --- a/src/components/shapeInspectorPanel/ClipRuleInspector.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback } from "react"; -import { ClipRule } from "../../models"; -import { BlockField } from "../atoms/BlockField"; -import iconClipIn from "../../assets/icons/clip_rule_in.svg"; -import iconClipOut from "../../assets/icons/clip_rule_out.svg"; -import { GroupShape } from "../../shapes/group"; - -const rules: { value: ClipRule; icon: string }[] = [ - { value: "out", icon: iconClipOut }, - { value: "in", icon: iconClipIn }, -]; - -interface Props { - targetShape: GroupShape; - updateTargetShape: (patch: Partial) => void; -} - -export const ClipRuleInspector: React.FC = ({ targetShape, updateTargetShape }) => { - const handleChange = useCallback( - (val: ClipRule) => { - updateTargetShape({ clipRule: val }); - }, - [updateTargetShape], - ); - - return ( - -
- {rules.map((rule) => ( - - ))} -
-
- ); -}; - -interface ItemButtonProps { - value: ClipRule; - icon: string; - selectedValue?: ClipRule; - onClick?: (val: ClipRule) => void; -} - -const ItemButton: React.FC = ({ value, icon, selectedValue, onClick }) => { - const handleClick = useCallback(() => { - onClick?.(value); - }, [value, onClick]); - - const highlightClassName = value === (selectedValue ?? 0) ? " border-cyan-400" : " border-white"; - - return ( - - ); -}; diff --git a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx index b2c8523a..b395d956 100644 --- a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx +++ b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx @@ -9,10 +9,9 @@ import { LineShapeInspector } from "./LineShapeInspector"; import { LineShape, isLineShape } from "../../shapes/line"; import { GroupConstraintInspector } from "./GroupConstraintInspector"; import { MultipleShapesInspector } from "./MultipleShapesInspector"; -import { ClipInspector } from "./ClipInspector"; import { canClip } from "../../shapes"; import { GroupShape, isGroupShape } from "../../shapes/group"; -import { ClipRuleInspector } from "./ClipRuleInspector"; +import { ClipInspector } from "./ClipInspector"; export const ShapeInspectorPanel: React.FC = () => { const targetShape = useSelectedShape(); @@ -164,10 +163,11 @@ const ShapeInspectorPanelWithShape: React.FC )} {canClip(getShapeComposite().getShapeStruct, targetShape) ? ( - - ) : undefined} - {isGroupShape(targetShape) ? ( - + ) : undefined}