187 lines · 5.9 KB · TypeScript
RawDownload
import type { ContextMenuItem } from "@t3tools/contracts";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option"; import * as Electron from "electron"; export interface ElectronMenuPosition {  readonly x: number;  readonly y: number;} export interface ElectronMenuContextInput {  readonly window: Electron.BrowserWindow;  readonly items: readonly ContextMenuItem[];  readonly position: Option.Option<ElectronMenuPosition>;} export interface ElectronMenuTemplateInput {  readonly window: Electron.BrowserWindow;  readonly template: readonly Electron.MenuItemConstructorOptions[];} export interface ElectronMenuShape {  readonly setApplicationMenu: (    template: readonly Electron.MenuItemConstructorOptions[],  ) => Effect.Effect<void>;  readonly showContextMenu: (    input: ElectronMenuContextInput,  ) => Effect.Effect<Option.Option<string>>;  readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;} export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()(  "@t3tools/desktop/electron/ElectronMenu",) {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {  const normalizedItems: ContextMenuItem[] = [];   for (const sourceItem of source) {    if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {      continue;    }     // Header items are decorative section labels for the web fallback only —    // Electron's native menu has no equivalent affordance, so we skip them.    if (sourceItem.header === true) {      continue;    }     const normalizedItem: ContextMenuItem = {      id: sourceItem.id,      label: sourceItem.label,      destructive: sourceItem.destructive === true,      disabled: sourceItem.disabled === true,    };     if (sourceItem.children) {      const normalizedChildren = normalizeContextMenuItems(sourceItem.children);      if (normalizedChildren.length === 0) {        continue;      }      normalizedItem.children = normalizedChildren;    }     normalizedItems.push(normalizedItem);  }   return normalizedItems;} const normalizePosition = (  position: Option.Option<ElectronMenuPosition>,): Option.Option<ElectronMenuPosition> =>  Option.filter(    position,    ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,  ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); export const layer = Layer.sync(ElectronMenu, () => {  let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;   const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {    if (process.platform !== "darwin") {      return Option.none();    }    if (destructiveMenuIconCache !== undefined) {      return destructiveMenuIconCache;    }     try {      const icon = Electron.nativeImage.createFromNamedImage("trash").resize({        width: 12,        height: 12,      });      destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);    } catch {      destructiveMenuIconCache = Option.none();    }     return destructiveMenuIconCache;  };   const buildTemplate = (    entries: readonly ContextMenuItem[],    complete: (selectedItemId: Option.Option<string>) => void,  ): Electron.MenuItemConstructorOptions[] => {    const template: Electron.MenuItemConstructorOptions[] = [];    let hasInsertedDestructiveSeparator = false;     for (const item of entries) {      if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {        template.push({ type: "separator" });        hasInsertedDestructiveSeparator = true;      }       const itemOption: Electron.MenuItemConstructorOptions = {        label: item.label,        enabled: !item.disabled,      };      if (item.children && item.children.length > 0) {        itemOption.submenu = buildTemplate(item.children, complete);      } else {        itemOption.click = () => complete(Option.some(item.id));      }      if (item.destructive && (!item.children || item.children.length === 0)) {        const destructiveIcon = getDestructiveMenuIcon();        if (Option.isSome(destructiveIcon)) {          itemOption.icon = destructiveIcon.value;        }      }       template.push(itemOption);    }     return template;  };   return ElectronMenu.of({    setApplicationMenu: (template) =>      Effect.sync(() => {        Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));      }),    popupTemplate: (input) =>      Effect.sync(() => {        if (input.template.length === 0) {          return;        }        Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });      }),    showContextMenu: (input) =>      Effect.callback<Option.Option<string>>((resume) => {        const normalizedItems = normalizeContextMenuItems(input.items);        if (normalizedItems.length === 0) {          resume(Effect.succeed(Option.none()));          return;        }         let completed = false;        const complete = (selectedItemId: Option.Option<string>) => {          if (completed) {            return;          }          completed = true;          resume(Effect.succeed(selectedItemId));        };         const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));        const popupPosition = normalizePosition(input.position);        const popupOptions = Option.match(popupPosition, {          onNone: (): Electron.PopupOptions => ({            window: input.window,            callback: () => complete(Option.none()),          }),          onSome: (position): Electron.PopupOptions => ({            window: input.window,            x: position.x,            y: position.y,            callback: () => complete(Option.none()),          }),        });        menu.popup(popupOptions);      }),  });});