Skip to Content
선언형 API

선언형 API (defineElement)

dmn.plugin.defineElement는 복잡한 DOM 조작 없이 선언적으로 플러그인 UI를 정의할 수 있는 API입니다.

핵심 장점

기능설명
자동 설정 UI설정 스키마만 정의하면 속성 패널(기본) 또는 모달 자동 생성
인스턴스별 상태 격리같은 플러그인으로 여러 패널 생성 가능
탭(모드)별 격리생성한 탭에서만 패널이 표시됨
자동 상태 동기화메인 ↔ 오버레이 간 상태 자동 동기화
컨텍스트 메뉴 통합우클릭 메뉴 자동 생성
라이프사이클 관리탭 전환 시 자동 마운트/언마운트
자유로운 리사이즈그리드에서 직접 크기를 조절 가능

기본 구조

// @id my-plugin dmn.plugin.defineElement({ // 플러그인 이름 (컨텍스트 메뉴에 표시) name: "내 플러그인", // 최대 인스턴스 개수 (선택사항, 0 = 무제한) maxInstances: 1, // 그리드에서 크기 조절 가능 여부 (선택사항, 기본값: false) resizable: true, // 설정 변경 시 유지할 축 (선택사항, resizable이 true일 때만 적용, 기본값: "both") // "width" | "height" | "both" | "none" preserveAxis: "both", // 리사이즈 앵커 (선택사항, 기본값: "top-left") resizeAnchor: "center", // 컨텍스트 메뉴 설정 contextMenu: { create: "패널 생성", delete: "패널 삭제", items: [ /* 커스텀 메뉴 항목 */ ], }, // 설정 UI 표시 방식 (선택사항, 기본값: "panel") // "panel" | "modal" settingsUI: "panel", // 설정 스키마 (자동 UI 생성) settings: { /* 설정 정의 */ }, // 다국어 메시지 messages: { /* 번역 데이터 */ }, // 메인 윈도우 미리보기 상태 previewState: { /* 미리보기용 초기 상태 */ }, // HTML 템플릿 함수 template: (state, settings, helpers) => { /* 템플릿 반환 */ }, // 오버레이 마운트 로직 onMount: (context) => { /* 초기화 로직 */ return () => { /* 클린업 */ }; }, });

설정 스키마 (settings)

설정 스키마를 정의하면 자동으로 설정 UI가 생성됩니다. 기본은 속성 패널이며, 기존 모달 방식이 필요하면 settingsUI: "modal"을 설정하세요.

지원 타입

