← Files AAC Card MakerARCHIVED FILE
examples/sample-aac-cards.html
30.1 KB · Sep 30, 2026 · 23:14 UTC
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AAC Card Editor</title>
<style>
:root {
font-family: Arial, "Noto Sans KR", sans-serif;
color: #172033;
background: #eef1f5;
--card-font-size: 32px;
--card-font-family: "Noto Sans KR", Arial, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
button, .file-button {
border: 1px solid #9aa5b1;
border-radius: 8px;
background: #fff;
color: #172033;
cursor: pointer;
font: inherit;
padding: 8px 12px;
}
button:hover, .file-button:hover { background: #f4f7fa; }
button.active { border-color: #2563eb; background: #dbeafe; }
.toolbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #cbd5e1;
}
.toolbar-group { display: flex; align-items: center; gap: 6px; }
.toolbar-title { margin-right: auto; font-weight: 700; }
.font-size-control { display: flex; align-items: center; gap: 6px; font-weight: 700; }
#font-size { width: 140px; }
#font-size-value { min-width: 46px; font-variant-numeric: tabular-nums; }
.font-family-control { display: flex; align-items: center; gap: 6px; font-weight: 700; }
#font-family { max-width: 180px; }
.editor-style-controls { display: none; align-items: center; gap: 6px; }
body.editing .editor-style-controls { display: flex; }
#pages {
display: grid;
justify-content: center;
gap: 18px;
padding: 18px;
}
.page-wrap { display: grid; gap: 6px; }
.page-caption { color: #475569; font-size: 12px; text-align: center; }
.page {
width: 210mm;
height: 297mm;
padding: 10mm;
overflow: hidden;
background: #fff;
border: 2px dashed #d22;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.16);
}
.cards {
display: grid;
gap: 5mm;
width: 100%;
height: 100%;
align-content: center;
}
.cards.layout-1 {
grid-template-columns: 190mm;
grid-auto-rows: 237.5mm;
}
.cards.layout-2 {
grid-template-columns: 108.8mm;
grid-auto-rows: 136mm;
justify-content: center;
}
.cards.layout-4 {
grid-template-columns: repeat(2, 92.5mm);
grid-auto-rows: 115.625mm;
}
.cards.layout-8 {
grid-template-columns: repeat(2, 52.4mm);
grid-auto-rows: 65.5mm;
justify-content: center;
}
.card {
position: relative;
aspect-ratio: 4 / 5;
overflow: hidden;
display: flex;
flex-direction: column;
border: 2px solid #111827;
border-radius: 5mm;
background: #fff;
}
.card-image {
position: relative;
height: 80%;
min-height: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #fff;
}
.card-image img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
transform: translate(calc(var(--image-x, 0) * 1%), calc(var(--image-y, 0) * 1%)) scale(var(--image-scale, 1));
transform-origin: center;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
}
.card-label {
height: 20%;
min-height: 0;
display: grid;
place-items: center;
padding: 2mm;
border-top: 2px solid #111827;
font-size: var(--card-font-size);
font-weight: 700;
line-height: 1.15;
text-align: center;
overflow-wrap: anywhere;
}
.card-label[contenteditable="true"] {
outline: 3px solid #2563eb;
outline-offset: -3px;
background: #eff6ff;
}
.edit-controls {
position: absolute;
inset: 2mm 2mm auto 2mm;
display: none;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.25);
}
body.editing .edit-controls { display: flex; }
.edit-controls button, .edit-controls .file-button {
padding: 4px 7px;
font-size: 12px;
}
.card-style-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
width: 100%;
font-size: 11px;
font-weight: 700;
}
.card-style-controls select { max-width: 150px; padding: 3px 4px; font-size: 11px; }
.card-style-controls input { width: 58px; padding: 3px 4px; font-size: 11px; }
.card-style-controls button { padding: 3px 5px; font-size: 11px; }
.image-input { display: none; }
.selection-outline {
position: absolute;
inset: 3px;
z-index: 3;
display: none;
border: 3px solid #1677ff;
pointer-events: none;
}
.resize-handle {
position: absolute;
z-index: 4;
display: none;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-radius: 2px;
background: #1677ff;
touch-action: none;
}
body.editing .card-image.selected .selection-outline,
body.editing .card-image.selected .resize-handle { display: block; }
.resize-handle[data-corner="nw"] { top: 0; left: 0; cursor: nwse-resize; }
.resize-handle[data-corner="ne"] { top: 0; right: 0; cursor: nesw-resize; }
.resize-handle[data-corner="sw"] { bottom: 0; left: 0; cursor: nesw-resize; }
.resize-handle[data-corner="se"] { right: 0; bottom: 0; cursor: nwse-resize; }
.empty-state {
align-self: center;
color: #64748b;
font-size: 18px;
text-align: center;
}
@page { size: A4 portrait; margin: 0; }
@media print {
html, body { background: #fff; }
.toolbar, .edit-only, .page-caption, .selection-outline, .resize-handle { display: none !important; }
#pages { display: block; padding: 0; }
.page-wrap { display: block; }
.page {
border: 0;
box-shadow: none;
break-after: page;
page-break-after: always;
}
.page-wrap:last-child .page {
break-after: auto;
page-break-after: auto;
}
}
</style>
</head>
<body>
<header class="toolbar">
<div class="toolbar-title" id="document-title">AAC Card Editor</div>
<div class="toolbar-group">
<button type="button" id="edit-toggle" onclick="toggleEditing()">편집 켜기</button>
<span>페이지당</span>
<button type="button" data-layout="1" onclick="setLayout(1)">1</button>
<button type="button" data-layout="2" onclick="setLayout(2)">2</button>
<button type="button" data-layout="4" onclick="setLayout(4)">4</button>
<button type="button" data-layout="8" onclick="setLayout(8)">8</button>
</div>
<label class="font-size-control editor-style-controls edit-only" for="font-size">전체 글자 크기
<input id="font-size" type="range" min="12" max="72" step="1" oninput="setFontSize(this.value)">
<output id="font-size-value" for="font-size">32px</output>
</label>
<label class="font-family-control editor-style-controls edit-only" for="font-family">전체 글꼴
<select id="font-family" onchange="setFontFamily(this.value)">
<option value="Noto Sans KR, Arial, sans-serif">고딕 (Noto Sans KR)</option>
<option value="Arial, sans-serif">Arial</option>
<option value="Malgun Gothic, Noto Sans KR, sans-serif">맑은 고딕</option>
<option value="Noto Serif KR, serif">명조 (Noto Serif KR)</option>
<option value="serif">명조 (기본)</option>
<option value="sans-serif">고딕 (기본)</option>
</select>
</label>
<button type="button" class="editor-style-controls edit-only" onclick="applyStyleToAll()">전체 카드에 적용</button>
<button type="button" onclick="addCard()">카드 추가</button>
<button type="button" onclick="saveHtml()">HTML 저장</button>
<button type="button" onclick="window.print()">인쇄</button>
</header>
<main id="pages"></main>
<script>
/*__AAC_STATE_START__*/const state = {"title":"생활 AAC 카드 예제","profile_alias":"sample-student","cards_per_page":4,"fontSize":32,"fontFamily":"Noto Sans KR, Arial, sans-serif","cards":[{"id":"drink-water","label":"물 마시기","image_data":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MDAiIGhlaWdodD0iODAwIiB2aWV3Qm94PSIwIDAgODAwIDgwMCIgcm9sZT0iaW1nIj4NCiAgPHJlY3Qgd2lkdGg9IjgwMCIgaGVpZ2h0PSI4MDAiIGZpbGw9IiNmNGY4ZmIiLz4NCiAgPHBhdGggZD0iTTMwMCAxNTVoMjAwbC0yNCA0MzBjLTMgNDQtMzkgNzgtODMgNzhoLTE4Yy00NCAwLTgwLTM0LTgzLTc4eiIgZmlsbD0iI2RiZWFmZSIgc3Ryb2tlPSIjMWUzYTVmIiBzdHJva2Utd2lkdGg9IjE4Ii8+DQogIDxwYXRoIGQ9Ik0zMTAgMzYwaDE4MGwtMTMgMjI1Yy0zIDM1LTMxIDYyLTY2IDYyaC01NGMtMzUgMC02My0yNy02Ni02MnoiIGZpbGw9IiM2MGE1ZmEiIG9wYWNpdHk9IjAuODUiLz4NCiAgPGVsbGlwc2UgY3g9IjQwMCIgY3k9IjE1NSIgcng9IjEwMCIgcnk9IjI3IiBmaWxsPSIjZWZmNmZmIiBzdHJva2U9IiMxZTNhNWYiIHN0cm9rZS13aWR0aD0iMTgiLz4NCiAgPHBhdGggZD0iTTUyMCAyNzBjNzUgMzAgMTA4IDgyIDkzIDE1NyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWUzYTVmIiBzdHJva2Utd2lkdGg9IjIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4NCjwvc3ZnPg0K","imageTransform":{"scale":1,"x":0,"y":0}},{"id":"use-toilet","label":"화장실 가기","image_data":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MDAiIGhlaWdodD0iODAwIiB2aWV3Qm94PSIwIDAgODAwIDgwMCIgcm9sZT0iaW1nIj4NCiAgPHJlY3Qgd2lkdGg9IjgwMCIgaGVpZ2h0PSI4MDAiIGZpbGw9IiNmN2Y3ZjIiLz4NCiAgPHJlY3QgeD0iMjUwIiB5PSIxMjUiIHdpZHRoPSIzMDAiIGhlaWdodD0iMjM1IiByeD0iMjQiIGZpbGw9IiNmZmZmZmYiIHN0cm9rZT0iIzMzNDE1NSIgc3Ryb2tlLXdpZHRoPSIxOCIvPg0KICA8cmVjdCB4PSIyODIiIHk9IjE2NSIgd2lkdGg9IjIzNiIgaGVpZ2h0PSIxNTUiIHJ4PSIxNiIgZmlsbD0iI2UyZThmMCIvPg0KICA8cGF0aCBkPSJNMjIwIDM1NWgzNjBjMCAxNDAtNTUgMjQ1LTE4MCAyNDVTMjIwIDQ5NSAyMjAgMzU1eiIgZmlsbD0iI2ZmZmZmZiIgc3Ryb2tlPSIjMzM0MTU1IiBzdHJva2Utd2lkdGg9IjE4Ii8+DQogIDxlbGxpcHNlIGN4PSI0MDAiIGN5PSIzNTUiIHJ4PSIxODAiIHJ5PSI2OCIgZmlsbD0iI2ZmZmZmZiIgc3Ryb2tlPSIjMzM0MTU1IiBzdHJva2Utd2lkdGg9IjE4Ii8+DQogIDxlbGxpcHNlIGN4PSI0MDAiIGN5PSIzNTUiIHJ4PSIxMDUiIHJ5PSIzMiIgZmlsbD0iI2NiZDVlMSIvPg0KICA8cGF0aCBkPSJNMzE1IDU5MGgxNzBsMzAgODBIMjg1eiIgZmlsbD0iI2ZmZmZmZiIgc3Ryb2tlPSIjMzM0MTU1IiBzdHJva2Utd2lkdGg9IjE4IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+DQogIDxjaXJjbGUgY3g9IjUwNSIgY3k9IjIwNSIgcj0iMTciIGZpbGw9IiMzOGJkZjgiLz4NCjwvc3ZnPg0K","imageTransform":{"scale":1,"x":0,"y":0}}]};/*__AAC_STATE_END__*/
let editing = false;
let selectedCardId = null;
let pointerOperation = null;
const DEFAULT_FONT_FAMILY = "Noto Sans KR, Arial, sans-serif";
const DEFAULT_FONT_SIZE = 32;
const FONT_FAMILIES = [
DEFAULT_FONT_FAMILY,
"Arial, sans-serif",
"Malgun Gothic, Noto Sans KR, sans-serif",
"Noto Serif KR, serif",
"serif",
"sans-serif"
];
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value)));
}
function normalizeFontSize(value) {
const numeric = Number(value);
return clamp(Number.isFinite(numeric) ? numeric : DEFAULT_FONT_SIZE, 12, 72);
}
function normalizeFontFamily(value) {
return FONT_FAMILIES.indexOf(value) === -1 ? DEFAULT_FONT_FAMILY : value;
}
function normalizeTransform(value) {
const source = value && typeof value === "object" ? value : {};
return {
scale: clamp(Number(source.scale) || 1, 0.25, 4),
x: clamp(Number(source.x) || 0, -80, 80),
y: clamp(Number(source.y) || 0, -80, 80)
};
}
state.fontSize = normalizeFontSize(state.fontSize);
state.fontFamily = normalizeFontFamily(state.fontFamily);
state.cards.forEach(function (card) {
card.imageTransform = normalizeTransform(card.imageTransform);
if (card.fontSize !== undefined && card.fontSize !== null && card.fontSize !== "") {
card.fontSize = normalizeFontSize(card.fontSize);
}
if (card.fontFamily !== undefined && card.fontFamily !== null && card.fontFamily !== "") {
if (FONT_FAMILIES.indexOf(card.fontFamily) === -1) delete card.fontFamily;
}
});
function cardIndex(cardId) {
return state.cards.findIndex(function (card) { return card.id === cardId; });
}
function uniqueId(prefix) {
let id;
do {
id = prefix + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 7);
} while (cardIndex(id) !== -1);
return id;
}
function neutralImage() {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800"><rect width="800" height="800" fill="%23f1f5f9"/><circle cx="400" cy="400" r="150" fill="%23cbd5e1"/></svg>';
return "data:image/svg+xml," + svg;
}
function makeButton(text, action, className) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.className = className || "edit-only";
button.addEventListener("click", action);
return button;
}
function populateFontSelect(select, selectedValue, includeDefault) {
if (includeDefault) {
const defaultOption = document.createElement("option");
defaultOption.value = "";
defaultOption.textContent = "전체 설정 사용";
select.appendChild(defaultOption);
}
const labels = ["고딕 (Noto Sans KR)", "Arial", "맑은 고딕", "명조 (Noto Serif KR)", "명조 (기본)", "고딕 (기본)"];
FONT_FAMILIES.forEach(function (family, index) {
const option = document.createElement("option");
option.value = family;
option.textContent = labels[index];
select.appendChild(option);
});
select.value = selectedValue || (includeDefault ? "" : state.fontFamily);
}
function applyLabelStyle(label, card) {
if (card.fontFamily) label.style.fontFamily = card.fontFamily;
else label.style.removeProperty("font-family");
if (card.fontSize) label.style.fontSize = card.fontSize + "px";
else label.style.removeProperty("font-size");
}
function createCard(card) {
const article = document.createElement("article");
article.className = "card";
article.dataset.cardId = card.id;
const imageBox = document.createElement("div");
imageBox.className = "card-image" + (card.id === selectedCardId ? " selected" : "");
imageBox.dataset.cardId = card.id;
const image = document.createElement("img");
image.src = card.image_data;
image.alt = "";
image.draggable = false;
image.style.setProperty("--image-scale", card.imageTransform.scale);
image.style.setProperty("--image-x", card.imageTransform.x);
image.style.setProperty("--image-y", card.imageTransform.y);
image.addEventListener("dblclick", function (event) {
if (!editing) return;
event.preventDefault();
selectedCardId = card.id;
render();
});
image.addEventListener("pointerdown", function (event) {
if (editing && selectedCardId === card.id) startImageDrag(event, card.id, imageBox);
});
imageBox.appendChild(image);
const outline = document.createElement("span");
outline.className = "selection-outline edit-only";
imageBox.appendChild(outline);
["nw", "ne", "sw", "se"].forEach(function (corner) {
const handle = document.createElement("span");
handle.className = "resize-handle edit-only";
handle.dataset.corner = corner;
handle.addEventListener("pointerdown", function (event) {
startImageResize(event, card.id, imageBox, corner);
});
imageBox.appendChild(handle);
});
const label = document.createElement("div");
label.className = "card-label";
label.setAttribute("contenteditable", editing ? "true" : "false");
label.textContent = card.label;
applyLabelStyle(label, card);
label.addEventListener("input", function () {
updateLabel(card.id, label.textContent);
});
const controls = document.createElement("div");
controls.className = "edit-controls edit-only";
const fileLabel = document.createElement("label");
fileLabel.className = "file-button";
fileLabel.textContent = "그림 교체";
const fileInput = document.createElement("input");
fileInput.className = "image-input";
fileInput.type = "file";
fileInput.accept = "image/png,image/jpeg,image/gif,image/webp,image/svg+xml";
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files[0]) {
replaceImage(card.id, fileInput.files[0]);
}
});
fileLabel.appendChild(fileInput);
controls.appendChild(fileLabel);
const styleControls = document.createElement("div");
styleControls.className = "card-style-controls";
const fontLabel = document.createElement("label");
fontLabel.textContent = "글꼴";
const fontSelect = document.createElement("select");
fontSelect.setAttribute("aria-label", "카드 글꼴");
populateFontSelect(fontSelect, card.fontFamily || "", true);
fontSelect.addEventListener("change", function () {
setCardFontFamily(card.id, fontSelect.value);
});
fontLabel.appendChild(fontSelect);
styleControls.appendChild(fontLabel);
const sizeLabel = document.createElement("label");
sizeLabel.textContent = "크기";
const sizeInput = document.createElement("input");
sizeInput.className = "card-style-size";
sizeInput.type = "number";
sizeInput.min = "12";
sizeInput.max = "72";
sizeInput.step = "1";
sizeInput.value = card.fontSize || "";
sizeInput.placeholder = "전체: " + state.fontSize + "px";
sizeInput.setAttribute("aria-label", "카드 글자 크기");
sizeInput.addEventListener("change", function () {
setCardFontSize(card.id, sizeInput.value);
});
sizeLabel.appendChild(sizeInput);
styleControls.appendChild(sizeLabel);
styleControls.appendChild(makeButton("기본값", function () { resetCardStyle(card.id); }));
controls.appendChild(styleControls);
controls.appendChild(makeButton("복제", function () { cloneCard(card.id); }));
controls.appendChild(makeButton("삭제", function () { deleteCard(card.id); }));
controls.appendChild(makeButton("앞으로", function () { moveCard(card.id, -1); }));
controls.appendChild(makeButton("뒤로", function () { moveCard(card.id, 1); }));
article.appendChild(imageBox);
article.appendChild(label);
article.appendChild(controls);
return article;
}
function render() {
document.title = state.title;
document.getElementById("document-title").textContent = state.title;
document.documentElement.style.setProperty("--card-font-size", state.fontSize + "px");
document.documentElement.style.setProperty("--card-font-family", state.fontFamily);
const fontSizeControl = document.getElementById("font-size");
const fontSizeValue = document.getElementById("font-size-value");
const fontFamilyControl = document.getElementById("font-family");
if (fontSizeControl) fontSizeControl.value = state.fontSize;
if (fontSizeValue) fontSizeValue.textContent = state.fontSize + "px";
if (fontFamilyControl) fontFamilyControl.value = state.fontFamily;
const pages = document.getElementById("pages");
pages.replaceChildren();
document.querySelectorAll("[data-layout]").forEach(function (button) {
button.classList.toggle("active", Number(button.dataset.layout) === state.cards_per_page);
});
if (state.cards.length === 0) {
const message = document.createElement("p");
message.className = "empty-state";
message.textContent = "카드가 없습니다. 카드 추가를 눌러 시작하세요.";
pages.appendChild(message);
return;
}
for (let start = 0; start < state.cards.length; start += state.cards_per_page) {
const wrap = document.createElement("section");
wrap.className = "page-wrap";
const caption = document.createElement("div");
caption.className = "page-caption";
caption.textContent = "A4 · 210 × 297mm · " + (start / state.cards_per_page + 1) + "쪽";
const page = document.createElement("div");
page.className = "page";
const grid = document.createElement("div");
grid.className = "cards layout-" + state.cards_per_page;
state.cards.slice(start, start + state.cards_per_page).forEach(function (card) {
grid.appendChild(createCard(card));
});
page.appendChild(grid);
wrap.appendChild(caption);
wrap.appendChild(page);
pages.appendChild(wrap);
}
}
function toggleEditing() {
editing = !editing;
if (!editing) selectedCardId = null;
document.body.classList.toggle("editing", editing);
document.getElementById("edit-toggle").textContent = editing ? "편집 끄기" : "편집 켜기";
render();
}
function setLayout(count) {
const value = Number(count);
if ([1, 2, 4, 8].indexOf(value) === -1) {
throw new Error("Unsupported layout: " + count);
}
state.cards_per_page = value;
render();
}
function setFontSize(value) {
state.fontSize = normalizeFontSize(value);
document.documentElement.style.setProperty("--card-font-size", state.fontSize + "px");
document.getElementById("font-size").value = state.fontSize;
document.getElementById("font-size-value").textContent = state.fontSize + "px";
document.querySelectorAll(".card-style-size").forEach(function (input) {
if (!input.value) input.placeholder = "전체: " + state.fontSize + "px";
});
}
function setFontFamily(value) {
state.fontFamily = normalizeFontFamily(value);
document.documentElement.style.setProperty("--card-font-family", state.fontFamily);
}
function setCardFontSize(cardId, value) {
const index = cardIndex(cardId);
if (index === -1) return;
if (String(value).trim() === "") delete state.cards[index].fontSize;
else state.cards[index].fontSize = normalizeFontSize(value);
render();
}
function setCardFontFamily(cardId, value) {
const index = cardIndex(cardId);
if (index === -1) return;
if (!value) delete state.cards[index].fontFamily;
else state.cards[index].fontFamily = normalizeFontFamily(value);
render();
}
function resetCardStyle(cardId) {
const index = cardIndex(cardId);
if (index === -1) return;
delete state.cards[index].fontFamily;
delete state.cards[index].fontSize;
render();
}
function applyStyleToAll() {
state.cards.forEach(function (card) {
delete card.fontFamily;
delete card.fontSize;
});
render();
}
function replaceImage(cardId, file) {
const index = cardIndex(cardId);
if (index === -1 || !file) return;
const reader = new FileReader();
reader.addEventListener("load", function () {
state.cards[index].image_data = reader.result;
state.cards[index].imageTransform = normalizeTransform();
render();
});
reader.readAsDataURL(file);
}
function updateLabel(cardId, value) {
const index = cardIndex(cardId);
if (index !== -1) state.cards[index].label = String(value);
}
function addCard() {
state.cards.push({
id: uniqueId("card"),
label: "새 카드",
image_data: neutralImage(),
imageTransform: normalizeTransform()
});
render();
}
function cloneCard(cardId) {
const index = cardIndex(cardId);
if (index === -1) return;
const copy = {
id: uniqueId(state.cards[index].id),
label: state.cards[index].label,
image_data: state.cards[index].image_data,
imageTransform: normalizeTransform(state.cards[index].imageTransform)
};
if (state.cards[index].fontFamily) copy.fontFamily = state.cards[index].fontFamily;
if (state.cards[index].fontSize) copy.fontSize = state.cards[index].fontSize;
state.cards.splice(index + 1, 0, copy);
render();
}
function deleteCard(cardId) {
const index = cardIndex(cardId);
if (index === -1) return;
state.cards.splice(index, 1);
if (selectedCardId === cardId) selectedCardId = null;
render();
}
function moveCard(cardId, delta) {
const index = cardIndex(cardId);
const target = index + Number(delta);
if (index === -1 || target < 0 || target >= state.cards.length) return;
const moved = state.cards.splice(index, 1)[0];
state.cards.splice(target, 0, moved);
render();
}
function applyImageTransform(frame, transform) {
const image = frame.querySelector("img");
if (!image) return;
image.style.setProperty("--image-scale", transform.scale);
image.style.setProperty("--image-x", transform.x);
image.style.setProperty("--image-y", transform.y);
}
function beginPointerOperation(event, details) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
pointerOperation = Object.assign({}, details, {
pointerId: event.pointerId,
target: event.currentTarget,
startX: event.clientX,
startY: event.clientY
});
event.currentTarget.addEventListener("pointermove", onPointerMove);
event.currentTarget.addEventListener("pointerup", endPointerOperation);
event.currentTarget.addEventListener("pointercancel", endPointerOperation);
}
function startImageDrag(event, cardId, frame) {
const index = cardIndex(cardId);
if (index === -1) return;
beginPointerOperation(event, { type: "move", cardId: cardId, frame: frame, startTransform: normalizeTransform(state.cards[index].imageTransform) });
}
function startImageResize(event, cardId, frame, corner) {
const index = cardIndex(cardId);
if (index === -1 || !editing || selectedCardId !== cardId) return;
beginPointerOperation(event, { type: "resize", cardId: cardId, frame: frame, corner: corner, startTransform: normalizeTransform(state.cards[index].imageTransform) });
}
function onPointerMove(event) {
const operation = pointerOperation;
if (!operation || event.pointerId !== operation.pointerId) return;
const index = cardIndex(operation.cardId);
if (index === -1) return;
const rect = operation.frame.getBoundingClientRect();
const dx = event.clientX - operation.startX;
const dy = event.clientY - operation.startY;
if (operation.type === "move") {
state.cards[index].imageTransform.x = clamp(operation.startTransform.x + dx / rect.width * 100, -80, 80);
state.cards[index].imageTransform.y = clamp(operation.startTransform.y + dy / rect.height * 100, -80, 80);
} else {
const horizontal = operation.corner.indexOf("e") !== -1 ? 1 : -1;
const vertical = operation.corner.indexOf("s") !== -1 ? 1 : -1;
const delta = ((dx * horizontal) + (dy * vertical)) / (2 * Math.max(1, Math.min(rect.width, rect.height)));
state.cards[index].imageTransform.scale = clamp(operation.startTransform.scale * (1 + delta * 2), 0.25, 4);
}
applyImageTransform(operation.frame, state.cards[index].imageTransform);
}
function endPointerOperation(event) {
const operation = pointerOperation;
if (!operation || event.pointerId !== operation.pointerId) return;
if (operation.target.hasPointerCapture(event.pointerId)) operation.target.releasePointerCapture(event.pointerId);
operation.target.removeEventListener("pointermove", onPointerMove);
operation.target.removeEventListener("pointerup", endPointerOperation);
operation.target.removeEventListener("pointercancel", endPointerOperation);
pointerOperation = null;
}
function safeStateJson() {
return JSON.stringify(state)
.replace(/&/g, "\\u0026")
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e");
}
function saveHtml() {
const clone = document.documentElement.cloneNode(true);
clone.querySelectorAll(".selected").forEach(function (node) { node.classList.remove("selected"); });
clone.querySelectorAll("[contenteditable]").forEach(function (node) {
node.setAttribute("contenteditable", "false");
});
let html = "<!doctype html>\n" + clone.outerHTML;
const replacement = "/*__AAC_STATE_START__*/const state = " + safeStateJson() + ";/*__AAC_STATE_END__*/";
html = html.replace(/\/\*__AAC_STATE_START__\*\/[\s\S]*?\/\*__AAC_STATE_END__\*\//, replacement);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = (state.title || "aac-cards").replace(/[\\/:*?"<>|]+/g, "-") + ".html";
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
}
document.addEventListener("pointerdown", function (event) {
if (event.target.closest(".card-image") || event.target.closest(".toolbar")) return;
selectedCardId = null;
document.querySelectorAll(".card-image.selected").forEach(function (node) { node.classList.remove("selected"); });
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
selectedCardId = null;
render();
}
});
render();
</script>
</body>
</html>
SHA-256: b655ccc0cc3928d8dcb3c7711e60a424bca852e1015f4882aca3b01da60a1b33