diff --git a/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json index 876a223e47..3db909fafb 100644 --- a/blocksuite/affine/components/package.json +++ b/blocksuite/affine/components/package.json @@ -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", diff --git a/blocksuite/affine/components/src/edgeless-line-styles-panel/line-styles-panel.ts b/blocksuite/affine/components/src/edgeless-line-styles-panel/line-styles-panel.ts index 31a67a4de5..41a568ed59 100644 --- a/blocksuite/affine/components/src/edgeless-line-styles-panel/line-styles-panel.ts +++ b/blocksuite/affine/components/src/edgeless-line-styles-panel/line-styles-panel.ts @@ -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` ) => { e.stopPropagation(); diff --git a/blocksuite/affine/components/src/edgeless-line-width-panel/line-width-panel.ts b/blocksuite/affine/components/src/edgeless-line-width-panel/line-width-panel.ts index 6a9175bc3f..a45c7128cb 100644 --- a/blocksuite/affine/components/src/edgeless-line-width-panel/line-width-panel.ts +++ b/blocksuite/affine/components/src/edgeless-line-width-panel/line-width-panel.ts @@ -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`
- ${repeat( - this.lineWidths, - w => w, - (w, n) => - html`
-
-
` - )} -
-
-
- ${this.hasTooltip - ? html`Thickness` - : nothing} -
`; + return html` { + e.stopPropagation(); + this._onSelect(e.detail.value); + }} + >`; } - override willUpdate(changedProperties: PropertyValues) { - 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 }) diff --git a/blocksuite/affine/components/src/slider/index.ts b/blocksuite/affine/components/src/slider/index.ts new file mode 100644 index 0000000000..50f2c3bb58 --- /dev/null +++ b/blocksuite/affine/components/src/slider/index.ts @@ -0,0 +1,6 @@ +import { Slider } from './slider'; +export * from './types'; + +export function effects() { + customElements.define('affine-slider', Slider); +} diff --git a/blocksuite/affine/components/src/slider/slider.ts b/blocksuite/affine/components/src/slider/slider.ts new file mode 100644 index 0000000000..b217e2f9fd --- /dev/null +++ b/blocksuite/affine/components/src/slider/slider.ts @@ -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 | 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) { + 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`
+ ${repeat( + this.range.points, + w => w, + (w, n) => + html`
+
+
` + )} +
+
+
+ ${this.tooltip + ? html`${this.tooltip}` + : nothing} +
`; + } +} diff --git a/blocksuite/affine/components/src/slider/styles.ts b/blocksuite/affine/components/src/slider/styles.ts new file mode 100644 index 0000000000..3075a6425e --- /dev/null +++ b/blocksuite/affine/components/src/slider/styles.ts @@ -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); + } +`; diff --git a/blocksuite/affine/components/src/slider/types.ts b/blocksuite/affine/components/src/slider/types.ts new file mode 100644 index 0000000000..2126d349bf --- /dev/null +++ b/blocksuite/affine/components/src/slider/types.ts @@ -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; +}>; diff --git a/blocksuite/affine/components/src/slider/utils.ts b/blocksuite/affine/components/src/slider/utils.ts new file mode 100644 index 0000000000..bb9238f89a --- /dev/null +++ b/blocksuite/affine/components/src/slider/utils.ts @@ -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) + ); +} diff --git a/blocksuite/affine/foundation/src/effects.ts b/blocksuite/affine/foundation/src/effects.ts index 7f59fc42b5..7d36326462 100644 --- a/blocksuite/affine/foundation/src/effects.ts +++ b/blocksuite/affine/foundation/src/effects.ts @@ -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(); diff --git a/blocksuite/framework/std/src/view/decorators/required.ts b/blocksuite/framework/std/src/view/decorators/required.ts index 96f3198518..175abc06ad 100644 --- a/blocksuite/framework/std/src/view/decorators/required.ts +++ b/blocksuite/framework/std/src/view/decorators/required.ts @@ -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>( diff --git a/tests/affine-local/e2e/blocksuite/edgeless/highlighter.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/highlighter.spec.ts index 480b8fb23f..3877976214 100644 --- a/tests/affine-local/e2e/blocksuite/edgeless/highlighter.spec.ts +++ b/tests/affine-local/e2e/blocksuite/edgeless/highlighter.spec.ts @@ -37,7 +37,8 @@ test('should add highlighter', async ({ page }) => { await expect(toolbar).toBeVisible(); const lineWidthButton = toolbar - .locator('.line-width-button[data-selected]') + .locator('affine-slider') + .locator('.point-button[data-selected]') .last(); const defaultLineWidth = await lineWidthButton.getAttribute('aria-label'); diff --git a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts index 6342499dc7..764a8de92f 100644 --- a/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts +++ b/tests/affine-local/e2e/blocksuite/edgeless/note.spec.ts @@ -330,7 +330,7 @@ test.describe('edgeless note element toolbar', () => { await toolbar.getByRole('button', { name: 'Border style' }).click(); await toolbar.locator('.mode-solid').click(); await toolbar.getByRole('button', { name: 'Border style' }).click(); - await toolbar.locator('edgeless-line-width-panel').getByLabel('8').click(); + await toolbar.locator('affine-slider').getByLabel('8').click(); expect(await getNoteEdgelessProps(page, noteId)).toEqual({ style: { diff --git a/tests/blocksuite/e2e/edgeless/connector/connector.spec.ts b/tests/blocksuite/e2e/edgeless/connector/connector.spec.ts index 723069f02b..c7e58ddac2 100644 --- a/tests/blocksuite/e2e/edgeless/connector/connector.spec.ts +++ b/tests/blocksuite/e2e/edgeless/connector/connector.spec.ts @@ -157,10 +157,6 @@ test('change connector line width', async ({ page }) => { await triggerComponentToolbarAction(page, 'changeConnectorStrokeStyles'); await changeConnectorStrokeWidth(page, 5); - await waitNextFrame(page); - - await triggerComponentToolbarAction(page, 'changeConnectorStrokeStyles'); - const pickedColor = await pickColorAtPoints(page, [ [start.x + 5, start.y], [start.x + 10, start.y], diff --git a/tests/blocksuite/e2e/utils/actions/edgeless.ts b/tests/blocksuite/e2e/utils/actions/edgeless.ts index b6bfb9cd65..4d8c73bb7a 100644 --- a/tests/blocksuite/e2e/utils/actions/edgeless.ts +++ b/tests/blocksuite/e2e/utils/actions/edgeless.ts @@ -670,7 +670,7 @@ export async function selectBrushSize(page: Page, size: string) { twelve: 6, }; const sizeButton = page.locator( - `edgeless-pen-menu .line-width-panel .line-width-button:nth-child(${sizeIndexMap[size]})` + `edgeless-pen-menu edgeless-line-width-panel .point-button:nth-child(${sizeIndexMap[size]})` ); await sizeButton.click(); } @@ -794,7 +794,7 @@ export async function updateExistedBrushElementSize( ) { // get the nth brush size button const btn = page.locator( - `.line-width-panel > div:nth-child(${nthSizeButton})` + `edgeless-line-width-panel .point-button:nth-child(${nthSizeButton})` ); await btn.click(); @@ -1192,9 +1192,7 @@ export async function triggerComponentToolbarAction( break; } case 'changeConnectorStrokeStyles': { - const button = locatorComponentToolbar(page).getByRole('button', { - name: 'Stroke style', - }); + const button = locatorComponentToolbar(page).getByLabel('Stroke style'); await button.click(); break; } @@ -1438,8 +1436,7 @@ export async function resizeConnectorByStartCapitalHandler( export function getEdgelessLineWidthPanel(page: Page) { return page .locator('affine-toolbar-widget editor-toolbar') - .locator('edgeless-line-width-panel') - .locator('.line-width-panel'); + .locator('edgeless-line-width-panel'); } export async function changeShapeStrokeWidth(page: Page) { const lineWidthPanel = getEdgelessLineWidthPanel(page); @@ -1501,7 +1498,7 @@ export function locatorConnectorStrokeWidthButton( ) { return locatorComponentToolbar(page) .locator('edgeless-line-width-panel') - .locator(`.line-width-button:nth-child(${buttonPosition})`); + .locator(`.point-button:nth-child(${buttonPosition})`); } export async function changeConnectorStrokeWidth( page: Page,