settings: { // 카드 섹션. 새 설정 카드를 시작 appearanceSection: { type: "section", label: "모양", // 카드 위 캡션, 선택사항 }, // 문자열 nickname: { type: "string", default: "", label: "닉네임", placeholder: "입력하세요", // 선택사항 }, // 숫자 fontSize: { type: "number", default: 14, label: "폰트 크기", min: 8, // 선택사항 max: 48, // 선택사항 step: 2, // 선택사항 }, // 불리언 (토글) showGraph: { type: "boolean", default: true, label: "그래프 표시", }, // 색상 textColor: { type: "color", default: "#FFFFFF", label: "텍스트 색상", }, // 선택 (드롭다운) theme: { type: "select", default: "dark", label: "테마", options: [ { label: "다크", value: "dark" }, { label: "라이트", value: "light" }, ], }, },

type: "section"은 새 카드를 시작합니다. section 키는 레이아웃 식별자이므로 저장값, 기본값, getSettings(), 변경 콜백에 포함되지 않습니다. 첫 section 앞의 설정은 암시적 무제목 카드에 표시되며, label이 없어도 카드 경계로 동작합니다.

과거의 type: "divider"(카드 안 구분선)는 section 도입과 함께 제거되었습니다. 기존 플러그인의 divider 항목은 무시되며(크래시 없음), 그룹 구분은 section을 사용하세요.

section은 속성 패널과 settingsUI: "modal"에서 동일하게 동작합니다.

조건부 표시 (visible)

설정 항목에 visible 속성을 추가하면 다른 설정값에 따라 동적으로 표시/숨김할 수 있습니다. visible이 없는 설정은 항상 표시됩니다 (기존 동작과 동일).

settings: { // 모드 선택 showGraph: { type: "boolean", default: true, label: "그래프 표시", }, graphSection: { type: "section", label: "그래프", visible: (settings) => settings.showGraph, }, // 정적 boolean, 항상 숨김 hiddenOption: { type: "number", default: 5, label: "숨겨진 옵션", visible: false, }, // 함수, 다른 설정값에 따라 동적으로 표시/숨김 graphColor: { type: "color", default: "#86EFAC", label: "그래프 색상", visible: (settings) => settings.showGraph, }, graphSpeed: { type: "number", default: 1000, label: "그래프 속도 (ms)", visible: (settings) => settings.showGraph, }, },

숨겨진 설정의 저장값은 유지됩니다. 표시만 제어하며 데이터 손실은 없습니다.

section의 visible은 다음 section 전까지의 그룹 전체에 적용됩니다. 숨겨져도 경계는 유지되므로 인접 그룹이 합쳐지지 않습니다. section 안의 값 설정이 모두 숨겨지면 카드와 캡션도 함께 숨겨집니다. visibility 함수가 예외를 던지면 해당 항목 또는 section을 숨기는 fail-closed 방식으로 처리하고 오류를 기록합니다. 패널과 모달의 의미 규칙 및 빈 상태 동작은 같습니다.

visible 시그니처는 컨텍스트 메뉴의 PluginMenuItem.visible과 동일한 패턴입니다:

visible?: boolean | ((settings: Record<string, any>) => boolean);

설정 접근

// template에서 template: (state, settings, { html }) => html` <div style="color: ${settings.textColor};"> ${settings.showGraph ? html`<div class="graph">...</div>` : ""} </div> `, // onMount에서 onMount: ({ getSettings }) => { const settings = getSettings(); console.log("현재 설정:", settings); },

리사이즈 앵커 (resizeAnchor)

요소의 크기가 변경될 때 기준이 되는 위치를 설정합니다. 기본값은 "top-left"입니다.

지원하는 앵커 위치 (9방향)

설명
top-left좌상단 (기본값)
top-center상단 중앙
top-right우상단
center-left좌측 중앙
center정중앙
center-right우측 중앙
bottom-left좌하단
bottom-center하단 중앙
bottom-right우하단

앵커 동작 설명

  • top-left: 크기가 변해도 좌상단 모서리가 고정됩니다 (기본 동작)
  • center: 크기가 변해도 중앙 위치가 고정됩니다
  • bottom-right: 크기가 변해도 우하단 모서리가 고정됩니다

Definition 레벨 설정

모든 인스턴스에 적용될 기본 앵커를 정의합니다:

dmn.plugin.defineElement({ name: "Centered Panel", resizeAnchor: "center", // 모든 인스턴스의 기본 앵커 // ... });

동적 앵커 변경 (onMount)

onMount에서 setAnchor()getAnchor()로 동적으로 앵커를 변경할 수 있습니다:

dmn.plugin.defineElement({ name: "Dynamic Anchor Panel", onMount: ({ setAnchor, getAnchor, getSettings }) => { // 현재 앵커 확인 console.log("현재 앵커:", getAnchor()); // "top-left" // 앵커를 중앙으로 변경 setAnchor("center"); // 조건에 따라 앵커 변경 const settings = getSettings(); if (settings.expandFromCenter) { setAnchor("center"); } else { setAnchor("top-left"); } }, });

앵커 우선순위

  1. 인스턴스별 resizeAnchor (setAnchor로 설정)
  2. Definition의 resizeAnchor
  3. 기본값 "top-left"

리사이즈 설정 (resizable, preserveAxis)

플러그인 요소의 크기를 사용자가 그리드에서 직접 조절할 수 있도록 하려면 resizable 옵션을 사용합니다.

resizable

resizable: true로 설정하면 요소에 8방향 리사이즈 핸들이 표시됩니다. 첫 생성 크기는 200×150이며, 저장된 크기나 추정 크기가 있으면 해당 값을 우선합니다.

dmn.plugin.defineElement({ name: "Resizable Panel", resizable: true, // 그리드에서 크기 조절 가능 // ... });

반응형 디자인 필수: resizable 옵션을 사용하려면 플러그인 내부 요소가 반응형으로 디자인되어야 합니다. 고정 크기(px) 대신 상대 단위(%, flex, fr 등)를 사용하고, 루트 요소에 width: 100%; height: 100%를 적용하세요. 그렇지 않으면 리사이즈해도 내부 콘텐츠가 컨테이너 크기에 맞게 조절되지 않습니다.

preserveAxis

설정이 변경될 때 유지할 크기 축을 지정합니다. resizable: true일 때만 적용됩니다.

설명
both가로와 세로 모두 유지 (기본값)
width가로 크기 유지, 세로는 콘텐츠에 맞춤
height세로 크기 유지, 가로는 콘텐츠에 맞춤
none둘 다 콘텐츠에 맞춤 (리사이즈 효과 리셋)
dmn.plugin.defineElement({ name: "KPS Panel", resizable: true, preserveAxis: "width", // 설정 변경 시 가로 크기 유지 // ... });

사용 예시

KPS 패널처럼 그래프 표시 옵션이 있는 경우:

  • preserveAxis: "width"로 설정하면 그래프 표시를 켜고 꺼도 가로 크기는 유지됩니다
  • 세로 크기는 그래프 유무에 따라 자동으로 조절됩니다
dmn.plugin.defineElement({ name: "KPS Panel", resizable: true, preserveAxis: "width", resizeAnchor: "bottom-left", // 하단 고정 (그래프가 위에서 펼쳐짐) settings: { showGraph: { type: "boolean", default: true, label: "그래프 표시" }, }, template: (state, settings, { html }) => html` <div style="width: 100%; height: 100%;"> <div class="kps-value">${state.kps ?? 0}</div> ${settings.showGraph ? html`<div class="graph">...</div>` : ""} </div> `, });

resizable 요소의 템플릿에서 루트 요소에 width: 100%; height: 100%를 적용하면 사용자가 조절한 크기에 맞게 내부 콘텐츠가 채워집니다.

컨텍스트 메뉴 (contextMenu)

기본 설정

contextMenu: { create: "패널 생성", // 그리드 빈 공간 우클릭 시 delete: "패널 삭제", // 패널 우클릭 시 },

커스텀 메뉴 항목

contextMenu: { create: "패널 생성", delete: "패널 삭제", items: [ { label: "통계 초기화", onClick: ({ actions }) => actions.reset(), }, { label: "데이터 내보내기", onClick: async ({ element, actions }) => { await actions.exportData(); }, // 조건부 표시 visible: ({ element }) => !!element.settings.enableExport, // 조건부 비활성화 disabled: ({ element }) => element.settings.exportFormat === "none", // 위치 (top 또는 bottom, 기본값: bottom) position: "bottom", }, ], }, // onMount에서 actions 등록 onMount: ({ expose, setState }) => { expose({ reset: () => setState({ count: 0 }), exportData: async () => { /* 내보내기 로직 */ }, }); },

position: "top" 항목은 기본 메뉴 항목 위에, 나머지는 아래에 표시됩니다.

메뉴 predicate는 메인 윈도우에서 { element, actions }로 평가됩니다. 오버레이에서 setState로 갱신한 런타임 상태는 기본적으로 이 컨텍스트에 동기화되지 않으며, element.state는 메인 윈도우 상태(previewState로 초기화)를 반영합니다. 오버레이 상태 기반 조건이 필요하면 contextMenuStateKeys에 키를 선언하세요.

predicate에서 오버레이 상태 사용 (contextMenuStateKeys)

메뉴 predicate가 참조할 오버레이 상태 키를 선언합니다. 선언된 키만 (setState 변경 시) 메인 윈도우로 미러링되어 predicate 평가 시 element.state에 병합됩니다. 고빈도 상태는 선언하지 않는 한 절대 전송되지 않고, 메인 윈도우의 프리뷰 상태도 변하지 않습니다.

// 메뉴 predicate용으로 active만 미러링 contextMenuStateKeys: ["active"], contextMenu: { items: [ { label: "캡처 중지", action: "stopCapture", visible: ({ element }) => !!element.state?.active, }, ], },

템플릿 (template)

템플릿 함수는 statesettings를 받아 UI를 반환합니다. 자세한 문법은 템플릿 문법 문서를 참고하세요.

template: (state, settings, { html, t, locale }) => html` <div style="color: ${settings.textColor};"> <strong>${state.value}</strong> ${settings.showDetails ? html` <div class="details">상세 정보</div> ` : ""} </div> `,

템플릿 헬퍼

헬퍼설명
htmlhtm 태그 함수 (React Element 생성)
t(key)다국어 번역 함수
locale현재 언어 코드

미리보기 상태 (previewState)

메인 윈도우에서 보여줄 미리보기용 초기 상태입니다. 실제 로직은 오버레이에서만 실행되므로, 메인에서는 이 상태가 표시됩니다.

previewState: { kps: 12, history: [5, 8, 12, 10, 15], },

마운트 로직 (onMount)

onMount는 오버레이에서만 실행되며, 실제 동작 로직을 구현합니다.

Context 객체

onMount: (context) => { const { setState, // 상태 업데이트 getSettings, // 현재 설정 조회 setAnchor, // 리사이즈 앵커 설정 getAnchor, // 현재 앵커 조회 onHook, // 이벤트 훅 등록 expose, // 컨텍스트 메뉴용 함수 노출 locale, // 현재 언어 코드 t, // 번역 함수 onLocaleChange, // 언어 변경 구독 onSettingsChange, // 설정 변경 구독 } = context; // 클린업 함수 반환 return () => { /* 정리 작업 */ }; },

이벤트 훅 (onHook)

onMount: ({ onHook, setState }) => { // 매핑된 키 이벤트 onHook("key", ({ key, state, mode }) => { if (state === "DOWN") { console.log(`${key} 눌림 (${mode})`); } }); // 모든 원시 입력 이벤트 (키보드, 마우스) onHook("rawKey", ({ device, label, state }) => { console.log(`[${device}] ${label} ${state}`); }); },

"key" 훅은 dmn.keys.onKeyState와 같은 canonical payload를 받습니다. UPholdDurationMs는 같은 물리 입력이 canonical 전환 양쪽을 만든 경우에만 포함되고, 그 외에는 생략됩니다. 전체 활성 시간이 필요하면 같은 canonical의 DOWNUP 시각을 직접 추적하세요.

설정 변경 감지 (onSettingsChange)

설정 변경에 즉시 반응해야 할 때 사용합니다.

대부분의 경우 getSettings()로 최신 설정을 조회하면 충분합니다. onSettingsChange는 외부 API 호출이나 리소스 재초기화가 필요한 경우에만 사용하세요.

onMount: ({ setState, getSettings, onSettingsChange }) => { const fetchData = async (nickname) => { const response = await fetch(`/api/user/${nickname}`); const data = await response.json(); setState({ data }); }; // 초기 로드 fetchData(getSettings().nickname); // 닉네임 변경 시 재요청 onSettingsChange((newSettings, oldSettings) => { if (newSettings.nickname !== oldSettings.nickname) { fetchData(newSettings.nickname); } }); },

다국어 지원 (messages)

dmn.plugin.defineElement({ name: "Localized Panel", messages: { ko: { "menu.create": "패널 생성", "menu.delete": "패널 삭제", "label.count": "카운트", }, en: { "menu.create": "Create Panel", "menu.delete": "Delete Panel", "label.count": "Count", }, }, contextMenu: { create: "menu.create", // 메시지 키 사용 delete: "menu.delete", }, settings: { count: { type: "number", default: 0, label: "label.count", // 메시지 키 사용 }, }, template: (state, settings, { html, t, locale }) => html` <div data-locale="${locale}">${t("label.count")}: ${state.value ?? 0}</div> `, });

실전 예제: KPS 패널

// @id kps-panel dmn.plugin.defineElement({ name: "KPS Panel", maxInstances: 1, contextMenu: { create: "KPS 패널 생성", delete: "KPS 패널 삭제", items: [ { label: "통계 초기화", onClick: ({ actions }) => actions.reset(), }, ], }, settings: { showGraph: { type: "boolean", default: true, label: "그래프 표시" }, textColor: { type: "color", default: "#FFFFFF", label: "텍스트 색상" }, graphColor: { type: "color", default: "#86EFAC", label: "그래프 색상", visible: (s) => s.showGraph, }, }, previewState: { kps: 12, max: 20, history: [5, 8, 12, 15, 10, 12], }, template: (state, settings, { html }) => html` <div style=" background: rgba(0, 0, 0, 0.8); padding: 16px; border-radius: 8px; color: ${settings.textColor}; min-width: 120px; " > <div style="font-size: 32px; font-weight: bold;"> ${state.kps ?? 0} <span style="font-size: 14px; opacity: 0.7;">KPS</span> </div> ${settings.showGraph ? html` <div style=" display: flex; gap: 2px; height: 40px; align-items: flex-end; margin-top: 8px; " > ${(state.history ?? []).map((v) => { const height = state.max ? (v / state.max) * 100 : 0; return html` <div style=" flex: 1; height: ${height}%; background: ${settings.graphColor}; border-radius: 2px 2px 0 0; opacity: 0.7; " ></div> `; })} </div> ` : ""} </div> `, onMount: ({ setState, expose, onHook }) => { const timestamps = []; let max = 0; const historySize = 20; const history = []; onHook("key", ({ state }) => { if (state === "DOWN") { timestamps.push(Date.now()); } }); const interval = setInterval(() => { const now = Date.now(); // 1초 이내 타임스탬프만 유지 while (timestamps.length && timestamps[0] < now - 1000) { timestamps.shift(); } const kps = timestamps.length; max = Math.max(max, kps); history.push(kps); if (history.length > historySize) history.shift(); setState({ kps, max, history: [...history] }); }, 50); expose({ reset: () => { timestamps.length = 0; history.length = 0; max = 0; setState({ kps: 0, max: 0, history: [] }); }, }); return () => clearInterval(interval); }, });