Skip to main content

WYSIWYG Editor

Markdown-OS uses a custom WYSIWYG (What You See Is What You Get) editor built on contenteditable with real-time markdown rendering.

How It Works

The editor uses a bidirectional content flow:
1

Load markdown

Server markdown → marked.parse() → rendered HTML in contenteditable div
2

Edit visually

Users edit rich text directly. Changes are captured via input and keydown events.
3

Save as markdown

contenteditable HTML → TurndownService → markdown string → POST /api/save
// Simplified rendering pipeline
const html = marked.parse(markdownContent);
editorElement.innerHTML = html;

// Simplified serialization
const turndown = new TurndownService();
const markdown = turndown.turndown(editorElement.innerHTML);
The editor is implemented in markdown_os/static/js/wysiwyg.js (~2000 lines) with no build step required.

Inline Formatting

Keyboard Shortcuts

Apply formatting with standard keyboard shortcuts:
ShortcutActionMarkdown
Cmd/Ctrl + BBold**text**
Cmd/Ctrl + IItalic*text* or _text_
Cmd/Ctrl + KLink[text](url)
Cmd/Ctrl + EInline code`code`
Cmd/Ctrl + DStrikethrough~~text~~

Inline Markdown Shortcuts

The editor detects markdown patterns as you type and applies formatting automatically: Bold
Type: This is **bold text** and it converts immediately
Italic
Type: This is *italic* or _italic_ and it converts
Strikethrough
Type: This is ~~deleted~~ text
Inline Code
Type: Use `const` for variables
Links
Type: Check [OpenAI](https://openai.com) for details
Inline shortcuts are detected on input and keydown events. Patterns must be complete (closing delimiter entered) to trigger conversion.

Toolbar

The floating toolbar (wysiwyg-toolbar.js) provides quick access to formatting commands.

Inline Formatting Buttons

Buttons change state based on current selection:
function updateInlineButtonState() {
  document.querySelectorAll(".toolbar-button[data-command]").forEach((button) => {
    const command = button.dataset.command;
    let active = false;
    
    if (command === "bold") {
      active = document.queryCommandState("bold");
    } else if (command === "italic") {
      active = document.queryCommandState("italic");
    }
    
    button.classList.toggle("active", active);
  });
}
Active buttons are highlighted to show current formatting.

Heading Selector

Click the heading dropdown to convert the current paragraph:
# Heading 1
## Heading 2
### Heading 3
Headings are detected and the dropdown shows the current level:
function getCurrentHeadingLevel() {
  const selection = window.getSelection();
  let node = selection.anchorNode;
  while (node && node !== editor) {
    if (node.nodeType === Node.ELEMENT_NODE && /^H[1-6]$/.test(node.tagName)) {
      return Number(node.tagName[1]);
    }
    node = node.parentElement;
  }
  return 0;
}

List Toggles

Convert paragraphs to lists:
  • Bullet List - Unordered list with - or * markers
  • Ordered List - Numbered list with 1. , 2. , etc.

Insert Menu

The insert menu provides templates for complex structures:
Inserts a 3×3 table template:
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1   | Cell 2   | Cell 3   |
| Cell 4   | Cell 5   | Cell 6   |

Block Editing

Code Blocks

Code blocks are click-to-edit. The rendered code block shows a modal when clicked:
// Block content is stored in data-original-content attribute
const originalContent = codeBlock.dataset.originalContent;

// Modal shows textarea with raw markdown
const updatedContent = await MarkdownOS.prompt("Edit code:", originalContent);

// Update the data attribute and re-render
codeBlock.dataset.originalContent = updatedContent;
Features:
  • Syntax highlighting via highlight.js
  • Language labels (e.g., python, javascript)
  • Copy button with success feedback
  • Line numbers for long blocks
Code blocks preserve exact indentation and whitespace. The raw content is stored separately from the rendered HTML.

Mermaid Diagrams

Mermaid blocks are also click-to-edit:
```mermaid
graph LR
  A[Client] --> B[Server]
  B --> C[Database]
```
Rendered diagrams support:
  • Pan and Zoom - Interactive navigation with svg-pan-zoom
  • Click to Edit - Modify the Mermaid source code
  • Error Display - Syntax errors show in red with helpful messages
try {
  const { svg } = await mermaid.render('mermaid-' + id, content);
  container.innerHTML = svg;
  svgPanZoom(container.querySelector('svg'), {
    controlIconsEnabled: true,
    zoomScaleSensitivity: 0.3,
  });
} catch (error) {
  container.innerHTML = `<div class="mermaid-error">${error.message}</div>`;
}

Math Equations

KaTeX renders inline and display math: Inline Math
The formula $E = mc^2$ is famous.
Display Math
$$
\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}
$$
Math blocks are click-to-edit like code blocks. LaTeX source is stored in data-original-content.

Auto-save

The editor auto-saves after 1 second of inactivity:
const SAVE_DEBOUNCE_MS = 1000;

let saveTimeout = null;
editor.addEventListener('input', () => {
  clearTimeout(saveTimeout);
  saveTimeout = setTimeout(() => {
    saveContent();
  }, SAVE_DEBOUNCE_MS);
});

Save Status Indicator

