Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend {
#container {
display: grid;
gap: 0;
grid-template-columns: minmax(0, 1fr) auto 16px;
grid-template-columns: minmax(0, 1fr) auto 16px auto;
width: 100%;
height: 24px;
align-items: stretch;
Expand Down Expand Up @@ -151,11 +151,12 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend {
<input id="input" type="text" spellcheck="false">
</div>
<select id="select"></select>
<div id="stepper">
<button id="increase" type="button" aria-label="Increase value">+</button>
<button id="decrease" type="button" aria-label="Decrease value">-</button>
</div>
<span id="measure"></span>
<div id="stepper">
<button id="increase" type="button" aria-label="Increase value">+</button>
<button id="decrease" type="button" aria-label="Decrease value">-</button>
</div>
<div id="addon"></div>
<span id="measure"></span>
</div>
`;

Expand Down Expand Up @@ -251,8 +252,9 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend {
this._unitValueConverter = value;
}

private _input: HTMLInputElement;
private _select: HTMLSelectElement;
private _input: HTMLInputElement;
private _select: HTMLSelectElement;
private _addonContainer: HTMLDivElement;
private _measure: HTMLSpanElement;
private _scrubberButton: HTMLButtonElement;
private _increaseButton: HTMLButtonElement;
Expand Down Expand Up @@ -282,19 +284,33 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend {
constructor() {
super();
this._restoreCachedInititalValues();
this._input = this._getDomElement<HTMLInputElement>('input');
this._select = this._getDomElement<HTMLSelectElement>('select');
this._input = this._getDomElement<HTMLInputElement>('input');
this._select = this._getDomElement<HTMLSelectElement>('select');
this._addonContainer = this._getDomElement<HTMLDivElement>('addon');
this._measure = this._getDomElement<HTMLSpanElement>('measure');
this._scrubberButton = this._getDomElement<HTMLButtonElement>('scrubber');
this._increaseButton = this._getDomElement<HTMLButtonElement>('increase');
this._decreaseButton = this._getDomElement<HTMLButtonElement>('decrease');
}

ready() {
ready() {
this._parseAttributesToProperties();
this._wireEvents();
this._updateValue();
}
}

private _addon: HTMLElement;
public get addon() {
return this._addon;
}
public set addon(value: HTMLElement) {
if (this._addon === value)
return;
this._addon?.remove();
this._addon = value ?? null;
if (this._addon)
this._addonContainer.appendChild(this._addon);
}

private _wireEvents() {
this._input.addEventListener('change', () => this._applyTypedValue());
Expand Down Expand Up @@ -909,4 +925,4 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend {
}
}

customElements.define('node-projects-numeric-style-input', NumericStyleInput);
customElements.define('node-projects-numeric-style-input', NumericStyleInput);
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { IPropertiesService } from './IPropertiesService.js';
import { IPropertyEditor } from './IPropertyEditor.js';
import { PropertyType } from './PropertyType.js';
import type { IDesignItem } from '../../item/IDesignItem.js';
import type { IDesignItem } from '../../item/IDesignItem.js';
import type { UnitEditorAddon } from './propertyEditors/UnitPropertyEditorConfig.js';

export interface IProperty {
name: string;
Expand All @@ -22,7 +23,8 @@ export interface IProperty {
units?: string[]; // selectable units for editors that support unit changes
unitSteps?: Record<string, number>;
numericValueDecimalPlaces?: number; // rounding used by numeric unit conversions
numericValueConverter?: (value: number, fromUnit: string, toUnit: string, property: IProperty, numericType: string, numberText?: string, rawValue?: string, designItems?: IDesignItem[]) => string | number | null | undefined;
numericValueConverter?: (value: number, fromUnit: string, toUnit: string, property: IProperty, numericType: string, numberText?: string, rawValue?: string, designItems?: IDesignItem[]) => string | number | null | undefined;
unitEditorAddon?: UnitEditorAddon;
enumValues?: [name: string, value: string | number][]; // list selectable enum values
createEditor?: (property: IProperty) => IPropertyEditor;
value?: any;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { UnitEditorAddon } from './UnitPropertyEditorConfig.js';

const popupSize = 108;
const angleUnitInDegrees: Record<string, number> = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };

function getAngle(event: PointerEvent, circle: HTMLElement) {
const rect = circle.getBoundingClientRect();
const x = event.clientX - (rect.left + rect.width / 2);
const y = event.clientY - (rect.top + rect.height / 2);
return (Math.atan2(-y, x) * 180 / Math.PI + 360) % 360;
}

export function getAngleInDegrees(value?: string | null) {
const match = value?.trim().match(/^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-z]+)?$/i);
if (!match)
return 0;
const angle = Number(match[1]) * (angleUnitInDegrees[match[2]?.toLowerCase() ?? 'deg'] ?? 1);
return Number.isFinite(angle) ? ((angle % 360) + 360) % 360 : 0;
}

/** Creates the built-in circular picker used by CSS angle properties. */
export const createAngleUnitEditorAddon: UnitEditorAddon = context => {
const button = document.createElement('button');
button.type = 'button';
button.title = 'Pick angle';
button.setAttribute('aria-label', 'Pick angle');
button.textContent = '◉';
button.style.cssText = 'border:0;background:transparent;color:inherit;cursor:pointer;padding:0 3px;height:24px;line-height:1;';
if (context.property.readonly) {
button.disabled = true;
button.style.cursor = 'default';
return button;
}

let popup: HTMLDivElement | null = null;
let circle: HTMLDivElement | null = null;
let dragging = false;
let cancelOnBlur: () => void;

const updateHand = (angle: number) => {
const hand = popup?.querySelector<HTMLElement>('[data-angle-hand]');
if (hand)
hand.style.transform = `translateX(-50%) rotate(${angle}deg)`;
};
const selectAngle = async (event: PointerEvent, commit: boolean) => {
if (!circle)
return;
const angle = Math.round(getAngle(event, circle));
updateHand(angle);
const value = `${angle}deg`;
if (commit)
await context.setValue(value);
else
await context.previewValue(value);
};
const close = async (removePreview = false) => {
if (removePreview)
await context.removePreviewValue();
popup?.remove();
popup = null;
circle = null;
dragging = false;
document.removeEventListener('pointerdown', outsidePointerDown, true);
window.removeEventListener('blur', cancelOnBlur);
};
const outsidePointerDown = (event: PointerEvent) => {
if (popup && !popup.contains(event.target as Node) && event.target !== button)
void close(dragging);
};
const open = () => {
if (popup) {
void close(dragging);
return;
}
popup = document.createElement('div');
popup.style.cssText = `position:fixed;z-index:100000;width:${popupSize}px;height:${popupSize}px;border:2px solid currentColor;border-radius:50%;background:var(--property-editor-popup-background,#fff);color:var(--property-editor-popup-color,#111);box-sizing:border-box;`;
circle = popup;
const labels = [['0', 'right'], ['90', 'top'], ['180', 'left'], ['270', 'bottom']];
for (const [label, position] of labels) {
const item = document.createElement('span');
item.textContent = label;
item.style.cssText = `position:absolute;font:10px sans-serif;${position}:4px;${position === 'right' || position === 'left' ? 'top:50%;transform:translateY(-50%);' : 'left:50%;transform:translateX(-50%);'}`;
popup.appendChild(item);
}
const hand = document.createElement('span');
hand.dataset.angleHand = '';
hand.style.cssText = `position:absolute;left:50%;top:50%;width:42%;height:2px;background:#f22;transform-origin:0 50%;transform:rotate(${getAngleInDegrees(context.value)}deg);`;
popup.appendChild(hand);
document.body.appendChild(popup);
const rect = button.getBoundingClientRect();
popup.style.left = `${Math.max(4, rect.right - popupSize)}px`;
popup.style.top = `${rect.bottom + 4}px`;
circle.addEventListener('pointerdown', event => { dragging = true; circle!.setPointerCapture?.(event.pointerId); void selectAngle(event, false); });
circle.addEventListener('pointermove', event => { if (dragging) void selectAngle(event, false); });
circle.addEventListener('pointerup', event => { if (dragging) { dragging = false; void selectAngle(event, true); } });
circle.addEventListener('pointercancel', () => { if (dragging) void close(true); });
cancelOnBlur = () => { if (dragging) void close(true); };
window.addEventListener('blur', cancelOnBlur);
document.addEventListener('pointerdown', outsidePointerDown, true);
};
button.addEventListener('click', open);
return button;
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,42 @@ export class UnitPropertyEditor extends BasePropertyEditor<NumericStyleInput> {
return;
await this._valueChanged(e.newValue === '' ? null : e.newValue);
});
if (config?.addon) {
const thisEditor = this;
let previewStartValue: string | null = null;
const context = {
property,
get value() {
return selector.value;
},
get designItems() {
return thisEditor.designItems;
},
setValue: async (value: string | null) => {
if (previewStartValue !== null)
await this._removePreviewValue();
selector.value = value ?? '';
previewStartValue = null;
await this._valueChanged(value);
},
previewValue: async (value: string | null) => {
previewStartValue ??= selector.value;
selector.value = value ?? '';
await this._previewValueChanged(value);
},
removePreviewValue: async () => {
try {
await this._removePreviewValue();
} finally {
if (previewStartValue !== null) {
selector.value = previewStartValue;
previewStartValue = null;
}
}
}
};
selector.addon = config.addon(context);
}
this.element = selector;
}

