선언형 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 생성)
settings: {
/* 설정 정의 */
},
// 다국어 메시지
messages: {
/* 번역 데이터 */
},
// 메인 윈도우 미리보기 상태
previewState: {
/* 미리보기용 초기 상태 */
},
// HTML 템플릿 함수
template: (state, settings, helpers) => {
/* 템플릿 반환 */
},
// 오버레이 마운트 로직
onMount: (context) => {
/* 초기화 로직 */
return () => {
/* 클린업 */
};
},
});설정 스키마 (settings)
설정 스키마를 정의하면 자동으로 설정 다이얼로그가 생성됩니다.
지원 타입
settings: {
// 문자열
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" },
],
},
},설정 접근
// 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 }) => {
// 현재 앵커 확인
console.log("현재 앵커:", getAnchor()); // "top-left"
// 앵커를 중앙으로 변경
setAnchor("center");
// 조건에 따라 앵커 변경
const settings = getSettings();
if (settings.expandFromCenter) {
setAnchor("center");
} else {
setAnchor("top-left");
}
},
});앵커 우선순위
- 인스턴스별
resizeAnchor(setAnchor로 설정) - Definition의
resizeAnchor - 기본값
"top-left"
리사이즈 설정 (resizable, preserveAxis)
플러그인 요소의 크기를 사용자가 그리드에서 직접 조절할 수 있도록 하려면 resizable 옵션을 사용합니다.
resizable
resizable: true로 설정하면 요소에 8방향 리사이즈 핸들이 표시됩니다.
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.state.hasData,
// 조건부 비활성화
disabled: ({ element }) => element.state.isLoading,
// 위치 (top 또는 bottom)
position: "bottom",
},
],
},
// onMount에서 actions 등록
onMount: ({ expose, setState }) => {
expose({
reset: () => setState({ count: 0 }),
exportData: async () => { /* 내보내기 로직 */ },
});
},템플릿 (template)
템플릿 함수는 state와 settings를 받아 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>
`,템플릿 헬퍼
| 헬퍼 | 설명 |
|---|---|
html | htm 태그 함수 (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}`);
});
},설정 변경 감지 (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: "그래프 색상" },
},
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);
},
});