Skip to content

Feature - New WYSIWYG editor via tiptap#12182

Open
Sadashii wants to merge 44 commits intointernetarchive:masterfrom
Sadashii:7308/feature/new-wysiwyg-editor
Open

Feature - New WYSIWYG editor via tiptap#12182
Sadashii wants to merge 44 commits intointernetarchive:masterfrom
Sadashii:7308/feature/new-wysiwyg-editor

Conversation

@Sadashii
Copy link
Copy Markdown
Contributor

@Sadashii Sadashii commented Mar 24, 2026

Closes #7308
Closes #2370

This PR removes the old outdated wmd markdown editor, and replaces it with a new widely supported tiptap editor, created as a lit component which can now be put-in on any page with the element.
This PR also removes all the legacy wmd css and all the relevant code for cleanup.

Technical

The Tiptap editor and it's dependencies are installed via npm, and the component in itself is defined as a lit component.

Testing

Which pages actually use it

The editor appears only on edit forms:

  1. Book edit (work description) — books/edit/about.html
  2. Edition edit (description + notes) — books/edit/edition.html
  3. Excerpts edit — books/edit/excerpts.html
  4. Author edit (bio) — type/author/edit.html
  5. User profile edit — type/user/edit.html
  6. List edit — type/list/edit.html
  7. Page/wiki edit — type/page/edit.html
  8. Tag form — type/tag/tag_form_inputs.html

Screenshot

text.mp4
image image

Stakeholders

@lokesh

Copilot AI review requested due to automatic review settings March 24, 2026 10:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the legacy WMD markdown editor with a new Tiptap-based WYSIWYG markdown editor implemented as a Lit web component (<ol-markdown-editor>), and removes the WMD submodule, JS glue code, and related CSS.

Changes:

  • Add a new Lit component (OLMarkdownEditor) backed by Tiptap + tiptap-markdown, and export it via the Lit bundle entrypoint.
  • Replace markdown <textarea> usages in multiple edit forms with <ol-markdown-editor> instances.
  • Remove WMD assets: submodule, initialization JS, and associated legacy CSS imports/files.

Reviewed changes

Copilot reviewed 21 out of 23 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
vendor/js/wmd Removes the WMD git submodule pointer.
static/css/legacy.css Stops importing legacy WMD styles.
static/css/legacy-wmd.css Deletes WMD styling file.
static/css/js-all.css Removes WMD prompt dialog CSS import.
static/css/components/work.css Removes WMD preview + related description/notes styling.
static/css/components/wmd-prompt-dialog--js.css Deletes WMD prompt dialog styles.
static/css/components/wmd-button-bar.css Deletes WMD button bar styles.
package.json Adds Tiptap + markdown dependencies.
package-lock.json Locks the new dependencies and removes now-unused packages.
openlibrary/templates/type/user/edit.html Swaps description textarea for <ol-markdown-editor>.
openlibrary/templates/type/tag/tag_form_inputs.html Swaps page body textarea for <ol-markdown-editor>.
openlibrary/templates/type/page/edit.html Swaps document body textarea for <ol-markdown-editor>.
openlibrary/templates/type/list/edit.html Swaps list description textarea for <ol-markdown-editor>.
openlibrary/templates/type/author/edit.html Swaps bio textarea for <ol-markdown-editor>.
openlibrary/templates/books/edit/excerpts.html Swaps excerpt comment textarea for <ol-markdown-editor>.
openlibrary/templates/books/edit/edition.html Swaps edition description/notes textareas for <ol-markdown-editor>.
openlibrary/templates/books/edit/about.html Swaps work description textarea for <ol-markdown-editor>.
openlibrary/plugins/openlibrary/js/markdown-editor/index.js Removes WMD init module.
openlibrary/plugins/openlibrary/js/index.js Removes dynamic import + initialization of WMD editor.
openlibrary/core/olmarkdown.py Adds a TODO note.
openlibrary/components/lit/index.js Exports the new Lit markdown editor component.
openlibrary/components/lit/OLMarkdownEditor.js Adds the new Lit-based Tiptap editor implementation.
.gitmodules Removes the WMD submodule definition.

Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/templates/type/tag/tag_form_inputs.html Outdated
Comment thread openlibrary/templates/books/edit/edition.html Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/index.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/templates/type/author/edit.html Outdated
Comment thread openlibrary/templates/books/edit/excerpts.html Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 23 changed files in this pull request and generated 4 comments.

Comment thread openlibrary/components/lit/index.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js
Comment thread static/css/markdown.css Outdated
Comment thread static/css/legacy.css
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 23 changed files in this pull request and generated 6 comments.

Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js Outdated
Comment thread openlibrary/components/lit/OLMarkdownEditor.js
Comment thread openlibrary/components/lit/index.js Outdated
Comment thread static/css/components/work.css
@lokesh
Copy link
Copy Markdown
Collaborator

lokesh commented Mar 25, 2026

Suggestion: Lazy-load Tiptap to avoid bloating the shared bundle

The ol-components.js Lit bundle loads on every page via footer.html, but the markdown editor only appears on ~8 edit pages. Tiptap + ProseMirror adds ~100KB+ to a bundle that otherwise serves lightweight components (ReadMore, Pagination, Chips) to all visitors.

The fix is a dynamic import() so Vite automatically code-splits the heavy dependencies into a separate chunk, loaded only when <ol-markdown-editor> initializes. No config, template, or server changes needed.

What changes:

  • OLMarkdownEditor.js — remove static Tiptap imports, firstUpdated() becomes async with await import('./editor-core.js'), adds a loading placeholder
  • editor-core.js — new file, contains all Tiptap imports and a createEditor() factory function

The toolbar renders immediately with disabled buttons (existing behavior when this.editor is null), then enables once the chunk loads. A placeholder matching the Tiptap placeholder style fills the editor area during load.

Apply with git apply:

Patch for OLMarkdownEditor.js

