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

Drag cells to move #1332

Merged
merged 18 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions quadratic-client/src/app/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ interface EventTypes {
resizeHeadingColumn: (column: number) => void;

offlineTransactions: (transactions: number, operations: number) => void;

cellMoving: (move: boolean) => void;
}

export const events = new EventEmitter<EventTypes>();
54 changes: 54 additions & 0 deletions quadratic-client/src/app/gridGL/UI/UICellMoving.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { sheets } from '@/app/grid/controller/Sheets';
import { pixiApp } from '@/app/gridGL/pixiApp/PixiApp';
import { colors } from '@/app/theme/colors';
import { BitmapText, Container, Graphics } from 'pixi.js';

const MOVING_THICKNESS = 3;

export class UICellMoving extends Container {
private graphics: Graphics;
private location: BitmapText;

dirty = false;

constructor() {
super();
this.graphics = this.addChild(new Graphics());
this.location = this.addChild(new BitmapText('', { fontName: 'OpenSans', fontSize: 12 }));
this.visible = false;
}

private drawMove() {
const moving = pixiApp.pointer.pointerCellMoving.moving;
if (!moving) {
throw new Error('Expected moving to be defined in drawMove');
}
this.visible = true;
this.graphics.clear();
this.graphics.lineStyle(1, colors.movingCells, MOVING_THICKNESS);
const offsets = sheets.sheet.offsets;
const start = offsets.getCellOffsets(moving.toColumn, moving.toRow);
const end = offsets.getCellOffsets(moving.toColumn + moving.width, moving.toRow + moving.height);
this.graphics.drawRect(start.x, start.y, end.x + end.w - start.x, end.y + end.h - start.y);
}

update() {
if (this.dirty) {
this.dirty = false;
switch (pixiApp.pointer.pointerCellMoving.state) {
case 'hover':
if (this.visible) {
this.visible = false;
}
break;
case 'move':
this.drawMove();
break;
default:
davidfig marked this conversation as resolved.
Show resolved Hide resolved
if (this.visible) {
this.visible = false;
}
}
}
}
}
25 changes: 20 additions & 5 deletions quadratic-client/src/app/gridGL/interaction/pointer/Pointer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PointerCellMoving } from '@/app/gridGL/interaction/pointer/PointerCellMoving';
import { pixiAppSettings } from '@/app/gridGL/pixiApp/PixiAppSettings';
import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer';
import { Viewport } from 'pixi-viewport';
Expand All @@ -15,13 +16,15 @@ export class Pointer {
pointerHtmlCells: PointerHtmlCells;
pointerCursor: PointerCursor;
pointerDown: PointerDown;
pointerCellMoving: PointerCellMoving;

constructor(viewport: Viewport) {
this.pointerHeading = new PointerHeading();
this.pointerAutoComplete = new PointerAutoComplete();
this.pointerDown = new PointerDown();
this.pointerCursor = new PointerCursor();
this.pointerHtmlCells = new PointerHtmlCells();
this.pointerCellMoving = new PointerCellMoving();

viewport.on('pointerdown', this.handlePointerDown);
viewport.on('pointermove', this.pointerMove);
Expand Down Expand Up @@ -75,32 +78,43 @@ export class Pointer {
if (this.isMoreThanOneTouch(e)) return;
const world = pixiApp.viewport.toWorld(e.data.global);
const event = e.data.originalEvent as PointerEvent;
this.pointerHtmlCells.pointerDown(e) ||
this.pointerCellMoving.pointerDown(event) ||
this.pointerHtmlCells.pointerDown(e) ||
this.pointerHeading.pointerDown(world, event) ||
this.pointerAutoComplete.pointerDown(world) ||
this.pointerDown.pointerDown(world, event);

this.updateCursor();
};

private pointerMove = (e: InteractionEvent): void => {
if (this.isMoreThanOneTouch(e) || this.isOverCodeEditor(e)) return;
const world = pixiApp.viewport.toWorld(e.data.global);
this.pointerHtmlCells.pointerMove(e) ||
const event = e.data.originalEvent as PointerEvent;
this.pointerCellMoving.pointerMove(event, world) ||
this.pointerHtmlCells.pointerMove(e) ||
this.pointerHeading.pointerMove(world) ||
this.pointerAutoComplete.pointerMove(world) ||
this.pointerDown.pointerMove(world) ||
this.pointerCursor.pointerMove(world);

// change the cursor based on pointer priority
this.updateCursor();
};

// change the cursor based on pointer priority
private updateCursor() {
const cursor =
pixiApp.pointer.pointerCellMoving.cursor ??
pixiApp.pointer.pointerHtmlCells.cursor ??
pixiApp.pointer.pointerHeading.cursor ??
pixiApp.pointer.pointerAutoComplete.cursor;
pixiApp.canvas.style.cursor = cursor ?? 'unset';
};
}

private pointerUp = (e: InteractionEvent): void => {
if (this.isMoreThanOneTouch(e)) return;
this.pointerHtmlCells.pointerUp() ||
this.pointerCellMoving.pointerUp() ||
this.pointerHtmlCells.pointerUp() ||
this.pointerHeading.pointerUp() ||
this.pointerAutoComplete.pointerUp() ||
this.pointerDown.pointerUp();
Expand All @@ -116,6 +130,7 @@ export class Pointer {
return true;
}
return (
this.pointerCellMoving.handleEscape() ||
this.pointerHtmlCells.handleEscape() ||
this.pointerHeading.handleEscape() ||
this.pointerAutoComplete.handleEscape()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { events } from '@/app/events/events';
import { sheets } from '@/app/grid/controller/Sheets';
import { pixiApp } from '@/app/gridGL/pixiApp/PixiApp';
import { PanMode, pixiAppSettings } from '@/app/gridGL/pixiApp/PixiAppSettings';
import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore';
import { Point } from 'pixi.js';
import { isMobile } from 'react-device-detect';

// Distance from top left corner to trigger a cell move.
const TOP_LEFT_CORNER_THRESHOLD_SQUARED = 100;

// Speed when turning on the mouseEdges plugin for pixi-viewport
const MOUSE_EDGES_SPEED = 8;
const MOUSE_EDGES_DISTANCE = 20;

interface MoveCells {
column: number;
row: number;
width: number;
height: number;
toColumn: number;
toRow: number;
}

export class PointerCellMoving {
moving?: MoveCells;
state?: 'hover' | 'move';

get cursor(): string | undefined {
switch (this.state) {
case 'move':
return 'grabbing';
case 'hover':
return 'grab';
default:
return undefined;
}
}

findCorner(world: Point): Point {
return world;
}
pointerDown(event: PointerEvent): boolean {
if (isMobile || pixiAppSettings.panMode !== PanMode.Disabled || event.button === 1) return false;

if (this.state === 'hover' && this.moving) {
this.state = 'move';
events.emit('cellMoving', true);
pixiApp.viewport.mouseEdges({
distance: MOUSE_EDGES_DISTANCE,
allowButtons: true,
speed: MOUSE_EDGES_SPEED / pixiApp.viewport.scale.x,
});
return true;
}
return false;
}

// Completes the move
private completeMove() {
if (this.state !== 'move' || !this.moving) {
throw new Error('Expected moving to be defined in completeMove');
}
quadraticCore.moveCells(
this.moving.column,
this.moving.row,
this.moving.width,
this.moving.height,
sheets.sheet.id,
this.moving.toColumn,
this.moving.toRow,
sheets.sheet.id
);
}

private reset() {
this.moving = undefined;
if (this.state === 'move') {
pixiApp.cellMoving.dirty = true;
events.emit('cellMoving', false);
pixiApp.viewport.plugins.remove('mouse-edges');
}
this.state = undefined;
}

private pointerMoveMoving(world: Point) {
if (this.state !== 'move' || !this.moving) {
throw new Error('Expected moving to be defined in pointerMoveMoving');
}
const offsets = sheets.sheet.offsets;
const position = offsets.getColumnRowFromScreen(world.x, world.y);
if (this.moving.toColumn !== position.column || this.moving.toRow !== position.row) {
this.moving.toColumn = position.column;
this.moving.toRow = position.row;
pixiApp.cellMoving.dirty = true;
}
}

private pointerMoveHover(world: Point): boolean {
const sheet = sheets.sheet;
const offsets = sheet.offsets;
const { column, row } = offsets.getColumnRowFromScreen(world.x, world.y);

const origin = sheet.cursor.originPosition;

// if not hovering over current selection, then there's nothing to move
if (column !== origin.x || row !== origin.y) {
this.reset();
return false;
}

// Check if we overlap the hit-box: (1) x and y are greater than the corner; (2) distance to the corner is less than the threshold
const position = offsets.getCellOffsets(column, row);
if (
world.x >= position.x &&
world.y >= position.y &&
Math.pow(position.x - world.x, 2) + Math.pow(position.y - world.y, 2) <= TOP_LEFT_CORNER_THRESHOLD_SQUARED
) {
this.state = 'hover';
const rectangle = sheet.cursor.getRectangle();
this.moving = { column, row, width: rectangle.width, height: rectangle.height, toColumn: column, toRow: row };
return true;
}
this.reset();
return false;
}

pointerMove(event: PointerEvent, world: Point): boolean {
if (isMobile || pixiAppSettings.panMode !== PanMode.Disabled || event.button === 1) return false;

if (this.state === 'move') {
this.pointerMoveMoving(world);
return true;
} else {
return this.pointerMoveHover(world);
}
}

pointerUp(): boolean {
if (this.state === 'move') {
this.completeMove();
this.reset();
return true;
}
return false;
}

handleEscape(): boolean {
if (this.state === 'move') {
this.reset();
return true;
}
return false;
}
}
5 changes: 5 additions & 0 deletions quadratic-client/src/app/gridGL/pixiApp/PixiApp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { events } from '@/app/events/events';
import { UICellMoving } from '@/app/gridGL/UI/UICellMoving';
import { isEmbed } from '@/app/helpers/isEmbed';
import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer';
import { renderWebWorker } from '@/app/web-workers/renderWebWorker/renderWebWorker';
Expand Down Expand Up @@ -44,13 +45,16 @@ export class PixiApp {
private waitingForFirstRender?: Function;
private alreadyRendered = false;

// todo: UI should be pulled out and separated into its own class

highlightedCells = new HighlightedCells();
canvas!: HTMLCanvasElement;
viewport!: Viewport;
gridLines!: GridLines;
axesLines!: AxesLines;
cursor!: Cursor;
multiplayerCursor!: UIMultiPlayerCursor;
cellMoving!: UICellMoving;
headings!: GridHeadings;
boxCells!: BoxCells;
cellsSheets: CellsSheets;
Expand Down Expand Up @@ -170,6 +174,7 @@ export class PixiApp {
this.boxCells = this.viewportContents.addChild(new BoxCells());
this.multiplayerCursor = this.viewportContents.addChild(new UIMultiPlayerCursor());
this.cursor = this.viewportContents.addChild(new Cursor());
this.cellMoving = this.viewportContents.addChild(new UICellMoving());
this.headings = this.viewportContents.addChild(new GridHeadings());

this.reset();
Expand Down
5 changes: 4 additions & 1 deletion quadratic-client/src/app/gridGL/pixiApp/Update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class Update {
pixiApp.headings.dirty ||
pixiApp.boxCells.dirty ||
pixiApp.multiplayerCursor.dirty ||
pixiApp.cellMoving.dirty ||
pixiApp.cursor.dirty;

if (rendererDirty && debugShowWhyRendering) {
Expand All @@ -98,7 +99,7 @@ export class Update {
pixiApp.axesLines.dirty ? 'axesLines ' : ''
}${pixiApp.headings.dirty ? 'headings ' : ''}${pixiApp.cursor.dirty ? 'cursor ' : ''}${
pixiApp.multiplayerCursor.dirty ? 'multiplayer cursor' : ''
}`
}${pixiApp.cellMoving.dirty ? 'cellMoving' : ''}`
);
}

Expand All @@ -115,6 +116,8 @@ export class Update {
debugTimeCheck('[Update] cursor');
pixiApp.multiplayerCursor.update();
debugTimeCheck('[Update] multiplayerCursor');
pixiApp.cellMoving.update();
debugTimeCheck('[Update] cellMoving');

if (pixiApp.viewport.dirty || rendererDirty) {
debugTimeReset();
Expand Down
2 changes: 1 addition & 1 deletion quadratic-client/src/app/helpers/parseEditorPythonCell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SheetRect } from '@/app/quadratic-rust-client/quadratic_rust_client';
import { SheetRect } from '@/app/quadratic-core-types';
import { ParseFormulaReturnType } from './formulaNotation';

export function parsePython(cellsAccessed?: SheetRect[] | null) {
Expand Down
3 changes: 2 additions & 1 deletion quadratic-client/src/app/quadratic-core-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface Rect { min: Pos, max: Pos, }
export interface Span { start: number, end: number, }
export interface SearchOptions { case_sensitive?: boolean, whole_cell?: boolean, search_code?: boolean, sheet_id?: string, }
export interface SheetPos { x: bigint, y: bigint, sheet_id: SheetId, }
export interface SheetRect { min: Pos, max: Pos, sheet_id: SheetId, }
export interface Placement { index: number, position: number, size: number, }
export interface ColumnRow { column: number, row: number, }
export interface SheetInfo { sheet_id: string, name: string, order: string, color: string | null, offsets: string, bounds: GridBounds, bounds_without_formatting: GridBounds, }
Expand All @@ -48,5 +49,5 @@ export interface JsCodeResult { transaction_id: string, success: boolean, error_
export interface MinMax { min: number, max: number, }
export interface TransientResize { row: bigint | null, column: bigint | null, old_size: number, new_size: number, }
export interface SheetBounds { sheet_id: string, bounds: GridBounds, bounds_without_formatting: GridBounds, }
export type TransactionName = "Unknown" | "ResizeColumn" | "ResizeRow" | "Autocomplete" | "SetBorders" | "SetCells" | "SetFormats" | "CutClipboard" | "PasteClipboard" | "SetCode" | "RunCode" | "Import" | "SetSheetMetadata" | "SheetAdd" | "SheetDelete" | "DuplicateSheet";
export type TransactionName = "Unknown" | "ResizeColumn" | "ResizeRow" | "Autocomplete" | "SetBorders" | "SetCells" | "SetFormats" | "CutClipboard" | "PasteClipboard" | "SetCode" | "RunCode" | "Import" | "SetSheetMetadata" | "SheetAdd" | "SheetDelete" | "DuplicateSheet" | "MoveCells";
export interface JsGetCellResponse { x: bigint, y: bigint, value: string, type_name: string, }
4 changes: 4 additions & 0 deletions quadratic-client/src/app/theme/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const colors = {
cellColorError: 0xf25f5c,
cursorCell: 0x6cd4ff,
searchCell: 0x50c878,

// todo: this should be changed
movingCells: 0x6cd4ff,

independence: 0x5d576b,
headerBackgroundColor: 0xffffff,
headerSelectedBackgroundColor: 0xe7f7ff,
Expand Down
3 changes: 1 addition & 2 deletions quadratic-client/src/app/ui/menus/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { usePythonState } from '@/app/atoms/usePythonState';
import { events } from '@/app/events/events';
import { sheets } from '@/app/grid/controller/Sheets';
import { Coordinate, SheetPosTS } from '@/app/gridGL/types/size';
import { JsCodeCell, JsRenderCodeCell, Pos } from '@/app/quadratic-core-types';
import { SheetRect } from '@/app/quadratic-rust-client/quadratic_rust_client';
import { JsCodeCell, JsRenderCodeCell, Pos, SheetRect } from '@/app/quadratic-core-types';
import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer';
import { EvaluationResult } from '@/app/web-workers/pythonWebWorker/pythonTypes';
import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore';
Expand Down