diff --git a/packages/affine/data-view/src/core/utils/drag.ts b/packages/affine/data-view/src/core/utils/drag.ts index b5443c1aaf2e..9afd49779572 100644 --- a/packages/affine/data-view/src/core/utils/drag.ts +++ b/packages/affine/data-view/src/core/utils/drag.ts @@ -35,6 +35,7 @@ export const startDrag = < const clear = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', up); window.removeEventListener('keydown', keydown); document.body.style.cursor = oldCursor; ops.onClear(); @@ -63,6 +64,7 @@ export const startDrag = < }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); + window.addEventListener('pointercancel', up); window.addEventListener('keydown', keydown); return result; diff --git a/packages/affine/data-view/src/view-presets/kanban/mobile/card.ts b/packages/affine/data-view/src/view-presets/kanban/mobile/card.ts index a56068436577..458b580ce0ec 100644 --- a/packages/affine/data-view/src/view-presets/kanban/mobile/card.ts +++ b/packages/affine/data-view/src/view-presets/kanban/mobile/card.ts @@ -23,6 +23,7 @@ const styles = css` box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.05); border-radius: 8px; background-color: var(--affine-background-kanban-card-color); + touch-action: none; } .mobile-card-header { diff --git a/packages/affine/data-view/src/view-presets/kanban/mobile/controller/drag.ts b/packages/affine/data-view/src/view-presets/kanban/mobile/controller/drag.ts new file mode 100644 index 000000000000..42fa860b5f30 --- /dev/null +++ b/packages/affine/data-view/src/view-presets/kanban/mobile/controller/drag.ts @@ -0,0 +1,246 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { ReactiveController } from 'lit'; + +import { assertExists, Point, Rect } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; + +import type { MobileDataViewKanban } from '../kanban-view.js'; + +import { autoScrollOnBoundary } from '../../../../core/utils/auto-scroll.js'; +import { startDrag } from '../../../../core/utils/drag.js'; +import { MobileKanbanCard } from '../card.js'; +import { MobileKanbanGroup } from '../group.js'; + +export class MobileKanbanDragController implements ReactiveController { + dragStart = (ele: MobileKanbanCard, evt: PointerEvent) => { + const eleRect = ele.getBoundingClientRect(); + const offsetLeft = evt.x - eleRect.left; + const offsetTop = evt.y - eleRect.top; + const preview = createDragPreview( + ele, + evt.x - offsetLeft, + evt.y - offsetTop + ); + const currentGroup = ele.closest('mobile-kanban-group'); + const drag = startDrag< + | { type: 'out'; callback: () => void } + | { + type: 'self'; + key: string; + position: InsertToPosition; + } + | undefined, + PointerEvent + >(evt, { + onDrag: () => undefined, + onMove: evt => { + if (!(evt.target instanceof HTMLElement)) { + return; + } + preview.display(evt.x - offsetLeft, evt.y - offsetTop); + if (!Rect.fromDOM(this.host).isPointIn(Point.from(evt))) { + const callback = this.host.props.onDrag; + if (callback) { + this.dropPreview.remove(); + return { + type: 'out', + callback: callback(evt, ele.cardId), + }; + } + return; + } + const result = this.shooIndicator(evt, ele); + if (result) { + return { + type: 'self', + key: result.group.group.key, + position: result.position, + }; + } + return; + }, + onClear: () => { + preview.remove(); + this.dropPreview.remove(); + cancelScroll(); + }, + onDrop: result => { + if (!result) { + return; + } + if (result.type === 'out') { + result.callback(); + return; + } + if (result && currentGroup) { + currentGroup.group.manager.moveCardTo( + ele.cardId, + currentGroup.group.key, + result.key, + result.position + ); + } + }, + }); + const cancelScroll = autoScrollOnBoundary( + this.scrollContainer, + computed(() => { + return { + left: drag.mousePosition.value.x, + right: drag.mousePosition.value.x, + top: drag.mousePosition.value.y, + bottom: drag.mousePosition.value.y, + }; + }) + ); + }; + + dropPreview = createDropPreview(); + + getInsertPosition = ( + evt: MouseEvent + ): + | { group: MobileKanbanGroup; card?: MobileKanbanCard; position: InsertToPosition } + | undefined => { + const eles = document.elementsFromPoint(evt.x, evt.y); + const target = eles.find(v => v instanceof MobileKanbanGroup) as MobileKanbanGroup; + if (target) { + const card = getCardByPoint(target, evt.y); + return { + group: target, + card, + position: card + ? { + before: true, + id: card.cardId, + } + : 'end', + }; + } else { + return; + } + }; + + shooIndicator = ( + evt: MouseEvent, + self: MobileKanbanCard | undefined + ): { group: MobileKanbanGroup; position: InsertToPosition } | undefined => { + const position = this.getInsertPosition(evt); + if (position) { + this.dropPreview.display(position.group, self, position.card); + } else { + this.dropPreview.remove(); + } + return position; + }; + + get scrollContainer() { + const scrollContainer = this.host.querySelector( + '.mobile-kanban-groups' + ) as HTMLElement; + assertExists(scrollContainer); + return scrollContainer; + } + + constructor(private host: MobileDataViewKanban) { + this.host.addController(this); + } + + hostConnected() { + if (this.host.props.view.readonly$.value) { + return; + } + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof Element) { + const cell = target.closest('mobile-kanban-cell'); + if (cell?.isEditing) { + return; + } + cell?.selectCurrentCell(false); + const card = target.closest('mobile-kanban-card'); + if (card) { + this.dragStart(card, event); + } + } + return true; + }) + ); + } +} + +const createDragPreview = (card: MobileKanbanCard, x: number, y: number) => { + const preOpacity = card.style.opacity; + card.style.opacity = '0.5'; + const div = document.createElement('div'); + const kanbanCard = new MobileKanbanCard(); + kanbanCard.cardId = card.cardId; + kanbanCard.view = card.view; + kanbanCard.isFocus = true; + kanbanCard.style.backgroundColor = 'var(--affine-background-primary-color)'; + div.append(kanbanCard); + div.className = 'with-data-view-css-variable'; + div.style.width = `${card.getBoundingClientRect().width}px`; + div.style.position = 'fixed'; + // div.style.pointerEvents = 'none'; + div.style.transform = 'rotate(-3deg)'; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.zIndex = '9999'; + document.body.append(div); + return { + display(x: number, y: number) { + div.style.left = `${Math.round(x)}px`; + div.style.top = `${Math.round(y)}px`; + }, + remove() { + card.style.opacity = preOpacity; + div.remove(); + }, + }; +}; +const createDropPreview = () => { + const div = document.createElement('div'); + div.style.height = '2px'; + div.style.borderRadius = '1px'; + div.style.backgroundColor = 'var(--affine-primary-color)'; + div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; + return { + display( + group: MobileKanbanGroup, + self: MobileKanbanCard | undefined, + card?: MobileKanbanCard + ) { + const target = card ?? group.querySelector('.add-card'); + assertExists(target); + if (target.previousElementSibling === self || target === self) { + div.remove(); + return; + } + if (target.previousElementSibling === div) { + return; + } + target.insertAdjacentElement('beforebegin', div); + }, + remove() { + div.remove(); + }, + }; +}; + +const getCardByPoint = ( + group: MobileKanbanGroup, + y: number +): MobileKanbanCard | undefined => { + const cards = Array.from( + group.querySelectorAll('mobile-kanban-card') + ); + const positions = cards.map(v => { + const rect = v.getBoundingClientRect(); + return (rect.top + rect.bottom) / 2; + }); + const index = positions.findIndex(v => v > y); + return cards[index]; +}; diff --git a/packages/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts b/packages/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts index 767163663fe8..42ed93e3136d 100644 --- a/packages/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts +++ b/packages/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts @@ -16,6 +16,7 @@ import type { KanbanViewSelectionWithType } from '../types.js'; import { type DataViewInstance, renderUniLit } from '../../../core/index.js'; import { sortable } from '../../../core/utils/wc-dnd/sort/sort-context.js'; import { DataViewBase } from '../../../core/view/data-view-base.js'; +import { MobileKanbanDragController } from './controller/drag.js'; const styles = css` mobile-data-view-kanban { @@ -52,6 +53,8 @@ export class MobileDataViewKanban extends DataViewBase< > { static override styles = styles; + private dragController = new MobileKanbanDragController(this); + renderAddGroup = () => { const addGroup = this.groupManager.addGroup; if (!addGroup) { @@ -95,8 +98,8 @@ export class MobileDataViewKanban extends DataViewBase< }, hideIndicator: () => {}, moveTo: () => {}, - showIndicator: () => { - return false; + showIndicator: evt => { + return this.dragController.shooIndicator(evt, undefined) != null; }, view: this.props.view, eventTrace: this.props.eventTrace, diff --git a/packages/affine/data-view/src/view-presets/kanban/pc/card.ts b/packages/affine/data-view/src/view-presets/kanban/pc/card.ts index a7b8a6c8743f..f6fb6e9ce369 100644 --- a/packages/affine/data-view/src/view-presets/kanban/pc/card.ts +++ b/packages/affine/data-view/src/view-presets/kanban/pc/card.ts @@ -23,6 +23,7 @@ const styles = css` border-radius: 8px; transition: background-color 100ms ease-in-out; background-color: var(--affine-background-kanban-card-color); + touch-action: none; } affine-data-view-kanban-card:hover { diff --git a/packages/blocks/src/database-block/data-source.ts b/packages/blocks/src/database-block/data-source.ts index eff75599eccf..5f0f1555344f 100644 --- a/packages/blocks/src/database-block/data-source.ts +++ b/packages/blocks/src/database-block/data-source.ts @@ -17,7 +17,6 @@ import { type ViewMeta, } from '@blocksuite/data-view'; import { propertyPresets } from '@blocksuite/data-view/property-presets'; -import { IS_MOBILE } from '@blocksuite/global/env'; import { assertExists } from '@blocksuite/global/utils'; import { type BlockModel, nanoid, Text } from '@blocksuite/store'; import { computed, type ReadonlySignal } from '@preact/signals-core'; @@ -71,9 +70,8 @@ export class DatabaseBlockDataSource extends DataSourceBase { readonly$: ReadonlySignal = computed(() => { return ( - this._model.doc.readonly || - // TODO(@L-Sun): use block level readonly - IS_MOBILE + // FIXME: make this readonly for most mobile blocks but readwrite for kanban? + this._model.doc.readonly ); }); diff --git a/tests/database/kanban.shared.spec.ts b/tests/database/kanban.shared.spec.ts new file mode 100644 index 000000000000..59cf4bd22d58 --- /dev/null +++ b/tests/database/kanban.shared.spec.ts @@ -0,0 +1,43 @@ +import { portableLocator } from 'utils/query.js'; + +import { + enterPlaygroundRoom, + initKanbanViewState, +} from '../utils/actions/index.js'; +import { test } from '../utils/playwright.js'; +import { focusKanbanCardHeader } from './actions.js'; + +test.describe('kanban view', () => { + test('drag and drop', async ({ page }) => { + await enterPlaygroundRoom(page); + await initKanbanViewState(page, { + rows: ['row1'], + columns: [ + { + type: 'number', + value: [1], + }, + { + type: 'rich-text', + value: ['text'], + }, + ], + }); + + await focusKanbanCardHeader(page); + // https://playwright.dev/docs/input#dragging-manually mentions that you may need two drags to + // trigger `dragover`, so we drag to our own column header before dragging to "Ungroups". + await portableLocator(page, 'affine-data-view-kanban-card').hover(); + await page.mouse.down(); + await page.locator('[data-wc-dnd-drag-handler-id="g:0"]').hover(); + await page + .locator('[data-wc-dnd-drag-handler-id="Ungroups"]') + .hover({ force: true }); + await page.mouse.up(); + + // When we drag into "Ungroups", our old group collapses. + await test + .expect(page.locator('[data-wc-dnd-drag-handler-id="g:0"]')) + .not.toBeVisible(); + }); +}); diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 728b25ad5bba..69280d5c841f 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -1,6 +1,6 @@ import type { PlaywrightWorkerOptions } from '@playwright/test'; -import { defineConfig } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test'; import process from 'node:process'; export default defineConfig({ @@ -17,6 +17,27 @@ export default defineConfig({ COVERAGE: process.env.COVERAGE ?? '', }, }, + projects: [ + // To avoid burning github actions minutes, we only run interesting tests on mobile. + // The rest of the tests are run on desktop. + // .spec.ts files are run on desktop only + // .mobile.spec.ts files are run on mobile only + // .shared.spec.ts files are run on both desktop and mobile + { + name: 'desktop', + testIgnore: 'tests/**/*.mobile.spec.ts', + }, + { + name: 'mobile', + testMatch: [ + 'tests/**/*.mobile.spec.ts', + 'tests/**/*.shared.spec.ts', + ], + // Note: we ignore the BROWSER environment variable here. + // We can special-case firefox/webkit in the future if this causes us to miss bugs in CI. + use: { ...devices['Pixel 7'] }, + }, + ], use: { browserName: (process.env.BROWSER as PlaywrightWorkerOptions['browserName']) ?? diff --git a/tests/utils/query.ts b/tests/utils/query.ts index d8c40d15af3e..dfcc12c51c69 100644 --- a/tests/utils/query.ts +++ b/tests/utils/query.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { waitNextFrame } from './actions/misc.js'; import { assertAlmostEqual } from './asserts.js'; @@ -123,3 +123,26 @@ export function getEmbedCardToolbar(page: Page) { cardStyleListButton, }; } + +/** + * mobile views typically use different component names. + * + * If you want to write a test that works on both desktop and mobile, + * you need to use a different component name on mobile. + * + * Typically, it is good enough to replace the `affine-data-view-` + * prefix and replace it with `mobile-`. + * + * In the future, we might want to consider using data-testid attributes instead. + */ +export function portableLocator(page: Page, selector: string) { + if (!selector.startsWith('affine-data-view-')) { + throw new Error(`Received invalid selector '${selector}'). Please use a selector starting with affine-data-view-`); + } + + if (test.info().project.name === 'mobile') { + return page.locator(selector.replaceAll('affine-data-view-', 'mobile-')); + } + + return page.locator(selector); +}