Skip to content

Multiple Editors

ts-medium-editor supports managing multiple editor instances on a single page, each with independent configurations, content, and behavior. This is useful for complex applications with multiple content areas, forms, or collaborative editing scenarios.

Overview

Multiple editor instances can be created and managed independently, allowing for:

  • Different configurations per editor
  • Independent content management
  • Separate event handling
  • Isolated extension systems
  • Cross-editor communication when needed

Basic Multiple Editors

Creating Multiple Instances

typescript
import { MediumEditor } from 'ts-medium-editor'

// Create multiple editors with different configurations
const titleEditor = new MediumEditor('#title', {
  toolbar: {
    buttons: ['bold', 'italic']
  },
  placeholder: {
    text: 'Enter your title...'
  }
})

const contentEditor = new MediumEditor('#content', {
  toolbar: {
    buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote']
  },
  placeholder: {
    text: 'Write your story...'
  }
})

const summaryEditor = new MediumEditor('#summary', {
  toolbar: {
    buttons: ['bold', 'italic']
  },
  placeholder: {
    text: 'Brief summary...'
  }
})

HTML Structure

html
<div class="article-form">
  <div id="title" class="title-editor" contenteditable="true"></div>
  <div id="content" class="content-editor" contenteditable="true"></div>
  <div id="summary" class="summary-editor" contenteditable="true"></div>
</div>

Editor Manager Pattern

Centralized Management

typescript
class EditorManager {
  private editors = new Map<string, MediumEditor>()
  private configs = new Map<string, any>()

  constructor() {
    this.setupDefaultConfigs()
  }

  private setupDefaultConfigs() {
    this.configs.set('title', {
      toolbar: {
        buttons: ['bold', 'italic']
      },
      placeholder: {
        text: 'Enter title...'
      }
    })

    this.configs.set('content', {
      toolbar: {
        buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote']
      },
      placeholder: {
        text: 'Write your content...'
      }
    })

    this.configs.set('summary', {
      toolbar: {
        buttons: ['bold', 'italic']
      },
      placeholder: {
        text: 'Brief summary...'
      }
    })
  }

  createEditor(id: string, selector: string, customConfig?: any): MediumEditor {
    const config = {
      ...this.configs.get(id),
      ...customConfig
    }

    const editor = new MediumEditor(selector, config)
    this.editors.set(id, editor)

    // Set up cross-editor communication
    this.setupCommunication(id, editor)

    return editor
  }

  getEditor(id: string): MediumEditor | undefined {
    return this.editors.get(id)
  }

  getAllEditors(): Map<string, MediumEditor> {
    return new Map(this.editors)
  }

  destroyEditor(id: string): void {
    const editor = this.editors.get(id)
    if (editor) {
      editor.destroy()
      this.editors.delete(id)
    }
  }

  destroyAll(): void {
    this.editors.forEach(editor => editor.destroy())
    this.editors.clear()
  }

  private setupCommunication(id: string, editor: MediumEditor) {
    // Set up event forwarding for cross-editor communication
    editor.subscribe('editableInput', (event, editable) => {
      this.handleEditorChange(id, editor, event, editable)
    })
  }

  private handleEditorChange(id: string, editor: MediumEditor, event: Event, editable: HTMLElement) {
    // Broadcast change to other editors if needed
    this.broadcastChange(id, {
      editorId: id,
      content: editor.getContent(),
      event,
      editable
    })
  }

  private broadcastChange(sourceId: string, data: any) {
    this.editors.forEach((editor, id) => {
      if (id !== sourceId) {
        editor.trigger('crossEditorChange', data)
      }
    })
  }
}

// Usage
const editorManager = new EditorManager()

// Create editors
const titleEditor = editorManager.createEditor('title', '#title')
const contentEditor = editorManager.createEditor('content', '#content')
const summaryEditor = editorManager.createEditor('summary', '#summary')

Cross-Editor Communication

Shared State Management

typescript
class SharedStateManager {
  private state = new Map<string, any>()
  private subscribers = new Map<string, Set<MediumEditor>>()

