/**
* Gets the value from etaniInput and applies it as a pattern to SVG animations
* within the 'etani' element, based on UI selections in 'etaniWindow'.
*
* Assumes 'etani' (SVG element) and 'etaniWindow' (a DOM element, not a window)
* are available in the scope where this function is called.
*/
function etaniAllPattern() {
// Find the input element, assuming it's in the main document.
const etaniInput = document.getElementById('etaniInput');
if (!etaniInput) {
console.error('etaniInput element not found.');
return;
}
const inputValue = etaniInput.value;
/**
* Helper function for (1.3)
* Converts a letter [a-zA-Z] to its corresponding index (a=0, z=25, A=26, Z=51).
* @param {string} char - The character to convert.
* @returns {number} - The calculated index, or -1 if invalid.
*/
function getIndexFromChar(char) {
const code = char.charCodeAt(0);
if (code >= 97 && code <= 122) { // a-z
return code - 97;
}
if (code >= 65 && code <= 90) { // A-Z
return code - 65 + 26;
}
return -1; // Invalid character
}
// Use a Set to store unique animation elements
const finalTargets = new Set();
// --- Branch 1: Alphabetical Input [a-zA-Z] ---
if (/^[a-zA-Z]+$/.test(inputValue)) {
const allSpan = etaniWindow.querySelector('.allSpan');
// 1.1: Determine base target elements (animateTransform)
if (allSpan && allSpan.classList.contains('selected')) {
// If .allSpan is selected, target all animateTransforms
const nodes = etani.querySelectorAll('.etdrop > use > animateTransform');
nodes.forEach(n => finalTargets.add(n));
} else {
// If .allSpan is not selected, target based on all .tilesSpan
const tilesSpans = etaniWindow.querySelectorAll('.tilesSpan');
if (tilesSpans && tilesSpans.length > 0) {
const selectors = Array.from(tilesSpans).map(span => {
const tileId = span.innerHTML.trim();
// Ensure tileId is not empty
if (tileId) {
return `.etdrop > use[href="#${tileId}"] > animateTransform`;
}
return null;
}).filter(Boolean); // Filter out any null entries
if (selectors.length > 0) {
const nodes = etani.querySelectorAll(selectors.join(', '));
nodes.forEach(n => finalTargets.add(n));
}
}
}
// 1.2: Add specific animations by ID
const selectedIds = etaniWindow.querySelectorAll('.allId.selected');
selectedIds.forEach(idSpan => {
const animateId = idSpan.innerHTML.trim();
if (!animateId) return;
// Find the element by ID within the 'etani' scope (using querySelector)
const animElement = etani.querySelector(`:scope > [id="${animateId}"]`);
if (animElement) {
finalTargets.add(animElement);
// If it's animateTransform, add the next two animateTransforms
if (animElement.tagName.toLowerCase() === 'animatetransform') {
let next = animElement.nextElementSibling;
// Check if next sibling exists and is an animateTransform
if (next && next.tagName.toLowerCase() === 'animatetransform') {
finalTargets.add(next);
let nextNext = next.nextElementSibling;
// Check if next-next sibling exists and is an animateTransform
if (nextNext && nextNext.tagName.toLowerCase() === 'animatetransform') {
finalTargets.add(nextNext);
}
}
}
}
});
// 1.3 & 1.4 (Preparation): Map input string to indices
const indices = inputValue.split('').map(getIndexFromChar);
// Check if getIndexFromChar returned -1 for any invalid char
if (indices.includes(-1)) {
console.error('Invalid characters in input. Must be [a-zA-Z].');
return;
}
const maxIndex = Math.max(...indices);
// 1.5: Validate values length before applying
const allValuesArrays = new Map();
let validationFailed = false;
for (const anim of finalTargets) {
const valuesStr = anim.getAttribute('values');
if (!valuesStr) {
console.warn(`Element ${anim.id || '(no id)'} has no 'values' attribute.`);
validationFailed = true;
break;
}
const valuesList = valuesStr.split(';');
allValuesArrays.set(anim, valuesList); // Store for later use
// Check: (count - 1) must be >= maxIndex
if (valuesList.length - 1 < maxIndex) {
console.error(`Validation failed: Element ${anim.id || '(no id)'} has ${valuesList.length} values, but needs at least ${maxIndex + 1} (for index ${maxIndex}).`);
validationFailed = true;
break;
}
}
if (validationFailed) {
console.warn('Aborting pattern application due to validation failure.');
return;
}
// 1.4 (Execution): Apply permutation
for (const anim of finalTargets) {
const valuesList = allValuesArrays.get(anim);
if (!valuesList) continue; // Should not happen if validation passed
// Build the new values string based on the indices
const newValues = indices.map(index => valuesList[index]).join(';');
anim.setAttribute('values', newValues);
// Optional: Restart animation if necessary
// if (typeof anim.beginElement === 'function') {
// anim.beginElement();
// }
}
// --- Branch 2: Binary Input [01] ---
} else if (/^[01]+$/.test(inputValue)) {
const allSpan = etaniWindow.querySelector('.allSpan');
// 2.1: Determine base target elements (animate attributeName=opacity)
if (allSpan && allSpan.classList.contains('selected')) {
const nodes = etani.querySelectorAll('.etdrop > use > animate[attributeName=opacity]');
nodes.forEach(n => finalTargets.add(n));
} else {
// If not .allSpan, use .tilesSpan
const tilesSpans = etaniWindow.querySelectorAll('.tilesSpan');
if (tilesSpans && tilesSpans.length > 0) {
const selectors = Array.from(tilesSpans).map(span => {
const tileId = span.innerHTML.trim();
if (tileId) {
return `.etdrop > use[href="#${tileId}"] > animate[attributeName=opacity]`;
}
return null;
}).filter(Boolean);
if (selectors.length > 0) {
const nodes = etani.querySelectorAll(selectors.join(', '));
nodes.forEach(n => finalTargets.add(n));
}
}
}
// 2.2: Add specific animations by ID (using querySelectorAll as requested)
const selectedIds = etaniWindow.querySelectorAll('.allId.selected');
selectedIds.forEach(idSpan => {
const animateId = idSpan.innerHTML.trim();
if (!animateId) return;
// Use querySelectorAll as specified in 2.2
const animElements = etani.querySelectorAll(`:scope > animate[attributeName=opacity][id="${animateId}"]`);
animElements.forEach(anim => finalTargets.add(anim));
});
// 2.3: Apply values. Input "010" becomes "0;1;0" values.
const newValues = inputValue.split('').join(';');
for (const anim of finalTargets) {
anim.setAttribute('values', newValues);
// Optional: Restart animation if necessary
// if (typeof anim.beginElement === 'function') {
// anim.beginElement();
// }
}
} else {
// Handle invalid (non-empty) input
if (inputValue.length > 0) {
console.warn(`Invalid input: "${inputValue}". Input must be only [a-zA-Z] or only [01].`);
}
// If input is empty, do nothing silently.
}
}
// --- Etani Undo/Redo System ---
// This system manages the undo/redo history for the global 'etani' element,
// enforcing both a maximum item count and a total size limit.
// --- History Storage ---
// Global history stack
// Stores objects: { value: "...", size: 123 }
let etaniHistory = [];
// Global history pointer
// Stores the current index within the etaniHistory array.
let etaniHistoryIndex = -1;
// Global history total size
// Stores the sum of all 'size' properties in etaniHistory.
let etaniHistoryTotalSize = 0;
// --- Limits ---
// Maximum number of history states to store.
const ETANI_MAX_HISTORY = 36;
// Maximum total size (in units provided by etaniSize()) for the entire history.
// 24m = 24,000,000
const ETANI_MAX_SIZE = 24000000;
// --- Mock etaniSize() Function ---
// This is a *placeholder* function.
// You MUST replace this with your actual etaniSize() function.
// This example implementation calculates the UTF-8 byte length of etani.value.
function etaniSize() {
// Assuming 'etani' is a <textarea> or <input>
if (typeof etani === 'undefined' || typeof etani.value === 'undefined') {
return 0;
}
// Use TextEncoder to get a realistic byte size (UTF-8).
// string.length is NOT accurate for memory size.
try {
return new TextEncoder().encode(etani.value).length;
} catch (e) {
// Fallback for older environments
return etani.value.length;
}
}
// ---------------------------------
// --- Core Functions ---
/**
* Captures the current state of the 'etani' element and saves it to history.
*
* IMPORTANT: This function assumes 'etani' is an element with a '.value' property.
* If 'etani' is a contenteditable <div>, change 'etani.value' to 'etani.innerHTML'
* in this function AND in the etaniSize() function.
*/
function etaniCapture() {
const currentValue = etani.value;
// Get the size of the new state by calling the provided etaniSize()
const currentSize = etaniSize();
// --- 1. Handle "future" (redo) states ---
// If we have undone, we must truncate the "future" states.
if (etaniHistoryIndex < etaniHistory.length - 1) {
etaniHistory = etaniHistory.slice(0, etaniHistoryIndex + 1);
// Recalculate the total size after truncation.
// This is safer than trying to subtract the removed items.
etaniHistoryTotalSize = etaniHistory.reduce((total, state) => total + state.size, 0);
}
// --- 2. Enforce limits (pruning old states) ---
// Remove old states from the beginning *until* the new state can fit
// without violating either the COUNT limit or the SIZE limit.
while (etaniHistory.length > 0 && (
// Condition 1: We're at or over the max item count (36)
(etaniHistory.length >= ETANI_MAX_HISTORY) ||
// Condition 2: Adding the new item would exceed the max size (24m)
(etaniHistoryTotalSize + currentSize > ETANI_MAX_SIZE)
)) {
// Remove the oldest state (from the front of the array).
const removedState = etaniHistory.shift();
// Update the total size.
etaniHistoryTotalSize -= removedState.size;
// IMPORTANT: We must also decrement the index, as the array has shifted.
etaniHistoryIndex--;
}
// Safety check: If the index became negative, reset it.
if (etaniHistoryIndex < -1) {
etaniHistoryIndex = -1;
}
// --- 3. Add the new state ---
const newState = {
value: currentValue,
size: currentSize
};
etaniHistory.push(newState);
etaniHistoryTotalSize += currentSize;
// After capturing, the current index is always the last item.
etaniHistoryIndex = etaniHistory.length - 1;
// --- 4. Update button states ---
etaniState();
}
/**
* Reverts the 'etani' element to the previous state in history (Undo).
*/
function etaniUndo() {
// Check if we can undo (i.e., we are not at the very first item).
if (etaniHistoryIndex > 0) {
// Move the pointer back one step.
etaniHistoryIndex--;
// Update the element's value from the history object.
etani.value = etaniHistory[etaniHistoryIndex].value;
// Update the button states.
etaniState();
}
}
/**
* Re-applies a state that was previously undone (Redo).
*/
function etaniRedo() {
// Check if we can redo (i.e., we are not at the very last item).
if (etaniHistoryIndex < etaniHistory.length - 1) {
// Move the pointer forward one step.
etaniHistoryIndex++;
// Update the element's value from the history object.
etani.value = etaniHistory[etaniHistoryIndex].value;
// Update the button states.
etaniState();
}
}
/**
* Updates the disabled state of the #undo and #redo buttons.
* This function's logic does not need to change.
*/
function etaniState() {
const undoButton = document.getElementById('undo');
const redoButton = document.getElementById('redo');
if (undoButton) {
// Can undo if the index is greater than 0.
undoButton.disabled = (etaniHistoryIndex <= 0);
}
if (redoButton) {
// Can redo if the index is not at the very end.
redoButton.disabled = (etaniHistoryIndex >= etaniHistory.length - 1);
}
}
/**
* 綁定到按鈕的事件,用於讀取和驗證 SVG 文件。
* 動態創建一個 <input type="file"> 元素,並在完成後將其刪除。
*/
function etaniReadfileClick() {
// 1. Create a file input element dynamically
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/svg+xml'; // Only allow SVG files
fileInput.style.display = 'none'; // Hide the element from view
// 2. Add an event listener for when a file is selected
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
// Cleanup function to remove the input
const cleanup = () => {
document.body.removeChild(fileInput);
};
if (!file) {
// No file selected, just clean up
cleanup();
return;
}
const reader = new FileReader();
// 3. Define what happens when the file is successfully read
reader.onload = (e) => {
const content = e.target.result;
let isValid = false;
try {
// 4. Parse the file content as XML/SVG
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'image/svg+xml');
// Check for parser errors (e.g., if it's not valid XML/SVG)
const parserError = doc.querySelector('parsererror');
if (parserError) {
console.error('Error parsing SVG:', parserError.textContent);
} else {
// 5. Validate if the root element is <svg> and its id is 'etani'
const svgElement = doc.documentElement;
if (svgElement && svgElement.tagName.toLowerCase() === 'svg' && svgElement.id === 'etani') {
isValid = true;
}
}
} catch (error) {
console.error('Error processing file:', error);
}
// 6. If validation passes, update the UI
if (isValid) {
// Display the file's text content
const textarea = document.querySelector('.etaniHTMLTextarea');
if (textarea) {
textarea.value = content;
}
// Display file name (assuming 'readfileName' is an ID)
const fileNameElement = document.querySelector('#readfileName');
if (fileNameElement) {
fileNameElement.textContent = file.name;
}
// Display file size (assuming 'rwadfileSize' is an ID)
const fileSizeElement = document.querySelector('#rwadfileSize');
if (fileSizeElement) {
fileSizeElement.textContent = `${file.size} bytes`;
}
} else {
alert('文件不是 ID 為 "etani" 的有效 SVG,或者文件已損壞。');
}
// 7. Clean up the dynamically created input
cleanup();
};
// Define error handling
reader.onerror = () => {
console.error('Error reading file.');
alert('讀取文件時發生錯誤。');
cleanup();
};
// Start reading the file as text
reader.readAsText(file);
});
// 8. Append the input to the body and click it to open the file dialog
document.body.appendChild(fileInput);
fileInput.click();
}
/**
* 綁定到按鈕的事件,用於處理、清理和導出 #etaniCovert 元素。
*/
function etaniEjtileClick() {
// Find the original element to process
const originalElement = document.querySelector('#etaniCovert');
if (!originalElement) {
console.error('Element with ID "etaniCovert" not found.');
alert('找不到 "etaniCovert" 元素。');
return;
}
// Create a deep clone to modify, preserving the original element on the page
const clone = originalElement.cloneNode(true);
// --- Operation 1 ---
// Change the ID of the clone to 'etmain'
clone.id = 'etmain';
// Find the .etdrop element inside the clone and remove its ID
const innerDrop = clone.querySelector('.etdrop');
if (innerDrop) {
innerDrop.removeAttribute('id');
}
// --- Operation 2 ---
// Remove specified animation elements within .etdrop > use
const animationsToDrop = clone.querySelectorAll(
'.etdrop > use > animate, .etdrop > use > animateTransform, .etdrop > use > animateMotion, .etdrop > use > set'
);
animationsToDrop.forEach(anim => anim.remove());
// --- Operation 3 ---
// Remove specified animation elements that are direct children (:scope > ...)
const directChildTags = ['animate', 'animateTransform', 'animateMotion', 'set'];
// Filter only direct children of the clone
const childAnimations = Array.from(clone.children).filter(child =>
directChildTags.includes(child.tagName.toLowerCase())
);
childAnimations.forEach(anim => anim.remove());
// --- Operation 4 ---
// Get the outerHTML of the modified clone
const processedHTML = clone.outerHTML;
// Display the processed HTML in the textarea
const textarea = document.querySelector('.etaniHTMLTextarea');
if (textarea) {
textarea.value = processedHTML;
}
// --- Operation 5 & 6 ---
// Create a Blob from the processed HTML
const blob = new Blob([processedHTML], { type: 'image/svg+xml' });
// --- Operation 5: Setup download link ---
const downloadLink = document.querySelector('.ejtileDownload'); // Assuming class
if (downloadLink) {
// Create an object URL for the Blob
const url = URL.createObjectURL(blob);
// Set the link's href to the new URL
downloadLink.href = url;
// Set the default filename for the download
downloadLink.download = 'etmain.svg';
}
// --- Operation 6: Display file size ---
const sizeElement = document.querySelector('#etaniEjtileSize'); // Assuming ID
if (sizeElement) {
sizeElement.textContent = `${blob.size} bytes`;
}
}
// ... (緊接在 'const cloneUsesMap = ...' 之後)
// Create a Set of original tile IDs for efficient lookup during deletion check
const originalTileIds = new Set(Array.from(originalTiles).map(g => g.id));
// ... (您原有的 'originalTiles.forEach(...){ ... }' 迴圈保持不變)
originalTiles.forEach(originalTileG => {
// ... (您原有的更新/添加邏輯)
});
// --- ADD THIS NEW SECTION ---
// Loop 2: Remove tiles that are in clone but not in original
cloneTilesMap.forEach((cloneTileG, tileId) => {
// Check if the tileId from clone exists in the original set
if (!originalTileIds.has(tileId)) {
// If not, remove the tile definition from clone defs
cloneTileG.remove();
// Also remove the corresponding <use> element from clone drop
const cloneUse = cloneUsesMap.get(tileId);
if (cloneUse) {
cloneUse.remove();
}
}
});
// --- END NEW SECTION ---
// update board
// ... (函數的其餘部分)
/**
* Handles the click event on an .etaniItem image.
* Manages item selection and reorders corresponding SVG <use> elements.
* Assumes 'etani' is a defined element variable in the accessible scope.
* * @param {Event} event - The click event (this function is intended to be used as an event handler).
*/
function etaniItemImageClick() {
// 'this' refers to the element that was clicked (e.g., the image)
// Find the parent column
const etaniCol = this.closest('.etaniCol');
if (!etaniCol) {
console.error('etaniItemImageClick: Could not find .etaniCol');
return; // Exit if column not found
}
// Find the item container for the clicked element
const thisItem = this.closest('.etaniItem');
if (!thisItem) {
console.error('etaniItemImageClick: Could not find .etaniItem');
return; // Exit if item not found
}
// Find the currently selected item in this column
const selectedItem = etaniCol.querySelector('.etaniItem.selected');
// Get data attributes from the clicked item
const thisItemType = thisItem.dataset.type;
const thisItemId = thisItem.dataset.id;
// --- Condition 1: Check item type ---
// If thisItemType is not 'tile', end.
if (thisItemType !== 'tile') {
return;
}
// --- Condition 2: No item is selected ---
// If selectedItem does not exist, add 'selected' to thisItem and end.
if (!selectedItem) {
thisItem.classList.add('selected');
return;
}
// --- Condition 3: Clicking the already selected item ---
// If selectedItem exists, and thisItem has 'selected' class (is the selected item)
// Remove 'selected' class and end.
if (thisItem.classList.contains('selected')) {
thisItem.classList.remove('selected');
return;
}
// --- Condition 4: Clicking a different item when one is already selected ---
// If selectedItem exists, and thisItem does not have 'selected' class.
// Get the ID of the selected item
const selectedItemId = selectedItem.dataset.id;
// --- Part 4.1: Get Indexes ---
// Get all items in the column to determine their indices
const allItems = Array.from(etaniCol.querySelectorAll('.etaniItem'));
const thisItemIndex = allItems.indexOf(thisItem);
const selectedItemIndex = allItems.indexOf(selectedItem);
// --- Part 4.2: Get <use> elements ---
// 'etani' is assumed to be a pre-defined element variable
const etdrop = etani.querySelector('.etdrop');
if (!etdrop) {
console.error('etaniItemImageClick: Could not find .etdrop within etani element');
return;
}
// Find the <use> elements corresponding to the item IDs
// Note: We use template literals to build the href selector
const thisUse = etdrop.querySelector(`use[href="#${thisItemId}"]`);
const selectedUse = etdrop.querySelector(`use[href="#${selectedItemId}"]`);
// Check if both <use> elements were found
if (!thisUse || !selectedUse) {
console.error('etaniItemImageClick: Could not find one or both <use> elements.');
return;
}
// --- Part 4.3: Reorder if thisItem is before selectedItem ---
if (thisItemIndex < selectedItemIndex) {
// Move selectedUse to be immediately before thisUse
// Example: 01234, this=2, selected=4. Result: 01423
thisUse.parentElement.insertBefore(selectedUse, thisUse);
}
// --- Part 4.4: Reorder if thisItem is after selectedItem ---
else if (thisItemIndex > selectedItemIndex) {
// Move selectedUse to be immediately after thisUse
// We use .nextSibling to insert *after*
// Example: 01234, this=4, selected=1. Result: 02341
thisUse.parentElement.insertBefore(selectedUse, thisUse.nextSibling);
}
// --- Final Step: Update selection state ---
// Remove 'selected' from the previously selected item
selectedItem.classList.remove('selected');
// Add 'selected' to the newly clicked item
thisItem.classList.add('selected');
}