The save status indicator shows:
  • Saving… - Request in progress
  • Saved - Last save succeeded (shows timestamp)
  • Error - Save failed (shows error message)
function updateSaveStatus(status, message) {
  const indicator = document.getElementById('save-status');
  indicator.textContent = message;
  indicator.className = `save-status-${status}`;
}
You can manually save with Cmd/Ctrl + S, but auto-save handles it automatically in most cases.

Conflict Detection

When external changes occur while you have unsaved edits, a conflict modal appears:
if (hasUnsavedChanges && externalContentDifferent) {
  const choice = await MarkdownOS.confirmWithOptions(
    "File changed externally",
    ["Save My Changes", "Discard My Changes", "Cancel"]
  );
  
  if (choice === 0) {
    await saveContent(); // Overwrite external changes
  } else if (choice === 1) {
    loadContent(); // Discard local changes
  }
}
Options:
  1. Save My Changes - Your edits win, overwriting external changes
  2. Discard My Changes - External changes win, your edits are lost
  3. Cancel - Keep editing, resolve manually later

Undo/Redo

The editor maintains an undo stack:
const MAX_HISTORY = 100;

class UndoStack {
  constructor() {
    this.history = [];
    this.currentIndex = -1;
  }
  
  push(state) {
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.history.push(state);
    if (this.history.length > MAX_HISTORY) {
      this.history.shift();
    }
    this.currentIndex = this.history.length - 1;
  }
  
  undo() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return this.history[this.currentIndex];
    }
  }
  
  redo() {
    if (this.currentIndex < this.history.length - 1) {
      this.currentIndex++;
      return this.history[this.currentIndex];
    }
  }
}
Shortcuts:
  • Cmd/Ctrl + Z - Undo
  • Cmd/Ctrl + Shift + Z - Redo
  • Toolbar buttons also trigger undo/redo
History is stored in memory only. Refreshing the page clears the undo stack.
Links have special handling: Click to Edit
Regular click opens an edit dialog:
link.addEventListener('click', (e) => {
  if (!e.metaKey && !e.ctrlKey) {
    e.preventDefault();
    const newUrl = await MarkdownOS.prompt("Edit URL:", link.href);
    if (newUrl) {
      link.href = newUrl;
    }
  }
});
Cmd/Ctrl+Click to Open
Holding Cmd (Mac) or Ctrl (Windows/Linux) opens the link in a new tab:
if (e.metaKey || e.ctrlKey) {
  window.open(link.href, '_blank');
}

Image Upload

Images can be added by:
  • Dragging and dropping onto the editor
  • Pasting from clipboard (Cmd/Ctrl + V)
  • Insert menu toolbar button (prompts for URL)
See the Images guide for detailed upload documentation.

Table of Contents

The TOC sidebar (toc.js) auto-generates navigation from headings:
function buildTOC() {
  const headings = editor.querySelectorAll('h1, h2, h3, h4, h5, h6');
  const toc = [];
  
  headings.forEach((heading, index) => {
    const level = parseInt(heading.tagName[1]);
    const text = heading.textContent;
    const id = `heading-${index}`;
    heading.id = id;
    
    toc.push({ level, text, id });
  });
  
  renderTOC(toc);
}
Features:
  • Nested indentation based on heading levels
  • Smooth scrolling to sections
  • Active section highlighting
  • Automatically updates when headings change

Search and Replace

The search dialog supports:
  • Case-sensitive and case-insensitive modes
  • Match highlighting in the editor
  • Replace and Replace All actions
  • Keyboard navigation (Enter for next, Shift+Enter for previous)
Access search with:
  • Cmd/Ctrl + F - Open find dialog
  • Cmd/Ctrl + H - Open find and replace dialog

Accessibility

Keyboard Navigation

All toolbar buttons and menus are keyboard accessible:
<button
  class="toolbar-button"
  data-command="bold"
  aria-label="Bold (Cmd+B)"
  tabindex="0"
>
  <strong>B</strong>
</button>

ARIA Labels

UI elements include descriptive ARIA labels for screen readers:
<div role="navigation" aria-label="File tree">
  <button aria-expanded="false" aria-label="Expand folder">...</button>
</div>

Focus Management

Dialogs trap focus and restore it after closing:
function showDialog(content) {
  const previousFocus = document.activeElement;
  dialog.showModal();
  dialog.querySelector('input').focus();
  
  dialog.addEventListener('close', () => {
    previousFocus?.focus();
  }, { once: true });
}

Performance Optimizations

Debounced Rendering

Markdown rendering is debounced to avoid blocking on rapid input:
const RENDER_DEBOUNCE_MS = 100;

let renderTimeout = null;
editor.addEventListener('input', () => {
  clearTimeout(renderTimeout);
  renderTimeout = setTimeout(() => {
    renderMarkdown();
  }, RENDER_DEBOUNCE_MS);
});

Virtual Scrolling

Large files with hundreds of headings use virtual scrolling in the TOC to maintain smooth performance.

Lazy Loading

Code highlighting and Mermaid rendering happen on-demand using Intersection Observer:
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      renderCodeBlock(entry.target);
      observer.unobserve(entry.target);
    }
  });
});

Build docs developers (and LLMs) love