```diff
diff --git a/openlibrary/components/lit/OLMarkdownEditor.js b/openlibrary/components/lit/OLMarkdownEditor.js
index e2075f839..c144723ec 100644
--- a/openlibrary/components/lit/OLMarkdownEditor.js
+++ b/openlibrary/components/lit/OLMarkdownEditor.js
@@ -1,8 +1,4 @@
import { LitElement, html, css } from 'lit';
-import { Editor } from '@tiptap/core';
-import StarterKit from '@tiptap/starter-kit';
-import { Markdown } from 'tiptap-markdown';
-import Placeholder from '@tiptap/extension-placeholder';

/**

  • OLMarkdownEditor - A web component for the tiptap wysiwyg editor
    @@ -193,6 +189,11 @@ export class OLMarkdownEditor extends LitElement {
    font-family: var(--font-family-body);
    margin-bottom: var(--spacing-stack-sm);
    }
  • .loading-placeholder {

  •  color: var(--light-grey);
    
  •  pointer-events: none;
    
  • }
    `;

    constructor() {
    @@ -231,7 +232,7 @@ export class OLMarkdownEditor extends LitElement {
    }
    }

  • firstUpdated() {
  • async firstUpdated() {
    if (!this.targetId) {
    this._errorMsg = 'Missing 'target-id' attribute.';
    throw new Error(OLMarkdownEditor: ${this._errorMsg});
    @@ -244,24 +245,15 @@ export class OLMarkdownEditor extends LitElement {
    throw new Error(OLMarkdownEditor: ${this._errorMsg});
    }

  •    const { createEditor } = await import('./editor-core.js');
    
  •    const initialContent = this.targetElement.value || '';
       const editorRoot = this.shadowRoot.getElementById('editor-root');
    
  •    this.editor = new Editor({
    
  •    this.editor = createEditor({
           element: editorRoot,
    
  •        extensions: [
    
  •            StarterKit.configure({
    
  •                heading: { levels: [1, 2] },
    
  •                codeBlock: false,
    
  •                code: false,
    
  •                hardBreak: false,
    
  •                link: { openOnClick: false },
    
  •                strike: false
    
  •            }),
    
  •            Markdown,
    
  •            Placeholder.configure({ placeholder: this.placeholder || 'Write something...' })
    
  •        ],
           content: initialContent,
    
  •        placeholder: this.placeholder || 'Write something...',
           onUpdate: ({ editor }) => {
               let markdownOutput = editor.storage.markdown.getMarkdown();
    

@@ -408,7 +400,9 @@ export class OLMarkdownEditor extends LitElement {
${this._renderButton({ title: 'Numbered List', icon: ICONS.ol, action: () => this.formatList('number'), isActive: this._isActive('orderedList') })}

  •    <div id="editor-root" class="editor-input" @click="${this._focusEditor}"></div>
    
  •    <div id="editor-root" class="editor-input" @click="${this._focusEditor}">
    
  •        ${!this.editor ? html`<span class="loading-placeholder">${this.placeholder || 'Write something...'}</span>` : ''}
    
  •    </div>
     </div>
    
    ; } \``
Patch for new editor-core.js

```diff
diff --git a/openlibrary/components/lit/editor-core.js b/openlibrary/components/lit/editor-core.js
new file mode 100644
index 000000000..9b1f62252
--- /dev/null
+++ b/openlibrary/components/lit/editor-core.js
@@ -0,0 +1,40 @@
+/**

    • Heavy Tiptap/ProseMirror dependencies, loaded lazily by OLMarkdownEditor.
    • This module is code-split by Vite into its own chunk.
  • */
    +import { Editor } from '@tiptap/core';
    +import StarterKit from '@tiptap/starter-kit';
    +import { Markdown } from 'tiptap-markdown';
    +import Placeholder from '@tiptap/extension-placeholder';

+/**

    • Creates a configured Tiptap editor instance.
    • @param {HTMLElement} options.element - DOM element to mount the editor into
    • @param {string} options.content - Initial markdown content
    • @param {string} options.placeholder - Placeholder text when editor is empty
    • @param {Function} options.onUpdate - Called on every content change
    • @param {Function} options.onTransaction - Called on every transaction (for re-renders)
  • */
    +export function createEditor({ element, content, placeholder, onUpdate, onTransaction }) {
  • return new Editor({
  •    element,
    
  •    extensions: [
    
  •        StarterKit.configure({
    
  •            heading: { levels: [1, 2] },
    
  •            codeBlock: false,
    
  •            code: false,
    
  •            hardBreak: false,
    
  •            link: { openOnClick: false },
    
  •            strike: false
    
  •        }),
    
  •        Markdown,
    
  •        Placeholder.configure({ placeholder })
    
  •    ],
    
  •    content,
    
  •    onUpdate,
    
  •    onTransaction
    
  • });
    +}
    ```

@github-actions github-actions Bot added the Needs: Response Issues which require feedback from lead label Mar 26, 2026
@lokesh
Copy link
Copy Markdown
Collaborator

lokesh commented Mar 26, 2026

Suggested improvements to OLMarkdownEditor.js

Apply with: git apply <patch-file>

Changes

1. Sticky toolbar
Added position: sticky to .toolbar and max-height: 70vh; overflow-y: auto to .editor-wrapper. For long-form content (e.g. book descriptions), the toolbar scrolled out of view, making formatting controls inaccessible. Now the toolbar stays pinned at the top while content scrolls beneath it.

2. Mobile link popover fix
On mobile, the link URL popover (with min-width: 260px) overflowed the right edge of the editor, breaking the page layout. Fixed by making .link-popover-wrapper position: static on mobile so the popover stretches across the toolbar width instead of anchoring to the link button.

3. Removed underline button
The previous WMD editor did not support underline. Markdown has no native underline syntax — it would output raw <u> HTML. Removing it keeps the editor consistent with standard Markdown and avoids user confusion.

4. Responsive toolbar with overflow "More" menu
Split the toolbar into primary (always visible) and secondary (overflow on mobile) groups:

  • Primary: Undo, Redo, Bold, Italic, Link, Bullet List, Numbered List
  • Secondary: H1, H2, Blockquote, Divider

On desktop all buttons show inline. On mobile (<768px), secondary buttons move into a ... overflow dropdown. This is the standard pattern for mobile rich-text editors — most-used actions stay visible, less-used ones are one tap away.

5. Mutual exclusion of popovers
Opening the link popover closes the overflow menu, and vice versa. Clicking outside closes both. Two open popovers at once would be confusing and cause layout issues on mobile.

6. Styling refinements

  • Border/color tokens aligned with form input conventions (--border-input, --dark-grey)
  • Blockquote: --beige-deep border + --off-white bg + italic (matches OL's existing blockquote style)
  • Toolbar buttons: padding-based sizing instead of fixed width/height (flexible across screen sizes)
  • Hover guard: :hover wrapped in @media (hover: hover) and (pointer: fine) to prevent sticky hover on touch
  • Active press: transform: scale(0.95) on :active for tactile feedback
  • Removed inset box-shadow from .is-active state
  • Typography: explicit font-size, heading sizes, paragraph/list margins inside .tiptap
  • Toolbar mousedown: only preventDefault() when not clicking a button (avoids interfering with button focus)
  • Custom event renamed: markdown-changeol-markdown-editor-change (follows component namespace)

Patch

index 056843024..b27595b57 100644
--- a/openlibrary/components/lit/OLMarkdownEditor.js
+++ b/openlibrary/components/lit/OLMarkdownEditor.js
@@ -1,29 +1,28 @@
 import { LitElement, html, css } from 'lit';
 
 /**
- * OLMarkdownEditor - A web component for the tiptap wysiwyg editor
+ * A WYSIWYG markdown editor built on Tiptap.
  *
- * Implemented with Tiptap, this component is the new WYSIWYG editor that works with markdown format.
+ * Syncs its output to a hidden target element (textarea or input) identified by `target-id`.
+ * The target element must exist in the DOM before the editor connects.
  *
- * Important: Ensure that the `target-id` provided matches an existing input element in
- * the DOM, which needs to be hidden
+ * @element ol-markdown-editor
  *
- * Example:
+ * @prop {String} targetId - The ID of the DOM element to sync the Markdown output with.
+ * @prop {String} placeholder - Text to display when the editor is empty (default: 'Write something...').
+ *
+ * @fires ol-markdown-editor-change - Dispatched whenever the editor content changes. `e.detail.value` contains the raw markdown string.
+ *
+ * @example
  * <textarea id="body-input">value</textarea>
  * <ol-markdown-editor target-id="body-input" placeholder="Type here..."></ol-markdown-editor>
  *
- * @property {String} target-id - The ID of the DOM element to sync the Markdown output with.
- * @property {String} placeholder - Text to display when the editor is empty (default: 'Write something...').
- * * @fires markdown-change - Dispatched whenever the editor content changes. `e.detail.value` contains the raw markdown string.
- *
  * @example
  * <form action="/save" method="POST">
- * <div class="formElement">
- * <label for="page--body">Document Body:</label>
- * <textarea id="page--body" name="body">**Initial** markdown.</textarea>
- * <ol-markdown-editor target-id="page--body" placeholder="Write the main content..."></ol-markdown-editor>
- * </div>
- * <button type="submit">Save Document</button>
+ *   <label for="page--body">Document Body:</label>
+ *   <textarea id="page--body" name="body">**Initial** markdown.</textarea>
+ *   <ol-markdown-editor target-id="page--body" placeholder="Write the main content..."></ol-markdown-editor>
+ *   <button type="submit">Save Document</button>
  * </form>
  */
 
@@ -34,14 +33,14 @@ const ICONS = {
     h2: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2.5 4-4.5 4-6s-2.5-2-4-1"/></svg>`,
     bold: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/></svg>`,
     italic: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
-    underline: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
     link: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
     save: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
     remove: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
     quote: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
     hr: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
     ul: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
-    ol: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`
+    ol: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
+    more: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>`
 };
 
 export class OLMarkdownEditor extends LitElement {
@@ -51,30 +50,37 @@ export class OLMarkdownEditor extends LitElement {
         editor: { state: true },
         showLinkPopover: { state: true },
         linkInputValue: { state: true },
-        _errorMsg: { state: true }
+        _errorMsg: { state: true },
+        showOverflowMenu: { state: true }
     };
 
     static styles = css`
     .loading-placeholder {
-        color: var(--light-grey);
-        pointer-events: none;
-      }
+      color: var(--light-grey);
+      pointer-events: none;
+    }
 
     .editor-wrapper {
-      border: var(--border-card);
-      border-radius: var(--border-radius-lg);
+      border: var(--border-input);
+      border-radius: var(--border-radius-card);
       background: var(--white);
-      color: var(--grey);
+      color: var(--dark-grey);
+      max-height: 70vh;
+      overflow-y: auto;
     }
 
     .toolbar {
       display: flex;
       flex-wrap: wrap;
       gap: var(--spacing-inline-sm);
-      padding: var(--spacing-inset-sm);
+      padding: var(--spacing-inset-xs);
       border-bottom: var(--border-card);
+      border-radius: var(--border-radius-card) var(--border-radius-card) 0 0;
       background: var(--grey-f4f4f4);
       align-items: center;
+      position: sticky;
+      top: 0;
+      z-index: var(--z-index-level-5);
     }
 
     .toolbar-divider {
@@ -84,8 +90,8 @@ export class OLMarkdownEditor extends LitElement {
     }
 
     .editor-input {
-      padding: var(--spacing-inset-lg);
-      min-height: 250px;
+      padding: var(--spacing-inset-sm);
+      min-height: 200px;
       display: flex;
       flex-direction: column;
       cursor: text;
@@ -95,9 +101,29 @@ export class OLMarkdownEditor extends LitElement {
       outline: none;
       flex-grow: 1;
       font-family: var(--font-family-body);
+      font-size: var(--font-size-body, 0.875rem);
       line-height: var(--line-height-body);
     }
 
+    .editor-input .tiptap h1 {
+      font-size: var(--font-size-h1, 1.5rem);
+      margin: 0 0 0.5em;
+    }
+
+    .editor-input .tiptap h2 {
+      font-size: var(--font-size-h2, 1.25rem);
+      margin: 0 0 0.45em;
+    }
+
+    .editor-input .tiptap p {
+      margin: 0 0 0.55em;
+    }
+
+    .editor-input .tiptap ul,
+    .editor-input .tiptap ol {
+      margin: 0 0 0.55em;
+    }
+
     .editor-input .tiptap a {
       color: var(--link-blue);
     }
@@ -105,11 +131,11 @@ export class OLMarkdownEditor extends LitElement {
     .editor-input .tiptap blockquote {
       margin-left: var(--spacing-lg);
       padding: var(--spacing-sm) var(--spacing-lg);
-      border-left: var(--border-width-heavy) solid var(--darker-brand-blue);
-      color: var(--dark-grey);
-      background: var(--lightest-grey);
+      border-left: var(--border-width-thick) solid var(--beige-deep);
+      color: var(--darker-grey);
+      background: var(--off-white);
       font-style: italic;
-      font-family: var(--font-family-quote, var(--font-family-body));
+      font-family: var(--font-family-body);
     }
 
     .editor-input .tiptap blockquote p {
@@ -126,29 +152,32 @@ export class OLMarkdownEditor extends LitElement {
 
     .toolbar-btn {
       background: transparent;
-      border: var(--border-width-none, 0);
+      border: var(--border-width-none);
       border-radius: var(--border-radius-button);
-      width: var(--spacing-3xl);
-      height: var(--spacing-3xl);
+      padding: var(--spacing-inset-xs);
       cursor: pointer;
       color: var(--darker-grey);
-      transition: all 0.15s ease;
+      transition: background 0.15s ease, color 0.15s ease;
       display: flex;
       align-items: center;
       justify-content: center;
     }
 
     .toolbar-btn svg { width: var(--spacing-xl); height: var(--spacing-xl); stroke-width: 2.2; }
-    .toolbar-btn:hover:not(:disabled) { background: var(--lighter-grey); }
+
+    @media (hover: hover) and (pointer: fine) {
+      .toolbar-btn:hover:not(:disabled) { background: var(--lighter-grey); }
+    }
+
+    .toolbar-btn:active:not(:disabled) { transform: scale(0.95); }
 
     .toolbar-btn.is-active {
       background: var(--light-grey);
-      box-shadow: inset 0 1px 2px var(--boxshadow-black);
       color: var(--black);
     }
 
     .toolbar-btn:focus-visible {
-      outline: var(--focus-width, 2px) solid var(--color-focus-ring);
+      outline: var(--focus-width) solid var(--color-focus-ring);
       outline-offset: -2px;
     }
 
@@ -161,13 +190,22 @@ export class OLMarkdownEditor extends LitElement {
       top: calc(100% + var(--spacing-xs));
       border: var(--border-card);
       border-radius: var(--border-radius-overlay);
-      padding: var(--spacing-inset-sm);
+      padding: var(--spacing-inset-xs);
       box-shadow: 0 4px 15px var(--boxshadow-black);
       background: var(--white);
       display: flex;
       gap: var(--spacing-inline-md);
       min-width: 260px;
-      z-index: var(--z-index-level-5, 999);
+      z-index: var(--z-index-level-5);
+    }
+
+    @media (max-width: 767px) {
+      .link-popover-wrapper { position: static; }
+      .link-popover {
+        left: var(--spacing-inset-xs);
+        right: var(--spacing-inset-xs);
+        min-width: auto;
+      }
     }
 
     .link-input {
@@ -186,14 +224,40 @@ export class OLMarkdownEditor extends LitElement {
     }
 
     .error-state {
-      padding: var(--spacing-inset-lg);
-      border: var(--border-width-control, 1px) solid var(--color-border-error);
+      padding: var(--spacing-inset-sm);
+      border: var(--border-width-control) solid var(--color-border-error);
       background: var(--baby-pink);
       color: var(--dark-red);
       border-radius: var(--border-radius-notification);
       font-family: var(--font-family-body);
       margin-bottom: var(--spacing-stack-sm);
     }
+
+    .overflow-secondary {
+      display: contents;
+    }
+
+    .overflow-menu-wrapper { position: relative; display: inline-flex; }
+    .overflow-menu-wrapper.overflow-toggle { display: none; }
+
+    .overflow-menu {
+      position: absolute;
+      top: calc(100% + var(--spacing-xs));
+      right: 0;
+      border: var(--border-card);
+      border-radius: var(--border-radius-overlay);
+      padding: var(--spacing-inset-xs);
+      box-shadow: 0 4px 15px var(--boxshadow-black);
+      background: var(--white);
+      display: flex;
+      gap: var(--spacing-inline-sm);
+      z-index: var(--z-index-level-5);
+    }
+
+    @media (max-width: 767px) {
+      .overflow-secondary { display: none; }
+      .overflow-menu-wrapper.overflow-toggle { display: inline-flex; }
+    }
   `;
 
     constructor() {
@@ -203,6 +267,7 @@ export class OLMarkdownEditor extends LitElement {
         this.showLinkPopover = false;
         this.linkInputValue = '';
         this._errorMsg = null;
+        this.showOverflowMenu = false;
         this._handleDocumentClick = this._handleDocumentClick.bind(this);
     }
 
@@ -226,9 +291,10 @@ export class OLMarkdownEditor extends LitElement {
     }
 
     _handleDocumentClick(e) {
-        if (!this.showLinkPopover) return;
+        if (!this.showLinkPopover && !this.showOverflowMenu) return;
         if (!e.composedPath().includes(this)) {
             this.showLinkPopover = false;
+            this.showOverflowMenu = false;
         }
     }
 
@@ -273,7 +339,7 @@ export class OLMarkdownEditor extends LitElement {
                     this.targetElement.value = markdownOutput;
                 }
 
-                this.dispatchEvent(new CustomEvent('markdown-change', {
+                this.dispatchEvent(new CustomEvent('ol-markdown-editor-change', {
                     detail: { value: markdownOutput },
                     bubbles: true,
                     composed: true
@@ -297,7 +363,9 @@ export class OLMarkdownEditor extends LitElement {
         }
     }
 
-    _handleToolbarMouseDown(e) { e.preventDefault(); }
+    _handleToolbarMouseDown(e) {
+        if (!e.target.closest('.toolbar-btn')) e.preventDefault();
+    }
 
     _focusEditor() {
         if (!this.editor) return;
@@ -314,6 +382,7 @@ export class OLMarkdownEditor extends LitElement {
         if (!this.editor) return;
         this.showLinkPopover = !this.showLinkPopover;
         if (this.showLinkPopover) {
+            this.showOverflowMenu = false;
             this.linkInputValue = this.editor.getAttributes('link').href || '';
             setTimeout(() => this.shadowRoot.querySelector('.link-input')?.focus(), 0);
         }
@@ -372,19 +441,26 @@ export class OLMarkdownEditor extends LitElement {
             `;
         }
 
+        const secondaryButtons = html`
+          ${this._renderButton({ title: 'Heading 1', icon: ICONS.h1, action: () => this.formatHeading(1), isActive: this._isActive('heading', { level: 1 }) })}
+          ${this._renderButton({ title: 'Heading 2', icon: ICONS.h2, action: () => this.formatHeading(2), isActive: this._isActive('heading', { level: 2 }) })}
+          ${this._renderButton({ title: 'Blockquote', icon: ICONS.quote, action: this.formatQuote.bind(this), isActive: this._isActive('blockquote') })}
+          ${this._renderButton({ title: 'Divider', icon: ICONS.hr, action: this.insertRule.bind(this) })}
+        `;
+
         return html`
       <div class="editor-wrapper">
         <div class="toolbar" @mousedown="${this._handleToolbarMouseDown}">
           ${this._renderButton({ title: 'Undo', icon: ICONS.undo, action: () => this.editor.chain().focus().undo().run(), isDisabled: !this.editor || !this.editor.can().undo() })}
           ${this._renderButton({ title: 'Redo', icon: ICONS.redo, action: () => this.editor.chain().focus().redo().run(), isDisabled: !this.editor || !this.editor.can().redo() })}
-          <div class="toolbar-divider"></div>
-          ${this._renderButton({ title: 'Heading 1', icon: ICONS.h1, action: () => this.formatHeading(1), isActive: this._isActive('heading', { level: 1 }) })}
-          ${this._renderButton({ title: 'Heading 2', icon: ICONS.h2, action: () => this.formatHeading(2), isActive: this._isActive('heading', { level: 2 }) })}
+          <div class="toolbar-divider overflow-secondary"></div>
+          <span class="overflow-secondary">
+            ${this._renderButton({ title: 'Heading 1', icon: ICONS.h1, action: () => this.formatHeading(1), isActive: this._isActive('heading', { level: 1 }) })}
+            ${this._renderButton({ title: 'Heading 2', icon: ICONS.h2, action: () => this.formatHeading(2), isActive: this._isActive('heading', { level: 2 }) })}
+          </span>
           <div class="toolbar-divider"></div>
           ${this._renderButton({ title: 'Bold', icon: ICONS.bold, action: () => this.formatText('bold'), isActive: this._isActive('bold') })}
           ${this._renderButton({ title: 'Italic', icon: ICONS.italic, action: () => this.formatText('italic'), isActive: this._isActive('italic') })}
-          ${this._renderButton({ title: 'Underline', icon: ICONS.underline, action: () => this.formatText('underline'), isActive: this._isActive('underline') })}
-          <div class="toolbar-divider"></div>
           <div class="link-popover-wrapper">
             ${this._renderButton({ title: 'Link', icon: ICONS.link, action: this.toggleLinkPopover.bind(this), isActive: this._isActive('link') || this.showLinkPopover })}
             ${this.showLinkPopover ? html`
@@ -395,11 +471,22 @@ export class OLMarkdownEditor extends LitElement {
               </div>
             ` : ''}
           </div>
-          ${this._renderButton({ title: 'Blockquote', icon: ICONS.quote, action: this.formatQuote.bind(this), isActive: this._isActive('blockquote') })}
-          ${this._renderButton({ title: 'Divider', icon: ICONS.hr, action: this.insertRule.bind(this) })}
           <div class="toolbar-divider"></div>
           ${this._renderButton({ title: 'Bullet List', icon: ICONS.ul, action: () => this.formatList('bullet'), isActive: this._isActive('bulletList') })}
           ${this._renderButton({ title: 'Numbered List', icon: ICONS.ol, action: () => this.formatList('number'), isActive: this._isActive('orderedList') })}
+          <div class="toolbar-divider"></div>
+          <span class="overflow-secondary">
+            ${this._renderButton({ title: 'Blockquote', icon: ICONS.quote, action: this.formatQuote.bind(this), isActive: this._isActive('blockquote') })}
+            ${this._renderButton({ title: 'Divider', icon: ICONS.hr, action: this.insertRule.bind(this) })}
+          </span>
+          <div class="overflow-menu-wrapper overflow-toggle">
+            ${this._renderButton({ title: 'More', icon: ICONS.more, action: () => { this.showOverflowMenu = !this.showOverflowMenu; if (this.showOverflowMenu) this.showLinkPopover = false; }, isActive: this.showOverflowMenu })}
+            ${this.showOverflowMenu ? html`
+              <div class="overflow-menu" @mousedown="${(e) => e.stopPropagation()}">
+                ${secondaryButtons}
+              </div>
+            ` : ''}
+          </div>
         </div>
 
         <div id="editor-root" class="editor-input" @click="${this._focusEditor}">
diff --git a/static/css/components/form.olform.css b/static/css/components/form.olform.css
index cfc87b6c0..dd82816fa 100644
--- a/static/css/components/form.olform.css
+++ b/static/css/components/form.olform.css
@@ -22,7 +22,7 @@
 }
 
 .olform .label {
-  padding: 10px 0 2px;
+  padding: 10px 0 var(--spacing-stack-xs);
 }
 
 .olform .label label {
diff --git a/static/css/markdown.css b/static/css/markdown.css
index 8d2bc58dd..58561f66a 100644
--- a/static/css/markdown.css
+++ b/static/css/markdown.css
@@ -26,9 +26,9 @@
 .markdown-content blockquote {
   margin-left: 1rem;
   padding: 0.5rem 1rem;
-  border-left: 3px solid var(--darker-brand-blue);
-  color: var(--dark-grey);
-  background: var(--lightest-grey);
+  border-left: 2px solid var(--beige-deep);
+  color: var(--darker-grey);
+  background: var(--off-white);
   font-style: italic;
 }
 .markdown-content blockquote p {

@lokesh
Copy link
Copy Markdown
Collaborator

lokesh commented Mar 26, 2026

@Sadashii
I've made quite a few quality of life improvements and polished the design. Check out the patch above, screenshots below:

image image image

Sadashii and others added 8 commits April 4, 2026 22:34
Add an HtmlBlock tiptap extension that preserves raw HTML in markdown
content. HTML blocks display as editable source code in the editor with
a </> label. Includes toolbar button to insert new blocks, image
extension with popover, and a mixed markdown/HTML demo on the design
page. Removes the anchor-id extension in favor of plain HTML.
…penlibrary into 7308/feature/new-wysiwyg-editor

# Conflicts:
#	openlibrary/components/lit/OLMarkdownEditor.js
… overflow menu

- Add enable-html-block attribute to ol-markdown-editor (default off),
  only enabled on the page editor template
- Move image button into the "More" overflow menu on mobile viewports
…IWYG editor

Extend editor heading levels from [1, 2] to [1, 2, 3, 4] so existing
h3/h4 content on wiki pages (e.g. /about/lib) survives round-trips.

Add 76 Jest tests covering markdown round-trip fidelity, idempotency,
list indentation post-processing, HTML block preservation, and real
OpenLibrary page patterns.
@lokesh lokesh added Needs: Testing Lead: @lokesh Issues pertaining to front-end design system [css, js, components] On Testing and removed Needs: Response Issues which require feedback from lead labels Apr 9, 2026
@lokesh
Copy link
Copy Markdown
Collaborator

lokesh commented Apr 9, 2026

Added a suite of tests:
34708b9#diff-6331ad3e200dbe7e7f7708b04394eefbe64d1eb1585c8d31e968caaf3eb186e1

Tested with real data:

Results across 185 real records from openlibrary.org:

  ┌───────────────────────────────────────┬───────┬───────┐                                                                        
  │                                       │ Count │   %   │
  ├───────────────────────────────────────┼───────┼───────┤                                                                        
  │ Exact match                           │ 146   │ 78.9% │                   
  ├───────────────────────────────────────┼───────┼───────┤
  │ Stable (minor formatting, idempotent) │ 38    │ 20.5% │                                                                        
  ├───────────────────────────────────────┼───────┼───────┤
  │ Drift (non-idempotent)                │ 1     │ 0.5%  │                                                                        
  ├───────────────────────────────────────┼───────┼───────┤                                                                        
  │ Content loss                          │ 0     │ 0%    │
  └───────────────────────────────────────┴───────┴───────┘                                                                        

181 book descriptions: 146 exact, 35 stable, 0 drift, 0 content loss.

Wiki pages: /volunteer, /about/vision, /about/lib are all stable (idempotent after first pass). The "stable" changes are all harmless normalizations: reference-style links inlined, italicitalic, * list markers → -.

The 1 drift is /help/faq/editing — a 28K wiki page that's non-idempotent due to Top inline HTML converting to Top then slightly shifting on subsequent passes. This is the same inline anchor pattern we discussed, and the content itself is fully preserved (98% ratio).

lokesh and others added 5 commits April 14, 2026 11:39
…only)

Enables Tiptap's `code` and `codeBlock` marks behind a new `enable-code`
attribute on `<ol-markdown-editor>`, opted in only for the wiki/page edit
form (matching the existing `enable-html-block` gating).

Also teaches OLMarkdown to recognize GitHub-style fenced code blocks, so
the server-side renderer emits `<pre><code>` instead of literal backticks
+ `<br />`. The fenced block is converted to a 4-space indented block in
a preprocessor so the vendored Python-Markdown 1.6b engine (which predates
fences) handles the rest.

Tests cover editor round-trip (10 new cases) and renderer output
(fenced_code test covering basic, language tag, multi-line, HTML escaping
inside fences, and the full user-repro body).
- Mixed Markdown/HTML example: enable the HTML block button via
  enable-html-block, and show the attribute in the code snippet.
- Remove the custom placeholder example (not distinctive enough).
- New example: code blocks + inline code, gated on enable-code.
@lokesh
Copy link
Copy Markdown
Collaborator

lokesh commented Apr 14, 2026

Update: Wed Apr 15, 2pm PST - Not currently on testing. Will attempt to get it back up, but you can also test locally.

@cdrini I would love it you could vet the user experience and feature set of the new text editor. It's available on testing now...

@RayBB If you could help review the code that would be great.

cc: @mekarpeles

Note

I highly recommend reading the handoff document below as it does a good job summarizing the changes, how this was tested, and any risks. It's lengthy, but there is a lot of good stuff to cover!


PR #12182 — Review Handoff

PR: Feature - New WYSIWYG editor via tiptap
Closes: #7308, #2370
Branch: 7308/feature/new-wysiwyg-editor
Author: @Sadashii · Lead: @lokesh

This PR replaces the legacy WMD/showdown markdown editor with a new Tiptap-based WYSIWYG editor, exposed as a Lit custom element (<ol-markdown-editor>).


Review Part 1 — User Experience

What changed from the user's perspective

Dimension Old editor (WMD) New editor (Tiptap)
Paradigm "Wikitext"-style editor: user typed raw markdown and saw a separate HTML preview pane below True WYSIWYG: user sees formatted text inline as they type, no separate preview
Surface textarea + toolbar injected by jQuery WMD plugin + "Preview" block <ol-markdown-editor> Lit web component that hides the textarea and renders a rich editor. The textarea remains in the DOM (hidden) as the source of truth at submit.
Output Markdown (stored as markdown in the DB) Still markdown — same on-disk format, so no data migration
Help link Old toolbar had a link to /help/markdown Removed. Toolbar is discoverable enough that a help link isn't needed for core features.

The editor appears on these edit forms:

  1. Book edit — work description (books/edit/about.html)
  2. Edition edit — description + notes (books/edit/edition.html)
  3. Excerpts (books/edit/excerpts.html)
  4. Author bio (type/author/edit.html)
  5. User profile (type/user/edit.html)
  6. List description (type/list/edit.html)
  7. Wiki/page edit (type/page/edit.html) — only surface with HTML block button enabled
  8. Tag form (type/tag/tag_form_inputs.html)

Toolbar features (left → right)

  • Undo / Redo
  • Heading 1, Heading 2 (secondary group — collapsed into "More" on mobile)
  • Bold, Italic
  • Link — opens a small popover with URL input, save, and remove-link buttons. Works on selected text or creates a new link. Enter to save, Escape to cancel.
  • Image — popover with URL input. Inserts an image by URL. No upload UI — URL only. (On mobile, image moves into the "More" overflow menu.)
  • Bullet List, Numbered List
  • Blockquote, Horizontal rule (secondary — collapsed on mobile)
  • Inline code, Code block — gated behind the enable-code attribute. Only visible on the wiki page editor (type/page/edit.html). Code support is off everywhere else because most surfaces render markdown via OL's server renderer; the wiki editor is the first surface to have end-to-end fenced-code support now that OLMarkdown understands triple-backtick fences.
  • HTML block — gated behind the enable-html-block attribute. Only visible on the wiki page editor (type/page/edit.html). Inserts an editable <textarea> region for raw HTML.
  • "More" overflow menu — appears only below 768px; holds H1/H2, image, blockquote, HR, and (where enabled) inline code, code block, HTML block.

What markdown/HTML is supported

Headings: #, ##, ###, #### (H1–H4). H5/H6 are downgraded to paragraphs. Note: toolbar only exposes H1/H2 buttons; H3/H4 must be typed as ### / #### but round-trip safely — this was deliberately expanded from the initial [1,2] levels so existing /about/lib and similar wiki content doesn't lose headings.

Inline: bold, italic, combined bold+italic, links, bare URLs (auto-linkified via linkify: true).

Blocks: paragraphs, bullet lists, ordered lists, nested lists (auto-normalized to 4-space indent to match legacy Python olmarkdown), blockquotes, horizontal rule, images (inline, URL-only — base64 disallowed).

Arbitrary HTML: supported only on the wiki page editor via the HTML block node. Appears as a dashed-bordered box with a </> HTML label and a raw source <textarea> inside the editor. Round-trips as html_block tokens. On other surfaces, inline HTML typed into markdown will survive through the round-trip at the paragraph level but there is no UI for authoring it.

Code (wiki only): inline `code` and fenced ```…``` blocks are enabled via the enable-code attribute on the wiki page editor. A matching FencedCodePreprocessor was added to openlibrary/core/olmarkdown.py so the server-side renderer emits <pre><code> instead of literal backticks + <br />. Both round-trip cleanly.

Disabled from Tiptap StarterKit: strike (strikethrough). code and codeBlock default-off but re-enabled per-surface via enable-code.

Nesting: yes — you can bold inside italic, links inside a heading, links inside list items, lists inside blockquotes, etc. (tested).

Images — upload? No. URL-only. Users must already have the image hosted somewhere. This matches old WMD behavior.

Testing performed

Automated — new Jest suite at tests/unit/js/OLMarkdownEditor.test.js (86 tests), runs as part of npm run test:js. It covers:

  • Round-trip fidelity (markdown → editor → markdown) for plain text, bold/italic, all heading levels, links, lists, blockquotes, HR, images.
  • Idempotency: double-round-trip equals single-round-trip for complex content, links, nested lists, real-world OL descriptions.
  • Mutation detection: verifies nothing is silently dropped (paragraphs not merged, list items not lost, URLs not mangled, link query params preserved).
  • Real OL page patterns: live content sampled from /volunteer, /about/lib, /help/faq/editing, and real book descriptions (LOTR, Ethan Frome).
  • HTML block round-trip: raw HTML preservation, tables, mixed markdown+HTML.
  • List indentation post-processing: unit tests for the 2-space → 4-space normalizer that bridges Tiptap's convention to OL's legacy olmarkdown Python renderer.
  • Code support (10 tests): inline code + fenced code block round-trip with enableCode: true, plus explicit assertions that <br /> is not injected inside fenced blocks, that fences don't collapse to single-backtick inline form, and a full reproduction of the heading + fence + inline + HTML block case that was reported as broken during review.
  • Graceful degradation: documents what happens to unsupported syntax (tables, reference links, strike, H5/H6).

Python-side: openlibrary/tests/core/test_olmarkdown.py gains test_olmarkdown_fenced_code covering basic fences, language info strings, multi-line preservation, HTML escaping inside fences, inline-code regression, and the full user repro body.

Manual — Tested all 8 edit surfaces above. Worth spot-checking before merge:

  • A book description that already has mixed markdown (bold + paragraphs + a link)
  • A wiki page that uses H3 headings, reference-style links, or inline <a name="anchor"> (content like /volunteer, /about/lib) — round-trip is idempotent but reference links get inlined on save, and name= anchor attrs are dropped (heading text + link survive).
  • Author bio, user profile — shorter content, simpler.
  • The wiki page editor specifically, to validate the HTML-block UX.

Risks to existing content

Risk Severity Notes
<a name="x"> anchors inside headings lose the name attr Low Text + link survive; legacy in-page anchors break. /volunteer and /help/faq/editing may have a few. Will require manual updating (5min est)
H5/H6 headings downgrade to paragraph Low Text preserved; visual hierarchy flattens. Rare.
Inline code, fenced code blocks on non-wiki surfaces strip formatting Low Content survives as plain text; code support is wiki-only (see enable-code).
HTML block button gated to wiki pages only By design Author bios / book descriptions can't author raw HTML — but existing HTML in those fields still round-trips through the editor fine.
User can't upload images By design URL-only. Same as before.

Things reviewer A should specifically verify

  1. Open each of the 8 edit surfaces, confirm the new editor renders and saves unchanged content unchanged (round-trip).
  2. On the wiki page editor, confirm the HTML block, inline code, and code block buttons appear and work; on all other surfaces, confirm they do not appear.
  3. On mobile viewport (<768px), confirm the "More" overflow menu appears with the secondary buttons (H1, H2, image, blockquote, HR, [inline code / code block / HTML if enabled]).
  4. Paste a known-good OL description from /volunteer or /about/lib into a scratch form, save, confirm visual output is unchanged.

Review Part 2 — Code

Major changes

New — Lit web component system for the editor

  • openlibrary/components/lit/OLMarkdownEditor.js (594 lines) — the <ol-markdown-editor> element. Handles toolbar UI, popover state for link/image, overflow menu, error state, label-click proxying.
  • openlibrary/components/lit/editor-core.js — thin factory that instantiates Tiptap with StarterKit, tiptap-markdown, Placeholder, Image, and the custom HtmlBlock. Lazy-loaded via dynamic import('./editor-core.js') inside firstUpdated() so the Tiptap/ProseMirror payload is only pulled when an editor is actually mounted.
  • openlibrary/components/lit/html-block.js — custom Tiptap Node that stores raw HTML as base64 on data-content, renders an editable <textarea> node view, and registers a markdown-it rule to capture html_block tokens on parse / write raw HTML on serialize.
  • openlibrary/components/lit/index.js registers the element.

Removed — legacy WMD editor

  • Deletes the vendor/js/wmd submodule (and its .gitmodules entry).
  • Deletes openlibrary/plugins/openlibrary/js/markdown-editor/ (the jQuery init wrapper).
  • Deletes static/css/components/wmd-button-bar.css, static/css/components/wmd-prompt-dialog--js.css, static/css/legacy-wmd.css.
  • Removes wmd references from static/css/base/common.css, form.olform.css, work.css, js-all.css, legacy.css, markdown.css, page-design.css.
  • Removes the import(...markdown-editor) dynamic import in openlibrary/plugins/openlibrary/js/index.js.

Templates — 8 edit templates updated: old class="markdown" textarea + jQuery plugin swapped for a plain textarea + <ol-markdown-editor target-id="...">. IDs changed from the shared wmd-input to per-field IDs.

Design librarydesign.html gains a new "Markdown Editor" pattern section with live demos (basic, mixed markdown+HTML with enable-html-block, code blocks + inline code with enable-code, change-event). design/popover.html got a small formatting cleanup while touching the same file.

Pythonopenlibrary/core/olmarkdown.py gets a new FencedCodePreprocessor inserted at the front of the preprocessor pipeline. It rewrites GitHub-style ```…``` fences to 4-space indented blocks so the vendored Python-Markdown 1.6b engine (which predates fences) renders them as <pre><code>. Additive change — existing indented code blocks, inline backticks, and everything else behave identically. This applies wherever OLMarkdown is used (wiki, book/author descriptions, edition notes, list descriptions), so the renderer gains fenced-code support everywhere even though the editor only exposes the buttons on the wiki surface. The editor's list-indent normalization (2-space → 4-space) is what keeps client output compatible with server-side olmarkdown.

Dependencies (package.json) — adds @tiptap/core, @tiptap/starter-kit, @tiptap/extension-image, @tiptap/extension-placeholder, @tiptap/pm, tiptap-markdown. lit is already present.

Architecture

Site-wide footer.html loads ol-components.js (Lit bundle) — eager, on every page
                                │
                                ▼
       customElements.define('ol-markdown-editor', ...)
                                │
  (editor is present on page)   ▼
              firstUpdated() → dynamic import('./editor-core.js')
                                │
                                ▼
       Tiptap + ProseMirror + tiptap-markdown pulled on demand
  • Build: openlibrary/components/vite-lit.config.mjs builds ol-components.js (+ legacy polyfills) into static/build/lit-components/production/. Vite should code-split editor-core.js into its own chunk via the dynamic import.
  • Shadow DOM: OLMarkdownEditor uses Lit's default open shadow DOM. All styles scoped to the component.
  • Data flow: the editor keeps the hidden textarea as source-of-truth. onUpdate writes markdown back to targetElement.value, which is what the form submits. Also dispatches an ol-markdown-editor-change CustomEvent (bubbles, composed) with detail.value for programmatic listeners.
  • State: component-local Lit reactive state (showLinkPopover, showImagePopover, showOverflowMenu, _errorMsg, editor instance).

Risks / concerns

  1. Custom popover implementation instead of <ol-popover>. A reusable OlPopover Lit component exists in the same directory and is not used here. The editor rolls its own popover for link and image (positioning, outside-click, mobile layout). If we standardize popover behavior, this is a future refactor target. Won't regress by leaving it as-is.
  2. Python olmarkdown.py gains fenced-code support. New FencedCodePreprocessor runs before LineBreaksPreprocessor and AUTOLINK_PREPROCESSOR, rewriting ```…``` fences to 4-space indented blocks. The editor is still configured with breaks: true + linkify: true to match the existing preprocessors, and code support in Tiptap is gated on enable-code so the server-side rendering and the editor agree. Any future change to the Python renderer must still be mirrored on the Tiptap side.
  3. HTML sanitization on the serveropenlibrary/core/helpers.py:sanitize() uses Genshi's HTMLSanitizer. The editor sends raw HTML blocks through as markdown; sanitization happens on render, not on save. Confirm the HTML-block feature doesn't open any new XSS vectors beyond what WMD already allowed (user could already submit raw HTML in the old editor — same sanitize path).

Continuing to iterate on this editor

Where to add toolbar buttons: in OLMarkdownEditor.js, render(). Follow the existing _renderButton({...}) pattern. If the feature is block-level, also add it to the secondaryButtons group so it collapses into the "More" menu on mobile. If it maps to a Tiptap command not already enabled in StarterKit, add the extension in editor-core.js.

Adding a Tiptap extension (e.g., table, task list):

  1. npm install @tiptap/extension-table (or equivalent).
  2. Import and register in editor-core.js. Any extension with a markdown.serialize/parse hook for tiptap-markdown will round-trip; otherwise content silently degrades — add a round-trip test.
  3. Add toolbar affordance in OLMarkdownEditor.js.
  4. Add round-trip tests in tests/unit/js/OLMarkdownEditor.test.js. The existing suite's roundTrip() helper is the right starting point.

Gating features per surface: mirror the enable-html-block attribute pattern — boolean attribute on the element → Lit property → conditional _renderButton in render. Keeps the component one binary instead of N variants.

Customizing per-form: the component accepts target-id, placeholder, height, enable-html-block. For richer customization (e.g., toolbar profiles), introduce an attribute like toolbar="minimal|standard|full" or slot-based toolbar injection — don't proliferate boolean attributes.

Limitations to know when extending:

  • Strike is intentionally disabled in StarterKit. Re-enabling requires CSS + toolbar affordances + migration plan for existing stored ~~ tokens.
  • Inline code and fenced code blocks are off by default; opt in per-surface with enable-code. Only the wiki page editor has it enabled today. Other surfaces can turn it on now that OLMarkdown renders fences end-to-end, but the decision to expose the buttons should be made per surface.
  • Tiptap-markdown is the serialization layer. Anything Tiptap can edit but the serializer can't represent will be silently dropped on save. Always write a round-trip test before shipping a new extension.
  • List indent normalization happens as a regex post-process in onUpdate. Any nested-list change in Tiptap or tiptap-markdown (e.g., they switch default indent) could silently break this — the test suite covers it, but keep that in mind.
  • The editor hides the real textarea with display: none. Any form code that reads/writes textarea.value directly still works (that's the contract). Anything that depends on textarea being visible (e.g., :focus styling, character counters listening to DOM events) will not see user interaction.

Where should this editor be used in the future?

Use <ol-markdown-editor> whenever:

  • A form field stores markdown and is edited by humans.
  • The field is reasonably-sized prose (description, bio, notes, body). Single-line inputs or free-form tag strings are not a fit.

Do not use it for:

  • Plain-text-only fields (names, titles, identifiers, subjects) — there's nothing to format.
  • Structured data that happens to be in a textarea (CSV, JSON, query strings).
  • Fields where the server does not render markdown on the way out.

Toolbar profile recommendations for future surfaces:

  • Minimal (bio, comment-like): bold, italic, link, bullet list.
  • Standard (description, notes — current default): everything except HTML block and code.
  • Full (wiki pages only): + HTML block + code (inline + block).

If/when we need distinct profiles, prefer a single toolbar="..." attribute over per-button booleans.

@lokesh lokesh requested review from RayBB and cdrini April 14, 2026 22:25
Copy link
Copy Markdown
Collaborator

@RayBB RayBB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In broad strokes this looks great. I'm very excited for it.

I have two relatively minor comments here.

Would it be feasible for either of you to record a talk through of the code changes?
I read the handoff doc but it still is a lot to wrap my head around the code for all those.

I'd also be curious to understand why this is in Lit vs Vue. Not that it matters super much but in the docs it says:

If you're considering Vue for a new feature, discuss it in the issue first.
So curious if there's ever a case we'd use Vue for something new?

Comment on lines +42 to +59
i = 0
n = len(lines)
while i < n:
if self.FENCE_RE.match(lines[i]):
j = i + 1
while j < n and not self.FENCE_RE.match(lines[j]):
j += 1
if j < n:
if result and result[-1].strip():
result.append("")
for inner in lines[i + 1 : j]:
result.append(" " + inner)
if j + 1 < n and lines[j + 1].strip():
result.append("")
i = j + 1
continue
result.append(lines[i])
i += 1
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use more descriptive variable names here? Maybe also some tests explicitly for this function. It seems to be part of test_olmarkdown_fenced_code

Comment thread package.json
Comment on lines +119 to 126
"dependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/extension-image": "^3.22.1",
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"tiptap-markdown": "^0.9.0"
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why exactly.. but we seem to have a tradition of putting everything in devDependencies so maybe these should go there too?

@cdrini
Copy link
Copy Markdown
Collaborator

cdrini commented Apr 24, 2026

Howdy folks! Trying to give this a test, could we rebase this off latest master branch? Seeing some conflicts.

@RayBB RayBB added the Needs: Submitter Input Waiting on input from the creator of the issue/pr [managed] label Apr 24, 2026
# Conflicts:
#	openlibrary/i18n/messages.pot
#	openlibrary/templates/design.html
@github-actions github-actions Bot removed the Needs: Submitter Input Waiting on input from the creator of the issue/pr [managed] label Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Lead: @lokesh Issues pertaining to front-end design system [css, js, components] Needs: Testing On Testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve WYSIWYG: Switch wmd markdown editor Review legacy-wmd.less

5 participants