Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kanban mobile drag PoC #9048

Closed
wants to merge 10 commits into from
2 changes: 2 additions & 0 deletions packages/affine/data-view/src/core/utils/drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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];
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 2 additions & 4 deletions packages/blocks/src/database-block/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,9 +70,8 @@ export class DatabaseBlockDataSource extends DataSourceBase {

readonly$: ReadonlySignal<boolean> = 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
);
});

Expand Down
43 changes: 43 additions & 0 deletions tests/database/kanban.shared.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading