Enhancing Chess Record Functionality

Here are the modifications to address your requirements. All instructions and code comments are in English.

1. CSS Modifications

Update the .ejceesrcdstart class to add position: relative; and add the new .ejceesrcdstart.has-comment::after rule so it can display the comment icon exactly like .ejceesrcdstep.

Find this block in your <style> section:

CSS

    .ejceesrcdstart {
       width: 100%;
       padding: 8px;
       box-sizing: border-box;
       text-align: center;
       font-weight: bold;
       border-bottom: 1px solid #444;
       color: #aaa;
       font-size: 14px;
       cursor: pointer;
   }

Replace it with the following:

CSS

    .ejceesrcdstart {
       width: 100%;
       padding: 8px;
       box-sizing: border-box;
       text-align: center;
       font-weight: bold;
       border-bottom: 1px solid #444;
       color: #aaa;
       font-size: 14px;
       cursor: pointer;
       position: relative; /* Added for absolute positioning of the marker */
   }

   .ejceesrcdstart.has-comment::after {
       content: '';
       position: absolute;
       bottom: 0;
       right: 0;
       width: 12px;
       height: 12px;
       background: url('data:image/svg+xml;utf8,<svg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27><path fill=%27%23ffd700%27 d=%27M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z%27/></svg>') no-repeat center/contain;
       pointer-events: none;
   }


2. JavaScript Modifications

A. Add Comment Icon to Start Node

In the renderRecordUI() function, add logic to check if the root historyFEN has a comment and apply the class.

Find this part inside renderRecordUI():

JavaScript

            if (historyFEN.fen === INITIAL_FEN) {
               startDiv.textContent = '棋局開始,紅方先行';
           } else if (historyFEN.fen.includes(' w')) {
               startDiv.textContent = '特定局面,輪到紅方';
           } else {
               startDiv.textContent = '特定局面,輪到黑方';
           }
           recordContainer.innerHTML = '';
           recordContainer.appendChild(startDiv);

Add the comment check right before appending:

JavaScript

            if (historyFEN.fen === INITIAL_FEN) {
               startDiv.textContent = '棋局開始,紅方先行';
           } else if (historyFEN.fen.includes(' w')) {
               startDiv.textContent = '特定局面,輪到紅方';
           } else {
               startDiv.textContent = '特定局面,輪到黑方';
           }
           
           // Check if root has comment
           if (historyFEN.c && historyFEN.c.trim() !== '') {
               startDiv.classList.add('has-comment');
           }

           recordContainer.innerHTML = '';
           recordContainer.appendChild(startDiv);

B. Allow getMoveNotation to Accept a Custom Map

We need getMoveNotation to accept a currentMap parameter to evaluate abstract logical states during text import without moving actual DOM elements.

Find the definition:

JavaScript

