ZDDC — Zero Day Document Control. A file-naming convention plus five single-file HTML tools (archive, transmittal, classifier, mdedit, landing) and an optional Go HTTP server (zddc-server) with ACL and a virtual archive index. Self-contained, offline-capable, dependency-free. See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the build/release/architecture detail, bootstrap/README.md for the two-level deployment install pattern, and zddc/README.md for the HTTP server.
403 lines
16 KiB
JavaScript
403 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;
|
|
|
|
// Save button (or Save As for scratchpads)
|
|
const saveButton = document.createElement('button');
|
|
saveButton.className = 'btn btn-primary btn-sm';
|
|
saveButton.textContent = isScratchpad ? 'Save As...' : 'Save File';
|
|
saveButton.disabled = !isScratchpad; // Scratchpads can always save
|
|
buttonContainer.appendChild(saveButton);
|
|
|
|
// Reload button (only for files, not scratchpads)
|
|
let reloadButton = null;
|
|
if (!isScratchpad) {
|
|
reloadButton = document.createElement('button');
|
|
reloadButton.className = 'btn btn-secondary btn-sm';
|
|
reloadButton.textContent = 'Reload from Disk';
|
|
reloadButton.title = 'Reload file from disk (discards unsaved changes)';
|
|
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 nav bar (collapsible)
|
|
const frontMatterNav = document.createElement('div');
|
|
frontMatterNav.className = 'front-matter-nav border-b border-gray-200 dark:border-gray-700';
|
|
|
|
const frontMatterHeader = document.createElement('div');
|
|
frontMatterHeader.className = 'front-matter-header 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 cursor-pointer flex items-center gap-2';
|
|
|
|
const toggleIcon = document.createElement('span');
|
|
toggleIcon.textContent = '▼';
|
|
toggleIcon.className = 'toggle-icon text-sm';
|
|
frontMatterHeader.appendChild(toggleIcon);
|
|
|
|
const headerText = document.createElement('span');
|
|
headerText.textContent = 'YAML Front Matter';
|
|
frontMatterHeader.appendChild(headerText);
|
|
|
|
frontMatterNav.appendChild(frontMatterHeader);
|
|
|
|
frontMatterTextarea = document.createElement('textarea');
|
|
frontMatterTextarea.className = 'front-matter-textarea w-full px-4 py-2 text-sm focus:outline-none resize-none overflow-x-auto';
|
|
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
|
|
|
|
// Set front matter content
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
frontMatterNav.appendChild(frontMatterTextarea);
|
|
tocPane.appendChild(frontMatterNav);
|
|
|
|
const tocHeader = document.createElement('div');
|
|
tocHeader.className = 'toc-header 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 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);
|
|
|
|
tocPane.appendChild(tocHeader);
|
|
|
|
tocContainer = document.createElement('div');
|
|
tocContainer.className = 'toc-container toc-content p-4 h-full overflow-auto';
|
|
tocPane.appendChild(tocContainer);
|
|
|
|
// Set up TOC container overflow when front matter is toggled
|
|
let fmIsCollapsed = false;
|
|
frontMatterHeader.addEventListener('click', () => {
|
|
fmIsCollapsed = !fmIsCollapsed;
|
|
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
|
|
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
|
|
});
|
|
|
|
// Auto-size textarea: no vertical scroll, horizontal scroll for long lines
|
|
frontMatterTextarea.style.overflowY = 'hidden';
|
|
frontMatterTextarea.style.overflowX = 'auto';
|
|
const autoResizeFm = () => {
|
|
frontMatterTextarea.style.height = 'auto';
|
|
frontMatterTextarea.style.height = frontMatterTextarea.scrollHeight + 'px';
|
|
};
|
|
frontMatterTextarea.addEventListener('input', autoResizeFm);
|
|
// Defer initial resize until element is in the DOM and has layout
|
|
requestAnimationFrame(() => requestAnimationFrame(autoResizeFm));
|
|
|
|
editorArea.appendChild(tocPane);
|
|
|
|
// TOC resizer
|
|
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');
|
|
contentArea.appendChild(tocResizer);
|
|
|
|
makeResizable(tocResizer, tocPane);
|
|
|
|
// TOC depth selector event
|
|
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;
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
}
|