lit#svg TypeScript Examples

The following examples show how to use lit#svg. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
/**
   * Draw compass with indicators
   */

  private svgCompass(directionOffset: number): SVGTemplateResult {
    return svg`
    <svg viewbox="0 0 152 152" preserveAspectRatio="xMidYMin slice" style="width: 100%; padding-bottom: 92%; height: 1px; overflow: visible">
      <defs>
        <pattern id="image" x="0" y="0" patternContentUnits="objectBoundingBox" height="100%" width="100%">
          <image x="0" y="0" height="1" width="1" href="${this.compass.circle.background_image}" preserveAspectRatio="xMidYMid meet"></image>
        </pattern>        
      </defs>
      ${this.getVisibility(this.compass.circle) ? this.svgCircle(this.compass.circle.offset_background ? directionOffset : 0) : ''}
        <g class="indicators" transform="rotate(${directionOffset},76,76)" stroke-width=".5">
          ${this.compass.north.show ? this.svgIndicatorNorth() : ''}
          ${this.compass.east.show ? this.svgIndicatorEast() : ''}
          ${this.compass.south.show ? this.svgIndicatorSouth() : ''}
          ${this.compass.west.show ? this.svgIndicatorWest() : ''}
          ${this.svgIndicators()}
        </g>
    </svg>
    `;
  }
