momoi-layer-ui
v0.1.4
Published
お絵かき用レイヤーシステム(momoi-layer)のReact UIコンポーネント
Readme
momoi-layer-ui
お絵かき用レイヤーシステム momoi-layer の React UI コンポーネントパッケージ。
Photoshop / ClipStudio 風のレイヤーパネルを提供し、ドラッグ&ドロップによるレイヤー並べ替え、ブレンドモード・不透明度変更、右クリックコンテキストメニュー、キーボードショートカットをサポートします。
アーキテクチャ
Controlled mode
LayerPanel は LayerStack を直接変更しません。全ての操作はコールバック (onSelect, onMove, onToggleVisible 等) で通知されるため、プロジェクト側が変更を行い ref.sync() で UI を更新します。この設計により Undo/Redo の導入が容易です。
ユーザー操作 → コールバック発火 → プロジェクト側で変更 → sync() → UI 更新主要モジュール
| モジュール | 役割 |
|---|---|
| LayerPanel | メインUIコンポーネント。レイヤーリスト、ブレンドモード、不透明度スライダーを含む |
| LayerToolbar | パネル下部のツールバー(新規レイヤー、グループ作成、削除ボタン) |
| ContextMenu | 右クリックメニュー。ペイントソフト風ダークテーマ |
| useLayerStack | 読み取り専用hook。スナップショット生成と展開状態管理 |
| useLayerDnD | Pointer Events ベースの DnD。グループ内外の移動対応 |
| useKeybindings | momoi-keybind 連携。ショートカットキー登録 |
| cloneLayer | レイヤーのディープコピー(ピクセル・パス・子レイヤー含む) |
インストール
npm install momoi-layer-ui momoi-layer react react-dom tailwindcsspeer dependencies
| パッケージ | バージョン | 必須 |
|---|---|---|
| react | ^18.0.0 || ^19.0.0 | 必須 |
| react-dom | ^18.0.0 || ^19.0.0 | 必須 |
| momoi-layer | >=0.1.0 | 必須 |
| tailwindcss | ^4.0.0 | 必須 |
| momoi-keybind | >=1.0.0 | オプション |
Tailwind CSS v4 が導入先プロジェクトで使われている前提です。コンポーネントは Tailwind クラスをそのまま使用します。
momoi-keybind(オプション)
キーボードショートカットを使う場合のみインストールしてください。
npm install momoi-keybindクイックスタート
import { useRef } from 'react'
import { LayerStack, RasterLayer } from 'momoi-layer'
import { LayerPanel, LayerToolbar, type LayerPanelHandle } from 'momoi-layer-ui'
// LayerStack を作成
const stack = new LayerStack()
stack.root.addChild(new RasterLayer('レイヤー1'))
stack.root.addChild(new RasterLayer('背景'))
function App() {
const panelRef = useRef<LayerPanelHandle>(null)
const handleToggleVisible = (id: number) => {
const layer = stack.findById(id)
if (layer) layer.visible = !layer.visible
panelRef.current?.sync()
}
const handleAddRaster = () => {
stack.root.addChild(new RasterLayer('新規レイヤー'))
panelRef.current?.sync()
}
return (
<div className="w-[260px]">
<LayerPanel
ref={panelRef}
stack={stack}
onSelect={(id) => {
stack.activeLayerId = id
panelRef.current?.sync()
}}
onToggleVisible={handleToggleVisible}
/>
<LayerToolbar
activeLayerId={stack.activeLayerId}
onAddRaster={handleAddRaster}
/>
</div>
)
}API リファレンス
コンポーネント
LayerPanel
メインのレイヤーパネルコンポーネント。forwardRef を使用し、ref.sync() で UI を更新します。
<LayerPanel ref={panelRef} stack={stack} onSelect={handleSelect} />Props: LayerPanelProps
Ref: LayerPanelHandle
LayerToolbar
パネル下部に配置するツールバー。新規レイヤー作成、グループ作成、削除ボタンを含みます。
<LayerToolbar
activeLayerId={activeLayerId}
onAddRaster={() => { /* ... */ }}
onAddGroup={() => { /* ... */ }}
onDelete={() => { /* ... */ }}
/>Props: LayerToolbarProps
ContextMenu
右クリックコンテキストメニュー。LayerPanel 内部で使用されていますが、単独でも利用可能です。
<ContextMenu
x={event.clientX}
y={event.clientY}
items={[
{ label: '複製', action: () => duplicate() },
{ separator: true },
{ label: '削除', action: () => remove() },
]}
onClose={() => setOpen(false)}
/>Props: ContextMenuProps
Hooks
useLayerStack(stack)
LayerStack を React state として追跡する hook。読み取り専用で、レイヤーのプロパティ変更は行いません。
const {
layers, // LayerSnapshot[] - フラットなスナップショット配列
activeLayerId, // number | null
sync, // () => void - UIを再描画
selectLayer, // (id: number | null) => void
toggleExpanded, // (id: number) => void
setExpanded, // (id: number, expanded: boolean) => void
isExpanded, // (id: number) => boolean
} = useLayerStack(stack)| 返り値 | 型 | 説明 |
|---|---|---|
| layers | LayerSnapshot[] | レイヤーツリーのスナップショット |
| activeLayerId | number \| null | 現在アクティブなレイヤーID |
| sync | () => void | LayerStack の現在の状態で UI を再描画する |
| selectLayer | (id: number \| null) => void | アクティブレイヤーを設定 |
| toggleExpanded | (id: number) => void | グループの展開/折りたたみをトグル |
| setExpanded | (id: number, expanded: boolean) => void | グループの展開状態を明示的に設定 |
| isExpanded | (id: number) => boolean | 指定IDが展開中かどうか |
useLayerDnD(onDrop)
Pointer Events ベースのドラッグ&ドロップ hook。レイヤーアイテムの並べ替えに使用します。
グループの before / after / inside 判定、カーソル X 座標によるインデントレベル推定に対応しています。
const {
draggingId, // number | null - ドラッグ中のレイヤーID
dropTarget, // DropTarget | null - 現在のドロップ先
isDragging, // boolean
onPointerDown, // (e: PointerEvent, layerId: number) => void
onPointerMove, // (e: PointerEvent) => void
onPointerUp, // (e: PointerEvent) => void
} = useLayerDnD((dragId, target) => {
console.log('dropped', dragId, target)
})useKeybindings(inputService, handlers, options?)
momoi-keybind と連携し、レイヤー操作のキーボードショートカットを登録する hook。
momoi-keybind がインストールされていない場合、inputService に undefined を渡すと何もしません。
import { useKeybindings, DEFAULT_LAYER_KEYBINDINGS } from 'momoi-layer-ui'
useKeybindings(inputService, {
onDelete: () => deleteActiveLayer(),
onDuplicate: () => duplicateActiveLayer(),
onMoveUp: () => moveLayerUp(),
onMoveDown: () => moveLayerDown(),
onToggleVisible: () => toggleVisible(),
onToggleLock: () => toggleLock(),
onToggleAlphaLock: () => toggleAlphaLock(),
onToggleClipping: () => toggleClipping(),
onRename: () => startRename(),
onGroup: () => groupSelected(),
onDeselect: () => deselectAll(),
}, { applyDefaults: true })| パラメータ | 型 | 説明 |
|---|---|---|
| inputService | InputService \| undefined | momoi-keybind の InputService インスタンス |
| handlers | object | 各コマンドのハンドラー(下表参照) |
| options.applyDefaults | boolean | デフォルトキーバインドを自動登録する(デフォルト: true) |
handlers の各プロパティ:
| プロパティ | コマンドID |
|---|---|
| onDelete | layer.delete |
| onDuplicate | layer.duplicate |
| onRename | layer.rename |
| onMoveUp | layer.moveUp |
| onMoveDown | layer.moveDown |
| onToggleVisible | layer.toggleVisible |
| onToggleLock | layer.toggleLock |
| onToggleAlphaLock | layer.toggleAlphaLock |
| onToggleClipping | layer.toggleClipping |
| onGroup | layer.group |
| onDeselect | layer.deselect |
DEFAULT_LAYER_KEYBINDINGS
Photoshop / ClipStudio 風のデフォルトキーバインド定義。layerPanelFocus コンテキストが有効なときのみ動作します。
import { DEFAULT_LAYER_KEYBINDINGS } from 'momoi-layer-ui'
// 他のキーバインドと合わせて登録
inputService.setDefaultKeybindings([
...otherKeybindings,
...DEFAULT_LAYER_KEYBINDINGS,
])
// コンテキストを有効にする
inputService.setContext('layerPanelFocus', true)| キー | コマンド |
|---|---|
| Delete | layer.delete |
| Ctrl+J | layer.duplicate |
| F2 | layer.rename |
| Ctrl+] | layer.moveUp |
| Ctrl+[ | layer.moveDown |
| Ctrl+Shift+H | layer.toggleVisible |
| Ctrl+L | layer.toggleLock |
| Ctrl+Shift+L | layer.toggleAlphaLock |
| Ctrl+Shift+C | layer.toggleClipping |
| Ctrl+G | layer.group |
| Escape | layer.deselect |
ユーティリティ
cloneLayer(layer)
レイヤーのディープコピーを作成します。ピクセルデータ、ベクターパスデータ、子レイヤーを全て含む完全なコピーです。新しい ID が自動採番されます。
import { cloneLayer } from 'momoi-layer-ui'
const original = stack.findById(layerId)
if (original) {
const copy = cloneLayer(original)
stack.root.addChild(copy)
}| パラメータ | 型 | 説明 |
|---|---|---|
| layer | Layer | 複製元のレイヤー |
| 戻り値 | Layer | 複製されたレイヤー(新規ID) |
型定義
LayerPanelProps
interface LayerPanelProps {
/** momoi-layer の LayerStack インスタンス */
stack: LayerStack
/** 追加の CSS クラス */
className?: string
/** レイヤー選択時 */
onSelect?: (layerId: number) => void
/** 可視性トグル時 */
onToggleVisible?: (layerId: number) => void
/** ロックトグル時 */
onToggleLock?: (layerId: number) => void
/** アルファロックトグル時 */
onToggleAlphaLock?: (layerId: number) => void
/** クリッピングトグル時 */
onToggleClipping?: (layerId: number) => void
/** レイヤー移動時(DnD) */
onMove?: (payload: LayerMovePayload) => void
/** レイヤーリネーム時 */
onRename?: (layerId: number, newName: string) => void
/** ブレンドモード変更時 */
onChangeBlendMode?: (layerId: number, mode: BlendMode) => void
/** 不透明度変更時 (0-1) */
onChangeOpacity?: (layerId: number, opacity: number) => void
/** レイヤー複製時 */
onDuplicate?: (layerId: number) => void
/** レイヤー削除時 */
onDelete?: (layerId: number) => void
}LayerPanelHandle
interface LayerPanelHandle {
/** LayerStack の現在の状態で UI を再描画する */
sync: () => void
}LayerSnapshot
interface LayerSnapshot {
id: number
name: string
type: LayerType // 'raster' | 'vector' | 'group'
opacity: number // 0-1
visible: boolean
blendMode: BlendMode
locked: boolean
alphaLock: boolean
clipping: boolean
depth: number // ネストの深さ(0 = ルート直下)
expanded: boolean // グループの展開状態
children: LayerSnapshot[] // グループの子レイヤー
raw: Layer // 元の Layer オブジェクトへの参照
}LayerMovePayload
interface LayerMovePayload {
layerId: number
targetParent: GroupLayer // 移動先の親グループ
targetIndex: number // 移動先のインデックス
}LayerToolbarProps
interface LayerToolbarProps {
activeLayerId: number | null
onAddRaster?: () => void
onAddVector?: () => void
onAddGroup?: () => void
onDelete?: () => void
onDuplicate?: () => void
}DropPosition / DropTarget
type DropPosition = 'before' | 'after' | 'inside'
interface DropTarget {
layerId: number
position: DropPosition
/** after 判定時、カーソル X 座標から推定したインデントレベル */
cursorDepth?: number
}ContextMenuEntry / ContextMenuProps
interface ContextMenuItem {
label: string
action: () => void
disabled?: boolean
}
interface ContextMenuSeparator {
separator: true
}
type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator
interface ContextMenuProps {
x: number
y: number
items: ContextMenuEntry[]
onClose: () => void
}ライセンス
MIT