  setState(key: string, value: any): void {
    const oldValue = this.state.get(key)
    this.state.set(key, value)

    // Notify all subscribed editors
    const editorSet = this.subscribers.get(key)
    if (editorSet) {
      editorSet.forEach((editor) => {
        editor.trigger('sharedStateChange', {
          key,
          value,
          oldValue
        })
      })
    }
  }

  getState(key: string): any {
    return this.state.get(key)
  }

  subscribe(key: string, editor: MediumEditor): void {
    if (!this.subscribers.has(key)) {
      this.subscribers.set(key, new Set())
    }
    this.subscribers.get(key)!.add(editor)
  }

  unsubscribe(key: string, editor: MediumEditor): void {
    const editorSet = this.subscribers.get(key)
    if (editorSet) {
      editorSet.delete(editor)
    }
  }
}

// Usage with multiple editors
const sharedState = new SharedStateManager()

// Subscribe editors to shared state
sharedState.subscribe('documentData', titleEditor)
sharedState.subscribe('documentData', contentEditor)
sharedState.subscribe('documentData', summaryEditor)

// Listen for state changes in each editor
titleEditor.subscribe('sharedStateChange', (event, data) => {
  if (data.key === 'documentData') {
    console.log('Document data changed:', data.value)
  }
})

Content Synchronization

typescript
class ContentSynchronizer {
  private editors: Map<string, MediumEditor>
  private syncRules: Map<string, SyncRule[]>

  constructor(editors: Map<string, MediumEditor>) {
    this.editors = editors
    this.syncRules = new Map()
    this.setupSynchronization()
  }

  addSyncRule(sourceEditor: string, rule: SyncRule): void {
    if (!this.syncRules.has(sourceEditor)) {
      this.syncRules.set(sourceEditor, [])
    }
    this.syncRules.get(sourceEditor)!.push(rule)
  }

  private setupSynchronization(): void {
    this.editors.forEach((editor, id) => {
      editor.subscribe('editableInput', (event, editable) => {
        this.handleContentChange(id, editor)
      })
    })
  }

  private handleContentChange(sourceId: string, sourceEditor: MediumEditor): void {
    const rules = this.syncRules.get(sourceId)
    if (!rules)
      return

    const sourceContent = sourceEditor.getContent()

    rules.forEach((rule) => {
      const targetEditor = this.editors.get(rule.targetEditor)
      if (targetEditor) {
        const transformedContent = rule.transform(sourceContent)
        if (transformedContent !== null) {
          targetEditor.setContent(transformedContent)
        }
      }
    })
  }
}

interface SyncRule {
  targetEditor: string
  transform: (content: string) => string | null
}

// Usage
const synchronizer = new ContentSynchronizer(editorManager.getAllEditors())

// Sync title to summary (first 100 characters)
synchronizer.addSyncRule('title', {
  targetEditor: 'summary',
  transform: (content: string) => {
    const text = content.replace(/<[^>]*>/g, '') // Strip HTML
    return text.length > 100 ? `${text.substring(0, 100)}...` : text
  }
})

// Sync content word count to a display element
synchronizer.addSyncRule('content', {
  targetEditor: 'wordCount',
  transform: (content: string) => {
    const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).length
    return `${words} words`
  }
})

Form Integration

Multi-Editor Form

typescript
class ArticleForm {
  private editorManager: EditorManager
  private formData = {
    title: '',
    content: '',
    summary: '',
    tags: []
  }

  constructor() {
    this.editorManager = new EditorManager()
    this.setupEditors()
    this.setupFormHandling()
  }

  private setupEditors(): void {
    // Create editors with form-specific configurations
    this.editorManager.createEditor('title', '#title', {
      toolbar: {
        buttons: ['bold', 'italic']
      },
      placeholder: {
        text: 'Article title...'
      }
    })

    this.editorManager.createEditor('content', '#content', {
      toolbar: {
        buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote']
      },
      placeholder: {
        text: 'Write your article...'
      }
    })

    this.editorManager.createEditor('summary', '#summary', {
      toolbar: {
        buttons: ['bold', 'italic']
      },
      placeholder: {
        text: 'Brief summary...'
      }
    })
  }

