Skip to content

Commit 5f3ce02

Browse files
committed
feat: Abstract resizing logic
1 parent 8dc98cd commit 5f3ce02

File tree

5 files changed

+459
-103
lines changed

5 files changed

+459
-103
lines changed

src/components/tile-manager/controllers/tile-resize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class TileResizeController implements ReactiveController {
8383

8484
this._element.setPointerCapture(event.pointerId);
8585
this.pointerCaptured = true;
86-
this._element.focus();
86+
// this._element.focus();
8787
}
8888

8989
private _handlePointerMove(event: PointerEvent) {
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
2+
import type { Ref } from 'lit/directives/ref.js';
3+
import { findElementFromEventPath } from '../common/util.js';
4+
5+
export type ResizeMode = 'immediate' | 'deferred';
6+
7+
export type ResizeCallbackParams = {
8+
event: PointerEvent;
9+
state: {
10+
initial: DOMRect;
11+
current: DOMRect;
12+
dx: number;
13+
dy: number;
14+
};
15+
};
16+
17+
type ResizeControllerCallback = (params: ResizeCallbackParams) => unknown;
18+
19+
type ResizeControllerConfig = {
20+
ref?: Ref<HTMLElement>;
21+
mode?: ResizeMode;
22+
deferredFactory?: (element?: HTMLElement) => HTMLElement;
23+
start?: ResizeControllerCallback;
24+
resize?: ResizeControllerCallback;
25+
end?: ResizeControllerCallback;
26+
};
27+
28+
class ResizeController implements ReactiveController {
29+
private static auxiliaryEvents = [
30+
'pointermove',
31+
'lostpointercapture',
32+
] as const;
33+
34+
private static createDefaultGhost(host: HTMLElement): HTMLElement {
35+
const { width, height } = host.getBoundingClientRect();
36+
const ghostElement = document.createElement('div');
37+
38+
Object.assign(ghostElement.style, {
39+
position: 'absolute',
40+
top: 0,
41+
left: 0,
42+
zIndex: 1000,
43+
background: 'pink',
44+
opacity: 0.85,
45+
width: `${width}px`,
46+
height: `${height}px`,
47+
});
48+
49+
return ghostElement;
50+
}
51+
52+
private _host: ReactiveControllerHost & HTMLElement;
53+
private _config: ResizeControllerConfig = {};
54+
private _id!: number;
55+
56+
private _ghost: HTMLElement | null = null;
57+
protected _initialState!: DOMRect;
58+
private _state!: DOMRect;
59+
60+
protected get _element() {
61+
return this._config?.ref ? this._config.ref.value! : this._host;
62+
}
63+
64+
constructor(
65+
host: ReactiveControllerHost & HTMLElement,
66+
config?: ResizeControllerConfig
67+
) {
68+
this._host = host;
69+
this._host.addController(this);
70+
71+
this.setConfig(config);
72+
}
73+
74+
// Internal state helpers
75+
76+
private _createGhost() {
77+
if (this._config.mode !== 'deferred') {
78+
return;
79+
}
80+
81+
this._ghost = this._config.deferredFactory
82+
? this._config.deferredFactory()
83+
: ResizeController.createDefaultGhost(this._host);
84+
this._host.append(this._ghost);
85+
}
86+
87+
private _disposeGhost() {
88+
if (this._ghost) {
89+
this._ghost.remove();
90+
this._ghost = null;
91+
}
92+
}
93+
94+
private _setInitialState(event: PointerEvent) {
95+
const rect = this._host.getBoundingClientRect();
96+
this._initialState = structuredClone(rect);
97+
this._state = rect;
98+
this._id = event.pointerId;
99+
}
100+
101+
private _createCallbackParams(event: PointerEvent): ResizeCallbackParams {
102+
return {
103+
event,
104+
state: {
105+
initial: this._initialState,
106+
current: this._state,
107+
dx: this._state.width - this._initialState.width,
108+
dy: this._state.height - this._initialState.height,
109+
},
110+
};
111+
}
112+
113+
private _toggleSubsequentEvents(set: boolean) {
114+
const method = set
115+
? this._host.addEventListener
116+
: this._host.removeEventListener;
117+
for (const type of ResizeController.auxiliaryEvents) {
118+
method(type, this);
119+
}
120+
}
121+
122+
private _shouldSkip(event: PointerEvent): boolean {
123+
return !findElementFromEventPath((e) => e === this._element, event);
124+
}
125+
126+
// Event handlers
127+
128+
private _handlePointerDown(event: PointerEvent) {
129+
// Non-primary buttons are ignored
130+
if (event.button) {
131+
return;
132+
}
133+
134+
if (this._config?.start) {
135+
this._setInitialState(event);
136+
this._config.start.call(this._host, this._createCallbackParams(event));
137+
138+
this._createGhost();
139+
140+
this._element.setPointerCapture(this._id);
141+
this._toggleSubsequentEvents(true);
142+
}
143+
}
144+
145+
private _handlePointerMove(event: PointerEvent) {
146+
if (!this._element.hasPointerCapture(this._id)) {
147+
return;
148+
}
149+
150+
// REVIEW: Sequencing
151+
152+
if (this._config?.resize) {
153+
this._state.width = event.clientX;
154+
this._state.height = event.clientY;
155+
156+
this._config.resize.call(this._host, this._createCallbackParams(event));
157+
}
158+
159+
const target = this._config.mode === 'deferred' ? this._ghost! : this._host;
160+
161+
Object.assign(target.style, {
162+
width: `${this._state.width}px`,
163+
height: `${this._state.height}px`,
164+
});
165+
}
166+
167+
private _handlePointerEnd(event: PointerEvent) {
168+
Object.assign(this._host.style, {
169+
width: `${this._state.width}px`,
170+
height: `${this._state.height}px`,
171+
});
172+
173+
if (this._config?.end) {
174+
this._config.end.call(this._host, this._createCallbackParams(event));
175+
}
176+
177+
this.dispose();
178+
}
179+
180+
public handleEvent(event: PointerEvent) {
181+
if (this._shouldSkip(event)) {
182+
return;
183+
}
184+
185+
switch (event.type) {
186+
case 'touchstart':
187+
return event.preventDefault();
188+
189+
case 'pointerdown':
190+
return this._handlePointerDown(event);
191+
case 'pointermove':
192+
return this._handlePointerMove(event);
193+
case 'lostpointercapture':
194+
return this._handlePointerEnd(event);
195+
}
196+
}
197+
198+
// Public API
199+
200+
public setConfig(config?: ResizeControllerConfig) {
201+
Object.assign(this._config, config);
202+
}
203+
204+
public dispose() {
205+
this._disposeGhost();
206+
this._toggleSubsequentEvents(false);
207+
this._element.releasePointerCapture(this._id);
208+
}
209+
210+
public hostConnected(): void {
211+
this._host.addEventListener('pointerdown', this);
212+
this._host.addEventListener('touchstart', this, { passive: false });
213+
}
214+
215+
public hostDisconnected(): void {
216+
this._host.removeEventListener('pointerdown', this);
217+
this._host.removeEventListener('touchstart', this);
218+
}
219+
}
220+
221+
export function addResizeController(
222+
host: ReactiveControllerHost & HTMLElement,
223+
config?: ResizeControllerConfig
224+
) {
225+
return new ResizeController(host, config);
226+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { LitElement, css, html } from 'lit';
2+
import { property, state } from 'lit/decorators.js';
3+
import { type Ref, createRef, ref } from 'lit/directives/ref.js';
4+
import {
5+
addKeybindings,
6+
escapeKey,
7+
} from '../common/controllers/key-bindings.js';
8+
import { registerComponent } from '../common/definitions/register.js';
9+
import type { Constructor } from '../common/mixins/constructor.js';
10+
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
11+
import {
12+
type ResizeCallbackParams,
13+
type ResizeMode,
14+
addResizeController,
15+
} from './resize-controller.js';
16+
17+
export interface IgcResizeComponentEventMap {
18+
igcResizeStart: CustomEvent<ResizeCallbackParams>;
19+
igcResize: CustomEvent<ResizeCallbackParams>;
20+
igcResizeEnd: CustomEvent<ResizeCallbackParams>;
21+
igcResizeCancel: CustomEvent<unknown>;
22+
}
23+
24+
export default class IgcResizeComponent extends EventEmitterMixin<
25+
IgcResizeComponentEventMap,
26+
Constructor<LitElement>
27+
>(LitElement) {
28+
public static tagName = 'igc-resize';
29+
30+
public static styles = css`
31+
:host {
32+
display: block;
33+
}
34+
35+
[part='resize-base'] {
36+
position: relative;
37+
min-height: 100%;
38+
}
39+
40+
[part='trigger'] {
41+
position: absolute;
42+
bottom: 0;
43+
right: 0;
44+
z-index: 1;
45+
}
46+
47+
[part='trigger']:focus {
48+
outline: none;
49+
}
50+
`;
51+
52+
/* blazorSuppress */
53+
public static register() {
54+
registerComponent(IgcResizeComponent);
55+
}
56+
57+
private _resizeController: ReturnType<typeof addResizeController>;
58+
59+
private _ghostFactory!: (element?: HTMLElement) => HTMLElement;
60+
private _mode: ResizeMode = 'immediate';
61+
private _adorner: Ref<HTMLElement> = createRef();
62+
63+
@state()
64+
private _state!: ResizeCallbackParams['state'];
65+
66+
@state()
67+
private _isResizing = false;
68+
69+
@property()
70+
public set mode(value: ResizeMode) {
71+
this._mode = value;
72+
this._resizeController.setConfig({ mode: value });
73+
}
74+
75+
public get mode(): ResizeMode {
76+
return this._mode;
77+
}
78+
79+
@property({ attribute: false })
80+
public set ghostFactory(func: (element?: HTMLElement) => HTMLElement) {
81+
this._ghostFactory = func;
82+
this._resizeController.setConfig({ deferredFactory: func });
83+
}
84+
85+
public get ghostFactory() {
86+
return this._ghostFactory;
87+
}
88+
89+
constructor() {
90+
super();
91+
92+
this._resizeController = addResizeController(this, {
93+
ref: this._adorner,
94+
start: this._handleResizeStart,
95+
resize: this._handleResize,
96+
end: this._handleResizeEnd,
97+
});
98+
99+
addKeybindings(this, {
100+
skip: () => !this._isResizing,
101+
bindingDefaults: { preventDefault: true },
102+
}).set(escapeKey, this._handleResizeCancel);
103+
}
104+
105+
private _setState(state: ResizeCallbackParams['state']) {
106+
this._state = state;
107+
}
108+
109+
private _handleResizeStart(params: ResizeCallbackParams) {
110+
params.event.preventDefault();
111+
112+
this._isResizing = true;
113+
this._setState(params.state);
114+
this._adorner.value!.focus();
115+
this.emitEvent('igcResizeStart', { bubbles: false, detail: params });
116+
}
117+
118+
private _handleResize(params: ResizeCallbackParams) {
119+
this._setState(params.state);
120+
this.emitEvent('igcResize', { bubbles: false, detail: params });
121+
}
122+
123+
private _handleResizeEnd(params: ResizeCallbackParams) {
124+
this._isResizing = false;
125+
this._setState(params.state);
126+
this.emitEvent('igcResizeEnd', { bubbles: false, detail: params });
127+
}
128+
129+
private _handleResizeCancel() {
130+
this._isResizing = false;
131+
this._resizeController.dispose();
132+
133+
const { width, height } = this._state.initial;
134+
135+
Object.assign(this.style, {
136+
width: `${width}px`,
137+
height: `${height}px`,
138+
});
139+
140+
this.emitEvent('igcResizeCancel', { bubbles: false });
141+
}
142+
143+
protected override render() {
144+
return html`
145+
<div part="resize-base">
146+
<slot></slot>
147+
<div ${ref(this._adorner)} part="trigger" tabindex="-1">↘️</div>
148+
</div>
149+
`;
150+
}
151+
}
152+
153+
declare global {
154+
interface HTMLElementTagNameMap {
155+
'igc-resize': IgcResizeComponent;
156+
}
157+
}

0 commit comments

Comments
 (0)