You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/src/core/with-props.ts

387 lines
15 KiB
TypeScript

import { Layer, Popup, Tooltip, type Class } from 'leaflet';
import {
kebab,
type PropDef,
type PropOptionValues,
type PropTable,
type PropValues,
} from './props.ts';
import { registerWithParent, type LeafletRegisterEvent } from './register.ts';
/**
* How an element joins the component tree:
*
* - `children` — register with the nearest parent and adopt registering
* descendants as layers, popups and tooltips (every layer type).
* - `self` — register with the nearest parent only (popups, tooltips, controls:
* they have a parent but manage no children here).
* - `none` — neither (the map is the tree root; icons aren't tree members).
*/
export type Attach = 'children' | 'self' | 'none';
/** Second argument to {@link WithProps}. */
export interface ElementOptions {
/** Tree-membership mode; defaults to `'children'`. See {@link Attach}. */
attach?: Attach;
/**
* Rebuild the Leaflet object on every attribute change instead of calling
* setters — for objects Leaflet gives no way to mutate in place (icons).
*/
recreate?: boolean;
}
/** The members {@link WithProps} contributes on top of the generated property accessors. */
export interface LeafletElement<TObj, TProps> {
/** The wrapped Leaflet object, once created (undefined before connect / after disconnect). */
readonly leafletObject?: TObj;
/**
* The one method every component must implement. `options` holds the decoded
* value of every prop whose attribute is present, keyed by property name,
* ready to hand to the Leaflet constructor.
*/
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
/** Hook called after the object is created and after every recreate; override to react. */
leafletObjectCreated(): void;
/** Tear the object down and rebuild it from current attributes (used by `recreate` and by WMS CRS children). */
recreateLeafletObject(): void;
/** Custom-element lifecycle: builds the object and joins the tree. Call `super.connectedCallback()` if you override. */
connectedCallback(): void;
/** Custom-element lifecycle: leaves the tree and destroys the object. Call `super.disconnectedCallback()` if you override. */
disconnectedCallback(): void;
/** Custom-element lifecycle: pushes an attribute change into the live object. */
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
}
/**
* The constructor type {@link WithProps} returns: an `HTMLElement` subclass with
* a two-way accessor per prop ({@link PropValues}) plus the
* {@link LeafletElement} lifecycle members. Element files annotate their
* `const Base` with this.
*/
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
PropValues<TProps> &
LeafletElement<TObj, TProps>;
/**
* Type-only narrowing for `addEventListener` so a component's `leaflet:<name>`
* events (see `#forwardEvents`) type-check against the right Leaflet payload. A
* component applies it with a `declare addEventListener: LeafletAddEventListener<TheEvents>`
* field — the same zero-runtime idiom as `declare readonly leafletObject?: X`.
*
* Deliberately has **no** generic `(type: string, …)` fallback overload (unlike
* the real DOM API): a fallback would silently accept any misspelled
* `leaflet:*` name. The cost is that a genuinely dynamic event-name string
* needs a cast.
*
* @typeParam TEvents - an event-name → payload map from `event-types.ts`
*/
export type LeafletAddEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void,
options?: boolean | AddEventListenerOptions,
) => void) &
(<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
) => void);
/** The `removeEventListener` counterpart of {@link LeafletAddEventListener}. */
export type LeafletRemoveEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void,
options?: boolean | EventListenerOptions,
) => void) &
(<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: boolean | EventListenerOptions,
) => void);
interface ResolvedProp<TObj extends Class> {
name: string;
attribute: string;
setter: keyof TObj;
def: PropDef<unknown, TObj>;
}
type ChildEntry = { type: 'layer' | 'popup' | 'tooltip'; object: Layer };
type AnyMethod = (...args: unknown[]) => unknown;
// Looks up a Leaflet method by name and binds it, or returns undefined. Leaflet
// objects are duck-typed here on purpose: Layer, Control and Icon share no
// common interface, and which setters exist varies per class.
// function method<TObj extends Record<string, unknown>, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined {
function method<TObj extends Class, TName extends keyof TObj>(
obj: TObj,
name: TName | string,
): AnyMethod | undefined {
const value = obj[name as TName];
return typeof value === 'function' ? value.bind(obj) : undefined;
}
function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>[] {
return Object.entries(props).map(([name, def]) => ({
name,
attribute:
typeof def.attribute === 'function' ? def.attribute(name) : (def.attribute ?? kebab(name)),
setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TObj,
def,
}));
}
/**
* Mixin factory: builds a custom-element base class from a table of
* {@link PropDef}s. Always extends `HTMLElement` internally (there is no
* base-class parameter).
*
* The generated class owns the whole lifecycle — it derives
* `observedAttributes`, defines a two-way property accessor per prop, builds
* the Leaflet options object on connect, wires the object into the component
* tree, keeps attributes and the live object in sync (both directions, no
* cycles), and re-fires every Leaflet event on the element as `leaflet:<type>`.
* A subclass only implements `createLeafletObject()`.
*
* Element files must not write `class Foo extends WithProps({…})` — JSR's
* type checker rejects a call expression as a superclass. Use a
* `const Base: LeafletElementConstructor<TheClass, typeof PROPS> = WithProps(PROPS)`
* and `extends Base` instead.
*
* @param props - the `PROPS` table (needs an explicit type annotation)
* @param options - tree-attach mode and `recreate` behaviour; see {@link ElementOptions}
*/
export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
props: TProps,
options: ElementOptions = {},
): LeafletElementConstructor<TObj, TProps> {
const resolved = resolve(props);
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
const attach = options.attach ?? 'children';
class WithPropsElement extends HTMLElement {
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
#obj?: TObj;
#connected = false;
#children = new Map<HTMLElement, ChildEntry>();
#listeners: [event: string, handler: () => void][] = [];
// Set while writing an attribute on the object's behalf, so the resulting
// attributeChangedCallback doesn't push the value straight back into
// Leaflet. This is the only place a cycle could form.
#syncing = false;
get leafletObject(): TObj | undefined {
return this.#obj;
}
createLeafletObject(_options: PropOptionValues<TProps>): TObj | undefined {
throw new Error(`<${this.localName}> does not implement createLeafletObject()`);
}
leafletObjectCreated(): void {
// Overridden by components that need to react to a new Leaflet object.
}
connectedCallback(): void {
if (this.#connected) return;
this.#connected = true;
this.#createObject();
if (attach === 'children') {
this.addEventListener('leaflet-register', this.#onChildRegister);
}
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
this.leafletObjectCreated();
}
disconnectedCallback(): void {
if (attach === 'children') {
this.removeEventListener('leaflet-register', this.#onChildRegister);
}
this.#releaseChildren();
this.#destroyObject();
this.#connected = false;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue || this.#syncing || !this.#connected) return;
const prop = byAttribute.get(name);
if (!prop) return;
if (options.recreate) {
this.recreateLeafletObject();
return;
}
const obj = this.#obj;
if (!obj) return;
const value = newValue === null ? prop.def.default : prop.def.decode(newValue);
if (prop.def.set) prop.def.set(obj, value, this);
else method(obj, prop.setter)?.(value);
}
// Throws the current object away and builds a fresh one from the current
// attributes, leaving the element's place in the tree untouched. Also
// re-registers the new object with the parent (attach !== 'none') --
// registerWithParent only fires once from connectedCallback otherwise,
// so without this a recreated layer would never get (re-)added anywhere.
recreateLeafletObject(): void {
this.#destroyObject();
this.#createObject();
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
this.leafletObjectCreated();
}
#createObject(): void {
const obj = this.createLeafletObject(this.#buildOptions());
this.#obj = obj;
if (!obj) return;
this.#watchObject(obj);
this.#forwardEvents(obj);
}
#destroyObject(): void {
const obj = this.#obj;
this.#obj = undefined;
if (!obj) return;
const off = method(obj, 'off');
for (const [event, handler] of this.#listeners) off?.(event, handler);
this.#listeners = [];
method(obj, 'remove')?.();
}
#buildOptions(): PropOptionValues<TProps> {
const opts: Record<string, unknown> = {};
for (const prop of resolved) {
if (prop.def.option === false) continue;
const raw = this.getAttribute(prop.attribute);
if (raw !== null) opts[prop.name] = prop.def.decode(raw);
}
return opts as PropOptionValues<TProps>;
}
// Registers one Leaflet listener per distinct event, updating every prop
// that names it. Dragging a marker fires `move` once and writes both lat
// and lng.
#watchObject(obj: TObj): void {
const on = method(obj, 'on');
if (!on) return;
const groups = new Map<string, ResolvedProp<TObj>[]>();
for (const prop of resolved) {
if (!prop.def.event || !prop.def.get) continue;
const group = groups.get(prop.def.event) ?? [];
group.push(prop);
groups.set(prop.def.event, group);
}
for (const [event, group] of groups) {
const handler = () => {
for (const prop of group) this.#syncAttribute(prop);
};
this.#listeners.push([event, handler]);
on(event, handler);
}
}
#syncAttribute(prop: ResolvedProp<TObj>): void {
const obj = this.#obj;
const value = obj && prop.def.get?.(obj);
if (value === undefined) return;
const raw = prop.def.encode(value);
if (this.getAttribute(prop.attribute) === raw) return;
this.#syncing = true;
try {
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
} finally {
this.#syncing = false;
}
}
// Leaflet has no wildcard listener, so we wrap the instance's own `fire` --
// it's an object we created and hold alone. Every Leaflet event becomes a
// `leaflet:<type>` DOM event carrying the Leaflet event as its detail,
// dispatched after Leaflet's handlers so synced attributes are current.
// Not bubbling: Leaflet already propagates layer events up to the map, so
// <leaflet-map> would otherwise see each of them twice.
#forwardEvents(obj: object): void {
const target = obj as {
fire?: (type: string, data?: object, propagate?: boolean) => unknown;
};
const fire = target.fire;
if (typeof fire !== 'function') return;
target.fire = (type, data, propagate) => {
const result = fire.call(obj, type, data, propagate);
// Mirrors Leaflet's own Evented#fire merge (extend({}, data, {type,
// target, sourceTarget})) so `detail` matches what a real `.on()`
// listener receives -- and so Leaflet's typed event interfaces
// (LeafletEvent's type/target/sourceTarget fields) are honest.
const sourceTarget = (data as { sourceTarget?: unknown } | undefined)?.sourceTarget ?? obj;
const detail = { ...data, type, target: obj, sourceTarget };
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail }));
return result;
};
}
// Claims descendants that announce themselves with `leaflet-register`.
// Skips our own announcement, which is dispatched on this element too.
#onChildRegister = (e: LeafletRegisterEvent) => {
const obj = this.#obj;
const child = e.detail.leafletObject;
const el = e.detail.element;
if (!obj || el === this) return;
if (child instanceof Popup) {
e.stopPropagation();
method(obj, 'bindPopup')?.(child);
this.#children.set(el, { type: 'popup', object: child });
} else if (child instanceof Tooltip) {
e.stopPropagation();
method(obj, 'bindTooltip')?.(child);
this.#children.set(el, { type: 'tooltip', object: child });
} else if (child instanceof Layer && 'addLayer' in obj) {
e.stopPropagation();
method(obj, 'addLayer')?.(child);
this.#children.set(el, { type: 'layer', object: child });
}
};
#releaseChildren(): void {
const obj = this.#obj;
if (obj) {
for (const entry of this.#children.values()) {
if (entry.type === 'popup') method(obj, 'unbindPopup')?.();
else if (entry.type === 'tooltip') method(obj, 'unbindTooltip')?.();
else method(obj, 'removeLayer')?.(entry.object);
}
}
this.#children.clear();
}
}
for (const prop of resolved) {
Object.defineProperty(WithPropsElement.prototype, prop.name, {
configurable: true,
enumerable: true,
get(this: WithPropsElement) {
const obj = this.leafletObject;
if (obj && prop.def.get) {
const live = prop.def.get(obj);
if (live !== undefined) return live;
}
const raw = this.getAttribute(prop.attribute);
return raw === null ? prop.def.default : prop.def.decode(raw);
},
set(this: WithPropsElement, value: unknown) {
const raw = prop.def.encode(value);
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
},
});
}
return WithPropsElement as unknown as LeafletElementConstructor<TObj, TProps>;
}