Example #2
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorWest(): SVGTemplateResult {
    return svg`
      <g class="west">
        <text x="-2" y="80.089" font-family="sans-serif" font-size="13.333" text-anchor="left" fill="${this.getColor(this.compass.west)}">
          <tspan x="-2" y="81">${localize('directions.W', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }
Example #3
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorSouth(): SVGTemplateResult {
    return svg`
      <g class="south">
        <text x="76" y="150.089" font-family="sans-serif" font-size="13.333" text-anchor="middle" fill="${this.getColor(this.compass.south)}">
          <tspan x="76" y="151">${localize('directions.S', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }
Example #4
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorEast(): SVGTemplateResult {
    return svg`
      <g class="east">
        <text x="140" y="80.089" font-family="sans-serif" font-size="13.333" text-anchor="right" fill="${this.getColor(this.compass.east)}">
          <tspan x="140" y="81">${localize('directions.E', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }
Example #5
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorNorth(): SVGTemplateResult {
    return svg`
      <g class="north">
        <text x="76" y="10.089" font-family="sans-serif" font-size="13.333" text-anchor="middle" fill="${this.getColor(this.compass.north)}">
          <tspan x="76" y="11">${localize('directions.N', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }
Example #6
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorCircle(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="circle">
        <path d="m76 5.8262a9.1809 9.1809 0 0 0-0.0244 0 9.1809 9.1809 0 0 0-9.1813 9.18 9.1809 9.1809 0 0 0 9.1813 9.1813 9.1809 9.1809 0 0 0 0.0244 0z" fill="${this.getColor(
          indicatorSensor.indicator,
        )}"/>
        <path d="m76 5.8262v18.361a9.1809 9.1809 0 0 0 9.1556-9.1813 9.1809 9.1809 0 0 0-9.1556-9.18z" fill="${this.getColor(indicatorSensor.indicator)}"/>
        <path d="m76 5.8262v18.361a9.1809 9.1809 0 0 0 9.1556-9.1813 9.1809 9.1809 0 0 0-9.1556-9.18z" fill="white" opacity="0.5"/>
      </g>
    `;
  }
Example #7
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorArrowInward(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="arrow-inward">
        <path d="M76 30.664v-23l-8-7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width=".5" />
        <path d="M76 30.664v-23l8-7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width="0" />
        <path d="M76 30.664v-23l8-7z" fill="white" opacity="0.5" stroke="white" stroke-width=".5" />
      </g>
    `;
  }
Example #8
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgIndicatorArrowOutward(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="arrow-outward">
        <path d="M76 0v23l-8 7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width=".5"/>
        <path d="M76 0v23l8 7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width="0"/>
        <path d="M76 0v23l8 7z" fill="white" opacity="0.5" stroke="white" stroke-width=".5"/>
      </g>
    `;
  }
Example #9
Source File: compass-card.ts    From compass-card with MIT License 6 votes vote down vote up
private svgSingleIndicator(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    const indicatorPath = this.svgIndicator(indicatorSensor);
    const { degrees } = this.computeIndicator(indicatorSensor);

    return svg`
      <g class="indicator" transform="rotate(${degrees},76,76)">
        ${indicatorPath}
      </g>
    `;
  }
Example #10
Source File: DistanceGuide.ts    From figspec with MIT License 6 votes vote down vote up
Line = ({ guide, reverseScale }: LineProps) => {
  const xLength = Math.abs(guide.points[0].x - guide.points[1].x);
  const yLength = Math.abs(guide.points[0].y - guide.points[1].y);

  if (xLength === 0 && yLength === 0) {
    return null;
  }

  return svg`
    <line
      class="distance-line"
      x1=${guide.points[0].x}
      y1=${guide.points[0].y}
      x2=${guide.points[1].x}
      y2=${guide.points[1].y}
    />

    ${
      guide.bisector &&
      svg`
        <line
          class="distance-line"
          x1=${guide.bisector[0].x}
          y1=${guide.bisector[0].y}
          x2=${guide.bisector[1].x}
          y2=${guide.bisector[1].y}
          style=${styleMap({
            strokeDasharray: `${4 * reverseScale}`,
          })}
          shape-rendering="geometricPrecision"
          fill="none"
        />
      `
    }
  `;
}
Example #11
Source File: Icons.ts    From figspec with MIT License 6 votes vote down vote up
FigmaIcon = () => svg`
  <svg title="figma logo" width="11" height="16" viewBox="0 0 12 17" xmlns="http://www.w3.org/2000/svg">
    <path
      d="M5.5 1.5h-2c-1.105 0-2 .895-2 2 0 1.105.895 2 2 2h2v-4zm-5 2c0 1.043.533 1.963 1.341 2.5C1.033 6.537.5 7.457.5 8.5c0 1.043.533 1.963 1.341 2.5C1.033 11.537.5 12.457.5 13.5c0 1.657 1.343 3 3 3 1.657 0 3-1.343 3-3V10.736c.53.475 1.232.764 2 .764 1.657 0 3-1.343 3-3 0-1.043-.533-1.963-1.341-2.5.808-.537 1.341-1.457 1.341-2.5 0-1.657-1.343-3-3-3h-5c-1.657 0-3 1.343-3 3zm1 5c0-1.105.895-2 2-2h2v4h-2c-1.105 0-2-.895-2-2zm0 5c0-1.105.895-2 2-2h2v2c0 1.105-.895 2-2 2-1.105 0-2-.895-2-2zm7-3c-1.105 0-2-.895-2-2 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2zm0-5h-2v-4h2c1.105 0 2 .895 2 2 0 1.105-.895 2-2 2z"
      fill-rule="evenodd"
      fill-opacity="1"
      fill="#000"
      stroke="none"
    ></path>
  </svg>
`
Example #12
Source File: compass-card.ts    From compass-card with MIT License 5 votes vote down vote up
private svgCircle(directionOffset: number): SVGTemplateResult {
    return svg`<circle class="circle" cx="76" cy="76" r="62" stroke="${this.getColor(this.compass.circle)}" stroke-width="2" fill="${this.circleFill()}" fill-opacity="${
      this.compass.circle.background_opacity
    }" stroke-opacity="1.0" transform="rotate(${directionOffset},76,76)" />`;
  }
Example #13
Source File: Node.ts    From figspec with MIT License 5 votes vote down vote up
Outline = ({
  node,
  selected = false,
  computedThickness,
  onClick,
}: OutlineProps) => {
  const { x, y, width, height } = node.absoluteBoundingBox;

  const radius: {
    topLeft: number;
    topRight: number;
    bottomRight: number;
    bottomLeft: number;
  } =
    "cornerRadius" in node && node.cornerRadius
      ? {
          topLeft: node.cornerRadius,
          topRight: node.cornerRadius,
          bottomRight: node.cornerRadius,
          bottomLeft: node.cornerRadius,
        }
      : "rectangleCornerRadii" in node && node.rectangleCornerRadii
      ? {
          topLeft: node.rectangleCornerRadii[0],
          topRight: node.rectangleCornerRadii[1],
          bottomRight: node.rectangleCornerRadii[2],
          bottomLeft: node.rectangleCornerRadii[3],
        }
      : {
          topLeft: 0,
          topRight: 0,
          bottomRight: 0,
          bottomLeft: 0,
        };

  // Since SVG can't control where to draw borders (I mean you can't draw inset borders), we need to
  // shift each drawing points by the half of the border width.
  const shift = computedThickness / 2;

  // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
  // [M] ... Move to
  // [L] ... Line to
  // [A] ... Arc to
  // [Z] ... Close path
  const moveTo = (x: number, y: number) => `M${x},${y}`;
  const lineTo = (x: number, y: number) => `L${x},${y}`;
  const arcTo = (r: number, x: number, y: number) =>
    `A${r},${r} 0 0 1 ${x},${y}`;

  const boxPath = [
    moveTo(radius.topLeft + shift, shift),
    lineTo(width - radius.topRight, shift),
    arcTo(radius.topRight - shift, width - shift, radius.topRight),
    lineTo(width - shift, height - radius.bottomRight),
    arcTo(
      radius.bottomRight - shift,
      width - radius.bottomRight,
      height - shift
    ),
    lineTo(radius.bottomLeft, height - shift),
    arcTo(radius.bottomLeft - shift, shift, height - radius.bottomLeft),
    lineTo(shift, radius.topLeft),
    arcTo(radius.topLeft - shift, radius.topLeft, shift),
    "Z",
  ].join(" ");

  return svg`
    <path
      class="guide"
      d=${boxPath}
      shape-rendering="geometricPrecision"
      fill="none"
      transform="translate(${x}, ${y})"
      ?data-selected=${selected}
      @click=${onClick}
    />
  `;
}
Example #14
Source File: Icons.ts    From figspec with MIT License 5 votes vote down vote up
VerticalPaddingIcon = () => svg`
  <svg title="vertical padding" width="14" height="14" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
    <rect x="8" y="21" width="14" height="14" transform="rotate(-90 8 21)" stroke="#B3B3B3" stroke-width="2"/>
    <path d="M1 1L28 0.999999" stroke="#B3B3B3" stroke-width="2"/>
    <path d="M0 27L28 27" stroke="#B3B3B3" stroke-width="2"/>
  </svg>
`
Example #15
Source File: Icons.ts    From figspec with MIT License 5 votes vote down vote up
HorizontalPaddingIcon = () => svg`
  <svg title="horizontal padding" width="14" height="14" viewBox="0 0 29 28" fill="none">
    <rect x="7" y="8" width="14" height="14" stroke="#B3B3B3" stroke-width="2"/>
    <path d="M27 1V28" stroke="#B3B3B3" stroke-width="2"/>
    <path d="M1 0V28" stroke="#B3B3B3" stroke-width="2"/>
  </svg>
`
Example #16
Source File: Icons.ts    From figspec with MIT License 5 votes vote down vote up
CopyIcon = ({ onClick = () => {} }) => svg`
  <svg @click=${onClick} title="copy icon" width="14" height="14" viewBox="0 0 30 30" fill="none">
  <path d="M21 25.5C21 24.9477 20.5523 24.5 20 24.5C19.4477 24.5 19 24.9477 19 25.5H21ZM13 2H25V0H13V2ZM28 5V21H30V5H28ZM25 24H13V26H25V24ZM10 21V5H8V21H10ZM13 24C11.3431 24 10 22.6569 10 21H8C8 23.7614 10.2386 26 13 26V24ZM28 21C28 22.6569 26.6569 24 25 24V26C27.7614 26 30 23.7614 30 21H28ZM25 2C26.6569 2 28 3.34315 28 5H30C30 2.23858 27.7614 0 25 0V2ZM13 0C10.2386 0 8 2.23858 8 5H10C10 3.34315 11.3431 2 13 2V0ZM16.5 28H5V30H16.5V28ZM2 25V10H0V25H2ZM5 28C3.34315 28 2 26.6569 2 25H0C0 27.7614 2.23858 30 5 30V28ZM5 7H8V5H5V7ZM2 10C2 8.34315 3.34315 7 5 7V5C2.23858 5 0 7.23858 0 10H2ZM16.5 30C18.9853 30 21 27.9853 21 25.5H19C19 26.8807 17.8807 28 16.5 28V30Z" fill="#B3B3B3"/>
</svg>
`
Example #17
Source File: Icons.ts    From figspec with MIT License 5 votes vote down vote up
CloseIcon = ({ onClick = () => {} }) => svg`
  <svg @click=${onClick} title="close icon" width="14" height="14" viewBox="0 0 20 20" fill="none">
    <path d="M1 19L19 1M19 19L1 1" stroke="#B3B3B3" stroke-width="2"/>
  </svg>
`
Example #18
Source File: DistanceGuide.ts    From figspec with MIT License 5 votes vote down vote up
Tooltip = ({ guide, reverseScale, fontSize }: TooltipProps) => {
  const xLength = Math.abs(guide.points[0].x - guide.points[1].x);
  const yLength = Math.abs(guide.points[0].y - guide.points[1].y);

  if (xLength === 0 && yLength === 0) {
    return null;
  }

  const text = round(Math.max(xLength, yLength)).toString(10);

  // Decreases font width because every text is a number (narrow).
  // We can measure the correct width with getComputedTextLength method on
  // <text> element, but it needs access to DOM or creating an element each
  // render cycle, both have performance costs.
  const width = text.length * fontSize * 0.5;

  const startMargin = fontSize * 0.25;

  const vPadding = fontSize * 0.25;
  const hPadding = fontSize * 0.5;

  const x =
    xLength > yLength
      ? (guide.points[0].x + guide.points[1].x) / 2 - width / 2
      : guide.points[0].x;

  const y =
    xLength > yLength
      ? guide.points[0].y
      : (guide.points[0].y + guide.points[1].y) / 2 - fontSize / 2;

  const transform = [
    `scale(${reverseScale})`,
    xLength > yLength
      ? `translate(0, ${startMargin + vPadding})`
      : `translate(${startMargin + hPadding}, 0)`,
  ].join(" ");

  const cx = x + width / 2;
  const cy = y + fontSize / 2;

  const transformOrigin = xLength > yLength ? `${cx} ${y}` : `${x} ${cy}`;

  return svg`
    <g class="distance-tooltip">
      <rect
        x=${x - hPadding}
        y=${y - vPadding}
        rx="2"
        width=${width + hPadding * 2}
        height=${fontSize + vPadding * 2}
        transform=${transform}
        transform-origin=${transformOrigin}
        stroke="none"
      />

      <text
        x=${cx}
        y=${y + fontSize - vPadding / 2}
        text-anchor="middle"
        transform=${transform}
        transform-origin=${transformOrigin}
        stroke="none"
        fill="white"
        style="font-size: ${fontSize}px"
      >
        ${text}
      </text>
    </g>
  `;
}
Example #19
Source File: ViewerMixin.ts    From figspec with MIT License 4 votes vote down vote up
ViewerMixin = <T extends Constructor<LitElement>>(
  superClass: T
): T & Constructor<IViewer & INodeSelectable & Positioned> => {
  class Viewer extends NodeSelectableMixin(PositionedMixin(superClass)) {
    @property({
      type: Number,
      attribute: "zoom-margin",
    })
    zoomMargin: number = 50;

    @property({
      type: String,
      attribute: "link",
    })
    link: string = "";

    static get styles() {
      // @ts-ignore
      const styles = super.styles;

      return extendStyles(styles, [
        css`
          :host {
            --default-error-bg: #fff;
            --default-error-fg: #333;

            --bg: var(--figspec-viewer-bg, #e5e5e5);
            --z-index: var(--figspec-viewer-z-index, 0);
            --error-bg: var(--figspec-viewer-error-bg, var(--default-error-bg));
            --error-fg: var(--figspec-viewer-error-fg, var(--default-error-fg));
            --error-color: var(--figspec-viewer-error-color, tomato);

            --guide-thickness: var(--figspec-viewer-guide-thickness, 1.5px);
            --guide-color: var(--figspec-viewer-guide-color, tomato);
            --guide-selected-color: var(
              --figspec-viewer-guide-selected-color,
              dodgerblue
            );
            --guide-tooltip-fg: var(--figspec-viewer-guide-tooltip-fg, white);
            --guide-selected-tooltip-fg: var(
              --figspec-viewer-guide-selected-tooltip-fg,
              white
            );
            --guide-tooltip-bg: var(
              --figspec-viewer-guide-tooltip-bg,
              var(--guide-color)
            );
            --guide-selected-tooltip-bg: var(
              --figspec-viewer-guide-selected-tooltip-bg,
              var(--guide-selected-color)
            );
            --guide-tooltip-font-size: var(
              --figspec-viewer-guide-tooltip-font-size,
              12px
            );

            position: relative;
            display: block;

            background-color: var(--bg);
            user-select: none;
            overflow: hidden;
            z-index: var(--z-index);
          }

          @media (prefers-color-scheme: dark) {
            :host {
              --default-error-bg: #222;
              --default-error-fg: #fff;
            }
          }

          .spec-canvas-wrapper {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column-reverse;
          }

          .canvas {
            position: absolute;
            top: 50%;
            left: 50%;
            flex: 1;
          }

          .rendered-image {
            position: absolute;
            top: 0;
            left: 0;
          }

          .guides {
            position: absolute;

            overflow: visible;
            stroke: var(--guide-color);
            fill: var(--guide-color);
            pointer-events: none;
            z-index: calc(var(--z-index) + 2);
          }
        `,
        Node.styles,
        ErrorMessage.styles,
        DistanceGuide.styles,
        InspectorView.styles,
        FigmaFooter.styles,
      ]);
    }

    get __images(): Record<string, string> {
      return {};
    }

    // Cached values
    #canvasSize?: Figma.Rect;
    #effectMargins?: Record<string, Margin>;
    #flattenedNodes?: readonly SizedNode[];

    constructor(...args: any[]) {
      super(...args);
    }

    deselectNode() {
      this.selectedNode = null;
    }

    get error(): string | Error | null | TemplateResult | undefined {
      if (!this.#canvasSize || !this.#flattenedNodes) {
        return ErrorMessage.ErrorMessage({
          title: "Error",
          children:
            "Please call `__updateTree/1` method with a valid parameter.",
        });
      }

      return null;
    }

    render() {
      if (this.error) {
        if (this.error instanceof Error) {
          return ErrorMessage.ErrorMessage({
            title: this.error.name || "Error",
            children: this.error.message,
          });
        }

        if (typeof this.error === "string") {
          return ErrorMessage.ErrorMessage({
            title: "Error",
            children: this.error,
          });
        }

        return this.error;
      }

      const canvasSize = this.#canvasSize!;

      const reverseScale = 1 / this.scale;

      const guideThickness = `calc(var(--guide-thickness) * ${reverseScale})`;

      const computedGuideThickness = parseFloat(
        getComputedStyle(this).getPropertyValue("--guide-thickness")
      );

      const computedGuideTooltipFontSize = parseFloat(
        getComputedStyle(this).getPropertyValue("--guide-tooltip-font-size")
      );

      return html`
        <div class="spec-canvas-wrapper" @click=${this.deselectNode}>
          <div
            class="canvas"
            style="
          width: ${canvasSize.width}px;
          height: ${canvasSize.height}px;

          transform: translate(-50%, -50%) ${this.canvasTransform.join(" ")}
        "
          >
            ${Object.entries(this.__images).map(([nodeId, uri]) => {
              const node = this.#getNodeById(nodeId);

              if (
                !node ||
                !("absoluteBoundingBox" in node) ||
                !this.#effectMargins?.[node.id]
              ) {
                return null;
              }

              const margin = this.#effectMargins[node.id];

              return html`
                <img class="rendered-image" src="${uri}"
                style=${styleMap({
                  top: `${node.absoluteBoundingBox.y - canvasSize.y}px`,
                  left: `${node.absoluteBoundingBox.x - canvasSize.x}px`,
                  marginTop: `${-margin.top}px`,
                  marginLeft: `${-margin.left}px`,
                  width:
                    node.absoluteBoundingBox.width +
                    margin.left +
                    margin.right +
                    "px",
                  height:
                    node.absoluteBoundingBox.height +
                    margin.top +
                    margin.bottom +
                    "px",
                })}"
                " />
              `;
            })}
            ${this.selectedNode &&
            Node.Tooltip({
              nodeSize: this.selectedNode.absoluteBoundingBox,
              offsetX: -canvasSize.x,
              offsetY: -canvasSize.y,
              reverseScale,
            })}
            ${svg`
            <svg
              class="guides"
              viewBox="0 0 5 5"
              width="5"
              height="5"
              style=${styleMap({
                left: `${-canvasSize.x}px`,
                top: `${-canvasSize.y}px`,
                strokeWidth: guideThickness,
              })}
            >
              ${
                this.selectedNode &&
                Node.Outline({
                  node: this.selectedNode,
                  selected: true,
                  computedThickness: computedGuideThickness * reverseScale,
                })
              }

              ${this.#flattenedNodes!.map((node) => {
                if (node.id === this.selectedNode?.id) {
                  return null;
                }

                return svg`
                  <g>
                    ${Node.Outline({
                      node,
                      computedThickness: computedGuideThickness * reverseScale,
                      onClick: this.#handleNodeClick(node),
                    })}
                    ${
                      this.selectedNode &&
                      DistanceGuide.Guides({
                        node,
                        distanceTo: this.selectedNode,
                        reverseScale,
                        fontSize: computedGuideTooltipFontSize,
                      })
                    }
                  </g>
                `;
              })}
            </svg>
          `}
          </div>
          ${InspectorView.View({
            node: this.selectedNode as FigmaNode,
            onClose: this.deselectNode,
          })}
          ${FigmaFooter.Footer(this.getMetadata())}
        </div>
      `;
    }

    // implemented in FileViewer/FrameViewer
    getMetadata() {
      return undefined;
    }

    connectedCallback() {
      super.connectedCallback();

      this.resetZoom();
    }

    updated(changedProperties: Parameters<LitElement["updated"]>[0]) {
      super.updated(changedProperties);
    }

    __updateTree(node: Figma.Node) {
      if (
        !(
          node.type === "CANVAS" ||
          node.type === "FRAME" ||
          node.type === "COMPONENT" ||
          //@ts-ignore NOTE: figma-js does not implement COMPONENT_SET type (yet?)
          node.type === "COMPONENT_SET"
        )
      ) {
        throw new Error(
          "Cannot update node tree: Top level node MUST be one of CANVAS, FRAME, COMPONENT, or COMPONENT_SET"
        );
      }

      this.#canvasSize =
        node.type === "CANVAS" ? getCanvasSize(node) : node.absoluteBoundingBox;

      this.#flattenedNodes = flattenNode(node);

      // Since above properties aren't "attribute", their changes does not
      // trigger an update. We need to manually request an update.
      this.requestUpdate();
    }

    __updateEffectMargins() {
      if (!this.__images) {
        return;
      }

      const containers = Object.keys(this.__images)
        .map(this.#getNodeById)
        .filter((n): n is NonNullable<typeof n> => !!n);

      this.#effectMargins = containers.reduce<Record<string, Margin>>(
        (margin, node) => {
          if (!("absoluteBoundingBox" in node)) {
            return margin;
          }

          return {
            ...margin,
            [node.id]: getEffectMargin(node, flattenNode(node)),
          };
        },
        {}
      );

      this.requestUpdate();
    }

    resetZoom() {
      if (this.#canvasSize) {
        // Set initial zoom level based on element size
        const { width, height } = this.#canvasSize;
        const {
          width: elementWidth,
          height: elementHeight,
        } = this.getBoundingClientRect();

        const wDiff = elementWidth / (width + this.zoomMargin * 2);
        const hDiff = elementHeight / (height + this.zoomMargin * 2);

        this.scale = Math.min(wDiff, hDiff, 1);
      }
    }

    #handleNodeClick = (node: SizedNode) => (ev: MouseEvent) => {
      ev.preventDefault();
      ev.stopPropagation();

      this.selectedNode = node;
    };

    #getNodeById = (id: string): Figma.Node | null => {
      return this.#flattenedNodes?.find((n) => n.id === id) ?? null;
    };
  }

  return Viewer;
}