function getMoveNotation(pieceId, startX, startY, endX, endY, capturedId) {

Replace it with:

JavaScript

function getMoveNotation(pieceId, startX, startY, endX, endY, capturedId, currentMap = tileMap) {

Inside that function, find this loop:

JavaScript

            for (const [pos, id] of tileMap.entries()) {

Replace it with:

JavaScript

            for (const [pos, id] of currentMap.entries()) {

C. Modify Export/Import SVG for Rotate/Flip

During SVG Export, record the ejceesrotate and ejceesflip attributes.

Inside the document.getElementById('tool-exp-svg').addEventListener('click', ...) callback, find this block:

JavaScript

            // 2. Remove specific elements
           if (isAnimation) {

Right before if (isAnimation) {, add these lines:

JavaScript

            // Record current view states onto the SVG
           cloneSvg.setAttribute('ejceesrotate', isRotateEnabled ? '1' : '0');
           cloneSvg.setAttribute('ejceesflip', isFlipEnabled ? '1' : '0');

Then, update importExportedSVG to reverse these view states when parsing coordinates.

Find this function:

JavaScript

        function importExportedSVG(svgText) {
           const parser = new DOMParser();
           const svgDoc = parser.parseFromString(svgText, "image/svg+xml");

Modify the beginning of the function and coordinate extraction logic like so:

JavaScript

        function importExportedSVG(svgText) {
           const parser = new DOMParser();
           const svgDoc = parser.parseFromString(svgText, "image/svg+xml");

           // Extract the saved view mode from the exported SVG
           const isRotated = svgDoc.documentElement.getAttribute('ejceesrotate') === '1';
           const isFlipped = svgDoc.documentElement.getAttribute('ejceesflip') === '1';

           // Helper to reverse export-time visual coordinates back to logical coordinates
           function parseExportVisCoords(px, py) {
               let visX = Math.round((px - 24) / 48);
               let visY = Math.round((py - 24) / 48);
               let lX = visX; let lY = visY;
               if (isRotated) { lX = 8 - lX; lY = 9 - lY; }
               if (isFlipped) { lX = 8 - lX; }
               return { x: lX, y: lY };
           }

           // 1. Get step0 for initial comment
           let initialComment;
           const step0 = svgDoc.getElementById('step0');
           if (svgDoc.documentElement.hasAttribute('data-comment')) {
               initialComment = b64DecodeUnicode(svgDoc.documentElement.getAttribute('data-comment'));
           } else {
               initialComment = step0 && step0.getAttribute('data-comment') ? b64DecodeUnicode(step0.getAttribute('data-comment')) : "";
           }

           // 2. Reconstruct Initial FEN from SVG DOM positions
           const etboard = svgDoc.querySelector('.etboard');
           const uses = etboard.querySelectorAll('use');
           const boardMap = Array(10).fill(null).map(() => Array(9).fill(''));

           let firstMovePieceId = null;
           const step1 = svgDoc.getElementById('step1');
           if (step1 && step1.parentElement) {
               firstMovePieceId = step1.parentElement.getAttribute('id');
           }

           uses.forEach(use => {
               const href = use.getAttribute('href') || use.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
               if (/^#(tile([2-9]|1[0-5]))$/.test(href)) {
                   const transform = use.getAttribute('transform');
                   const match = transform && transform.match(/translate\(([^,]+),\s*([^)]+)\)/);
                   if (match) {
                       const px = parseFloat(match[1]);
                       const py = parseFloat(match[2]);
                       
                       // Apply reversal for initial piece layout
                       const pt = parseExportVisCoords(px, py);
                       const x = pt.x;
                       const y = pt.y;
                       
                       const id = use.getAttribute('id');
                       if (id && !id.endsWith('f') && x >= 0 && x < 9 && y >= 0 && y < 10) {
                           boardMap[y][x] = id[0];
                       }
                   }
               }
           });

           let initialFenBoard = "";
           for (let y = 0; y < 10; y++) {
               let emptyCount = 0;
               for (let x = 0; x < 9; x++) {
                   if (boardMap[y][x] === '') {
                       emptyCount++;
                   } else {
                       if (emptyCount > 0) {
                           initialFenBoard += emptyCount;
                           emptyCount = 0;
                       }
                       initialFenBoard += boardMap[y][x];
                   }
               }
               if (emptyCount > 0) initialFenBoard += emptyCount;
               if (y < 9) initialFenBoard += "/";
           }

           let initialTurn = 'w';
           if (firstMovePieceId && firstMovePieceId[0] === firstMovePieceId[0].toLowerCase()) {
               initialTurn = 'b';
           }
           const baseFen = initialFenBoard + " " + initialTurn;

           let newHistoryFEN = {
               fen: baseFen,
               move: null,
               lastMove: null,
               c: initialComment,
               v: []
           };
           let currentNode = newHistoryFEN;
           let currentFen = baseFen;
           let stepIndex = 1;

           loadFEN(baseFen);

           // 3. Reconstruct moves by analyzing animateTransform values
           while (true) {
               const stepAnim = svgDoc.getElementById(`step${stepIndex}`);
               if (!stepAnim) break;

               const commentStr = stepAnim.getAttribute('data-comment');
               const comment = commentStr ? b64DecodeUnicode(commentStr) : "";

               const fromStr = stepAnim.getAttribute('from');
               const toStr = stepAnim.getAttribute('to');
               const pId = stepAnim.parentElement.getAttribute('id');

               const fParts = fromStr.split(',');
               const tParts = toStr.split(',');
               
               // Apply reversal for animations
               const startPt = parseExportVisCoords(parseFloat(fParts[0]), parseFloat(fParts[1]));
               const endPt = parseExportVisCoords(parseFloat(tParts[0]), parseFloat(tParts[1]));
               
               const startX = startPt.x;
               const startY = startPt.y;
               const endX = endPt.x;
               const endY = endPt.y;

               const capturedId = tileMap.get(`${endX},${endY}`) || null;
               const moveNotation = getMoveNotation(pId, startX, startY, endX, endY, capturedId);
               const dc = { startX, startY, endX, endY };
               const nextFen = simulateMove(currentFen, dc);

               tileMap.delete(`${startX},${startY}`);
               tileMap.set(`${endX},${endY}`, pId);

               let newNode = {
                   fen: nextFen,
                   move: moveNotation,
                   lastMove: dc,
                   c: comment,
                   v: []
               };
               currentNode.v.push(newNode);
               currentNode = newNode;
               currentFen = nextFen;
               stepIndex++;
           }

           historyFEN = newHistoryFEN;
           initBranch();
           currentStepIndex = 0;
           renderRecordUI();
           renderNoteUI();
           jumpToStep(0);
           saveStateToUndo();
           updateToolHighlights();
       }

D. Enhanced Text Export UI & Format Toggle

Update renderExportTextUI() to include the engine format logic and the toggle button SVG.

Find function renderExportTextUI() { and replace it entirely with:

JavaScript

        function renderExportTextUI() {
           const recordContainer = document.querySelector('.ejceesrecord');
           const commentDiv = document.querySelector('.ejceescomment');
           const btnDiv = document.querySelector('.ejceestextbtn');

           const cnText = generateExportText(false);
           const enText = generateExportText(true);

           // Generate UCCI Engine Format String
           let path = getGamePath();
           let engineText = "position fen " + path.fen + " moves";
           let currNode = path;
           while(currNode && currNode.v && currNode.v.length > 0) {
               currNode = currNode.v[0];
               let dc = currNode.lastMove;
               if(dc) {
                   // Convert to coordinates like a0b0
                   engineText += " " + String.fromCharCode(97 + dc.startX) + (9 - dc.startY) + String.fromCharCode(97 + dc.endX) + (9 - dc.endY);
               }
           }

           let isEngineFormat = false;

           recordContainer.innerHTML = `<textarea class="ejceescomment-edit" id="export-textarea" style="width:100%; height:100%; resize:none; border:none; outline:none; background:#2a2a2a; color:#fff; padding:8px; font-family:monospace; font-size:14px; white-space:pre-wrap; overflow:auto;">${cnText}</textarea>`;

           const cnBlob = new Blob([cnText], { type: 'text/plain' });
           const enBlob = new Blob([enText], { type: 'text/plain' });

           const now = new Date();
           const timestamp =
               now.getFullYear().toString().padStart(4, '0') +
               (now.getMonth() + 1).toString().padStart(2, '0') +
               now.getDate().toString().padStart(2, '0') +
               now.getHours().toString().padStart(2, '0') +
               now.getMinutes().toString().padStart(2, '0') +
               now.getSeconds().toString().padStart(2, '0');

           // Insert Toggle Button alongside the links
           commentDiv.innerHTML = `<div style="display:flex; flex-direction:column; gap:10px; padding:10px;">
                   <a href="${URL.createObjectURL(cnBlob)}" download="ejcees_fen_${timestamp}.txt" style="color:#66b2ff; text-decoration:none;">fen (${cnBlob.size} bytes)</a>
                   <a href="${URL.createObjectURL(enBlob)}" download="ejcees_en_fen_${timestamp}.txt" style="color:#66b2ff; text-decoration:none;">en_fen (${enBlob.size} bytes)</a>
                   <div id="exp-toggle" title="Toggle Format" style="width: 72px; height: 48px; border-radius: 4px; display: flex; justify-content: center; align-items: center; cursor: pointer; background-color: #555; margin-top: 10px;">
                       <svg viewBox="0 0 24 24" style="width:32px;height:32px;fill:#fff;"><path d="M12 4l-4 4h3v7h2V8h3l-4-4zm0 16l4-4h-3V9h-2v7H8l4 4z"/></svg>
                   </div>
               </div>`;

           btnDiv.innerHTML = `
                   <div class="ejceestextbtninner">
                       <div class="note-btn btn-confirm" id="exp-confirm" title="Confirm">
                           <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
                       </div>
                       <div class="note-btn btn-cancel" id="exp-cancel" title="Cancel">
                           <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
                       </div>
                   </div>
               `;

           document.getElementById('exp-cancel').addEventListener('click', () => {
               isExportTextMode = false;
               renderRecordUI();
               renderNoteUI();
               updateToolHighlights();
           });

           document.getElementById('exp-confirm').addEventListener('click', () => {
               const text = document.getElementById('export-textarea').value;
               isExportTextMode = false;
               importExportedText(text);
           });

           document.getElementById('exp-toggle').addEventListener('click', () => {
               isEngineFormat = !isEngineFormat;
               document.getElementById('export-textarea').value = isEngineFormat ? engineText : cnText;
           });
       }

E. New Virtual Map Helper and Enhanced Text Parsing Logic

Add a helper function to create logic maps during FEN manipulation, and rewrite importExportedText() to fulfill the branch matching/attachment rules and engine format parsing.

Find function importExportedText(text) { and replace it and its contents entirely with:

JavaScript

        // Helper to evaluate notations without touching the actual DOM layout
       function buildVirtualMap(fen) {
           const map = new Map();
           const counts = {};
           const rows = fen.split(' ')[0].split('/');
           rows.forEach((row, y) => {
               let x = 0;
               for (let i = 0; i < row.length; i++) {
                   const char = row[i];
                   if (/[0-9]/.test(char)) x += parseInt(char, 10);
                   else {
                       counts[char] = counts[char] || 0;
                       map.set(`${x},${y}`, `${char}${counts[char]++}`);
                       x++;
                   }
               }
           });
           return map;
       }

       function importExportedText(text) {
           text = text.trim();
           if (!text) {
               renderRecordUI();
               renderNoteUI();
               updateToolHighlights();
               return;
           }

           // Normalize Chinese notations
           text = text.replace(/车/g, '車').replace(/马/g, '馬').replace(/帅/g, '帥').replace(/将/g, '將').replace(/后/g, '後').replace(/进/g, '進');
           text = text.replace(/ r(\n|\s|$)/g, ' w$1');

           // Handle Direct JSON structure
           if (text.startsWith('{')) {
               try {
                   const data = JSON.parse(text);
                   function expand(node, parentFen) {
                       let dc, simfen, newNode;
                       if (node.m) {
                           dc = deriveCoordsFromMove(parentFen, node.m);
                           simfen = simulateMove(parentFen, dc);
                           newNode = { fen: simfen, move: node.m, lastMove: dc, c: node.c || "", v: [] };
                       } else {
                           newNode = { fen: parentFen, c: node.c || "", v: [] };
                       }
                       if (node.v) { newNode.v = node.v.map(child => expand(child, newNode.fen)); }
                       return newNode;
                   }
                   historyFEN = expand(data, data.fen);
                   initBranch();
                   currentStepIndex = 0;
                   renderRecordUI();
                   renderNoteUI();
                   jumpToStep(0);
                   saveStateToUndo();
                   updateToolHighlights();
                   return;
               } catch (e) {
                   console.log("JSON parse failed, processing as text notation.");
               }
           }

           // 1. Locate explicitly provided FEN if present
           let importedFen = null;
           const fenMatch = text.match(/(?:position fen )?([a-zA-Z0-9/]+ [wb])/);
           let movesStr = text;

           if (fenMatch) {
               importedFen = fenMatch[1];
               // Strip the FEN string declaration so it isn't parsed as moves later
               movesStr = movesStr.replace(/position fen [a-zA-Z0-9/]+ [wb](\s+moves)?/, '');
               if (movesStr === text) {
                   movesStr = movesStr.replace(fenMatch[1], '');
               }
           }

           // 2. Identify the Attach Point (Parent Node)
           let attachIndex = currentStepIndex;
           let attachNode = null;

           if (importedFen) {
               // If FEN exists, trace backwards to find a matching state
               for (let i = currentStepIndex; i >= 0; i--) {
                   const node = getNodeAtStep(i);
                   if (node && node.fen === importedFen) {
                       attachIndex = i;
                       attachNode = node;
                       break;
                   }
               }
               // No match down to root -> Overwrite everything
               if (!attachNode) {
                   attachIndex = 0;
                   attachNode = { fen: importedFen, move: null, lastMove: null, c: "", v: [] };
                   historyFEN = attachNode;
                   currentBranch = [];
               }
           } else {
               // No FEN -> Default attach point is the current active step
               attachNode = getNodeAtStep(attachIndex);
               importedFen = attachNode.fen;
           }

           // 3. Process move sequence text
           // Remove line numbers (e.g., "1. " or " 23. ") and ellipsis "..."
           movesStr = movesStr.replace(/^\d+\.\s*/gm, ' ').replace(/\s\d+\.\s/g, ' ').replace(/\.\.\./g, ' ');
           let tokens = movesStr.split(/\s+/).filter(t => t.length > 0 && t !== 'moves');

           let currentFen = importedFen;
           let vMap = buildVirtualMap(currentFen);
           let currNode = attachNode;
           let attachDepth = attachIndex;
           
           // Truncate currentBranch up to the attachIndex to prepare for the new branch selection
           currentBranch.length = attachDepth;

           let hasError = false;

           for (let i = 0; i < tokens.length; i++) {
               let token = tokens[i];
               let dc = null;
               let moveEn = "";
               let isRed = currentFen.includes(' w');

               // Check for UCCI Engine Coordinate Format (e.g., "a0b0" or "h7e7")
               if (/^[a-i][0-9][a-i][0-9]$/.test(token)) {
                   let startX = token.charCodeAt(0) - 97;
                   let startY = 9 - parseInt(token.charAt(1), 10);
                   let endX = token.charCodeAt(2) - 97;
                   let endY = 9 - parseInt(token.charAt(3), 10);
                   dc = { startX, startY, endX, endY };

                   let pId = vMap.get(`${startX},${startY}`);
                   if (!pId) { hasError = true; break; }

                   // Generate UI notation dynamically from coordinates
                   moveEn = getMoveNotation(pId, startX, startY, endX, endY, null, vMap);
               } else {
                   // Standard English or Chinese notation
                   let isEnglish = /^[a-zA-Z]/.test(token) || /^[+\-=1-9][a-zA-Z0-9]/.test(token);
                   moveEn = isEnglish ? token : NotationConverter.toEnglish(token, isRed);
                   dc = deriveCoordsFromMove(currentFen, moveEn, isRed);
                   if (!dc) { hasError = true; break; }
               }

               let nextFen = simulateMove(currentFen, dc);

               // Check if this move already exists as a branch from the current node
               let childIdx = currNode.v.findIndex(c => c.move === moveEn && c.fen === nextFen);
               if (childIdx === -1) {
                   // Create new branch
                   let newNode = { fen: nextFen, move: moveEn, lastMove: dc, c: "", v: [] };
                   currNode.v.push(newNode);
                   childIdx = currNode.v.length - 1;
               }

               // Force selection onto this path
               currentBranch[attachDepth] = childIdx;
               currNode = currNode.v[childIdx];
               currentFen = nextFen;
               attachDepth++;

               // Update virtual map for next iterations
               let pId = vMap.get(`${dc.startX},${dc.startY}`);
               vMap.delete(`${dc.startX},${dc.startY}`);
               if (pId) vMap.set(`${dc.endX},${dc.endY}`, pId);
           }

           if (hasError && tokens.length > 0) {
               alert("Parsed partially due to an invalid move notation.");
           }
           
           // Pad currentBranch with 0s if the newly attached path has existing single children further down
           while (currNode && currNode.v && currNode.v.length > 0) {
               currentBranch[attachDepth] = 0;
               currNode = currNode.v[0];
               attachDepth++;
           }

           // Jump to the parent node of the newly added branch (attach point)
           currentStepIndex = attachIndex;
           renderRecordUI();
           renderNoteUI();
           jumpToStep(currentStepIndex);
           
           saveStateToUndo();
           updateToolHighlights();
       }