HTML 드래그앤드롭 통합점 구현
이 예제는 같은 미디어 유형을 쓰는 drag zone과 drop zone을 연결합니다. Foundry 객체 RID를 받는 영역에는 같은 Foundry 유형을 전달합니다. Gotham 표현과 교환하려면 유형 매핑과 보강을 추가해야 합니다.
예제의 입출력
브라우저에서 실제 객체 RID 문자열 배열을 입력하면 drag zone이 그 목록을 DataTransfer에 넣습니다. Drop zone은 같은 미디어 유형의 데이터를 받아 JSON 형식을 확인하고 식별자를 화면에 표시합니다. 객체를 조회하거나 온톨로지 데이터를 변경하는 예제는 아닙니다.
세 파일을 같은 폴더에 두면 됩니다. index.html, drag-drop.js, drag-drop.css의 전체 파일도 사용할 수 있습니다.
Drag zone
HTML의 draggable이 true인 요소에 dragstart handler를 연결합니다. 데이터 준비가 되지 않은 동안 예제의 draggable은 false입니다.
setData의 첫 인자는 미디어 유형, 두 번째 인자는 문자열입니다. 객체 RID 배열은 JSON.stringify로 직렬화합니다. effectAllowed="move"는 브라우저에 허용할 드래그 동작을 알리며, 그것만으로 원래 객체를 삭제하거나 온톨로지에서 이동하지 않습니다.
DataTransfer에 넣은 데이터는 다른 drop zone에서 읽을 수 있습니다. 토큰이나 비밀을 넣지 않고 전달할 식별자와 문맥 범위를 정합니다.
Drop zone
허용할 미디어 유형이 types에 있을 때 dragenter와 dragover의 기본 동작을 취소해야 drop을 받을 수 있습니다. drop에서는 기본 링크 열기 동작을 막고 getData로 문자열을 읽습니다.
getData는 데이터가 없거나 그 유형이 없으면 빈 문자열을 반환합니다. JSON.parse 뒤에도 문자열 배열인지 확인합니다. 미디어 유형이 있다는 사실만으로 본문의 형식, 실제 객체 존재나 접근 권한이 검증되지는 않습니다.
이벤트와 강조 상태
문서의 dragenter/dragleave를 capture 단계에서 받아 자식 요소의 이벤트까지 추적합니다. 앱 안에 들어온 깊이와 drop zone 위에 있는 깊이를 별도로 관리하므로 자식 요소 사이를 이동할 때 강조가 불필요하게 사라지는 것을 줄입니다.
같은 페이지에서 시작하는 drag는 payload를 작성한 뒤 유효 상태를 표시합니다. document의 dragstart 관찰은 bubbling 단계에서 수행합니다. capture 단계에서는 대상 handler가 아직 setData를 호출하기 전일 수 있습니다.
drop과 dragend에서 class와 깊이 카운터를 정리합니다. 다음 드래그에는 이전 강조 상태를 남기지 않습니다. 예제는 유효/hover 상태를 구현하며 loading 및 invalid의 전체 시각 규칙은 디자인 지침에 설명합니다.
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foundry 객체 RID 드래그앤드롭</title>
<link rel="stylesheet" href="drag-drop.css">
<script src="drag-drop.js" defer></script>
</head>
<body>
<main>
<h1>Foundry 객체 RID 전달</h1>
<p>접근할 권한이 있는 실제 객체 RID의 JSON 배열을 입력합니다. 이 화면은 RID를 전달하고 표시하며 객체를 조회하거나 변경하지 않습니다.</p>
<label for="input-rids">보낼 객체 RID 배열</label>
<textarea id="input-rids" rows="5" spellcheck="false">[]</textarea>
<button id="apply-data" type="button">드래그 데이터 적용</button>
<p id="source-status" role="status">객체 RID를 입력한 뒤 적용하세요.</p>
<div id="drag-zone" draggable="false" aria-disabled="true">객체 RID를 여기서 끌어오세요.</div>
<div id="drop-zone">
<strong>Foundry 객체 RID 받기</strong>
<span>이 영역에 드롭하면 식별자 목록을 표시합니다.</span>
</div>
<p id="drop-status" role="status" aria-live="polite">받은 데이터가 없습니다.</p>
<ul id="received-rids"></ul>
<button id="clear-result" type="button">받은 목록 지우기</button>
</main>
</body>
</html>
drag-drop.css
body { font-family: system-ui, sans-serif; margin: 24px; color: #1c2127; }
main { max-width: 780px; margin: auto; }
textarea { display: block; box-sizing: border-box; width: 100%; margin: 8px 0; }
button { padding: 8px 12px; }
#drag-zone, #drop-zone { padding: 20px; margin: 16px 0; border: 2px solid #738091; border-radius: 4px; }
#drag-zone[draggable="true"] { cursor: grab; }
#drag-zone[draggable="false"] { cursor: not-allowed; opacity: 0.6; }
#drop-zone span { display: block; margin-top: 8px; }
#drop-zone.valid-small { background: rgba(24, 74, 144, 0.5); border: 2px dashed #8ABBFF; }
#drop-zone.valid-small-drag-hover { border: 2px solid #8ABBFF; }
#received-rids { overflow-wrap: anywhere; }
drag-drop.js
"use strict";
const FOUNDRY_OBJECTS = "application/x-vnd.palantir.rid.phonograph2-objects.object";
function element(id) {
const value = document.getElementById(id);
if (value === null) throw new Error(`Missing element: ${id}`);
return value;
}
const input = element("input-rids");
const source = element("drag-zone");
const dropZone = element("drop-zone");
const sourceStatus = element("source-status");
const dropStatus = element("drop-status");
const receivedList = element("received-rids");
let selectedRids = [];
let applicationDepth = 0;
let dropZoneDepth = 0;
function parseIdentifiers(text) {
const value = JSON.parse(text);
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new TypeError("문자열 배열 형식의 객체 RID가 필요합니다.");
}
return value;
}
function accepts(event) {
return event.dataTransfer !== null && event.dataTransfer.types.includes(FOUNDRY_OBJECTS);
}
function markAvailable(event) {
if (accepts(event)) dropZone.classList.add("valid-small");
}
function resetDragState() {
applicationDepth = 0;
dropZoneDepth = 0;
dropZone.classList.remove("valid-small", "valid-small-drag-hover");
}
input.addEventListener("input", () => {
selectedRids = [];
source.draggable = false;
source.setAttribute("aria-disabled", "true");
sourceStatus.textContent = "변경한 RID 배열을 다시 적용하세요.";
});
element("apply-data").addEventListener("click", () => {
try {
const identifiers = parseIdentifiers(input.value);
if (identifiers.length === 0) throw new Error("드래그할 실제 객체 RID를 하나 이상 입력하세요.");
selectedRids = identifiers;
source.draggable = true;
source.setAttribute("aria-disabled", "false");
sourceStatus.textContent = `${identifiers.length}개 식별자를 드래그할 수 있습니다.`;
} catch (error) {
selectedRids = [];
source.draggable = false;
source.setAttribute("aria-disabled", "true");
sourceStatus.textContent = error instanceof Error ? error.message : String(error);
}
});
source.addEventListener("dragstart", (event) => {
if (event.dataTransfer === null || selectedRids.length === 0) {
event.preventDefault();
return;
}
event.dataTransfer.setData(FOUNDRY_OBJECTS, JSON.stringify(selectedRids));
event.dataTransfer.effectAllowed = "move";
markAvailable(event);
});
document.addEventListener("dragstart", markAvailable);
document.addEventListener("dragenter", (event) => {
applicationDepth += 1;
markAvailable(event);
}, { capture: true });
document.addEventListener("dragleave", () => {
applicationDepth = Math.max(0, applicationDepth - 1);
if (applicationDepth === 0) resetDragState();
}, { capture: true });
document.addEventListener("dragend", resetDragState, { capture: true });
document.addEventListener("drop", resetDragState, { capture: true });
dropZone.addEventListener("dragover", (event) => {
if (accepts(event)) event.preventDefault();
});
dropZone.addEventListener("dragenter", (event) => {
if (!accepts(event)) return;
event.preventDefault();
dropZoneDepth += 1;
dropZone.classList.add("valid-small", "valid-small-drag-hover");
});
dropZone.addEventListener("dragleave", () => {
dropZoneDepth = Math.max(0, dropZoneDepth - 1);
if (dropZoneDepth === 0) dropZone.classList.remove("valid-small-drag-hover");
});
dropZone.addEventListener("drop", (event) => {
event.preventDefault();
receivedList.replaceChildren();
try {
if (!accepts(event)) throw new Error("Foundry 객체 RID 미디어 유형이 아닙니다.");
const serialized = event.dataTransfer.getData(FOUNDRY_OBJECTS);
if (serialized === "") throw new Error("드롭한 데이터가 비어 있습니다.");
const identifiers = parseIdentifiers(serialized);
const items = document.createDocumentFragment();
for (const identifier of identifiers) {
const item = document.createElement("li");
item.textContent = identifier;
items.appendChild(item);
}
receivedList.appendChild(items);
dropStatus.textContent = `${identifiers.length}개 식별자를 받았습니다. 객체 조회나 편집은 수행하지 않았습니다.`;
} catch (error) {
dropStatus.textContent = error instanceof Error ? error.message : String(error);
} finally {
resetDragState();
}
});
element("clear-result").addEventListener("click", () => {
receivedList.replaceChildren();
dropStatus.textContent = "받은 데이터가 없습니다.";
});
DataTransfer 데이터는 dragstart와 drop의 동기 실행 안에서 접근해야 합니다. 비동기 작업이 필요하면 drop에서 먼저 문자열을 지역 변수로 복사하고 그 후 await합니다. dragstart 뒤 비동기 응답을 받아 setData하는 방식은 사용할 수 없습니다.
실제 앱에서는 표시한 식별자 목록을 해당 앱의 조회 또는 상태 처리에 연결합니다. OSDK 컴포넌트는 객체 집합의 hydration과 타입별 drop 처리 경로를 제공합니다.