  private setupFormHandling(): void {
    // Listen for changes in all editors
    this.editorManager.getAllEditors().forEach((editor, id) => {
      editor.subscribe('editableInput', () => {
        this.updateFormData(id, editor.getContent())
      })
    })

    // Set up form submission
    const form = document.getElementById('article-form') as HTMLFormElement
    form.addEventListener('submit', this.handleSubmit.bind(this))
  }

  private updateFormData(field: string, content: string): void {
    if (field in this.formData) {
      (this.formData as any)[field] = content
    }

    // Validate form
    this.validateForm()
  }

  private validateForm(): void {
    const isValid = this.formData.title.trim().length > 0
      && this.formData.content.trim().length > 0

    const submitButton = document.getElementById('submit-button') as HTMLButtonElement
    submitButton.disabled = !isValid
  }

  private async handleSubmit(event: Event): Promise<void> {
    event.preventDefault()

    try {
      const response = await fetch('/api/articles', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(this.formData)
      })

      if (response.ok) {
        this.showSuccess('Article saved successfully!')
        this.resetForm()
      }
      else {
        this.showError('Failed to save article')
      }
    }
    catch (error) {
      this.showError('Network error occurred')
    }
  }

  private resetForm(): void {
    this.editorManager.getAllEditors().forEach((editor) => {
      editor.setContent('')
    })

    this.formData = {
      title: '',
      content: '',
      summary: '',
      tags: []
    }
  }

  private showSuccess(message: string): void {
    // Show success message
  }

  private showError(message: string): void {
    // Show error message
  }
}

// Initialize form
const articleForm = new ArticleForm()

Performance Optimization

Lazy Loading Editors

typescript
class LazyEditorManager {
  private editorConfigs = new Map<string, any>()
  private loadedEditors = new Map<string, MediumEditor>()
  private observers = new Map<string, IntersectionObserver>()

  registerEditor(id: string, selector: string, config: any): void {
    this.editorConfigs.set(id, { selector, config })
    this.setupLazyLoading(id, selector)
  }

  private setupLazyLoading(id: string, selector: string): void {
    const element = document.querySelector(selector)
    if (!element)
      return

    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && !this.loadedEditors.has(id)) {
          this.loadEditor(id)
          observer.disconnect()
        }
      })
    }, {
      rootMargin: '100px' // Load when element is 100px away from viewport
    })

    observer.observe(element)
    this.observers.set(id, observer)
  }

  private loadEditor(id: string): void {
    const config = this.editorConfigs.get(id)
    if (!config)
      return

    const editor = new MediumEditor(config.selector, config.config)
    this.loadedEditors.set(id, editor)

    console.log(`Editor ${id} loaded lazily`)
  }

  getEditor(id: string): MediumEditor | undefined {
    return this.loadedEditors.get(id)
  }

  forceLoad(id: string): MediumEditor | undefined {
    if (!this.loadedEditors.has(id)) {
      this.loadEditor(id)
    }
    return this.loadedEditors.get(id)
  }

  destroy(): void {
    this.observers.forEach(observer => observer.disconnect())
    this.loadedEditors.forEach(editor => editor.destroy())
    this.observers.clear()
    this.loadedEditors.clear()
    this.editorConfigs.clear()
  }
}

// Usage
const lazyManager = new LazyEditorManager()

// Register editors that will be loaded when needed
lazyManager.registerEditor('comments', '#comments-editor', {
  toolbar: { buttons: ['bold', 'italic'] }
})

lazyManager.registerEditor('notes', '#notes-editor', {
  toolbar: { buttons: ['bold', 'italic', 'underline'] }
})

Memory Management

typescript
class MemoryEfficientEditorManager {
  private activeEditors = new Map<string, MediumEditor>()
  private editorPool: MediumEditor[] = []
  private maxPoolSize = 5
  private maxActiveEditors = 3