Expand All @@ -38,4 +74,4 @@ export class UnitPropertyEditor extends BasePropertyEditor<NumericStyleInput> {
refreshValue(valueType: ValueType, value: any) {
this.element.value = value == null ? '' : String(value);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import type { IDesignItem } from '../../../item/IDesignItem.js';
import type { IProperty } from '../IProperty.js';
import { createAngleUnitEditorAddon } from './AngleUnitEditorAddon.js';

export type UnitPropertyType = 'css-length' | 'css-angle' | 'css-time' | 'css-frequency' | 'css-flex' | 'css-resolution' | 'css-scale' | 'svg-length';

export type UnitConversionResult = string | number | null | undefined;

/**
* Context supplied to an optional control hosted next to a numeric unit editor.
* An addon can use the callbacks to commit or preview values selected by its own popup.
*/
export type UnitEditorAddonContext = {
property: IProperty,
readonly value: string,
readonly designItems: IDesignItem[],
setValue: (value: string | null) => Promise<void>,
previewValue: (value: string | null) => Promise<void>,
removePreviewValue: () => Promise<void>
};

/**
* Creates a control, such as a button that opens a specialised unit picker,
* which is displayed beside the standard numeric unit controls.
*/
export type UnitEditorAddon = (context: UnitEditorAddonContext) => HTMLElement;

export type UnitConversionContext = {
property: IProperty,
numericType: UnitPropertyType,
Expand All @@ -21,7 +41,8 @@ export type UnitEditorConfig = {
units: string[],
fixedValues: string[],
unitSteps: Record<string, number>,
convertValue: (context: Omit<UnitConversionContext, 'property' | 'numericType'>) => string
convertValue: (context: Omit<UnitConversionContext, 'property' | 'numericType'>) => string,
addon?: UnitEditorAddon
};

const cssNumericKeywordValues = ['initial', 'inherit', 'unset'];
Expand Down Expand Up @@ -542,6 +563,7 @@ export function getCssNumericEditorConfig(property: IProperty): UnitEditorConfig
units: property.units?.length ? property.units : defaultCssNumericUnits[numericType],
fixedValues: getCssNumericKeywordValues(property.values),
unitSteps: { ...defaultUnitSteps, ...(property.unitSteps ?? {}) },
convertValue: context => convertNumericUnitValue({ ...context, property, numericType })
convertValue: context => convertNumericUnitValue({ ...context, property, numericType }),
addon: property.unitEditorAddon ?? (numericType === 'css-angle' ? createAngleUnitEditorAddon : undefined)
};
}
}
2 changes: 2 additions & 0 deletions packages/web-component-designer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export * from "./elements/services/propertiesService/propertyEditors/BooleanProp
export * from "./elements/services/propertiesService/propertyEditors/ColorPropertyEditor.js";
export * from "./elements/services/propertiesService/propertyEditors/CssPropertyEditor.js";
export * from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditor.js";
export type { UnitEditorAddon, UnitEditorAddonContext } from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.js";
export { createAngleUnitEditorAddon, getAngleInDegrees } from "./elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.js";
export * from "./elements/services/propertiesService/propertyEditors/DatePropertyEditor.js";
export * from "./elements/services/propertiesService/propertyEditors/ImageButtonListPropertyEditor.js";
export * from "./elements/services/propertiesService/propertyEditors/JsonPropertyEditor.js";
Expand Down
61 changes: 61 additions & 0 deletions packages/web-component-designer/tests/AngleUnitEditorAddon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** @jest-environment jsdom */
import { expect, jest, test } from '@jest/globals';
import type { IProperty } from '../src/elements/services/propertiesService/IProperty';
import { createAngleUnitEditorAddon, getAngleInDegrees } from '../src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon';

