refactor(editor): extract slider component (#12210)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new slider component for line width selection, providing a more interactive and streamlined UI.
  - Added support for using the slider component across relevant panels.
- **Improvements**
  - Simplified the line width selection panel for easier use and improved maintainability.
  - Enhanced event handling to prevent dropdowns from closing when interacting with the panel.
- **Bug Fixes**
  - Improved event propagation control within the line styles panel.
- **Chores**
  - Updated package exports to include the new slider component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-05-12 09:42:52 +00:00
parent bc00a58ae1
commit f3ca17fcb3
14 changed files with 308 additions and 227 deletions

View File

@@ -72,7 +72,8 @@
"./edgeless-line-width-panel": "./src/edgeless-line-width-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",
"./open-doc-dropdown-menu": "./src/open-doc-dropdown-menu/index.ts"
"./open-doc-dropdown-menu": "./src/open-doc-dropdown-menu/index.ts",
"./slider": "./src/slider/index.ts"
},
"files": [
"src",

View File

@@ -1,6 +1,7 @@
import { LineWidth, StrokeStyle } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/lit';
import { BanIcon, DashLineIcon, StraightLineIcon } from '@blocksuite/icons/lit';
import { html, LitElement } from 'lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
@@ -33,7 +34,13 @@ const LINE_STYLE_LIST = [
},
];
export class EdgelessLineStylesPanel extends LitElement {
export class EdgelessLineStylesPanel extends WithDisposable(LitElement) {
static override styles = css`
edgeless-line-width-panel {
flex: 1;
}
`;
select(detail: LineDetailType) {
this.dispatchEvent(
new CustomEvent('select', {
@@ -47,10 +54,9 @@ export class EdgelessLineStylesPanel extends LitElement {
override render() {
const { lineSize, lineStyle, lineStyles } = this;
return html`
<edgeless-line-width-panel
?disabled="${lineStyle === StrokeStyle.None}"
.disabled=${lineStyle === StrokeStyle.None}
.selectedSize=${lineSize}
@select=${(e: CustomEvent<LineWidth>) => {
e.stopPropagation();

View File

@@ -1,130 +1,12 @@
import { BRUSH_LINE_WIDTHS, LineWidth } from '@blocksuite/affine-model';
import { on, once } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import clamp from 'lodash-es/clamp';
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
interface Config {
width: number;
itemSize: number;
itemIconSize: number;
dragHandleSize: number;
count: number;
}
import type { SliderSelectEvent } from '../slider';
export class EdgelessLineWidthPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
--width: 140px;
--item-size: 16px;
--item-icon-size: 8px;
--drag-handle-size: 14px;
--cursor: 0;
--count: 6;
/* (16 - 14) / 2 + (cursor / (count - 1)) * (140 - 16) */
--drag-handle-center-x: calc(
(var(--item-size) - var(--drag-handle-size)) / 2 +
(var(--cursor) / (var(--count) - 1)) *
(var(--width) - var(--item-size))
);
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
.line-width-panel {
width: var(--width);
height: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
position: relative;
cursor: default;
}
.line-width-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--item-size);
height: var(--item-size);
z-index: 2;
}
.line-width-icon {
width: var(--item-icon-size);
height: var(--item-icon-size);
background-color: var(--affine-border-color);
border-radius: 50%;
}
.line-width-button[data-selected] .line-width-icon {
background-color: var(--affine-icon-color);
}
.drag-handle {
position: absolute;
width: var(--drag-handle-size);
height: var(--drag-handle-size);
border-radius: 50%;
background-color: var(--affine-icon-color);
z-index: 3;
transform: translateX(var(--drag-handle-center-x));
}
.bottom-line,
.line-width-overlay {
position: absolute;
height: 1px;
left: calc(var(--item-size) / 2);
}
.bottom-line {
width: calc(100% - var(--item-size));
background-color: var(--affine-border-color);
}
.line-width-overlay {
background-color: var(--affine-icon-color);
z-index: 1;
width: var(--drag-handle-center-x);
}
`;
private readonly _getDragHandlePosition = (e: PointerEvent) => {
return clamp(e.offsetX, 0, this.config.width);
};
private readonly _onPointerDown = (e: PointerEvent) => {
e.preventDefault();
this._onPointerMove(e);
const dispose = on(this, 'pointermove', this._onPointerMove);
this._disposables.add(once(this, 'pointerup', dispose));
this._disposables.add(once(this, 'pointerout', dispose));
};
private readonly _onPointerMove = (e: PointerEvent) => {
e.preventDefault();
const x = this._getDragHandlePosition(e);
this._updateLineWidthPanelByDragHandlePosition(x);
};
private _onSelect(lineWidth: number) {
// If the selected size is the same as the previous one, do nothing.
if (lineWidth === this.selectedSize) return;
this.dispatchEvent(
new CustomEvent('select', {
detail: lineWidth,
@@ -133,98 +15,22 @@ export class EdgelessLineWidthPanel extends WithDisposable(LitElement) {
cancelable: true,
})
);
this.selectedSize = lineWidth;
}
private _updateLineWidthPanel(selectedSize: number) {
if (!this._lineWidthOverlay) return;
const index = this.lineWidths.findIndex(w => w === selectedSize);
if (index === -1) return;
this.style.setProperty('--cursor', `${index}`);
}
private _updateLineWidthPanelByDragHandlePosition(x: number) {
// Calculate the selected size based on the drag handle position.
// Need to select the nearest size.
const {
config: { width, itemSize, count },
lineWidths,
} = this;
const targetWidth = width - itemSize;
const halfItemSize = itemSize / 2;
const offsetX = halfItemSize + (width - itemSize * count) / (count - 1) / 2;
const selectedSize = lineWidths.findLast((_, n) => {
const cx = halfItemSize + (n / (count - 1)) * targetWidth;
return x >= cx - offsetX && x < cx + offsetX;
});
if (!selectedSize) return;
this._updateLineWidthPanel(selectedSize);
this._onSelect(selectedSize);
}
override connectedCallback() {
super.connectedCallback();
const {
style,
config: { width, itemSize, itemIconSize, dragHandleSize, count },
} = this;
style.setProperty('--width', `${width}px`);
style.setProperty('--item-size', `${itemSize}px`);
style.setProperty('--item-icon-size', `${itemIconSize}px`);
style.setProperty('--drag-handle-size', `${dragHandleSize}px`);
style.setProperty('--count', `${count}`);
}
override firstUpdated() {
this._updateLineWidthPanel(this.selectedSize);
this._disposables.addFromEvent(this, 'pointerdown', this._onPointerDown);
}
override render() {
return html`<div class="line-width-panel">
${repeat(
this.lineWidths,
w => w,
(w, n) =>
html`<div
class="line-width-button"
aria-label=${w}
data-index=${n}
?data-selected=${w <= this.selectedSize}
>
<div class="line-width-icon"></div>
</div>`
)}
<div class="drag-handle"></div>
<div class="bottom-line"></div>
<div class="line-width-overlay"></div>
${this.hasTooltip
? html`<affine-tooltip .offset=${8}>Thickness</affine-tooltip>`
: nothing}
</div>`;
return html`<affine-slider
?disabled=${this.disabled}
.range=${{ points: this.lineWidths }}
.value=${this.selectedSize}
.tooltip=${this.hasTooltip ? 'Thickness' : undefined}
@select=${(e: SliderSelectEvent) => {
e.stopPropagation();
this._onSelect(e.detail.value);
}}
></affine-slider>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('selectedSize')) {
this._updateLineWidthPanel(this.selectedSize);
}
}
@query('.line-width-overlay')
private accessor _lineWidthOverlay!: HTMLElement;
accessor config: Config = {
width: 140,
itemSize: 16,
itemIconSize: 8,
dragHandleSize: 14,
count: BRUSH_LINE_WIDTHS.length,
};
@property({ attribute: false, type: Boolean })
@property({ attribute: false })
accessor disabled = false;
@property({ attribute: false })

View File

@@ -0,0 +1,6 @@
import { Slider } from './slider';
export * from './types';
export function effects() {
customElements.define('affine-slider', Slider);
}

View File

@@ -0,0 +1,159 @@
import { on, once } from '@blocksuite/affine-shared/utils';
import { clamp } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styles } from './styles';
import type { SliderRange, SliderSelectEvent, SliderStyle } from './types';
import { isDiscreteRange } from './utils';
const defaultSliderStyle: SliderStyle = {
width: '100%',
itemSize: 16,
itemIconSize: 8,
dragHandleSize: 14,
};
@requiredProperties({
range: PropTypes.of(isDiscreteRange),
})
export class Slider extends WithDisposable(LitElement) {
static override styles = styles;
@property({ attribute: false })
accessor value: number = 0;
@property({ attribute: true, type: Boolean })
accessor disabled = false;
@property({ attribute: false })
accessor tooltip: string | undefined = undefined;
@property({ attribute: false })
accessor range!: SliderRange;
@property({ attribute: false })
accessor sliderStyle: Partial<SliderStyle> | undefined = defaultSliderStyle;
private get _sliderStyle(): SliderStyle {
return {
...defaultSliderStyle,
...this.sliderStyle,
};
}
private _onSelect(value: number) {
this.dispatchEvent(
new CustomEvent('select', {
detail: { value },
bubbles: true,
composed: true,
}) satisfies SliderSelectEvent
);
}
private _updateLineWidthPanelByDragHandlePosition(x: number) {
// Calculate the selected size based on the drag handle position.
// Need to select the nearest size.
const {
_sliderStyle: { itemSize },
} = this;
const width = this.getBoundingClientRect().width;
const { points } = this.range;
const count = points.length;
const targetWidth = width - itemSize;
const halfItemSize = itemSize / 2;
const offsetX = halfItemSize + (width - itemSize * count) / (count - 1) / 2;
const selectedSize = points.findLast((_, n) => {
const cx = halfItemSize + (n / (count - 1)) * targetWidth;
return x >= cx - offsetX && x < cx + offsetX;
});
if (!selectedSize) return;
this._onSelect(selectedSize);
}
private readonly _getDragHandlePosition = (e: PointerEvent) => {
const width = this.getBoundingClientRect().width;
return clamp(e.offsetX, 0, width);
};
private readonly _onPointerDown = (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
this._onPointerMove(e);
const dispose = on(this, 'pointermove', this._onPointerMove);
this._disposables.add(once(this, 'pointerup', dispose));
this._disposables.add(once(this, 'pointerout', dispose));
};
private readonly _onPointerMove = (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
const x = this._getDragHandlePosition(e);
this._updateLineWidthPanelByDragHandlePosition(x);
};
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(this, 'pointerdown', this._onPointerDown);
this._disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
});
}
override willUpdate(changedProperties: PropertyValues<this>) {
const { style } = this;
if (changedProperties.has('sliderStyle')) {
const {
_sliderStyle: { width, itemSize, itemIconSize, dragHandleSize },
} = this;
style.setProperty('--width', width);
style.setProperty('--item-size', `${itemSize}px`);
style.setProperty('--item-icon-size', `${itemIconSize}px`);
style.setProperty('--drag-handle-size', `${dragHandleSize}px`);
}
if (changedProperties.has('range')) {
style.setProperty('--count', `${this.range.points.length}`);
}
if (changedProperties.has('value')) {
const index = this.range.points.findIndex(p => p === this.value);
style.setProperty('--cursor', `${index}`);
}
}
override render() {
return html`<div class="slider-container">
${repeat(
this.range.points,
w => w,
(w, n) =>
html`<div
class="point-button"
aria-label=${w}
data-index=${n}
?data-selected=${w <= this.value}
>
<div class="point-circle"></div>
</div>`
)}
<div class="drag-handle"></div>
<div class="bottom-line"></div>
<div class="slider-selected-overlay"></div>
${this.tooltip
? html`<affine-tooltip .offset=${8}>${this.tooltip}</affine-tooltip>`
: nothing}
</div>`;
}
}

View File

@@ -0,0 +1,74 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const styles = css`
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
.slider-container {
--drag-handle-center-x: calc(
(var(--item-size) - var(--drag-handle-size)) / 2 +
(var(--cursor) / (var(--count) - 1)) *
calc(var(--width) - var(--item-size))
);
width: var(--width);
height: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
position: relative;
cursor: default;
}
.point-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--item-size);
height: var(--item-size);
z-index: 2;
}
.point-circle {
width: var(--item-icon-size);
height: var(--item-icon-size);
background-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 50%;
}
.point-button[data-selected] .point-circle {
background-color: ${unsafeCSSVarV2('icon/primary')};
}
.drag-handle {
position: absolute;
width: var(--drag-handle-size);
height: var(--drag-handle-size);
border-radius: 50%;
background-color: ${unsafeCSSVarV2('icon/primary')};
z-index: 3;
left: var(--drag-handle-center-x);
}
.bottom-line,
.slider-selected-overlay {
position: absolute;
height: 1px;
left: calc(var(--item-size) / 2);
}
.bottom-line {
width: calc(100% - var(--item-size));
background-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.slider-selected-overlay {
background-color: ${unsafeCSSVarV2('icon/primary')};
z-index: 1;
width: var(--drag-handle-center-x);
}
`;

View File

@@ -0,0 +1,22 @@
export type SliderRange = {
/**
* a series of points in slider
*/
points: number[];
/**
* whether the points are uniformly distributed
* @default true
*/
uniform?: boolean;
};
export type SliderStyle = {
width: string;
itemSize: number;
itemIconSize: number;
dragHandleSize: number;
};
export type SliderSelectEvent = CustomEvent<{
value: number;
}>;

View File

@@ -0,0 +1,10 @@
import type { SliderRange } from './types';
export function isDiscreteRange(range: unknown): range is SliderRange {
return (
typeof range === 'object' &&
range !== null &&
'points' in range &&
Array.isArray(range.points)
);
}

View File

@@ -20,6 +20,7 @@ import { effects as componentOpenDocDropdownMenuEffects } from '@blocksuite/affi
import { effects as componentPortalEffects } from '@blocksuite/affine-components/portal';
import { effects as componentResourceEffects } from '@blocksuite/affine-components/resource';
import { effects as componentSizeDropdownMenuEffects } from '@blocksuite/affine-components/size-dropdown-menu';
import { effects as componentSliderEffects } from '@blocksuite/affine-components/slider';
import { SmoothCorner } from '@blocksuite/affine-components/smooth-corner';
import { effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button';
import { ToggleSwitch } from '@blocksuite/affine-components/toggle-switch';
@@ -53,6 +54,7 @@ export function effects() {
componentViewDropdownMenuEffects();
componentTooltipContentWithShortcutEffects();
componentSizeDropdownMenuEffects();
componentSliderEffects();
componentEdgelessLineWidthEffects();
componentEdgelessLineStylesEffects();
componentEdgelessShapeColorPickerEffects();

View File

@@ -18,6 +18,7 @@ export const PropTypes = {
if (typeof value !== 'object' || value === null) return false;
return Object.values(value).every(validator);
},
of: (validator: ValidatorFunction) => (value: unknown) => validator(value),
};
function validatePropTypes<T extends InstanceType<Constructor>>(