Skip to content

Latest commit

 

History

History
356 lines (323 loc) · 10 KB

File metadata and controls

356 lines (323 loc) · 10 KB

Live tiles

This library implements live tiles with drag-n-drop, including group transfer and shifting of conflicting tiles.

Note: The internal layout algorithm is subject to improvement.

Getting started

For creating live tiles, you need some additional code due to the drag-n-drop behavior; it is optional however. You can refer to the API documentation for more information.

// register Metro design fonts
import "com.sweaxizone.metro/fonts";

// third-party
import { createRoot } from "react-dom/client";
import * as React from "react";
import {
  Root,
  Group,
  HGroup,
  VGroup,
  CheckBox,
  Button,
  Label,
  Icon,
  Tiles,
  TileGroup,
  Tile,
  TilePage,
  TileDND,
} from "com.sweaxizone.metro/components";
import {
  type BulkChange,
  type TileSize,
  type CoreDirection,
} from "com.sweaxizone.metro/liveTiles";
import { RTLProvider } from "com.sweaxizone.metro/layout";
import {
  Primary,
  ThemePresets,
  ThemeProvider,
  type Theme,
} from "com.sweaxizone.metro/theme";
import {
  randomHex,
} from "com.sweaxizone.metro/utils";

/**
 * The test.
 */
