refactor(editor): edgeless shape toolbar config extension (#10821)

This commit is contained in:
fundon
2025-03-20 02:08:15 +00:00
parent 1acc7e5a9e
commit 54bc60aa4d
14 changed files with 836 additions and 115 deletions
@@ -0,0 +1 @@
export * from '@blocksuite/affine-components/edgeless-shape-color-picker';
+2
View File
@@ -27,6 +27,7 @@ import { effects as componentDatePickerEffects } from '@blocksuite/affine-compon
import { effects as componentDropIndicatorEffects } from '@blocksuite/affine-components/drop-indicator';
import { effects as componentEdgelessLineStylesEffects } from '@blocksuite/affine-components/edgeless-line-styles-panel';
import { effects as componentEdgelessLineWidthEffects } from '@blocksuite/affine-components/edgeless-line-width-panel';
import { effects as componentEdgelessShapeColorPickerEffects } from '@blocksuite/affine-components/edgeless-shape-color-picker';
import { effects as componentEmbedCardModalEffects } from '@blocksuite/affine-components/embed-card-modal';
import { FilterableListComponent } from '@blocksuite/affine-components/filterable-list';
import { effects as componentHighlightDropdownMenuEffects } from '@blocksuite/affine-components/highlight-dropdown-menu';
@@ -153,6 +154,7 @@ export function effects() {
componentSizeDropdownMenuEffects();
componentEdgelessLineWidthEffects();
componentEdgelessLineStylesEffects();
componentEdgelessShapeColorPickerEffects();
widgetScrollAnchoringEffects();
widgetFrameTitleEffects();
@@ -17,6 +17,7 @@ import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import { computed, effect, type Signal, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import { ShapeComponentConfig } from './shape-menu-config.js';
@@ -125,31 +126,39 @@ export class EdgelessShapeMenu extends SignalWatcher(
return html`
<edgeless-slide-menu>
<div class="menu-content">
<div class="shape-style-container">
<edgeless-tool-icon-button
.tooltip=${'General'}
.active=${shapeStyle === ShapeStyle.General}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.General);
}}
>
${StyleGeneralIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Scribbled'}
.active=${shapeStyle === ShapeStyle.Scribbled}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.Scribbled);
}}
>
${StyleScribbleIcon()}
</edgeless-tool-icon-button>
</div>
<menu-divider .vertical=${true}></menu-divider>
${
// TODO(@fundon): add a flag
when(
false,
() => html`
<div class="shape-style-container">
<edgeless-tool-icon-button
.tooltip=${'General'}
.active=${shapeStyle === ShapeStyle.General}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.General);
}}
>
${StyleGeneralIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Scribbled'}
.active=${shapeStyle === ShapeStyle.Scribbled}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.Scribbled);
}}
>
${StyleScribbleIcon()}
</edgeless-tool-icon-button>
</div>
<menu-divider .vertical=${true}></menu-divider>
`
)
}
<div class="shape-type-container">
${ShapeComponentConfig.map(
({ name, generalIcon, scribbledIcon, tooltip }) => {
@@ -126,7 +126,6 @@ export const builtinConnectorToolbarConfig = {
actions: [
{
id: 'a.stroke-color',
tooltip: 'Stroke style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(ConnectorElementModel);
if (!models.length) return null;
@@ -358,9 +357,9 @@ export const builtinConnectorToolbarConfig = {
// No need to adjust element bounds
if (props['textAlign']) {
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
labelStyle,
});
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, { labelStyle });
return;
}
@@ -9,7 +9,10 @@ import {
MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import type { ToolbarModuleConfig } from '@blocksuite/affine-shared/services';
import type {
ToolbarContext,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import { RadiantIcon, RightLayoutIcon, StyleIcon } from '@blocksuite/icons/lit';
@@ -55,6 +58,49 @@ const MINDMAP_LAYOUT_LIST = [
},
] as const satisfies MenuItem<LayoutType>[];
export const createMindmapStyleActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const style = getMostCommonValue(models, 'style') ?? MindmapStyle.ONE;
const onPick = (style: MindmapStyle) => {
for (const model of models) {
model.style = style;
}
ctx.settings.recordLastProps('mindmap', { style });
};
return renderMenu({
label: 'Style',
icon: StyleIcon(),
items: MINDMAP_STYLE_LIST,
currentValue: style,
onPick,
});
};
export const createMindmapLayoutActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const layoutType =
getMostCommonValue(models, 'layoutType') ?? LayoutType.BALANCE;
const onPick = (layoutType: LayoutType) => {
for (const model of models) {
model.layoutType = layoutType;
model.layout();
}
ctx.settings.recordLastProps('mindmap', { layoutType });
};
return renderMenu({
label: 'Layout',
items: MINDMAP_LAYOUT_LIST,
currentValue: layoutType,
onPick,
});
};
export const builtinMindmapToolbarConfig = {
actions: [
{
@@ -63,21 +109,7 @@ export const builtinMindmapToolbarConfig = {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
const style = getMostCommonValue(models, 'style') ?? MindmapStyle.ONE;
const onPick = (style: MindmapStyle) => {
for (const model of models) {
model.style = style;
}
ctx.settings.recordLastProps('mindmap', { style });
};
return renderMenu({
label: 'Style',
icon: StyleIcon(),
items: MINDMAP_STYLE_LIST,
currentValue: style,
onPick,
});
return createMindmapStyleActionMenu(ctx, models);
},
},
{
@@ -86,22 +118,7 @@ export const builtinMindmapToolbarConfig = {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
const layoutType =
getMostCommonValue(models, 'layoutType') ?? LayoutType.BALANCE;
const onPick = (layoutType: LayoutType) => {
for (const model of models) {
model.layoutType = layoutType;
model.layout();
}
ctx.settings.recordLastProps('mindmap', { layoutType });
};
return renderMenu({
label: 'Layout',
items: MINDMAP_LAYOUT_LIST,
currentValue: layoutType,
onPick,
});
return createMindmapLayoutActionMenu(ctx, models);
},
},
],
@@ -1,39 +1,388 @@
import type { ToolbarModuleConfig } from '@blocksuite/affine-shared/services';
import {
AddTextIcon,
ShapeIcon,
StyleGeneralIcon,
} from '@blocksuite/icons/lit';
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 {
FeatureFlagService,
type ToolbarGenericAction,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import isEqual from 'lodash-es/isEqual';
import { EdgelessRootBlockComponent, type ShapeToolOption } from '../..';
import { ShapeComponentConfig } from '../../components/toolbar/shape/shape-menu-config';
import { mountShapeTextEditor } from '../../utils/text';
import { LINE_STYLE_LIST } from './consts';
import {
createMindmapLayoutActionMenu,
createMindmapStyleActionMenu,
} from './mindmap';
import { createTextActions } from './text-common';
import { renderMenu } from './utils';
export const builtinShapeToolbarConfig = {
actions: [
{
id: 'a.switch-type',
icon: ShapeIcon(),
tooltip: 'Switch type',
run() {},
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.style',
icon: StyleGeneralIcon(),
tooltip: 'Style',
run() {},
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.fill-color',
label: 'Fill color',
run() {},
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<ShapeToolOption, 'shapeName'>(
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.border-style',
label: 'Border style',
run() {},
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.text',
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.std
.get(FeatureFlagService)
.getFlag('enable_color_picker');
const theme = ctx.themeProvider.edgelessTheme;
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<PickColorEvent>) => {
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<PickColorEvent>) => {
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<LineDetailType>) => {
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`
<edgeless-shape-color-picker
@pickFillColor=${onPickFillColor}
@pickStrokeColor=${onPickStrokeColor}
@pickStrokeStyle=${onPickStrokeStyle}
.payload=${{
fillColor,
strokeColor,
strokeWidth,
strokeStyle,
originalFillColor,
originalStrokeColor,
theme,
enableCustomColor,
}}
>
</edgeless-shape-color-picker>
`;
},
},
{
id: 'f.text',
icon: AddTextIcon(),
tooltip: 'Show add button or text menu',
run() {},
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 rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
mountShapeTextEditor(model, edgeless);
},
},
// 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<ToolbarGenericAction>(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;
}
@@ -4,6 +4,7 @@ export type MenuItem<T> = {
key?: string;
value: T;
icon?: TemplateResult;
disabled?: boolean;
};
export type Menu<T> = {
@@ -46,9 +46,10 @@ export function renderMenuItems<T>(
return repeat(
items,
item => item.value,
({ key, value, icon }) => html`
({ key, value, icon, disabled }) => html`
<editor-icon-button
aria-label="${ifDefined(key)}"
.disabled=${ifDefined(disabled)}
.tooltip="${ifDefined(key)}"
.active="${currentValue === value}"
.activeMode="${'background'}"
+2 -1
View File
@@ -68,7 +68,8 @@
"./tooltip-content-with-shortcut": "./src/tooltip-content-with-shortcut/index.ts",
"./size-dropdown-menu": "./src/size-dropdown-menu/index.ts",
"./edgeless-line-width-panel": "./src/edgeless-line-width-panel/index.ts",
"./edgeless-line-styles-panel": "./src/edgeless-line-styles-panel/index.ts"
"./edgeless-line-styles-panel": "./src/edgeless-line-styles-panel/index.ts",
"./edgeless-shape-color-picker": "./src/edgeless-shape-color-picker/index.ts"
},
"files": [
"src",
@@ -12,8 +12,9 @@ import { when } from 'lit-html/directives/when.js';
import type { EditorMenuButton } from '../toolbar/menu-button';
import type { PickColorEvent } from './types';
import {
calcCustomButtonStyle,
keepColor,
packColorsWithColorScheme,
packColorsWith,
preprocessColor,
rgbaToHex8,
} from './utils.js';
@@ -35,39 +36,20 @@ export class EdgelessColorPickerButton extends WithDisposable(LitElement) {
};
get colorWithoutAlpha() {
return this.isCSSVariable ? this.color : keepColor(this.color);
}
get customButtonStyle() {
let b = 'transparent';
let c = 'transparent';
if (!this.isCustomColor) {
return { '--b': b, '--c': c };
}
if (this.isCSSVariable) {
if (!this.color.endsWith('transparent')) {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(
rgbaToHex8(
return keepColor(
this.color.startsWith('--')
? rgbaToHex8(
preprocessColor(window.getComputedStyle(this))({
type: 'normal',
value: this.color,
}).rgba
)
);
}
} else {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(this.color);
}
return { '--b': b, '--c': c };
: this.color
);
}
get isCSSVariable() {
return this.color.startsWith('--');
get customButtonStyle() {
return calcCustomButtonStyle(this.color, this.isCustomColor, this);
}
get isCustomColor() {
@@ -160,7 +142,7 @@ export class EdgelessColorPickerButton extends WithDisposable(LitElement) {
[
'custom',
() => {
const packed = packColorsWithColorScheme(
const packed = packColorsWith(
this.theme,
this.color,
this.originalColor
@@ -283,7 +283,7 @@ export const packColor = (key: string, color: Color) => {
* @param oldColor - The old color
* @returns A color array
*/
export const packColorsWithColorScheme = (
export const packColorsWith = (
colorScheme: ColorScheme,
value: string,
oldColor: Color
@@ -308,3 +308,35 @@ export const packColorsWithColorScheme = (
return { type, colors };
};
export const calcCustomButtonStyle = (
color: string,
isCustomColor: boolean,
ele: Element
) => {
let b = 'transparent';
let c = 'transparent';
if (!isCustomColor) {
return { '--b': b, '--c': c };
}
if (color.startsWith('---')) {
if (!color.endsWith('transparent')) {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(
rgbaToHex8(
preprocessColor(window.getComputedStyle(ele))({
type: 'normal',
value: color,
}).rgba
)
);
}
} else {
b = 'var(--affine-background-overlay-panel-color)';
c = keepColor(color);
}
return { '--b': b, '--c': c };
};
@@ -50,7 +50,7 @@ export class EdgelessLineStylesPanel extends LitElement {
return html`
<affine-edgeless-line-width-panel
?disabled=${lineStyle === StrokeStyle.None}
?disabled="${lineStyle === StrokeStyle.None}"
.selectedSize=${lineSize}
@select=${(e: CustomEvent<LineWidth>) => {
e.stopPropagation();
@@ -0,0 +1,317 @@
import {
type Color,
type ColorScheme,
DefaultTheme,
type LineWidth,
type Palette,
resolveColor,
type ShapeProps,
type StrokeStyle,
} from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
type ColorEvent,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { batch, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit-html/directives/choose.js';
import { repeat } from 'lit-html/directives/repeat.js';
import { styleMap } from 'lit-html/directives/style-map.js';
import { when } from 'lit-html/directives/when.js';
import {
calcCustomButtonStyle,
keepColor,
packColorsWith,
type PickColorEvent,
preprocessColor,
rgbaToHex8,
} from '../color-picker';
import type { LineDetailType } from '../edgeless-line-styles-panel';
import type { EditorMenuButton } from '../toolbar';
type TabType = 'normal' | 'custom';
type ColorType = Extract<keyof ShapeProps, 'fillColor' | 'strokeColor'>;
type PickerType = {
label: string;
type: ColorType;
value: string;
hollowCircle: boolean;
onPick: (e: ColorEvent) => void;
};
export class EdgelessShapeColorPicker extends WithDisposable(
SignalWatcher(LitElement)
) {
static override styles = css`
.pickers {
display: flex;
align-self: stretch;
gap: 12px;
}
.picker {
display: flex;
align-self: stretch;
gap: 8px;
}
.picker-label {
color: ${unsafeCSSVarV2('text/secondary')};
font-weight: 400;
}
`;
tabType$ = signal<TabType>('normal');
colorType$ = signal<ColorType>('fillColor');
readonly #pickFillColor = (e: ColorEvent) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<PickColorEvent>('pickFillColor', {
detail: {
type: 'pick',
detail: e.detail,
},
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickStrokeColor = (e: ColorEvent) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<PickColorEvent>('pickStrokeColor', {
detail: {
type: 'pick',
detail: e.detail,
},
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickColor = (detail: PickColorEvent) => {
const type =
this.colorType$.peek() === 'fillColor'
? 'pickFillColor'
: 'pickStrokeColor';
this.dispatchEvent(
new CustomEvent<PickColorEvent>(type, {
detail,
bubbles: true,
composed: true,
cancelable: true,
})
);
};
readonly #pickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent('pickStrokeStyle', {
detail: e.detail,
bubbles: true,
composed: true,
cancelable: true,
})
);
};
#calcCustomButtonStyle(color: string, isCustomColor: boolean) {
return calcCustomButtonStyle(color, isCustomColor, this);
}
#calcCustomButtonState(color: string, theme: ColorScheme) {
return !this.palettes
.map(({ value }) => resolveColor(value, theme))
.includes(color);
}
#switchToCustomWith(type: ColorType) {
batch(() => {
this.tabType$.value = 'custom';
this.colorType$.value = type;
});
}
get fillColorWithoutAlpha() {
const { fillColor } = this.payload;
return keepColor(
fillColor.startsWith('--')
? rgbaToHex8(
preprocessColor(window.getComputedStyle(this))({
type: 'normal',
value: fillColor,
}).rgba
)
: fillColor
);
}
override firstUpdated() {
this.disposables.addFromEvent(
this.menuButton,
'toggle',
(e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened && this.tabType$.peek() === 'custom') {
this.tabType$.value = 'normal';
}
}
);
}
override render() {
const {
tabType$: { value: tabType },
colorType$: { value: colorType },
palettes,
fillColorWithoutAlpha,
payload: {
fillColor,
strokeColor,
strokeWidth,
strokeStyle,
originalFillColor,
originalStrokeColor,
theme,
enableCustomColor,
},
} = this;
return html`
<editor-menu-button
.contentPadding="${tabType === 'normal' ? '8px' : '0px'}"
@click=${stopPropagation}
.button=${html`
<editor-icon-button aria-label="Color" .tooltip="${'Color'}">
<edgeless-color-button
.color=${fillColorWithoutAlpha}
></edgeless-color-button>
</editor-icon-button>
`}
>
<div class="pickers" data-orientation="vertical">
${choose(tabType, [
[
'normal',
() => {
return html`
${repeat(
[
{
label: 'Fill color',
type: 'fillColor',
value: fillColor,
hollowCircle: false,
onPick: this.#pickFillColor,
},
{
label: 'Border color',
type: 'strokeColor',
value: strokeColor,
hollowCircle: true,
onPick: this.#pickStrokeColor,
},
] satisfies PickerType[],
item => item.type,
({ label, type, value, onPick, hollowCircle }) => html`
<div class="picker-label">${label}</div>
<edgeless-color-panel
role="listbox"
.hasTransparent=${false}
.hollowCircle=${hollowCircle}
.value=${value}
.theme=${theme}
.palettes=${palettes}
@select=${onPick}
>
${when(enableCustomColor, () => {
const isCustomColor = this.#calcCustomButtonState(
value,
theme
);
const styleInfo = this.#calcCustomButtonStyle(
value,
isCustomColor
);
return html`
<edgeless-color-custom-button
slot="custom"
style=${styleMap(styleInfo)}
?active=${isCustomColor}
@click=${() => this.#switchToCustomWith(type)}
></edgeless-color-custom-button>
`;
})}
</edgeless-color-panel>
`
)}
<div class="picker-label">Border style</div>
<affine-edgeless-line-styles-panel
class="picker"
.lineSize=${strokeWidth}
.lineStyle=${strokeStyle}
@select=${this.#pickStrokeStyle}
></affine-edgeless-line-styles-panel>
`;
},
],
[
'custom',
() => {
const isFillColor = colorType === 'fillColor';
const packed = packColorsWith(
theme,
isFillColor ? fillColor : strokeColor,
isFillColor ? originalFillColor : originalStrokeColor
);
const type = packed.type === 'palette' ? 'normal' : packed.type;
const modes = packed.colors.map(
preprocessColor(window.getComputedStyle(this))
);
return html`
<edgeless-color-picker
class="custom"
.pick=${this.#pickColor}
.colors=${{ type, modes }}
></edgeless-color-picker>
`;
},
],
])}
</div>
</editor-menu-button>
`;
}
@property({ attribute: false })
accessor payload!: {
fillColor: string;
strokeColor: string;
strokeWidth: LineWidth;
strokeStyle: StrokeStyle;
originalFillColor: Color;
originalStrokeColor: Color;
theme: ColorScheme;
enableCustomColor: boolean;
};
@property({ attribute: false })
accessor palettes: Palette[] = DefaultTheme.Palettes;
@query('editor-menu-button')
accessor menuButton!: EditorMenuButton;
}
@@ -0,0 +1,10 @@
import { EdgelessShapeColorPicker } from './color-picker';
export * from './color-picker';
export function effects() {
customElements.define(
'edgeless-shape-color-picker',
EdgelessShapeColorPicker
);
}