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

feat: run custom scripts on element events #62

Merged
merged 9 commits into from
Jul 2, 2024
Merged
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
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
Loading