Skip to content

Commit

Permalink
feat: run custom scripts on element events (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
lars-berger authored Jul 2, 2024
1 parent aefa62b commit 0b3deb8
Show file tree
Hide file tree
Showing 14 changed files with 329 additions and 17 deletions.
1 change: 1 addition & 0 deletions packages/client-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { createLogger, toCssSelector } from './utils';
export {
getScriptManager,
getChildConfigs,
type GlobalConfig,
type WindowConfig,
Expand Down
21 changes: 21 additions & 0 deletions packages/client-api/src/init-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getParsedElementConfig,
getChildConfigs,
type GlobalConfig,
getScriptManager,
} from './user-config';
import { getElementProviders } from './providers';
import type { ElementContext } from './element-context.model';
Expand All @@ -36,6 +37,7 @@ export async function initElement(
): Promise<ElementContext> {
try {
const styleBuilder = getStyleBuilder();
const scriptManager = getScriptManager();
const childConfigs = getChildConfigs(args.rawConfig);

// Create partial element context; `providers` and `parsedConfig` are set later.
Expand Down Expand Up @@ -71,6 +73,7 @@ export async function initElement(
// provider.
setElementContext('parsedConfig', parsedConfig);

// Build the CSS for the element.
runWithOwner(args.owner, () => {
createEffect(async () => {
if (parsedConfig.styles) {
Expand All @@ -89,6 +92,24 @@ export async function initElement(
});
});

// Preload the scripts used for the element's events.
runWithOwner(args.owner, () => {
createEffect(async () => {
try {
await Promise.all(
parsedConfig.events
.map(config => config.fn_path)
.map(scriptManager.loadScriptForFn),
);
} catch (err) {
await showErrorDialog({
title: `Non-fatal: Error in ${args.type}/${args.id}`,
error: err,
});
}
});
});

async function initChildElement(id: string) {
const childConfig = childConfigs.find(
childConfig => childConfig.id === id,
Expand Down
92 changes: 92 additions & 0 deletions packages/client-api/src/user-config/get-script-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { join, homeDir } from '@tauri-apps/api/path';

import { createLogger } from '~/utils';
import type { ElementContext } from '../element-context.model';

const logger = createLogger('script-manager');

/**
* Map of module paths to asset paths.
*/
const assetPathCache: Record<string, string> = {};

/**
* Map of asset paths to promises that resolve to the module.
*/
const moduleCache: Record<string, Promise<any>> = {};

/**
* Abstraction over loading and invoking user-defined scripts.
*/
export function getScriptManager() {
return {
loadScriptForFn,
callFn,
};
}

async function loadScriptForFn(fnPath: string): Promise<any> {
const { modulePath } = parseFnPath(fnPath);
return resolveModule(modulePath);
}

async function callFn(
fnPath: string,
event: Event,
context: ElementContext,
): Promise<any> {
const { modulePath, functionName } = parseFnPath(fnPath);
const foundModule = await resolveModule(modulePath);
const fn = foundModule[functionName];

if (!fn) {
throw new Error(
`No function with the name '${functionName}' at function path '${fnPath}'.`,
);
}

return fn(event, context);
}

async function resolveModule(modulePath: string): Promise<any> {
const assetPath = await getAssetPath(modulePath);
const foundModule = moduleCache[assetPath];

if (foundModule) {
return foundModule;
}

logger.info(`Loading script at path '${assetPath}'.`);
return (moduleCache[assetPath] = import(assetPath));
}

/**
* Converts user-defined path to a URL that can be loaded by the webview.
*/
async function getAssetPath(modulePath: string): Promise<string> {
const foundAssetPath = assetPathCache[modulePath];

if (foundAssetPath) {
return foundAssetPath;
}

return (assetPathCache[modulePath] = convertFileSrc(
await join(await homeDir(), '.glzr/zebar', modulePath),
));
}

function parseFnPath(fnPath: string): {
modulePath: string;
functionName: string;
} {
const [modulePath, functionName] = fnPath.split('#');

// Should never been thrown, as the path is validated during config
// deserialization.
if (!modulePath || !functionName) {
throw new Error(`Invalid function path '${fnPath}'.`);
}

return { modulePath, functionName };
}
1 change: 1 addition & 0 deletions packages/client-api/src/user-config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './get-parsed-element-config';
export * from './get-script-manager';
export * from './get-style-builder';
export * from './get-user-config';
export * from './global-config.model';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { z } from 'zod';

import type { Prettify } from '~/utils';
import { ProvidersConfigSchema } from './providers-config.model';
import { ElementEventsConfigSchema } from './element-events-config.model';

export const BaseElementConfigSchema = z.object({
id: z.string(),
class_names: z.array(z.string()).default([]),
styles: z.string().optional(),
providers: ProvidersConfigSchema,
events: ElementEventsConfigSchema,
});

/** Base config for windows, groups, and components. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { z } from 'zod';
import type { Prettify } from '~/utils';

/**
* All available events on HTML elements.
**/
const HTML_EVENTS = [
'click',
'fullscreenchange',
'fullscreenerror',
'abort',
'animationcancel',
'animationend',
'animationiteration',
'animationstart',
'auxclick',
'beforeinput',
'blur',
'cancel',
'canplay',
'canplaythrough',
'change',
'close',
'contextmenu',
'copy',
'cuechange',
'cut',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'dragstart',
'drop',
'durationchange',
'emptied',
'ended',
'error',
'focus',
'formdata',
'gotpointercapture',
'input',
'invalid',
'keydown',
'keypress',
'keyup',
'load',
'loadeddata',
'loadedmetadata',
'loadstart',
'lostpointercapture',
'mousedown',
'mouseenter',
'mouseleave',
'mousemove',
'mouseout',
'mouseover',
'mouseup',
'paste',
'pause',
'play',
'playing',
'pointercancel',
'pointerdown',
'pointerenter',
'pointerleave',
'pointermove',
'pointerout',
'pointerover',
'pointerup',
'progress',
'ratechange',
'reset',
'resize',
'scroll',
'scrollend',
'securitypolicyviolation',
'seeked',
'seeking',
'select',
'selectionchange',
'selectstart',
'slotchange',
'stalled',
'submit',
'suspend',
'timeupdate',
'toggle',
'touchcancel',
'touchend',
'touchmove',
'touchstart',
'transitioncancel',
'transitionend',
'transitionrun',
'transitionstart',
'volumechange',
'waiting',
'webkitanimationend',
'webkitanimationiteration',
'webkitanimationstart',
'webkittransitionend',
'wheel',
] as const;

export const ElementEventsConfigSchema = z
.array(
z.object({
type: z.enum(HTML_EVENTS),
fn_path: z
.string()
.regex(
/^(.+)#([a-zA-Z_$][a-zA-Z0-9_$]*)$/,
"Invalid function path. Needs to be in format 'path/to/my-script.js#functionName'.",
),
selector: z.string().optional(),
}),
)
.default([]);

export type ElementEventsConfig = Prettify<
z.infer<typeof ElementEventsConfigSchema>
>;
1 change: 1 addition & 0 deletions packages/client-api/src/user-config/window/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './base-element-config.model';
export * from './element-events-config.model';
export * from './group-config.model';
export * from './provider-config.model';
export * from './provider-type.model';
Expand Down
53 changes: 50 additions & 3 deletions packages/client/src/app/template-element.component.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import { createEffect, onCleanup, onMount } from 'solid-js';
import { type ElementContext, createLogger, toCssSelector } from 'zebar';
import {
type ElementContext,
createLogger,
toCssSelector,
getScriptManager,
} from 'zebar';

export interface TemplateElementProps {
context: ElementContext;
}

interface ElementEventListener {
eventType: string;
eventCallback: (event: Event) => Promise<any>;
selectorElement: Element;
}

export function TemplateElement(props: TemplateElementProps) {
const config = props.context.parsedConfig;
const logger = createLogger(`#${config.id}`);
const scriptManager = getScriptManager();

// Create element with ID.
const element = createRootElement();

// Currently active event listeners.
let listeners: ElementEventListener[] = [];

// Update the HTML element when the template changes.
// @ts-ignore - TODO
createEffect(() => (element.innerHTML = config.template));
createEffect(() => {
// @ts-ignore - TODO
element.innerHTML = config.template;
updateEventListeners();
});

onMount(() => logger.debug('Mounted'));
onCleanup(() => logger.debug('Cleanup'));
Expand All @@ -26,5 +44,34 @@ export function TemplateElement(props: TemplateElementProps) {
return element;
}

function updateEventListeners() {
// Remove existing event listeners.
listeners.forEach(({ eventType, eventCallback, selectorElement }) =>
selectorElement.removeEventListener(eventType, eventCallback),
);

listeners = [];

config.events.forEach(eventConfig => {
const eventCallback = (event: Event) =>
scriptManager.callFn(eventConfig.fn_path, event, props.context);

// Default to the root element if no selector is provided.
const selectorElement = eventConfig.selector
? element.querySelector(eventConfig.selector)
: element;

if (selectorElement) {
selectorElement.addEventListener(eventConfig.type, eventCallback);

listeners.push({
eventType: eventConfig.type,
eventCallback,
selectorElement,
});
}
});
}

return element;
}
7 changes: 7 additions & 0 deletions packages/desktop/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 0b3deb8

Please sign in to comment.