function App() {
  // short defer for reordering groups
  const group_reorder_timeout = React.useRef(-1);

  // drag-n-drop related
  const [tile_dragging, set_tile_dragging] = React.useState<null | { id: string, tile: MyTile }>(null);

  // live tiles direction
  const [tiles_direction, set_tiles_direction] = React.useState<CoreDirection>(window.matchMedia("(orientation: portrait)").matches ? "vertical" : "horizontal");

  // our groups
  const [groups, set_groups] = React.useState<MyGroup[]>([
    {
      id: "group1",
      label: "Group 1",
      tiles: new Map([
        ["terminal", { size: "medium", x: -1, y: -1 }],
        ["camera", { size: "medium", x: -1, y: -1 }],
        ["bing", { size: "small", x: -1, y: -1 }],
      ]),
    },
    {
      id: "group2",
      label: "Group 2",
      tiles: new Map([
        ["internetExplorer", { size: "medium", x: -1, y: -1 }],
      ]),
    },
    {
      id: "group3",
      label: "Group 3",
      tiles: new Map([
        ["settings", { size: "medium", x: -1, y: -1 }],
      ]),
    },
  ]);
  const groups_sync = React.useRef(groups);

  // initialization
  React.useEffect(() => {

    // observe orientation
    const portrait_media_query = window.matchMedia("(orientation: portrait)");
    portrait_media_query.addEventListener("change", adapt);
    function adapt(e: MediaQueryListEvent): void {
      set_tiles_direction(e.matches ? "vertical" : "horizontal");
    }

    // cleanup
    return () => {
      portrait_media_query.removeEventListener("change", adapt);
    };

  }, []);

  // sync groups into a ref
  React.useEffect(() => {

    groups_sync.current = groups;

  }, [groups]);

  // render groups.
  function render_groups(): React.ReactNode[] {
    let group_nodes: React.ReactNode[] = [];
    for (let [i, group] of groups.entries()) {
      const tile_nodes: React.ReactNode[] = [];
      for (const [id, tile] of group.tiles) {
        const node = render_tile(id, tile);
        if (node) {
          tile_nodes.push(node);
        }
      }
      group_nodes.push(
        <TileGroup key={group.id} id={group.id} index={i} label={group.label}>
          {tile_nodes}
        </TileGroup>
      );
    }
    return group_nodes;
  }

  // render a tile.
  function render_tile(id: string, tile: MyTile): undefined | React.ReactNode {
    switch (id) {
      case "terminal": {
        return (
          <Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
            <TilePage variant="iconLabel" size="small">
              <Group><Icon variant="terminal"/></Group>
              <Label>Terminal</Label>
            </TilePage>
            <TilePage size="medium">
              <VGroup padding={10} gap={10}>
                <Icon variant="terminal" size={27}/>
                <Label variant="heading" style={{fontSize: "1rem", margin: "0"}}>Run some Bash</Label>
              </VGroup>
            </TilePage>
            <TilePage size=">= wide">
              <VGroup padding={10} gap={10}>
                <Icon variant="terminal" size={27}/>
                <Label variant="subsubsubheading" style={{margin: "0"}}>Run some Bash</Label>
              </VGroup>
            </TilePage>
          </Tile>
        );
      }
      case "camera": {
        return (
          <Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#937" foreground="white">
            <TilePage variant="labelIcon">
              <Label>Camera</Label>
              <Group><Icon variant="camera"/></Group>
            </TilePage>
            <TilePage size=">= wide">
              <Group paddingLeft={10}>
                <Label variant="subsubheading">Shoot!</Label>
              </Group>
            </TilePage>
          </Tile>
        );
      }
      case "bing": {
        return (
          <Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#f9c000" foreground="white">
            <TilePage variant="iconLabel">
              <Group><Icon variant="bing"/></Group>
              <Label>Bing</Label>
            </TilePage>
          </Tile>
        );
      }
      case "internetExplorer": {
        return (
          <Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
            <TilePage variant="iconLabel">
              <Group><Icon variant="internetExplorer"/></Group>
              <Label>Internet Explorer</Label>
            </TilePage>
          </Tile>
        );
      }
      case "settings": {
        return (
          <Tile key={id} id={id} size={tile.size} x={tile.x} y={tile.y} background="#04bed6" foreground="white">
            <TilePage variant="iconLabel">
              <Group><Icon variant="settings"/></Group>
              <Label>Settings</Label>
            </TilePage>
          </Tile>
        );
      }
      default: {
        return undefined;
      }
    }
  }

  // handle bulk change in live tiles
  function bulk_change(e: BulkChange): void {
    const new_groups = structuredClone(groups);
    for (const m of e.movedTiles) {
      const g = new_groups.find(g => g.tiles.has(m.id));
      if (g) {
        const t = g.tiles.get(m.id)!;
        t.x = m.x;
        t.y = m.y;
      }
    }
    for (const transfer of e.groupTransfers) {
      const old_group = new_groups.find(g => g.tiles.has(transfer.id))!;
      const new_group = new_groups.find(g => g.id == transfer.group)!;
      const t = old_group.tiles.get(transfer.id)!;
      t.x = transfer.x;
      t.y = transfer.y;
      old_group.tiles.delete(transfer.id);
      new_group.tiles.set(transfer.id, t);
    }
    for (const { id: group_id } of e.groupRemovals) {
      const i = new_groups.findIndex(g => g.id == group_id);
      if (i !== -1) {
        new_groups.splice(i, 1);
      }
    }
    if (e.groupCreation) {
      const tile_id = e.groupCreation!.tile;
      const old_group = new_groups.find(g => g.tiles.has(tile_id))!;
      const new_group: MyGroup = {
        id: "__" + randomHex(true),
        label: "",
        tiles: new Map(),
      };
      new_groups.push(new_group);
      const t = old_group.tiles.get(tile_id)!;
      t.x = -1;
      t.y = -1;
      old_group.tiles.delete(tile_id);
      new_group.tiles.set(tile_id, t);
    }
    set_groups(new_groups);
  }

  // re-order groups
  //
  // here, keep order sequential and contiguous.
  function reorder_groups(e: Map<number, string>): void {
    if (group_reorder_timeout.current != -1) {
      window.clearTimeout(group_reorder_timeout.current);
      group_reorder_timeout.current = -1;
    }
    group_reorder_timeout.current = window.setTimeout(() => {
      let new_groups = structuredClone(groups);
      let seq_1 = Array.from(e.entries());
      seq_1.sort(([a], [b]) => a - b);
      let seq_2: string[] = [];
      for (let [, group_id] of seq_1) {
        seq_2.push(group_id);
      }
      new_groups = seq_2.map(group_id => new_groups.find(g => g.id == group_id)!);
      set_groups(new_groups);
    }, 3);
  }

  //
  function rename_group(e: { id: string, label: string }): void {
    let new_groups = structuredClone(groups);
    const group = new_groups.find(g => g.id == e.id);
    if (group) {
      group!.label = e.label;
    }
    set_groups(new_groups);
  }

  //
  function drag_start(e: { id: string, dnd: HTMLElement }): void {
    const group = groups.find(g => g.tiles.has(e.id))!;
    const tile = group.tiles.get(e.id)!;
    set_tile_dragging({ id: e.id, tile: structuredClone(tile) });
  }

  //
  function drag_end(e: { id: string, dnd: HTMLElement }): void {
    set_tile_dragging(null);
  }

  return (
    <ThemeProvider theme={ThemePresets.get("cyan")}>
      <RTLProvider rtl={false}>
        <Primary prefer={false}>
          <Root
            full
            solid
            selection={false}
            style={{
              overflowX: "auto",
              overflowY: "auto",
            }}
            padding={tiles_direction == "horizontal" ? 10 : 1 }
            wheelHorizontal={tiles_direction == "horizontal"}
            wheelVertical={tiles_direction == "vertical"}>

            <Tiles
              direction={tiles_direction}
              dragEnabled
              checkEnabled
              renamingGroupsEnabled
              bulkChange={bulk_change}
              reorderGroups={reorder_groups}
              renameGroup={rename_group}
              dragStart={drag_start}
              dragEnd={drag_end}>

              {render_groups()}

              <TileDND>
                {tile_dragging ? render_tile(tile_dragging!.id, tile_dragging!.tile) : undefined}
              </TileDND>

            </Tiles>

          </Root>
        </Primary>
      </RTLProvider>
    </ThemeProvider>
  );
}

// a tile group
export type MyGroup = {
  id: string,
  label: string,
  tiles: Map<string, MyTile>,
};

// a tile
export type MyTile = {
  x: number,
  y: number,
  size: TileSize,
};

// Render App
const root = createRoot(document.getElementById("root")!);
root.render(<App />);