From f762797772fffae414e4d26abe288ae644af435c Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Fri, 21 Mar 2025 16:47:39 +0000 Subject: [PATCH] refactor(editor): move shape toolbar config and components to its package (#11082) --- .../src/edgeless/configs/toolbar/shape.ts | 427 +++--------------- .../affine/blocks/block-root/src/effects.ts | 9 - blocksuite/affine/gfx/shape/package.json | 1 + .../affine/gfx/shape/src/draggable/index.ts | 2 + .../shape/src/draggable}/shape-draggable.ts | 9 +- .../shape/src/draggable}/shape-tool-button.ts | 2 +- .../shape/src/draggable}/utils.ts | 0 blocksuite/affine/gfx/shape/src/effects.ts | 14 +- .../affine/gfx/shape/src/toolbar/index.ts | 1 + .../affine/gfx/shape/src/toolbar/shape.ts | 328 ++++++++++++++ blocksuite/affine/gfx/shape/tsconfig.json | 1 + tools/utils/src/workspace.gen.ts | 1 + yarn.lock | 1 + 13 files changed, 410 insertions(+), 386 deletions(-) rename blocksuite/affine/{blocks/block-root/src/edgeless/components/toolbar/shape => gfx/shape/src/draggable}/shape-draggable.ts (98%) rename blocksuite/affine/{blocks/block-root/src/edgeless/components/toolbar/shape => gfx/shape/src/draggable}/shape-tool-button.ts (97%) rename blocksuite/affine/{blocks/block-root/src/edgeless/components/toolbar/shape => gfx/shape/src/draggable}/utils.ts (100%) create mode 100644 blocksuite/affine/gfx/shape/src/toolbar/shape.ts diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts index eeb2d5fdce..0d06907a32 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts @@ -1,383 +1,72 @@ +import { shapeToolbarConfig } from '@blocksuite/affine-gfx-shape'; import { - EdgelessCRUDIdentifier, - normalizeShapeBound, -} from '@blocksuite/affine-block-surface'; -import { - packColor, - type PickColorEvent, -} from '@blocksuite/affine-components/color-picker'; -import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel'; -import { - mountShapeTextEditor, - ShapeComponentConfig, - type ShapeToolOption, -} from '@blocksuite/affine-gfx-shape'; -import { - type Color, - DefaultTheme, - FontFamily, - getShapeName, - getShapeRadius, - getShapeType, - isTransparent, - LineWidth, MindmapElementModel, - resolveColor, ShapeElementModel, - type ShapeName, - ShapeStyle, - ShapeType, - StrokeStyle, } from '@blocksuite/affine-model'; -import type { - ToolbarGenericAction, - ToolbarModuleConfig, -} from '@blocksuite/affine-shared/services'; -import { getMostCommonValue } from '@blocksuite/affine-shared/utils'; -import { - createTextActions, - getRootBlock, - LINE_STYLE_LIST, - renderMenu, -} from '@blocksuite/affine-widget-edgeless-toolbar'; -import { Bound } from '@blocksuite/global/gfx'; -import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit'; -import { html } from 'lit'; -import isEqual from 'lodash-es/isEqual'; +import type { ToolbarActions } from '@blocksuite/affine-shared/services'; import { createMindmapLayoutActionMenu, createMindmapStyleActionMenu, } from './mindmap'; +const mindmapActions = [ + { + id: 'a.mindmap-style', + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return models.some(hasGrouped); + }, + content(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + if (!models.length) return null; + + let mindmaps = models + .map(model => model.group) + .filter(model => ctx.matchModel(model, MindmapElementModel)); + if (!mindmaps.length) return null; + + // Not displayed when there is both a normal shape and a mindmap shape. + if (models.length !== mindmaps.length) return null; + + mindmaps = Array.from(new Set(mindmaps)); + + return createMindmapStyleActionMenu(ctx, mindmaps); + }, + }, + { + id: 'b.mindmap-layout', + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return models.some(hasGrouped); + }, + content(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + if (!models.length) return null; + + let mindmaps = models + .map(model => model.group) + .filter(model => ctx.matchModel(model, MindmapElementModel)); + if (!mindmaps.length) return null; + + // Not displayed when there is both a normal shape and a mindmap shape. + if (models.length !== mindmaps.length) return null; + + mindmaps = Array.from(new Set(mindmaps)); + + // It's a sub node. + if (models.length === 1 && mindmaps[0].tree.element !== models[0]) + return null; + + return createMindmapLayoutActionMenu(ctx, mindmaps); + }, + }, +] as const satisfies ToolbarActions; + export const builtinShapeToolbarConfig = { - actions: [ - { - id: 'a.mindmap-style', - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return models.some(hasGrouped); - }, - content(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - if (!models.length) return null; - - let mindmaps = models - .map(model => model.group) - .filter(model => ctx.matchModel(model, MindmapElementModel)); - if (!mindmaps.length) return null; - - // Not displayed when there is both a normal shape and a mindmap shape. - if (models.length !== mindmaps.length) return null; - - mindmaps = Array.from(new Set(mindmaps)); - - return createMindmapStyleActionMenu(ctx, mindmaps); - }, - }, - { - id: 'b.mindmap-layout', - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return models.some(hasGrouped); - }, - content(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - if (!models.length) return null; - - let mindmaps = models - .map(model => model.group) - .filter(model => ctx.matchModel(model, MindmapElementModel)); - if (!mindmaps.length) return null; - - // Not displayed when there is both a normal shape and a mindmap shape. - if (models.length !== mindmaps.length) return null; - - mindmaps = Array.from(new Set(mindmaps)); - - // It's a sub node. - if (models.length === 1 && mindmaps[0].tree.element !== models[0]) - return null; - - return createMindmapLayoutActionMenu(ctx, mindmaps); - }, - }, - { - id: 'c.switch-type', - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return models.length > 0 && models.every(model => !hasGrouped(model)); - }, - content(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - if (!models.length) return null; - - const shapeName = - getMostCommonValue( - models.map(model => ({ - shapeName: getShapeName(model.shapeType, model.radius), - })), - 'shapeName' - ) ?? ShapeType.Rect; - - const onPick = (shapeName: ShapeName) => { - const shapeType = getShapeType(shapeName); - const radius = getShapeRadius(shapeName); - - ctx.std.store.captureSync(); - - for (const model of models) { - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, { shapeType, radius }); - } - }; - - return renderMenu({ - icon: ShapeIcon(), - label: 'Switch type', - items: ShapeComponentConfig.map(item => ({ - key: item.tooltip, - value: item.name, - // TODO(@fundon): should add a feature flag to switch style - icon: item.generalIcon, - disabled: item.disabled, - })), - currentValue: shapeName, - onPick, - }); - }, - }, - { - id: 'd.style', - // TODO(@fundon): should add a feature flag - when: false, - content(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - if (!models.length) return null; - - const field = 'shapeStyle'; - const shapeStyle = - getMostCommonValue(models, field) ?? ShapeStyle.General; - const onPick = (value: boolean) => { - const shapeStyle = value ? ShapeStyle.Scribbled : ShapeStyle.General; - const fontFamily = value ? FontFamily.Kalam : FontFamily.Inter; - - for (const model of models) { - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, { shapeStyle, fontFamily }); - } - }; - - return renderMenu({ - label: 'Style', - items: LINE_STYLE_LIST, - currentValue: shapeStyle === ShapeStyle.Scribbled, - onPick, - }); - }, - }, - { - id: 'e.color', - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return models.length > 0 && models.every(model => !hasGrouped(model)); - }, - content(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - if (!models.length) return null; - - const enableCustomColor = ctx.features.getFlag('enable_color_picker'); - const theme = ctx.theme.edgeless$.value; - - const firstModel = models[0]; - const originalFillColor = firstModel.fillColor; - const originalStrokeColor = firstModel.strokeColor; - - const mapped = models.map( - ({ filled, fillColor, strokeColor, strokeWidth, strokeStyle }) => ({ - fillColor: filled - ? resolveColor(fillColor, theme) - : DefaultTheme.transparent, - strokeColor: resolveColor(strokeColor, theme), - strokeWidth, - strokeStyle, - }) - ); - const fillColor = - getMostCommonValue(mapped, 'fillColor') ?? - resolveColor(DefaultTheme.shapeFillColor, theme); - const strokeColor = - getMostCommonValue(mapped, 'strokeColor') ?? - resolveColor(DefaultTheme.shapeStrokeColor, theme); - const strokeWidth = - getMostCommonValue(mapped, 'strokeWidth') ?? LineWidth.Four; - const strokeStyle = - getMostCommonValue(mapped, 'strokeStyle') ?? StrokeStyle.Solid; - - const onPickFillColor = (e: CustomEvent) => { - e.stopPropagation(); - - const d = e.detail; - - const field = 'fillColor'; - - if (d.type === 'pick') { - const value = d.detail.value; - const filled = isTransparent(value); - for (const model of models) { - const props = packColor(field, value); - // If `filled` can be set separately, this logic can be removed - if (field && !model.filled) { - const color = getTextColor(value, filled); - Object.assign(props, { filled, color }); - } - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, props); - } - return; - } - - for (const model of models) { - model[d.type === 'start' ? 'stash' : 'pop'](field); - } - }; - const onPickStrokeColor = (e: CustomEvent) => { - e.stopPropagation(); - - const d = e.detail; - - const field = 'strokeColor'; - - if (d.type === 'pick') { - const value = d.detail.value; - for (const model of models) { - const props = packColor(field, value); - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, props); - } - return; - } - - for (const model of models) { - model[d.type === 'start' ? 'stash' : 'pop'](field); - } - }; - const onPickStrokeStyle = (e: CustomEvent) => { - e.stopPropagation(); - - const { type, value } = e.detail; - - if (type === 'size') { - const strokeWidth = value; - for (const model of models) { - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, { strokeWidth }); - } - return; - } - - const strokeStyle = value; - for (const model of models) { - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, { strokeStyle }); - } - }; - - return html` - - - `; - }, - }, - { - id: 'f.text', - tooltip: 'Add text', - icon: AddTextIcon(), - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return models.length === 1 && !hasGrouped(models[0]) && !models[0].text; - }, - run(ctx) { - const model = ctx.getCurrentModelByType(ShapeElementModel); - if (!model) return; - - const rootBlock = getRootBlock(ctx); - if (!rootBlock) return; - - mountShapeTextEditor(model, rootBlock); - }, - }, - // id: `g.text` - ...createTextActions(ShapeElementModel, 'shape', (ctx, model, props) => { - // No need to adjust element bounds - if (props['textAlign']) { - ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props); - return; - } - - const xywh = normalizeShapeBound( - model, - Bound.fromXYWH(model.deserializedXYWH) - ).serialize(); - - ctx.std - .get(EdgelessCRUDIdentifier) - .updateElement(model.id, { ...props, xywh }); - }).map(action => ({ - ...action, - id: `g.text-${action.id}`, - when(ctx) { - const models = ctx.getSurfaceModelsByType(ShapeElementModel); - return ( - models.length > 0 && - models.every(model => !hasGrouped(model) && model.text) - ); - }, - })), - ], - - when: ctx => ctx.getSurfaceModelsByType(ShapeElementModel).length > 0, -} as const satisfies ToolbarModuleConfig; - -// When the shape is filled with black color, the text color should be white. -// When the shape is transparent, the text color should be set according to the theme. -// Otherwise, the text color should be black. -function getTextColor(fillColor: Color, isNotTransparent = false) { - if (isNotTransparent) { - if (isEqual(fillColor, DefaultTheme.black)) { - return DefaultTheme.white; - } else if (isEqual(fillColor, DefaultTheme.white)) { - return DefaultTheme.black; - } else if (isEqual(fillColor, DefaultTheme.pureBlack)) { - return DefaultTheme.pureWhite; - } else if (isEqual(fillColor, DefaultTheme.pureWhite)) { - return DefaultTheme.pureBlack; - } - } - - // aka `DefaultTheme.pureBlack` - return DefaultTheme.shapeTextColor; -} + ...shapeToolbarConfig, + actions: [...mindmapActions, ...shapeToolbarConfig.actions], +}; function hasGrouped(model: ShapeElementModel) { return model.group instanceof MindmapElementModel; diff --git a/blocksuite/affine/blocks/block-root/src/effects.ts b/blocksuite/affine/blocks/block-root/src/effects.ts index 90e8d38d8f..868f1ae4bf 100644 --- a/blocksuite/affine/blocks/block-root/src/effects.ts +++ b/blocksuite/affine/blocks/block-root/src/effects.ts @@ -51,8 +51,6 @@ import { EdgelessFrameOrderMenu } from './edgeless/components/toolbar/present/fr import { EdgelessNavigatorSettingButton } from './edgeless/components/toolbar/present/navigator-setting-button.js'; import { EdgelessPresentButton } from './edgeless/components/toolbar/present/present-button.js'; import { PresentationToolbar } from './edgeless/components/toolbar/presentation-toolbar.js'; -import { EdgelessToolbarShapeDraggable } from './edgeless/components/toolbar/shape/shape-draggable.js'; -import { EdgelessShapeToolButton } from './edgeless/components/toolbar/shape/shape-tool-button.js'; import { OverlayScrollbar } from './edgeless/components/toolbar/template/overlay-scrollbar.js'; import { AffineTemplateLoading } from './edgeless/components/toolbar/template/template-loading.js'; import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/template-panel.js'; @@ -168,7 +166,6 @@ function registerEdgelessToolbarComponents() { EdgelessMindmapToolButton ); customElements.define('edgeless-note-tool-button', EdgelessNoteToolButton); - customElements.define('edgeless-shape-tool-button', EdgelessShapeToolButton); customElements.define('edgeless-template-button', EdgelessTemplateButton); // Menus @@ -181,10 +178,6 @@ function registerEdgelessToolbarComponents() { customElements.define('edgeless-slide-menu', EdgelessSlideMenu); // Toolbar components - customElements.define( - 'edgeless-toolbar-shape-draggable', - EdgelessToolbarShapeDraggable - ); customElements.define('toolbar-arrow-up-icon', ToolbarArrowUpIcon); // Frame order components @@ -322,8 +315,6 @@ declare global { 'edgeless-frame-order-menu': EdgelessFrameOrderMenu; 'edgeless-navigator-setting-button': EdgelessNavigatorSettingButton; 'edgeless-present-button': EdgelessPresentButton; - 'edgeless-toolbar-shape-draggable': EdgelessToolbarShapeDraggable; - 'edgeless-shape-tool-button': EdgelessShapeToolButton; 'overlay-scrollbar': OverlayScrollbar; 'affine-template-loading': AffineTemplateLoading; 'edgeless-templates-panel': EdgelessTemplatePanel; diff --git a/blocksuite/affine/gfx/shape/package.json b/blocksuite/affine/gfx/shape/package.json index 6e2ceecb1c..f6922531e0 100644 --- a/blocksuite/affine/gfx/shape/package.json +++ b/blocksuite/affine/gfx/shape/package.json @@ -15,6 +15,7 @@ "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-rich-text": "workspace:*", "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-edgeless-toolbar": "workspace:*", "@blocksuite/block-std": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.2.6", diff --git a/blocksuite/affine/gfx/shape/src/draggable/index.ts b/blocksuite/affine/gfx/shape/src/draggable/index.ts index ceb895bd3f..4f2585ab3e 100644 --- a/blocksuite/affine/gfx/shape/src/draggable/index.ts +++ b/blocksuite/affine/gfx/shape/src/draggable/index.ts @@ -1,2 +1,4 @@ +export * from './shape-draggable'; export * from './shape-menu'; +export * from './shape-tool-button'; export * from './shape-tool-element'; diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-draggable.ts b/blocksuite/affine/gfx/shape/src/draggable/shape-draggable.ts similarity index 98% rename from blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-draggable.ts rename to blocksuite/affine/gfx/shape/src/draggable/shape-draggable.ts index cae9fac164..770d273b9e 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-draggable.ts +++ b/blocksuite/affine/gfx/shape/src/draggable/shape-draggable.ts @@ -2,12 +2,6 @@ import { CanvasElementType, EdgelessCRUDIdentifier, } from '@blocksuite/affine-block-surface'; -import { - ellipseSvg, - roundedSvg, - ShapeTool, - triangleSvg, -} from '@blocksuite/affine-gfx-shape'; import { getShapeRadius, getShapeType, @@ -30,6 +24,8 @@ import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { ShapeTool } from '../shape-tool.js'; +import { ellipseSvg, roundedSvg, triangleSvg } from '../toolbar/icons.js'; import type { DraggableShape } from './utils.js'; import { buildVariablesObject } from './utils.js'; @@ -210,6 +206,7 @@ export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin( this._setShapeOverlayLock(false); this.readyToDrop = false; + // @ts-expect-error FIXME: resolve after gfx tool refactor this.gfx.tool.setTool('default'); this.gfx.selection.set({ elements: [id], diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-tool-button.ts b/blocksuite/affine/gfx/shape/src/draggable/shape-tool-button.ts similarity index 97% rename from blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-tool-button.ts rename to blocksuite/affine/gfx/shape/src/draggable/shape-tool-button.ts index 3cbaddca01..487a98a96e 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/shape-tool-button.ts +++ b/blocksuite/affine/gfx/shape/src/draggable/shape-tool-button.ts @@ -1,9 +1,9 @@ -import { ShapeTool } from '@blocksuite/affine-gfx-shape'; import { type ShapeName, ShapeType } from '@blocksuite/affine-model'; import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar'; import { SignalWatcher } from '@blocksuite/global/lit'; import { css, html, LitElement } from 'lit'; +import { ShapeTool } from '../shape-tool.js'; import type { DraggableShape } from './utils.js'; export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin( diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/utils.ts b/blocksuite/affine/gfx/shape/src/draggable/utils.ts similarity index 100% rename from blocksuite/affine/blocks/block-root/src/edgeless/components/toolbar/shape/utils.ts rename to blocksuite/affine/gfx/shape/src/draggable/utils.ts diff --git a/blocksuite/affine/gfx/shape/src/effects.ts b/blocksuite/affine/gfx/shape/src/effects.ts index 54e65ad51e..0397f2051e 100644 --- a/blocksuite/affine/gfx/shape/src/effects.ts +++ b/blocksuite/affine/gfx/shape/src/effects.ts @@ -1,4 +1,9 @@ -import { EdgelessShapeMenu, EdgelessShapeToolElement } from './draggable'; +import { + EdgelessShapeMenu, + EdgelessShapeToolButton, + EdgelessShapeToolElement, + EdgelessToolbarShapeDraggable, +} from './draggable'; import { EdgelessShapeTextEditor } from './text/edgeless-shape-text-editor'; export function effects() { @@ -8,6 +13,11 @@ export function effects() { 'edgeless-shape-tool-element', EdgelessShapeToolElement ); + customElements.define('edgeless-shape-tool-button', EdgelessShapeToolButton); + customElements.define( + 'edgeless-toolbar-shape-draggable', + EdgelessToolbarShapeDraggable + ); } declare global { @@ -15,5 +25,7 @@ declare global { 'edgeless-shape-text-editor': EdgelessShapeTextEditor; 'edgeless-shape-menu': EdgelessShapeMenu; 'edgeless-shape-tool-element': EdgelessShapeToolElement; + 'edgeless-toolbar-shape-draggable': EdgelessToolbarShapeDraggable; + 'edgeless-shape-tool-button': EdgelessShapeToolButton; } } diff --git a/blocksuite/affine/gfx/shape/src/toolbar/index.ts b/blocksuite/affine/gfx/shape/src/toolbar/index.ts index 81630f688d..0509c17f6d 100644 --- a/blocksuite/affine/gfx/shape/src/toolbar/index.ts +++ b/blocksuite/affine/gfx/shape/src/toolbar/index.ts @@ -1,2 +1,3 @@ export * from './icons'; +export * from './shape'; export * from './shape-menu-config'; diff --git a/blocksuite/affine/gfx/shape/src/toolbar/shape.ts b/blocksuite/affine/gfx/shape/src/toolbar/shape.ts new file mode 100644 index 0000000000..eaeb7131e5 --- /dev/null +++ b/blocksuite/affine/gfx/shape/src/toolbar/shape.ts @@ -0,0 +1,328 @@ +import { + EdgelessCRUDIdentifier, + normalizeShapeBound, +} from '@blocksuite/affine-block-surface'; +import { + packColor, + type PickColorEvent, +} from '@blocksuite/affine-components/color-picker'; +import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel'; +import { + type Color, + DefaultTheme, + FontFamily, + getShapeName, + getShapeRadius, + getShapeType, + isTransparent, + LineWidth, + MindmapElementModel, + resolveColor, + ShapeElementModel, + type ShapeName, + ShapeStyle, + ShapeType, + StrokeStyle, +} from '@blocksuite/affine-model'; +import type { + ToolbarGenericAction, + ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getMostCommonValue } from '@blocksuite/affine-shared/utils'; +import { + createTextActions, + getRootBlock, + LINE_STYLE_LIST, + renderMenu, +} from '@blocksuite/affine-widget-edgeless-toolbar'; +import { Bound } from '@blocksuite/global/gfx'; +import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; +import isEqual from 'lodash-es/isEqual'; + +import type { ShapeToolOption } from '../shape-tool'; +import { mountShapeTextEditor } from '../text'; +import { ShapeComponentConfig } from './shape-menu-config'; + +export const shapeToolbarConfig = { + actions: [ + { + id: 'c.switch-type', + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return models.length > 0 && models.every(model => !hasGrouped(model)); + }, + content(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + if (!models.length) return null; + + const shapeName = + getMostCommonValue( + models.map(model => ({ + shapeName: getShapeName(model.shapeType, model.radius), + })), + 'shapeName' + ) ?? ShapeType.Rect; + + const onPick = (shapeName: ShapeName) => { + const shapeType = getShapeType(shapeName); + const radius = getShapeRadius(shapeName); + + ctx.std.store.captureSync(); + + for (const model of models) { + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, { shapeType, radius }); + } + }; + + return renderMenu({ + icon: ShapeIcon(), + label: 'Switch type', + items: ShapeComponentConfig.map(item => ({ + key: item.tooltip, + value: item.name, + // TODO(@fundon): should add a feature flag to switch style + icon: item.generalIcon, + disabled: item.disabled, + })), + currentValue: shapeName, + onPick, + }); + }, + }, + { + id: 'd.style', + // TODO(@fundon): should add a feature flag + when: false, + content(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + if (!models.length) return null; + + const field = 'shapeStyle'; + const shapeStyle = + getMostCommonValue(models, field) ?? ShapeStyle.General; + const onPick = (value: boolean) => { + const shapeStyle = value ? ShapeStyle.Scribbled : ShapeStyle.General; + const fontFamily = value ? FontFamily.Kalam : FontFamily.Inter; + + for (const model of models) { + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, { shapeStyle, fontFamily }); + } + }; + + return renderMenu({ + label: 'Style', + items: LINE_STYLE_LIST, + currentValue: shapeStyle === ShapeStyle.Scribbled, + onPick, + }); + }, + }, + { + id: 'e.color', + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return models.length > 0 && models.every(model => !hasGrouped(model)); + }, + content(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + if (!models.length) return null; + + const enableCustomColor = ctx.features.getFlag('enable_color_picker'); + const theme = ctx.theme.edgeless$.value; + + const firstModel = models[0]; + const originalFillColor = firstModel.fillColor; + const originalStrokeColor = firstModel.strokeColor; + + const mapped = models.map( + ({ filled, fillColor, strokeColor, strokeWidth, strokeStyle }) => ({ + fillColor: filled + ? resolveColor(fillColor, theme) + : DefaultTheme.transparent, + strokeColor: resolveColor(strokeColor, theme), + strokeWidth, + strokeStyle, + }) + ); + const fillColor = + getMostCommonValue(mapped, 'fillColor') ?? + resolveColor(DefaultTheme.shapeFillColor, theme); + const strokeColor = + getMostCommonValue(mapped, 'strokeColor') ?? + resolveColor(DefaultTheme.shapeStrokeColor, theme); + const strokeWidth = + getMostCommonValue(mapped, 'strokeWidth') ?? LineWidth.Four; + const strokeStyle = + getMostCommonValue(mapped, 'strokeStyle') ?? StrokeStyle.Solid; + + const onPickFillColor = (e: CustomEvent) => { + e.stopPropagation(); + + const d = e.detail; + + const field = 'fillColor'; + + if (d.type === 'pick') { + const value = d.detail.value; + const filled = isTransparent(value); + for (const model of models) { + const props = packColor(field, value); + // If `filled` can be set separately, this logic can be removed + if (field && !model.filled) { + const color = getTextColor(value, filled); + Object.assign(props, { filled, color }); + } + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, props); + } + return; + } + + for (const model of models) { + model[d.type === 'start' ? 'stash' : 'pop'](field); + } + }; + const onPickStrokeColor = (e: CustomEvent) => { + e.stopPropagation(); + + const d = e.detail; + + const field = 'strokeColor'; + + if (d.type === 'pick') { + const value = d.detail.value; + for (const model of models) { + const props = packColor(field, value); + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, props); + } + return; + } + + for (const model of models) { + model[d.type === 'start' ? 'stash' : 'pop'](field); + } + }; + const onPickStrokeStyle = (e: CustomEvent) => { + e.stopPropagation(); + + const { type, value } = e.detail; + + if (type === 'size') { + const strokeWidth = value; + for (const model of models) { + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, { strokeWidth }); + } + return; + } + + const strokeStyle = value; + for (const model of models) { + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, { strokeStyle }); + } + }; + + return html` + + + `; + }, + }, + { + id: 'f.text', + tooltip: 'Add text', + icon: AddTextIcon(), + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return models.length === 1 && !hasGrouped(models[0]) && !models[0].text; + }, + run(ctx) { + const model = ctx.getCurrentModelByType(ShapeElementModel); + if (!model) return; + + const rootBlock = getRootBlock(ctx); + if (!rootBlock) return; + + mountShapeTextEditor(model, rootBlock); + }, + }, + // id: `g.text` + ...createTextActions(ShapeElementModel, 'shape', (ctx, model, props) => { + // No need to adjust element bounds + if (props['textAlign']) { + ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props); + return; + } + + const xywh = normalizeShapeBound( + model, + Bound.fromXYWH(model.deserializedXYWH) + ).serialize(); + + ctx.std + .get(EdgelessCRUDIdentifier) + .updateElement(model.id, { ...props, xywh }); + }).map(action => ({ + ...action, + id: `g.text-${action.id}`, + when(ctx) { + const models = ctx.getSurfaceModelsByType(ShapeElementModel); + return ( + models.length > 0 && + models.every(model => !hasGrouped(model) && model.text) + ); + }, + })), + ], + + when: ctx => ctx.getSurfaceModelsByType(ShapeElementModel).length > 0, +} as const satisfies ToolbarModuleConfig; + +// When the shape is filled with black color, the text color should be white. +// When the shape is transparent, the text color should be set according to the theme. +// Otherwise, the text color should be black. +function getTextColor(fillColor: Color, isNotTransparent = false) { + if (isNotTransparent) { + if (isEqual(fillColor, DefaultTheme.black)) { + return DefaultTheme.white; + } else if (isEqual(fillColor, DefaultTheme.white)) { + return DefaultTheme.black; + } else if (isEqual(fillColor, DefaultTheme.pureBlack)) { + return DefaultTheme.pureWhite; + } else if (isEqual(fillColor, DefaultTheme.pureWhite)) { + return DefaultTheme.pureBlack; + } + } + + // aka `DefaultTheme.pureBlack` + return DefaultTheme.shapeTextColor; +} + +function hasGrouped(model: ShapeElementModel) { + return model.group instanceof MindmapElementModel; +} diff --git a/blocksuite/affine/gfx/shape/tsconfig.json b/blocksuite/affine/gfx/shape/tsconfig.json index 46d91a2e30..5b280fe89a 100644 --- a/blocksuite/affine/gfx/shape/tsconfig.json +++ b/blocksuite/affine/gfx/shape/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../model" }, { "path": "../../rich-text" }, { "path": "../../shared" }, + { "path": "../../widgets/widget-edgeless-toolbar" }, { "path": "../../../framework/block-std" }, { "path": "../../../framework/global" }, { "path": "../../../framework/store" } diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index fd14db9839..1442a0a56b 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -448,6 +448,7 @@ export const PackageList = [ 'blocksuite/affine/model', 'blocksuite/affine/rich-text', 'blocksuite/affine/shared', + 'blocksuite/affine/widgets/widget-edgeless-toolbar', 'blocksuite/framework/block-std', 'blocksuite/framework/global', 'blocksuite/framework/store', diff --git a/yarn.lock b/yarn.lock index b315e67ad5..c098e82b21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2983,6 +2983,7 @@ __metadata: "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-rich-text": "workspace:*" "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/affine-widget-edgeless-toolbar": "workspace:*" "@blocksuite/block-std": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.2.6"