fix: harden array builder editing workflows
This commit is contained in:
+245
-44
@@ -49,7 +49,7 @@ const optionalIds = [
|
||||
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
|
||||
"frequency-response-summary", "preset-axis", "preset-edge-inset",
|
||||
"microphone-search", "microphone-selection-empty", "microphone-fields",
|
||||
"workspace-summary",
|
||||
"workspace-summary", "canvas-instructions", "mobile-export-button",
|
||||
];
|
||||
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
||||
@@ -80,14 +80,20 @@ const state = {
|
||||
history: [],
|
||||
future: [],
|
||||
dragSnapshot: null,
|
||||
importRevision: 0,
|
||||
baselineSignature: "",
|
||||
};
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
const MAX_HISTORY_BYTES = 24 * 1024 * 1024;
|
||||
const frequencyResponseFingerprintCache = new WeakMap();
|
||||
const BUILTIN_ARRAY_IDS = new Set([
|
||||
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6",
|
||||
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact",
|
||||
"rectangular-2x2-450mm", "rectangular-4x3-450mm",
|
||||
"rectangular-8x6-450mm", "rectangular-16x12",
|
||||
"rectangular-24x16-450mm", "rectangular-16x12-compact",
|
||||
"rectangular-16x12-large", "fermat-spiral-192",
|
||||
]);
|
||||
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||
|
||||
function finiteInput(element, label, options = {}) {
|
||||
const value = element.valueAsNumber;
|
||||
@@ -121,35 +127,152 @@ function selectedMicrophone() {
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function cloneArrayForHistory(arrayDefinition) {
|
||||
return {
|
||||
...arrayDefinition,
|
||||
coordinateSystem: { ...arrayDefinition.coordinateSystem },
|
||||
aperture: { ...arrayDefinition.aperture },
|
||||
microphones: arrayDefinition.microphones.map((microphone) => ({
|
||||
...microphone,
|
||||
position: [...microphone.position],
|
||||
...(microphone.frequencyResponse === undefined
|
||||
? {}
|
||||
: { frequencyResponse: microphone.frequencyResponse }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function captureSnapshot() {
|
||||
return {
|
||||
arrayDefinition: structuredClone(state.arrayDefinition),
|
||||
arrayDefinition: cloneArrayForHistory(state.arrayDefinition),
|
||||
apertureShape: state.apertureShape,
|
||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotSignature(snapshot) {
|
||||
return JSON.stringify(snapshot);
|
||||
function editableSnapshotSignature(snapshot) {
|
||||
const arrayDefinition = snapshot.arrayDefinition;
|
||||
return JSON.stringify({
|
||||
document: editableDocumentView(arrayDefinition),
|
||||
apertureShape: snapshot.apertureShape,
|
||||
selectedMicrophoneId: snapshot.selectedMicrophoneId,
|
||||
});
|
||||
}
|
||||
|
||||
function fingerprintFrequencyResponse(response) {
|
||||
if (!Array.isArray(response)) return null;
|
||||
const cached = frequencyResponseFingerprintCache.get(response);
|
||||
if (cached) return cached;
|
||||
const canonical = JSON.stringify(response.map((point) => [
|
||||
point.frequencyHz,
|
||||
point.gainDb,
|
||||
point.phaseDeg ?? null,
|
||||
]));
|
||||
let first = 0x811c9dc5;
|
||||
let second = 0x9e3779b9;
|
||||
for (let index = 0; index < canonical.length; index += 1) {
|
||||
const code = canonical.charCodeAt(index);
|
||||
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
||||
second = Math.imul(second ^ code, 0x85ebca6b) >>> 0;
|
||||
}
|
||||
const fingerprint =
|
||||
String(canonical.length) + ":" +
|
||||
first.toString(16).padStart(8, "0") + ":" +
|
||||
second.toString(16).padStart(8, "0");
|
||||
frequencyResponseFingerprintCache.set(response, fingerprint);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
function editableDocumentView(arrayDefinition) {
|
||||
return {
|
||||
schemaVersion: arrayDefinition.schemaVersion,
|
||||
kind: arrayDefinition.kind,
|
||||
id: arrayDefinition.id,
|
||||
name: arrayDefinition.name,
|
||||
description: arrayDefinition.description,
|
||||
units: arrayDefinition.units,
|
||||
coordinateSystem: arrayDefinition.coordinateSystem,
|
||||
aperture: arrayDefinition.aperture,
|
||||
microphones: arrayDefinition.microphones.map((microphone) => [
|
||||
microphone.id,
|
||||
...microphone.position,
|
||||
microphone.sensitivityDb ?? null,
|
||||
microphone.phaseOffsetDeg ?? null,
|
||||
fingerprintFrequencyResponse(microphone.frequencyResponse),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function editableDocumentSignature(arrayDefinition) {
|
||||
return JSON.stringify(editableDocumentView(arrayDefinition));
|
||||
}
|
||||
|
||||
function currentSnapshotView() {
|
||||
return {
|
||||
arrayDefinition: state.arrayDefinition,
|
||||
apertureShape: state.apertureShape,
|
||||
selectedMicrophoneId: state.selectedMicrophoneId,
|
||||
};
|
||||
}
|
||||
|
||||
function estimatedSnapshotBytes(snapshot) {
|
||||
const responsePointCount = snapshot.arrayDefinition.microphones.reduce(
|
||||
(count, microphone) => count + (microphone.frequencyResponse?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
return editableSnapshotSignature(snapshot).length * 4 + responsePointCount * 160;
|
||||
}
|
||||
|
||||
function historyEntry(snapshot, label) {
|
||||
return { snapshot, label, bytes: estimatedSnapshotBytes(snapshot) };
|
||||
}
|
||||
|
||||
function trimHistoryMemory() {
|
||||
while (state.history.length > MAX_HISTORY) state.history.shift();
|
||||
let totalBytes = [...state.history, ...state.future].reduce(
|
||||
(sum, entry) => sum + (entry.bytes ?? estimatedSnapshotBytes(entry.snapshot)),
|
||||
0,
|
||||
);
|
||||
while (totalBytes > MAX_HISTORY_BYTES && state.history.length > 1) {
|
||||
totalBytes -= state.history.shift().bytes;
|
||||
}
|
||||
while (totalBytes > MAX_HISTORY_BYTES && state.future.length > 1) {
|
||||
totalBytes -= state.future.shift().bytes;
|
||||
}
|
||||
while (
|
||||
totalBytes > MAX_HISTORY_BYTES &&
|
||||
state.history.length + state.future.length > 1
|
||||
) {
|
||||
const removed = state.history.length
|
||||
? state.history.shift()
|
||||
: state.future.shift();
|
||||
totalBytes -= removed.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDirtyState() {
|
||||
state.dirty = editableDocumentSignature(state.arrayDefinition) !== state.baselineSignature;
|
||||
}
|
||||
|
||||
function restoreSnapshot(snapshot, message) {
|
||||
state.arrayDefinition = structuredClone(snapshot.arrayDefinition);
|
||||
state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition);
|
||||
state.apertureShape = snapshot.apertureShape;
|
||||
state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
|
||||
if (!selectedMicrophone()) {
|
||||
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||
}
|
||||
state.dirty = true;
|
||||
refreshDirtyState();
|
||||
renderAll(message);
|
||||
}
|
||||
|
||||
function recordSnapshot(before, label) {
|
||||
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false;
|
||||
state.history.push({ snapshot: before, label });
|
||||
if (state.history.length > MAX_HISTORY) state.history.shift();
|
||||
if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) {
|
||||
return false;
|
||||
}
|
||||
state.history.push(historyEntry(before, label));
|
||||
state.future.length = 0;
|
||||
state.dirty = true;
|
||||
trimHistoryMemory();
|
||||
refreshDirtyState();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -165,7 +288,11 @@ function showActionError(error) {
|
||||
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
|
||||
elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
|
||||
elements["validation-message"].textContent = message;
|
||||
if (elements.validationCard) elements.validationCard.dataset.severity = "error";
|
||||
if (elements.validationCard) {
|
||||
elements.validationCard.dataset.severity = "error";
|
||||
elements.validationCard.classList.add("is-invalid");
|
||||
elements.validationCard.classList.remove("is-valid", "is-warning");
|
||||
}
|
||||
elements["array-builder-workspace"].dataset.actionError = message;
|
||||
}
|
||||
|
||||
@@ -174,6 +301,9 @@ function safely(callback) {
|
||||
delete elements["array-builder-workspace"].dataset.actionError;
|
||||
callback();
|
||||
} catch (error) {
|
||||
renderDocumentEditor();
|
||||
syncSelectedEditor();
|
||||
setNumber(elements["snap-step"], state.snapStepM * 1000);
|
||||
showActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -183,9 +313,14 @@ function announce(message) {
|
||||
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
|
||||
elements["save-state"].classList.toggle("is-dirty", state.dirty);
|
||||
}
|
||||
if (elements["selection-status"] && message) {
|
||||
elements["selection-status"].textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function announceSelection(message) {
|
||||
if (elements["selection-status"]) elements["selection-status"].textContent = message;
|
||||
}
|
||||
|
||||
function dismissCanvasInstructions() {
|
||||
elements["canvas-instructions"]?.classList.add("is-hidden");
|
||||
}
|
||||
|
||||
function setTool(tool, options = {}) {
|
||||
@@ -196,6 +331,10 @@ function setTool(tool, options = {}) {
|
||||
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
|
||||
elements["array-canvas"].dataset.tool = state.tool;
|
||||
canvasView.render(canvasState());
|
||||
if (options.userInitiated) {
|
||||
dismissCanvasInstructions();
|
||||
announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구");
|
||||
}
|
||||
if (options.focus) canvasView.focus();
|
||||
}
|
||||
|
||||
@@ -258,15 +397,29 @@ function selectMicrophone(id, options = {}) {
|
||||
renderMicrophoneList();
|
||||
syncSelectedEditor();
|
||||
canvasView.render(canvasState());
|
||||
if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택");
|
||||
const selectedButton = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")]
|
||||
.find((button) => button.dataset.microphoneId === state.selectedMicrophoneId);
|
||||
if (options.focusList) selectedButton?.focus();
|
||||
if (options.scrollList !== false) {
|
||||
selectedButton?.scrollIntoView?.({ block: "nearest" });
|
||||
}
|
||||
announceSelection(
|
||||
state.selectedMicrophoneId
|
||||
? state.selectedMicrophoneId + " 선택"
|
||||
: "선택된 마이크 없음",
|
||||
);
|
||||
if (options.focusCanvas) canvasView.focus();
|
||||
}
|
||||
|
||||
const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||
onSelect(id) {
|
||||
selectMicrophone(id);
|
||||
dismissCanvasInstructions();
|
||||
selectMicrophone(id, {
|
||||
scrollList: window.matchMedia("(min-width: 761px)").matches,
|
||||
});
|
||||
},
|
||||
onAdd(position) {
|
||||
dismissCanvasInstructions();
|
||||
safely(() => mutate("마이크 추가됨", () => {
|
||||
const microphone = addMicrophone(
|
||||
state.arrayDefinition,
|
||||
@@ -276,6 +429,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||
}));
|
||||
},
|
||||
onDragStart(id) {
|
||||
dismissCanvasInstructions();
|
||||
if (state.dragSnapshot) return;
|
||||
state.selectedMicrophoneId = id;
|
||||
state.dragSnapshot = captureSnapshot();
|
||||
@@ -320,7 +474,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
||||
}));
|
||||
},
|
||||
onToolChange(tool) {
|
||||
setTool(tool);
|
||||
setTool(tool, { userInitiated: true });
|
||||
},
|
||||
onFit() {
|
||||
canvasView.fit();
|
||||
@@ -372,11 +526,14 @@ function renderMicrophoneList() {
|
||||
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
|
||||
list.replaceChildren();
|
||||
state.arrayDefinition.microphones.forEach((microphone, index) => {
|
||||
if (query && !microphone.id.toLowerCase().includes(query)) return;
|
||||
const matchesQuery = !query || microphone.id.toLowerCase().includes(query);
|
||||
if (!matchesQuery && microphone.id !== state.selectedMicrophoneId) return;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "microphone-row";
|
||||
button.dataset.microphoneId = microphone.id;
|
||||
button.dataset.searchPinned = String(!matchesQuery);
|
||||
button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1;
|
||||
button.setAttribute("role", "option");
|
||||
button.setAttribute(
|
||||
"aria-selected",
|
||||
@@ -412,9 +569,16 @@ function renderMicrophoneList() {
|
||||
identity.append(calibration);
|
||||
}
|
||||
button.append(channel, identity);
|
||||
button.addEventListener("click", () => selectMicrophone(microphone.id));
|
||||
button.addEventListener("click", () => {
|
||||
dismissCanvasInstructions();
|
||||
selectMicrophone(microphone.id, { focusList: true });
|
||||
});
|
||||
list.append(button);
|
||||
});
|
||||
if (!list.querySelector('[tabindex="0"]')) {
|
||||
const firstButton = list.querySelector("[data-microphone-id]");
|
||||
if (firstButton) firstButton.tabIndex = 0;
|
||||
}
|
||||
if (!list.childElementCount) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "microphone-list__empty";
|
||||
@@ -521,6 +685,9 @@ async function validateCurrent() {
|
||||
state.valid = errors.length === 0;
|
||||
document.documentElement.dataset.arrayValid = String(state.valid);
|
||||
elements["export-button"].disabled = !state.valid;
|
||||
if (elements["mobile-export-button"]) {
|
||||
elements["mobile-export-button"].disabled = !state.valid;
|
||||
}
|
||||
elements.validationCard?.setAttribute(
|
||||
"data-severity",
|
||||
errors.length ? "error" : warnings.length ? "warning" : "success",
|
||||
@@ -603,24 +770,31 @@ function applySelectedPosition() {
|
||||
}
|
||||
|
||||
function presetOptions() {
|
||||
return {
|
||||
type: elements["preset-type"].value,
|
||||
const type = elements["preset-type"].value;
|
||||
const options = {
|
||||
type,
|
||||
aperture: state.arrayDefinition.aperture,
|
||||
shape: state.apertureShape,
|
||||
count: integerInput(elements["preset-count"], "마이크 수", 1, 1024),
|
||||
columns: integerInput(elements["preset-columns"], "Columns", 1, 1024),
|
||||
rows: integerInput(elements["preset-rows"], "Rows", 1, 1024),
|
||||
seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295),
|
||||
minimumSpacingM: finiteInput(
|
||||
elements["preset-minimum-spacing"],
|
||||
"최소 간격",
|
||||
{ minimum: 0 },
|
||||
),
|
||||
edgeInsetM: elements["preset-edge-inset"]
|
||||
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
|
||||
: 0,
|
||||
axis: elements["preset-axis"]?.value ?? "x",
|
||||
};
|
||||
if (type === "grid") {
|
||||
options.columns = integerInput(elements["preset-columns"], "Columns", 1, 1024);
|
||||
options.rows = integerInput(elements["preset-rows"], "Rows", 1, 1024);
|
||||
} else {
|
||||
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
|
||||
}
|
||||
if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x";
|
||||
if (type === "random") {
|
||||
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
|
||||
options.minimumSpacingM = finiteInput(
|
||||
elements["preset-minimum-spacing"],
|
||||
"최소 간격",
|
||||
{ minimum: 0 },
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
elements["array-id"].addEventListener("change", () => safely(() => {
|
||||
@@ -680,8 +854,10 @@ elements["generate-preset-button"].addEventListener("click", () => safely(() =>
|
||||
);
|
||||
}));
|
||||
|
||||
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true }));
|
||||
elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true }));
|
||||
elements["select-tool-button"].addEventListener("click", () =>
|
||||
setTool("select", { focus: true, userInitiated: true }));
|
||||
elements["add-tool-button"].addEventListener("click", () =>
|
||||
setTool("add", { focus: true, userInitiated: true }));
|
||||
elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
|
||||
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
|
||||
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
|
||||
@@ -701,6 +877,7 @@ elements["snap-step"].addEventListener("change", () => safely(() => {
|
||||
}));
|
||||
|
||||
elements["add-microphone-button"].addEventListener("click", () => safely(() => {
|
||||
dismissCanvasInstructions();
|
||||
mutate("마이크 추가됨", () => {
|
||||
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
|
||||
state.selectedMicrophoneId = microphone.id;
|
||||
@@ -763,6 +940,19 @@ elements["move-microphone-down-button"]?.addEventListener("click", () => safely(
|
||||
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
|
||||
}));
|
||||
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
|
||||
elements["microphone-list"].addEventListener("keydown", (event) => {
|
||||
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
|
||||
const buttons = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")];
|
||||
if (!buttons.length) return;
|
||||
const focusedIndex = Math.max(0, buttons.indexOf(document.activeElement));
|
||||
let targetIndex = focusedIndex;
|
||||
if (event.key === "ArrowUp") targetIndex = Math.max(0, focusedIndex - 1);
|
||||
else if (event.key === "ArrowDown") targetIndex = Math.min(buttons.length - 1, focusedIndex + 1);
|
||||
else if (event.key === "Home") targetIndex = 0;
|
||||
else targetIndex = buttons.length - 1;
|
||||
event.preventDefault();
|
||||
selectMicrophone(buttons[targetIndex].dataset.microphoneId, { focusList: true });
|
||||
});
|
||||
|
||||
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
|
||||
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
|
||||
@@ -789,14 +979,16 @@ elements["clamp-microphones-button"]?.addEventListener("click", () => safely(()
|
||||
function undo() {
|
||||
const entry = state.history.pop();
|
||||
if (!entry) return;
|
||||
state.future.push({ snapshot: captureSnapshot(), label: entry.label });
|
||||
state.future.push(historyEntry(captureSnapshot(), entry.label));
|
||||
trimHistoryMemory();
|
||||
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
|
||||
}
|
||||
|
||||
function redo() {
|
||||
const entry = state.future.pop();
|
||||
if (!entry) return;
|
||||
state.history.push({ snapshot: captureSnapshot(), label: entry.label });
|
||||
state.history.push(historyEntry(captureSnapshot(), entry.label));
|
||||
trimHistoryMemory();
|
||||
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
|
||||
}
|
||||
|
||||
@@ -821,31 +1013,36 @@ window.addEventListener("keydown", (event) => {
|
||||
elements["array-input"].addEventListener("change", async () => {
|
||||
const file = elements["array-input"].files?.[0];
|
||||
if (!file) return;
|
||||
const importRevision = ++state.importRevision;
|
||||
try {
|
||||
const value = await readJsonFile(file);
|
||||
const issues = await validateArrayDefinition(value);
|
||||
if (importRevision !== state.importRevision) return;
|
||||
if (issues.length) {
|
||||
const details = issues.slice(0, 4).map(
|
||||
(issue) => issue.path + ": " + issue.message,
|
||||
).join(" · ");
|
||||
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
|
||||
}
|
||||
const previousSnapshot = captureSnapshot();
|
||||
state.arrayDefinition = prepareArrayForEditing(value);
|
||||
state.apertureShape = inferApertureShape(value);
|
||||
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
|
||||
state.history.length = 0;
|
||||
state.history = [historyEntry(previousSnapshot, "Array 불러오기")];
|
||||
state.future.length = 0;
|
||||
trimHistoryMemory();
|
||||
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||
state.dirty = false;
|
||||
canvasView.fit();
|
||||
renderAll(file.name + " 불러옴");
|
||||
renderAll(file.name + " 불러옴 · 실행 취소 가능");
|
||||
} catch (error) {
|
||||
showActionError(error);
|
||||
if (importRevision === state.importRevision) showActionError(error);
|
||||
} finally {
|
||||
elements["array-input"].value = "";
|
||||
if (importRevision === state.importRevision) elements["array-input"].value = "";
|
||||
}
|
||||
});
|
||||
|
||||
elements["export-button"].addEventListener("click", async () => {
|
||||
async function exportArray() {
|
||||
await validateCurrent();
|
||||
if (!state.valid) {
|
||||
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
|
||||
@@ -855,9 +1052,13 @@ elements["export-button"].addEventListener("click", async () => {
|
||||
state.arrayDefinition,
|
||||
safeArrayFileStem(state.arrayDefinition) + ".array.json",
|
||||
);
|
||||
state.dirty = false;
|
||||
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
|
||||
refreshDirtyState();
|
||||
announce("Array JSON 저장됨");
|
||||
});
|
||||
}
|
||||
|
||||
elements["export-button"].addEventListener("click", exportArray);
|
||||
elements["mobile-export-button"]?.addEventListener("click", exportArray);
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!state.dirty) return;
|
||||
|
||||
Reference in New Issue
Block a user