ZDDC/mdedit/js/editor.js
ZDDC c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:

- landing: substantial rework of the project picker (sortable/filterable
  table, presets refactor, ?projects= filter, ?v= channel propagation,
  loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
  alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
  base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
  generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
  alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
  *_alpha.html — these are produced by `sh build.sh` and per project
  convention are committed alongside the source changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:27 -05:00

419 lines
16 KiB
JavaScript

/**
* Toast UI Editor initialization and management
*/
/**
* Initialize or update the Toast UI Editor for a file
* @param {string} content - Content to display
* @param {boolean} isMarkdown - Whether content is markdown
* @param {string} filePath - Path of the file
* @param {string} fileName - Name of the file
* @param {FileSystemFileHandle} fileHandle - File handle for saving
* @param {number} lastModified - Timestamp of last modification
*/
function initializeEditor(content, isMarkdown = true, filePath = '', fileName = '', fileHandle = null, lastModified = null) {
// Parse front matter
let frontMatterData = {};
let markdownBody = content;
if (isMarkdown && content) {
try {
const parsed = parseFrontMatter(content);
frontMatterData = parsed.data;
markdownBody = parsed.content;
} catch (error) {
console.error('Failed to parse front matter:', error);
}
}
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
// Hide all file view containers
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
// Check if file already has an instance
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return existingInstance.editor;
}
// Create file view container
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
// Create file header
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
// Button container for alignment
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex gap-2';
// Determine if this is a scratchpad (no file handle)
const isScratchpad = !fileHandle;
const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly);
// Save button (or Save As for scratchpads / read-only server files)
const saveButton = document.createElement('button');
saveButton.className = 'btn btn-primary btn-sm';
saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File';
saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit
buttonContainer.appendChild(saveButton);
// Reload button (only for files, not scratchpads) — icon to match file-tree refresh
let reloadButton = null;
if (!isScratchpad) {
reloadButton = document.createElement('button');
reloadButton.className = 'btn btn-secondary btn-sm';
reloadButton.textContent = '↻';
reloadButton.title = 'Reload from disk (discards unsaved changes)';
reloadButton.setAttribute('aria-label', 'Reload from disk');
buttonContainer.appendChild(reloadButton);
}
fileHeader.appendChild(buttonContainer);
fileViewContainer.appendChild(fileHeader);
// Content area
const contentArea = document.createElement('div');
contentArea.className = 'flex flex-col flex-1 overflow-hidden';
// Editor area with TOC
const editorArea = document.createElement('div');
editorArea.className = 'flex flex-row flex-1 overflow-hidden';
// TOC pane (markdown only)
let tocContainer = null;
let frontMatterTextarea = null;
if (isMarkdown) {
const tocPane = document.createElement('div');
tocPane.className = 'toc-pane bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700';
tocPane.style.width = '325px';
tocPane.style.minWidth = '150px';
// Front matter section (collapsible, height-resizable)
const frontMatterNav = document.createElement('div');
frontMatterNav.className = 'front-matter-nav';
frontMatterNav.style.height = '180px';
const frontMatterHeader = document.createElement('div');
frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer';
const toggleIcon = document.createElement('span');
toggleIcon.textContent = '▼';
toggleIcon.className = 'toggle-icon';
frontMatterHeader.appendChild(toggleIcon);
const headerText = document.createElement('span');
headerText.textContent = 'YAML Front Matter';
frontMatterHeader.appendChild(headerText);
frontMatterNav.appendChild(frontMatterHeader);
const frontMatterContent = document.createElement('div');
frontMatterContent.className = 'front-matter-content';
frontMatterTextarea = document.createElement('textarea');
frontMatterTextarea.className = 'front-matter-textarea';
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
try {
let yamlText = '';
for (const [key, value] of Object.entries(frontMatterData)) {
if (Array.isArray(value)) {
yamlText += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
} else {
yamlText += `${key}: ${value}\n`;
}
}
frontMatterTextarea.value = yamlText.trim();
} catch (error) {
console.warn('Failed to stringify front matter:', error);
frontMatterTextarea.value = '';
}
}
frontMatterContent.appendChild(frontMatterTextarea);
frontMatterNav.appendChild(frontMatterContent);
tocPane.appendChild(frontMatterNav);
// Horizontal resizer between front-matter and TOC
const fmTocResizer = document.createElement('div');
fmTocResizer.className = 'pane-resizer horizontal';
tocPane.appendChild(fmTocResizer);
// TOC section
const tocSection = document.createElement('div');
tocSection.className = 'toc-section';
const tocHeader = document.createElement('div');
tocHeader.className = 'toc-header pane-section-header';
const tocTitle = document.createElement('span');
tocTitle.textContent = 'Table of Contents';
tocHeader.appendChild(tocTitle);
const tocDepthSelector = document.createElement('select');
tocDepthSelector.className = 'toc-depth-selector';
tocDepthSelector.innerHTML = `
<option value="6">All Levels</option>
<option value="1">H1 Only</option>
<option value="2">H1-H2</option>
<option value="3" selected>H1-H3</option>
<option value="4">H1-H4</option>
<option value="5">H1-H5</option>
`;
tocHeader.appendChild(tocDepthSelector);
tocSection.appendChild(tocHeader);
tocContainer = document.createElement('div');
tocContainer.className = 'toc-container toc-content';
tocSection.appendChild(tocContainer);
tocPane.appendChild(tocSection);
// Toggle: collapsed only shows the header. Hide content + horizontal resizer.
let fmIsCollapsed = false;
frontMatterHeader.addEventListener('click', () => {
fmIsCollapsed = !fmIsCollapsed;
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
fmTocResizer.style.display = fmIsCollapsed ? 'none' : '';
if (fmIsCollapsed) {
frontMatterNav.style.height = '';
} else {
frontMatterNav.style.height = '180px';
}
});
editorArea.appendChild(tocPane);
// Vertical resizer between toc-pane and editor (placed inside editorArea)
const tocResizer = document.createElement('div');
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
editorArea.appendChild(tocResizer);
makeResizable(tocResizer, tocPane);
// Make the front-matter / TOC split height-adjustable
makeHeightResizable(fmTocResizer, frontMatterNav, tocPane);
tocDepthSelector.addEventListener('change', function () {
const depth = parseInt(this.value);
if (window.updateToc && editorInstance) {
const currentContent = editorInstance.getMarkdown();
window.updateToc(currentContent, tocContainer, editorInstance, depth);
}
});
}
// Editor container
const editorContainer = document.createElement('div');
editorContainer.className = 'editor-instance flex-1 overflow-hidden';
editorArea.appendChild(editorContainer);
contentArea.appendChild(editorArea);
fileViewContainer.appendChild(contentArea);
contentContainer.appendChild(fileViewContainer);
// Check Toast UI availability
if (typeof toastui === 'undefined') {
alert('Error: Toast UI library not loaded!');
editorContainer.innerHTML = '<div style="padding: 20px; background: #ffeeee; color: red;">Error: Toast UI library not loaded!</div>';
return;
}
let editorInstance;
try {
// Initialize Toast UI Editor
const editor = new toastui.Editor({
el: editorContainer,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: markdownBody,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
});
editorInstance = editor;
if (!isMarkdown) {
editorInstance.changeMode('wysiwyg');
}
// Generate initial TOC
if (isMarkdown && window.updateToc && tocContainer) {
try {
window.updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
} catch (error) {
console.error('Error generating TOC:', error);
}
const debouncedUpdateToc = debounce(() => {
const currentContent = editorInstance.getMarkdown();
window.updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
}, 300);
editorInstance.on('change', () => {
debouncedUpdateToc();
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState();
});
// Scroll listener for TOC highlighting
const mdPreview = editorInstance.getEditorElements().mdPreview;
if (mdPreview) {
let activeTimeout = null;
let lastHeader = null;
const updateActiveHeader = () => {
// Re-query live headings (TOC may have been regenerated)
const liveHeaders = mdPreview.querySelectorAll('h1, h2, h3, h4, h5, h6');
const previewRect = mdPreview.getBoundingClientRect();
// Use a threshold slightly below the top so a header touching
// the top edge counts as "active"
const threshold = previewRect.top + 4;
let activeHeader = null;
for (const header of liveHeaders) {
if (header.getBoundingClientRect().top <= threshold) {
activeHeader = header.textContent.trim();
} else {
break;
}
}
if (activeHeader !== lastHeader) {
lastHeader = activeHeader;
setActiveTocItem(tocContainer, activeHeader);
}
};
const onScroll = () => {
cancelAnimationFrame(activeTimeout);
activeTimeout = requestAnimationFrame(updateActiveHeader);
};
mdPreview.addEventListener('scroll', onScroll);
}
} else {
editorInstance.on('change', () => {
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
});
}
// Front matter change listener
if (frontMatterTextarea) {
frontMatterTextarea.addEventListener('input', () => {
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
});
}
// Button event listeners
saveButton.addEventListener('click', async () => {
if (isScratchpad) {
// For scratchpads, use Save As
const content = editorInstance.getMarkdown();
const savedHandle = await saveFileAs(content, 'untitled.md');
if (savedHandle && hasFileSystemAccess) {
// Check if saved to current directory - add to file tree
if (directoryHandle) {
try {
// Try to get the file from the directory to verify it's there
const checkHandle = await directoryHandle.getFileHandle(savedHandle.name);
// File is in current directory, add to tree
fileTree.entries[savedHandle.name] = {
name: savedHandle.name,
type: 'file',
handle: checkHandle
};
renderFileTree();
} catch (e) {
// File not in current directory, that's fine
}
}
// Clear scratchpad content after successful save
editorInstance.setMarkdown('');
saveButton.disabled = true;
const instanceData = editorInstances.get(filePath);
if (instanceData) {
instanceData.isDirty = false;
}
}
} else {
saveFile(filePath);
}
});
if (reloadButton) {
reloadButton.addEventListener('click', async () => {
await reloadFileFromDisk(filePath);
});
}
// Store instance data
const instanceData = {
editor: editor,
fileViewContainer: fileViewContainer,
tocContainer: tocContainer,
saveButton: saveButton,
reloadButton: reloadButton,
frontMatterTextarea: frontMatterTextarea,
frontMatterData: frontMatterData,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
return editorInstance;
} catch (error) {
console.error('Error initializing editor:', error);
alert(`Error initializing Toast UI Editor: ${error}`);
return null;
}
}