  getEditor(id: string, selector: string, config: any): MediumEditor {
    // Check if editor is already active
    if (this.activeEditors.has(id)) {
      return this.activeEditors.get(id)!
    }

    // If we have too many active editors, deactivate the oldest
    if (this.activeEditors.size >= this.maxActiveEditors) {
      this.deactivateOldestEditor()
    }

    // Try to reuse an editor from the pool
    let editor = this.editorPool.pop()
    if (editor) {
      this.reconfigureEditor(editor, selector, config)
    }
    else {
      editor = new MediumEditor(selector, config)
    }

    this.activeEditors.set(id, editor)
    return editor
  }

  private deactivateOldestEditor(): void {
    const [oldestId, oldestEditor] = this.activeEditors.entries().next().value

    // Save editor state if needed
    this.saveEditorState(oldestId, oldestEditor)

    // Move to pool if there's space
    if (this.editorPool.length < this.maxPoolSize) {
      this.editorPool.push(oldestEditor)
    }
    else {
      oldestEditor.destroy()
    }

    this.activeEditors.delete(oldestId)
  }

  private reconfigureEditor(editor: MediumEditor, selector: string, config: any): void {
    // Reconfigure existing editor for new use
    // This is a simplified version - actual implementation would be more complex
    editor.setup(selector, config)
  }

  private saveEditorState(id: string, editor: MediumEditor): void {
    // Save editor content and state for later restoration
    const state = {
      content: editor.getContent(),
      selection: editor.exportSelection()
    }
    localStorage.setItem(`editor-state-${id}`, JSON.stringify(state))
  }

  restoreEditorState(id: string, editor: MediumEditor): void {
    const stateJson = localStorage.getItem(`editor-state-${id}`)
    if (stateJson) {
      const state = JSON.parse(stateJson)
      editor.setContent(state.content)
      if (state.selection) {
        editor.importSelection(state.selection)
      }
    }
  }

  destroy(): void {
    this.activeEditors.forEach(editor => editor.destroy())
    this.editorPool.forEach(editor => editor.destroy())
    this.activeEditors.clear()
    this.editorPool.length = 0
  }
}

Testing Multiple Editors

Test Setup

typescript
// multiple-editors.test.ts
describe('Multiple Editors', () => {
  let container: HTMLElement
  let editorManager: EditorManager

  beforeEach(() => {
    container = document.createElement('div')
    container.innerHTML = `
      <div id="title" contenteditable="true"></div>
      <div id="content" contenteditable="true"></div>
      <div id="summary" contenteditable="true"></div>
    `
    document.body.appendChild(container)

    editorManager = new EditorManager()
  })

  afterEach(() => {
    editorManager.destroyAll()
    document.body.removeChild(container)
  })

  test('should create multiple independent editors', () => {
    const titleEditor = editorManager.createEditor('title', '#title')
    const contentEditor = editorManager.createEditor('content', '#content')

    expect(titleEditor).toBeDefined()
    expect(contentEditor).toBeDefined()
    expect(titleEditor).not.toBe(contentEditor)
  })

  test('should maintain independent content', () => {
    const titleEditor = editorManager.createEditor('title', '#title')
    const contentEditor = editorManager.createEditor('content', '#content')

    titleEditor.setContent('Title Content')
    contentEditor.setContent('Content Body')

    expect(titleEditor.getContent()).toBe('Title Content')
    expect(contentEditor.getContent()).toBe('Content Body')
  })

  test('should handle cross-editor communication', (done) => {
    const titleEditor = editorManager.createEditor('title', '#title')
    const contentEditor = editorManager.createEditor('content', '#content')

    contentEditor.subscribe('crossEditorChange', (event, data) => {
      expect(data.editorId).toBe('title')
      expect(data.content).toBe('New Title')
      done()
    })

    titleEditor.setContent('New Title')
    // Trigger input event to simulate user input
    const inputEvent = new Event('input')
    titleEditor.elements[0].dispatchEvent(inputEvent)
  })
})

Next Steps

Released under the MIT License.