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:
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user