test.each([
['90deg', 90], ['100grad', 90], ['1.5707963268rad', 90], ['0.25turn', 90]
])('normalizes %s for the angle picker hand', (value, expected) => {
expect(getAngleInDegrees(value)).toBeCloseTo(expected);
});

test('angle picker removes a preview when pointer interaction is cancelled', async () => {
const removePreviewValue = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
const context = {
property: {} as IProperty,
value: '1rad',
designItems: [],
setValue: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
previewValue: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
removePreviewValue
};
const button = createAngleUnitEditorAddon(context);
document.body.appendChild(button);
button.click();
const popup = document.body.lastElementChild as HTMLElement;
popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX: 10, clientY: 10, pointerId: 1 }));
popup.dispatchEvent(new Event('pointercancel'));
await Promise.resolve();
expect(context.previewValue).toHaveBeenCalled();
expect(removePreviewValue).toHaveBeenCalled();
expect(document.body.contains(popup)).toBe(false);
button.remove();
});

test.each([
['right', 100, 50, '0deg'],
['top', 50, 0, '90deg'],
['left', 0, 50, '180deg'],
['bottom', 50, 100, '270deg']
])('dragging to the %s of the dial selects the expected angle', async (_position, clientX, clientY, expected) => {
const previewValue = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
const context = {
property: {} as IProperty,
value: '0deg',
designItems: [],
setValue: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
previewValue,
removePreviewValue: jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
};
const button = createAngleUnitEditorAddon(context);
document.body.appendChild(button);
button.click();
const popup = document.body.lastElementChild as HTMLElement;
Object.defineProperty(popup, 'getBoundingClientRect', { value: () => ({ left: 0, top: 0, width: 100, height: 100 }) });
popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX, clientY, pointerId: 1 }));
popup.dispatchEvent(Object.assign(new Event('pointerup'), { clientX, clientY, pointerId: 1 }));
await Promise.resolve();
expect(previewValue).toHaveBeenCalledWith(expected);
button.remove();
popup.remove();
});
Loading