feat: add first-class text layers
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
||||
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash } from "@phosphor-icons/react";
|
||||
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash, TextT } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { ColorAdjustment } from "@core/adjustment-layer";
|
||||
import type { TextLayer, TextStyle } from "@core/text-layer";
|
||||
import { builtInTextFonts } from "@core/text-layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
|
||||
@@ -11,7 +13,7 @@ import type { MaskEditState, SelectionState } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { resolveLayerDrop } from "@input/index";
|
||||
import type { DocumentActions } from "@app/document-actions";
|
||||
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, addTextLayer, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
|
||||
import { LayerThumbnail } from "./layers/LayerThumbnail";
|
||||
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
|
||||
@@ -95,6 +97,7 @@ function LayersSheetBody({
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
|
||||
<SlidersHorizontal size={24} />
|
||||
</button>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mb-4 grid grid-cols-5 gap-2">
|
||||
@@ -115,6 +118,7 @@ function LayersSheetBody({
|
||||
</button>
|
||||
</div>
|
||||
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
|
||||
{selectedLayer?.layer.type === "text" ? <TextInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
||||
{document.artboards.map((artboard) => {
|
||||
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
|
||||
@@ -204,6 +208,28 @@ function LayersSheetBody({
|
||||
);
|
||||
}
|
||||
|
||||
function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
|
||||
const [content, setContent] = useState(layer.content);
|
||||
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
|
||||
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]);
|
||||
const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style });
|
||||
return <section aria-label="Text settings" className="mb-3 space-y-2 rounded-[1.5rem] bg-white/[0.05] p-3">
|
||||
<textarea aria-label="Text content" className="w-full resize-y rounded-xl bg-black/25 p-2 text-sm outline-none ring-1 ring-white/10" rows={2} value={content} disabled={layer.locked} onChange={(event) => setContent(event.target.value)} onBlur={commit} onKeyDown={(event) => event.stopPropagation()} />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select aria-label="Font family" className={textFieldClass()} value={style.fontFamily} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontFamily: event.target.value as TextStyle["fontFamily"] })} onBlur={commit}>{builtInTextFonts.map((font) => <option key={font}>{font}</option>)}</select>
|
||||
<input aria-label="Font size" className={textFieldClass()} type="number" min="1" max="1000" value={style.fontSize} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontSize: Number(event.target.value) })} onBlur={commit} />
|
||||
<input aria-label="Text color" className={textFieldClass()} type="color" value={style.color} disabled={layer.locked} onChange={(event) => setStyle({ ...style, color: event.target.value })} onBlur={commit} />
|
||||
<select aria-label="Font weight" className={textFieldClass()} value={style.fontWeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontWeight: Number(event.target.value) as 400 | 700 })} onBlur={commit}><option value="400">Regular</option><option value="700">Bold</option></select>
|
||||
<select aria-label="Font style" className={textFieldClass()} value={style.fontStyle} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontStyle: event.target.value as TextStyle["fontStyle"] })} onBlur={commit}><option value="normal">Normal</option><option value="italic">Italic</option></select>
|
||||
<select aria-label="Text alignment" className={textFieldClass()} value={style.alignment} disabled={layer.locked} onChange={(event) => setStyle({ ...style, alignment: event.target.value as TextStyle["alignment"] })} onBlur={commit}><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select>
|
||||
<input aria-label="Line height" className={textFieldClass()} type="number" min="0.5" max="5" step="0.1" value={style.lineHeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, lineHeight: Number(event.target.value) })} onBlur={commit} />
|
||||
</div>
|
||||
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function textFieldClass() { return "min-w-0 rounded-lg bg-black/25 px-2 py-1.5 text-xs text-white outline-none ring-1 ring-white/10"; }
|
||||
|
||||
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
|
||||
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
|
||||
useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]);
|
||||
@@ -294,8 +320,8 @@ function LayerRow({
|
||||
const layerInfo = documentIndex.layerInfoById.get(layer.id);
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && layer.type !== "adjustment" && !layerMask);
|
||||
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
|
||||
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
||||
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
|
||||
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
|
||||
@@ -414,7 +440,7 @@ function LayerRow({
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
{maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? (
|
||||
{maskAsset && (maskLayer.type === "image" || maskLayer.type === "raster") ? (
|
||||
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
const layer = layerInfo?.layer;
|
||||
const locked = layer?.locked ?? false;
|
||||
const mask = layer ? getLayerMask(layer) : undefined;
|
||||
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.type !== "adjustment" && layer.transform.rotation !== 0);
|
||||
const rotatedMaskEditingUnsupported = Boolean(layer && (layer.type === "image" || layer.type === "raster") && layer.transform.rotation !== 0);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(draftFromBounds(bounds));
|
||||
@@ -102,12 +102,12 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
|
||||
<Copy size={20} /> Duplicate
|
||||
</button>
|
||||
{layer.type !== "group" && layer.type !== "adjustment" ? (
|
||||
{layer.type === "image" || layer.type === "raster" ? (
|
||||
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
|
||||
<Crop size={20} /> Crop
|
||||
</button>
|
||||
) : null}
|
||||
{layer.type !== "group" && layer.type !== "adjustment" ? (
|
||||
{layer.type === "image" || layer.type === "raster" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass()}
|
||||
@@ -122,7 +122,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
|
||||
</button>
|
||||
) : null}
|
||||
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
|
||||
{cropOpen && layer.type !== "group" && layer.type !== "adjustment" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
|
||||
{cropOpen && (layer.type === "image" || layer.type === "raster") ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
|
||||
</>
|
||||
) : target.type === "artboard" ? (
|
||||
<>
|
||||
@@ -154,7 +154,7 @@ function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: {
|
||||
}
|
||||
|
||||
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
|
||||
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") return { x: "0", y: "0", w: "1", h: "1" };
|
||||
if (!layerInfo || (layerInfo.layer.type !== "image" && layerInfo.layer.type !== "raster")) return { x: "0", y: "0", w: "1", h: "1" };
|
||||
const asset = index.assetById.get(layerInfo.layer.assetId);
|
||||
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
|
||||
return draftFromBounds(rect);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderSimple, ImageBroken, SlidersHorizontal } from "@phosphor-icons/react";
|
||||
import { FolderSimple, ImageBroken, SlidersHorizontal, TextT } from "@phosphor-icons/react";
|
||||
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
|
||||
|
||||
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
|
||||
@@ -16,6 +16,7 @@ export function LayerThumbnail({ model, label, compact = false }: { model: Layer
|
||||
) : null}
|
||||
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
|
||||
{model.kind === "adjustment" ? <SlidersHorizontal size={compact ? 14 : 17} className="text-violet-200" /> : null}
|
||||
{model.kind === "text" ? <TextT size={compact ? 14 : 17} style={{ color: model.color }} aria-label={model.content} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RasterThumbnailModel = {
|
||||
export type LayerThumbnailModel =
|
||||
| RasterThumbnailModel
|
||||
| { kind: "adjustment" }
|
||||
| { kind: "text"; content: string; color: string }
|
||||
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
|
||||
| { kind: "empty" };
|
||||
|
||||
@@ -25,6 +26,7 @@ export function resolveLayerThumbnail(
|
||||
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
|
||||
): LayerThumbnailModel {
|
||||
if (layer.type === "adjustment") return { kind: "adjustment" };
|
||||
if (layer.type === "text") return { kind: "text", content: layer.content, color: layer.style.color };
|
||||
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
|
||||
|
||||
const previews: RasterThumbnailModel[] = [];
|
||||
@@ -59,6 +61,7 @@ export function createLayerThumbnailIndex(
|
||||
if (frame.visited || layer.type !== "group") {
|
||||
if (layer.type !== "group") {
|
||||
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
|
||||
if (layer.type === "text") { result.set(layer.id, { kind: "text", content: layer.content, color: layer.style.color }); continue; }
|
||||
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
|
||||
continue;
|
||||
}
|
||||
@@ -83,7 +86,7 @@ export function createLayerThumbnailIndex(
|
||||
}
|
||||
|
||||
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
|
||||
if (layer.type === "adjustment") return undefined;
|
||||
if (layer.type === "adjustment" || layer.type === "text") return undefined;
|
||||
const asset = assetById.get(layer.assetId);
|
||||
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user