feat: add command palette functionality and shortcuts

- Implemented command palette with keyboard shortcut (⌘K) for opening.
- Added commands for opening, closing, setting query, and selecting items in the command palette.
- Created tests for command palette commands and input handling.
- Enhanced layer actions with functions for adding, grouping, and deleting layers.
- Updated UI components to integrate command palette and shortcuts.
This commit is contained in:
syntaxbullet
2026-07-05 17:15:20 +02:00
parent 5eaf37ba28
commit c3a75cf0d6
17 changed files with 1073 additions and 154 deletions

View File

@@ -6,11 +6,12 @@ import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions";
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
export type LayersSheetProps = {
@@ -76,17 +77,17 @@ function LayersSheetBody({
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span>
</button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span>
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
<FolderPlus size={24} />
</button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
@@ -470,46 +471,6 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
);
}
function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = documentIndex.assetById.get(layer.assetId);
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
dispatch(commandIds.documentAddLayerMask, {
layerId: layer.id,
asset: {
id: assetId,
name: `${layer.name} Mask`,
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: bounds.x, y: bounds.y },
scale: { x: bounds.w / width, y: bounds.h / height },
rotation: layer.transform.rotation,
},
},
});
}
function dropLayer(
document: ImageDocument,
sourceLayerId: string,
@@ -527,111 +488,6 @@ function dropLayer(
if (command) dispatch(commandIds.documentMoveLayer, command);
}
function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
}
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
}
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(),
name: `Artboard ${index}`,
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
});
}
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
dispatch(commandIds.documentAddAsset, {
asset: {
id: assetId,
name: "Empty Layer",
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
});
dispatch(commandIds.documentAddRasterLayer, {
artboardId,
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
layer: {
id: layerId,
type: "raster",
name: "Layer",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
}
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.siblings;
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
const blocks = siblings.flatMap((layer, index) => {
if (maskLayerIds.has(layer.id)) return [];
const layerMask = getLayerMask(layer);
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
});
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
const currentBlock = blocks[currentBlockIndex];
const targetBlock = blocks[currentBlockIndex + direction];
if (!currentBlock || !targetBlock) return;
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: insertionIndex - removedBeforeInsertion,
});
}
const emptyLayerIds = new Set<string>();
function createGroup(name: string): Extract<Layer, { type: "group" }> {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}
function toolbarButtonClass() {
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
}