fix(ascii-art): wire Box/Templates mode + correct standalone <link href>; v4.9.2

- src/ascii-generator.html line 7: ../fonts.css -> fonts.css (src/-relative;
  fixes silent stylesheet miss — same class as the v4.9.0->v4.9.1 script-tag fix)
- src/renderer/ascii-controller.js: wire all 3 mode tabs, 18 template
  buttons, and the 3 box form fields. setMode() toggles .active on tabs
  and matching .mode-section. Templates route through the orchestrator's
  template:<id> font namespace. Box mode renders text with single/double/
  rounded/bold/ascii borders via a pure renderBox() helper. All 11 T9
  behaviours preserved.
- tests/ascii-controller.test.js: 6 tests covering stylesheet path,
  pure box renderer, mode-tab switching, and template button wiring.
- package.json 4.9.1 -> 4.9.2; README + UPDATES updated.

Amit Haridas
This commit is contained in:
2026-09-14 22:37:02 +05:30
parent 8c708b7abb
commit ac31162d3b
6 changed files with 354 additions and 3 deletions
+1 -1
View File
@@ -257,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version
v4.9.1
v4.9.2
+13
View File
@@ -1,5 +1,18 @@
# PanConverter - Updates & Changelog
## Version 4.9.2 (2026-09-14)
### Fixes
- **Standalone ASCII Art Generator — broken stylesheet path**: `src/ascii-generator.html:7` linked `<link rel="stylesheet" href="../fonts.css" />`. The HTML loads via `BrowserWindow.loadFile(path.join(__dirname, 'ascii-generator.html'))` where `__dirname` is `src/`, so `../fonts.css` escaped the `src/` directory and resolved to a non-existent `<project>/fonts.css`. Changed to `fonts.css` (same file, src/-relative — mirrors `src/index.html:29`). The window rendered without its font rules, leaving the header in the fallback system stack.
- **Standalone ASCII Art Generator — dead Box/Templates UI**: the controller (`src/renderer/ascii-controller.js`) shipped three mode tabs (`Text Banner` / `Box-Frame` / `Templates`), 18 `.template-btn[data-template]` buttons, and a Box form (`#box-text` / `#box-style` / `#box-padding`) but wired none of them. Clicking any tab or button was a no-op. Wired all of them:
- `setMode(mode)` toggles `.active` on the right `.mode-tab` and the matching `.mode-section` (`text-mode` / `box-mode` / `templates-mode`).
- Template buttons call `api.generate({ text: '', font: 'template:<id>' })` so the orchestrator owns template content; preview updates and the button gets `.active`.
- Box mode renders the user text with a border using the chosen style (`single` / `double` / `rounded` / `bold` / `ascii`) and padding, exposed as a pure `window.ASCIIBoxRenderer.renderBox(text, style, padding)` helper.
- All 11 brief-required behaviours (text-input / font-picker / font-search / insert / copy / save / generate, last-font persistence, debounced preview) remain intact.
### Tests
- New `tests/ascii-controller.test.js` — 6 tests covering the stylesheet path (no `..` escape), pure box renderer (single + ascii styles), mode-tab switching (Box and Templates), and template button → preview wiring.
## Version 4.9.1 (2026-09-14)
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
"version": "4.9.1",
"version": "4.9.2",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Art Generator - MarkdownConverter</title>
<link rel="stylesheet" href="../fonts.css" />
<link rel="stylesheet" href="fonts.css" />
<style>
:root {
--ci-dark-gray: #464646;
+191
View File
@@ -6,6 +6,11 @@
* Insert / Copy / Save actions. Last-used font is restored from
* electron-store (via main) and saved on every change.
*
* Wires three mode tabs (Text Banner / Box-Frame / Templates) plus the
* 18 template buttons and the Box form fields (#box-text, #box-style,
* #box-padding). Templates route through `api.generate({ font: 'template:<id>' })`
* so the orchestrator owns template content.
*
* No bundler, no framework — pure DOM + window.electronAPI bridge.
*/
(function () {
@@ -23,12 +28,26 @@
btnSave: document.getElementById('btn-save'),
btnGenerate: document.getElementById('btn-generate'),
warning: document.getElementById('ascii-warning'),
modeTabs: document.querySelectorAll('.mode-tab[data-mode]'),
modeSections: document.querySelectorAll('.mode-section'),
templateButtons: document.querySelectorAll('.template-btn[data-template]'),
boxText: document.getElementById('box-text'),
boxStyle: document.getElementById('box-style'),
boxPadding: document.getElementById('box-padding'),
};
// Section ID for each mode. Keep in sync with ascii-generator.html IDs.
const MODE_SECTION_ID = {
text: 'text-mode',
box: 'box-mode',
templates: 'templates-mode',
};
let _fonts = [];
let _currentFont = 'standard';
let _previewText = '';
let _searchDebounce = null;
let _currentMode = 'text';
function showWarning(msg) {
if (!els.warning) return;
@@ -45,8 +64,124 @@
};
}
/**
* Switch the active mode tab and the visible mode section.
* Toggles the `.active` class on tabs and on the matching `.mode-section`.
* @param {'text'|'box'|'templates'} mode
*/
function setMode(mode) {
if (!MODE_SECTION_ID[mode]) return;
_currentMode = mode;
// Toggle tab buttons.
els.modeTabs.forEach((tab) => {
if (tab.dataset.mode === mode) tab.classList.add('active');
else tab.classList.remove('active');
});
// Toggle mode sections.
els.modeSections.forEach((sec) => {
if (sec.id === MODE_SECTION_ID[mode]) sec.classList.add('active');
else sec.classList.remove('active');
});
// Each mode has its own preview pipeline; trigger a refresh.
refreshPreview();
}
/**
* Build a simple box border around the user's text using the chosen style.
* Pure function — exported on window for tests via ASCIIBoxRenderer.
* @param {string} text
* @param {'single'|'double'|'rounded'|'bold'|'ascii'} style
* @param {number} padding 0..10 spaces on each side
* @returns {string}
*/
function renderBox(text, style, padding) {
const lines = String(text ?? '').split('\n');
const pad = Math.max(0, Math.min(10, Number(padding) || 0));
const padded = lines.map((l) => ' '.repeat(pad) + l + ' '.repeat(pad));
const width = padded.reduce((w, l) => Math.max(w, visibleLength(l)), 0);
let tl, tr, bl, br, h, v;
switch (style) {
case 'double':
tl = '╔';
tr = '╗';
bl = '╚';
br = '╝';
h = '═';
v = '║';
break;
case 'rounded':
tl = '╭';
tr = '╮';
bl = '╰';
br = '╯';
h = '─';
v = '│';
break;
case 'bold':
tl = '┏';
tr = '┓';
bl = '┗';
br = '┛';
h = '━';
v = '┃';
break;
case 'ascii':
tl = '+';
tr = '+';
bl = '+';
br = '+';
h = '-';
v = '|';
break;
case 'single':
default:
tl = '┌';
tr = '┐';
bl = '└';
br = '┘';
h = '─';
v = '│';
break;
}
const top = tl + h.repeat(width + 2) + tr;
const bot = bl + h.repeat(width + 2) + br;
const body = padded.map((l) => v + ' ' + l.padEnd(width, ' ') + ' ' + v).join('\n');
return [top, body, bot].join('\n');
}
/**
* Count printable character width, ignoring box-drawing characters and
* spaces (which all occupy a single monospace column).
*/
function visibleLength(s) {
// Box-drawing chars and most printable ASCII occupy one column in our
// monospace font. This is a deliberate simplification — emoji or wide
// CJK would need a more sophisticated width table.
return Array.from(s).length;
}
async function refreshPreview() {
if (!api) return;
if (_currentMode === 'templates') {
// Template mode is button-driven; no live preview here.
return;
}
if (_currentMode === 'box') {
const text = els.boxText ? els.boxText.value : '';
const style = els.boxStyle ? els.boxStyle.value : 'single';
const padding = els.boxPadding ? els.boxPadding.value : 2;
_previewText = text;
const out = renderBox(text, style, padding);
els.preview.textContent = out;
showWarning('');
return;
}
// text mode (default)
const text = els.textInput ? els.textInput.value : 'HELLO';
_previewText = text;
try {
@@ -93,6 +228,26 @@
renderFontList('');
}
/**
* Render a template by id: fetch its content via the orchestrator's
* template:<id> font namespace and update the preview.
*/
async function renderTemplate(templateId) {
if (!api) return;
try {
const out = await api.generate({ text: '', font: `template:${templateId}` });
els.preview.textContent = out;
showWarning('');
// Mark the clicked button as active.
els.templateButtons.forEach((btn) => {
if (btn.dataset.template === templateId) btn.classList.add('active');
else btn.classList.remove('active');
});
} catch (err) {
showWarning(`Template render failed: ${err.message || err}`);
}
}
function wireEvents() {
if (els.textInput) {
els.textInput.addEventListener('input', debounce(refreshPreview, 200));
@@ -151,6 +306,39 @@
}
});
}
// ---- mode-tab switching (Text Banner / Box-Frame / Templates) ----
els.modeTabs.forEach((tab) => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
if (mode) setMode(mode);
});
});
// ---- Box form: any change re-renders the box preview ----
if (els.boxText) {
els.boxText.addEventListener('input', () => {
if (_currentMode === 'box') refreshPreview();
});
}
if (els.boxStyle) {
els.boxStyle.addEventListener('change', () => {
if (_currentMode === 'box') refreshPreview();
});
}
if (els.boxPadding) {
els.boxPadding.addEventListener('input', () => {
if (_currentMode === 'box') refreshPreview();
});
}
// ---- Template buttons: render the chosen template into the preview ----
els.templateButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.template;
if (id) renderTemplate(id);
});
});
}
async function bootstrap() {
@@ -159,7 +347,10 @@
await refreshPreview();
}
// Expose pure helpers for tests; do not pollute window in production beyond
// what the bootstrap entry-point needs.
window.ASCIIController = { bootstrap };
window.ASCIIBoxRenderer = { renderBox };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
+147
View File
@@ -0,0 +1,147 @@
/**
* @jest-environment jsdom
*
* Tests the standalone ASCII Art Generator window's renderer controller
* (src/renderer/ascii-controller.js). Loads the real HTML and the real
* controller script into jsdom, mocks window.electronAPI.generators.ascii,
* and asserts that:
* - The fonts.css <link> uses a src/-relative path (no `..` escape).
* - Mode-tab clicks switch the visible mode section.
* - Template-button clicks update the preview with the orchestrator's
* template:<id> output.
* - The pure box renderer wraps text with the requested border style.
*/
const fs = require('fs');
const path = require('path');
const HTML_PATH = path.join(__dirname, '..', 'src', 'ascii-generator.html');
const CONTROLLER_PATH = path.join(__dirname, '..', 'src', 'renderer', 'ascii-controller.js');
/**
* Build the ascii-generator DOM in the jsdom document and execute the
* controller script in the same realm (window + document globals).
*
* The HTML file is parsed by the jsdom document and the controller
* source is run via `vm.runInThisContext` so its IIFE captures the real
* `window` and `document` without string interpolation.
*/
function mount() {
// Parse the HTML body content into the jsdom document. We use the
// existing testRealm — the controller only inspects IDs that exist in
// the standalone window's HTML, so a minimal copy is fine.
const html = fs.readFileSync(HTML_PATH, 'utf-8');
// Strip <head>/<body> wrappers — jsdom already provides those.
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
// Mock the electronAPI bridge before the controller reads it.
const generateMock = jest.fn(async ({ text, font }) => {
if (typeof font === 'string' && font.startsWith('template:')) {
return `<<${font.slice('template:'.length)}>>`;
}
return `<<font=${font}::text=${text}>>`;
});
const apiMock = {
listFonts: jest.fn(async () => [
{ id: 'standard', label: 'Standard', kind: 'hand-coded' },
{ id: 'big', label: 'Big', kind: 'hand-coded' },
{ id: 'template:arrow-right', label: 'Template · Arrow Right', kind: 'template' },
]),
generate: generateMock,
lastFont: jest.fn(async () => null),
copy: jest.fn(async () => undefined),
save: jest.fn(async () => ({ canceled: true })),
};
window.electronAPI = {
generators: { ascii: apiMock },
send: jest.fn(),
};
// Execute the controller source in the test realm. The IIFE references
// `window` and `document` directly, so we wrap it in an outer function
// that passes the jsdom globals as parameters. Using Function() here is
// safe: the only interpolated value is the file contents (read from a
// path we control in the repo), not user input.
const src = fs.readFileSync(CONTROLLER_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', src)(window, document);
return { apiMock, generateMock };
}
afterEach(() => {
jest.clearAllMocks();
});
describe('ascii-generator.html — fonts.css link', () => {
test('does not escape src/ (no "../" in href)', () => {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const linkRe = /<link[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/i;
const match = html.match(linkRe);
expect(match).not.toBeNull();
const href = match[1];
expect(href.startsWith('../')).toBe(false);
// The standalone window's document base is src/, so fonts.css must be
// src/-relative (not ../fonts.css).
expect(href).toBe('fonts.css');
});
});
describe('ascii-controller: pure box renderer', () => {
test('wraps text with a single-line border by default', () => {
mount();
const { renderBox } = window.ASCIIBoxRenderer;
const out = renderBox('Hi', 'single', 0);
expect(out).toBe('┌────┐\n│ Hi │\n└────┘');
});
test('honours padding and ascii (+|-) style', () => {
mount();
const { renderBox } = window.ASCIIBoxRenderer;
const out = renderBox('Hi', 'ascii', 1);
// 1 space pad + 'Hi' + 1 space pad = width 4 inside, +2 borders = 6 wide.
expect(out).toBe('+------+\n| Hi |\n+------+');
});
});
describe('ascii-controller: mode-tab switching', () => {
test('clicking the Box tab reveals box-mode and hides the others', async () => {
mount();
await window.ASCIIController.bootstrap();
const boxTab = document.querySelector('.mode-tab[data-mode="box"]');
boxTab.click();
expect(boxTab.classList.contains('active')).toBe(true);
expect(document.getElementById('box-mode').classList.contains('active')).toBe(true);
expect(document.getElementById('text-mode').classList.contains('active')).toBe(false);
expect(document.getElementById('templates-mode').classList.contains('active')).toBe(false);
});
test('clicking the Templates tab reveals templates-mode', async () => {
mount();
await window.ASCIIController.bootstrap();
const tplTab = document.querySelector('.mode-tab[data-mode="templates"]');
tplTab.click();
expect(tplTab.classList.contains('active')).toBe(true);
expect(document.getElementById('templates-mode').classList.contains('active')).toBe(true);
});
});
describe('ascii-controller: template buttons', () => {
test('clicking a template button renders its content into the preview', async () => {
const { generateMock } = mount();
await window.ASCIIController.bootstrap();
const btn = document.querySelector('.template-btn[data-template="arrow-right"]');
btn.click();
// Wait for the async generate() call to resolve.
await Promise.resolve();
await Promise.resolve();
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ font: 'template:arrow-right' })
);
expect(document.getElementById('preview').textContent).toBe('<<arrow-right>>');
expect(btn.classList.contains('active')).toBe(true);
});
});