Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Draft
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
198 changes: 191 additions & 7 deletions src/FrameCanvas/FrameCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export class FrameCanvas {
left: 0;
}

.fc-hitbox:focus-visible {
outline: 2px dashed var(--guide-color);
outline-offset: -1px;
}

.fc-hitbox[data-select-mute] {
pointer-events: none;
}
Expand All @@ -127,7 +132,12 @@ export class FrameCanvas {
#canvas = el("div", [className("fc-canvas")]);
#viewport: HTMLElement;

#hitboxToNodeMap: WeakMap<Element, figma.Node> = new WeakMap();
#hitboxToNodeMap: WeakMap<HTMLElement, figma.Node> = new WeakMap();
#nodeToHitboxMap: WeakMap<figma.Node, HTMLElement> = new WeakMap();

#rootFocusableNodes: readonly figma.Node[] = [];
#focusableNodes: figma.Node[] = [];
#nodeToFocus: figma.Node | null = null;

#x = 0;
#y = 0;
Expand All @@ -136,6 +146,7 @@ export class FrameCanvas {
#preferences!: Readonly<Preferences>;
#selected: Signal<figma.Node | null>;
#hovered = new Signal<figma.Node | null>(null);
#viewportHovered = new Signal<boolean>(false);

#dragState = new Signal<DragState>(DragState.Disabled);
#isActive = false;
Expand Down Expand Up @@ -200,6 +211,8 @@ export class FrameCanvas {

const hitboxLayer = el("div", [className("fc-hitbox-layer")], []);

this.#setRootFocusableNodes(nodes);

for (const child of nodes) {
for (const node of figma.walk(child)) {
if (!figma.hasBoundingBox(node)) {
Expand Down Expand Up @@ -275,6 +288,9 @@ export class FrameCanvas {
);

this.#hitboxToNodeMap.set(hitbox, node);
this.#nodeToHitboxMap.set(node, hitbox);

this.#addHitboxEvents(hitbox, node);

hitboxLayer.appendChild(hitbox);
}
Expand Down Expand Up @@ -421,6 +437,27 @@ export class FrameCanvas {
};
});

effect(() => {
const selected = this.#selected.get();

if (selected) {
const newFocusableNodes = figma.hasChildren(selected)
? selected.children
: [selected];

this.#setFocusableNodes(
newFocusableNodes,
this.#nodeToFocus || newFocusableNodes[0],
);
this.#nodeToFocus = null;
} else {
this.#setFocusableNodes(this.#rootFocusableNodes);
this.#viewport.setAttribute("tabindex", "0");
this.#viewport.focus();
this.#viewport.removeAttribute("tabindex");
}
});

// Change cursor based on drag state
effect(() => {
switch (this.#dragState.get()) {
Expand Down Expand Up @@ -691,6 +728,8 @@ export class FrameCanvas {
return;
}

this.#viewportHovered.set(true);

const node = this.#hitboxToNodeMap.get(ev.target);
if (!node) {
return;
Expand All @@ -702,6 +741,7 @@ export class FrameCanvas {
};

#onPointerLeave = (_ev: Event) => {
this.#viewportHovered.set(false);
this.#hovered.set(null);
};

Expand Down Expand Up @@ -779,21 +819,58 @@ export class FrameCanvas {
this.#applyTransform();
};

#focusIsOnBody = () => {
return document.activeElement === document.body;
};

#focusIsInViewport = () => {
const shadowRoot = this.#container.getRootNode();

if (!(shadowRoot instanceof ShadowRoot)) {
return;
}

const activeElement = shadowRoot.activeElement;

if (!activeElement) {
return false;
} else {
return this.#container.contains(activeElement);
}
};

#onKeyDown = (ev: KeyboardEvent) => {
if (ev.key !== FrameCanvas.DRAG_MODE_KEY) {
return;
}

// If drag state is already enabled or idle, prevent default and exit
if (this.#dragState.once() !== DragState.Disabled) {
ev.preventDefault();
ev.stopPropagation();
return;
}

// If the viewport is not hovered or focus is elsewhere, exit
if (
!this.#viewportHovered.once() ||
(!this.#focusIsInViewport() && !this.#focusIsOnBody())
) {
return;
}

// Otherwise prevent default and set the drag state to idle
ev.preventDefault();
ev.stopPropagation();

if (this.#dragState.once() === DragState.Disabled) {
this.#dragState.set(DragState.Idle);
}
this.#dragState.set(DragState.Idle);
};

#onKeyUp = (ev: KeyboardEvent) => {
if (ev.key !== FrameCanvas.DRAG_MODE_KEY) {
if (
ev.key !== FrameCanvas.DRAG_MODE_KEY ||
this.#dragState.once() === DragState.Disabled
) {
return;
}

Expand Down Expand Up @@ -907,9 +984,116 @@ export class FrameCanvas {
this.#scale = state.initialScale * (dist / state.initialDist);
this.#applyTransform();
};
}

/**
#onLastNodeTab = (ev: KeyboardEvent) => {
if (ev.key === "Tab" && !ev.shiftKey && this.#focusableNodes.length) {
ev.preventDefault();
ev.stopPropagation();

this.#nodeToHitboxMap.get(this.#focusableNodes[0])?.focus();
}
};

#onFirstNodeShiftTab = (ev: KeyboardEvent) => {
if (ev.key === "Tab" && ev.shiftKey && this.#focusableNodes.length) {
ev.preventDefault();
ev.stopPropagation();

const lastNode = this.#focusableNodes.at(-1);

if (lastNode) {
this.#nodeToHitboxMap.get(lastNode)?.focus();
}
}
};

#setRootFocusableNodes = (nodes: readonly figma.Node[]) => {
this.#rootFocusableNodes =
nodes.length === 1 && figma.isCanvas(nodes[0])
? nodes[0].children
: nodes;
};

#resetFocusableNodes = () => {
this.#focusableNodes.forEach((node, index) => {
const hitbox = this.#nodeToHitboxMap.get(node);

if (!hitbox) {
return;
}

hitbox.removeAttribute("tabIndex");

if (!this.#rootFocusableNodes.includes(node)) {
if (index === 0) {
hitbox.removeEventListener("keydown", this.#onFirstNodeShiftTab);
}

if (index === this.#focusableNodes.length - 1) {
hitbox.removeEventListener("keydown", this.#onLastNodeTab);
}
}
});
};

#setFocusableNodes = (
nodes: readonly figma.Node[],
nodeToFocus?: figma.Node,
) => {
this.#resetFocusableNodes();

this.#focusableNodes = [...nodes];

nodes.forEach((node, index) => {
const hitbox = this.#nodeToHitboxMap.get(node);

if (!hitbox) return;

hitbox.setAttribute("tabIndex", "0");

if (!this.#rootFocusableNodes.includes(node)) {
if (index === 0) {
hitbox.addEventListener("keydown", this.#onFirstNodeShiftTab);
}
if (index === this.#focusableNodes.length - 1) {
hitbox.addEventListener("keydown", this.#onLastNodeTab);
}
}
});

if (nodeToFocus) {
this.#nodeToHitboxMap.get(nodeToFocus)?.focus();
}
};

#addHitboxEvents = (hitbox: Element, node: figma.Node) => {
hitbox.addEventListener("keydown", (event: Event) => {
const ev = event as KeyboardEvent;
if (ev.key === "Enter") {
let newSelectedNode = null;
if (ev.shiftKey) {
const selected = this.#selected.once();
const parent = figma.findParent(this.#rootFocusableNodes, node);
this.#nodeToFocus = parent === selected ? parent : node;
newSelectedNode =
parent === selected
? figma.findParent(this.#rootFocusableNodes, parent)
: parent;
} else {
newSelectedNode = node;
this.#nodeToFocus = null;
}

this.#selected.set(newSelectedNode);
}

if (ev.key === "Escape") {
this.#selected.set(null);
}
});
};
}
/*
* Returns distance between a first touch and center point of every touches.
*/
function getTouchAvgDist(touches: TouchList): number | null {
Expand Down
29 changes: 28 additions & 1 deletion src/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,9 +601,36 @@ export function* walk(node: Node): Generator<Node, void, undefined> {
}
}

export function findParent(
rootNodes: readonly Node[],
child: Node | null | undefined,
): Node | null {
if (!child) {
return null;
}

for (const rootNode of rootNodes) {
if (rootNode === child) return null;

if (hasChildren(rootNode)) {
if (rootNode.children.includes(child)) {
return rootNode;
}

const nodeInSubtree = findParent(rootNode.children, child);

if (nodeInSubtree) {
return nodeInSubtree;
}
}
}

return null;
}

export type Canvas = Node & HasChildren & HasBackgroundColor;

function isCanvas(node: Node): node is Canvas {
export function isCanvas(node: Node): node is Canvas {
return (
node.type === "CANVAS" && hasChildren(node) && hasBackgroundColor(node)
);
Expand Down