This vignette demonstrates how to build a collaborative meeting notes
application using autoedit’s editor() widget with a sync
server, and explains why simpler approaches using standard textareas are
not suitable for real-time collaboration.
Setup
To run these examples, you need the autoedit package installed:
# install.packages("pak")
pak::pak("shikokuchuo/autoedit")Open multiple browser windows or tabs pointing to the same Shiny app URL to see real-time collaboration in action.
Collaborative editor with sync server
The editor() widget provides the best collaborative
editing experience. It uses CodeMirror 6 with the
automerge-codemirror integration, which applies Automerge
operations directly to CodeMirror’s document model. This means your
cursor stays in place when others edit, you get full syntax
highlighting, and documents can be saved and restored across
sessions.
Editor example with autosync
The following example uses the autosync package, which provides an in-process Automerge sync server:
library(shiny)
library(bslib)
library(autoedit)
library(autosync)
library(automerge)
# Room-specific initial content templates
room_templates <- list(
standup = "# Daily Standup\n\n## What I did yesterday\n- \n\n## What I'm doing today\n- \n\n## Blockers\n- \n",
planning = "# Sprint Planning\n\n## Goals\n- \n\n## User Stories\n- \n\n## Capacity\n- \n",
retrospective = "# Retrospective\n\n## What went well\n- \n\n## What could be improved\n- \n\n## Action items\n- \n",
brainstorm = "# Brainstorming Session\n\n## Ideas\n- \n\n## Discussion Notes\n- \n"
)
# Create and start the sync server (once, at app startup)
sync_server <- amsync_server()
sync_server$start()
# Create a document for each room with initial content
doc_ids <- list()
for (room in names(room_templates)) {
doc_ids[[room]] <- create_document(sync_server)
doc <- get_document(sync_server, doc_ids[[room]])
am_put(doc, AM_ROOT, "text", am_text(room_templates[[room]]))
am_commit(doc, "init")
}
ui <- page_fillable(
padding = "1rem",
div(class = "d-flex justify-content-between align-items-center mb-3",
h2("Collaborative Meeting Notes", class = "mb-0"),
downloadButton("export", "Export as Quarto", class = "btn-sm")
),
navset_card_pill(
id = "room",
nav_panel("Daily Standup", value = "standup", uiOutput("editor_standup")),
nav_panel("Sprint Planning", value = "planning", uiOutput("editor_planning")),
nav_panel("Retrospective", value = "retrospective", uiOutput("editor_retrospective")),
nav_panel("Brainstorming", value = "brainstorm", uiOutput("editor_brainstorm"))
)
)
server <- function(input, output, session) {
output$editor_standup <- renderUI(editor_output("ed_standup", height = "500px"))
output$editor_planning <- renderUI(editor_output("ed_planning", height = "500px"))
output$editor_retrospective <- renderUI(editor_output("ed_retrospective", height = "500px"))
output$editor_brainstorm <- renderUI(editor_output("ed_brainstorm", height = "500px"))
output$ed_standup <- editor_render(editor(sync_server$url, doc_ids[["standup"]], height = "500px"))
output$ed_planning <- editor_render(editor(sync_server$url, doc_ids[["planning"]], height = "500px"))
output$ed_retrospective <- editor_render(editor(sync_server$url, doc_ids[["retrospective"]], height = "500px"))
output$ed_brainstorm <- editor_render(editor(sync_server$url, doc_ids[["brainstorm"]], height = "500px"))
current_content <- reactive({
switch(input$room,
"standup" = input$ed_standup_content,
"planning" = input$ed_planning_content,
"retrospective" = input$ed_retrospective_content,
"brainstorm" = input$ed_brainstorm_content
)
})
output$export <- downloadHandler(
filename = function() paste0(input$room, "-notes-", Sys.Date(), ".qmd"),
content = function(file) {
titles <- c(standup = "Daily Standup", planning = "Sprint Planning",
retrospective = "Retrospective", brainstorm = "Brainstorming")
front_matter <- sprintf("---\ntitle: \"%s\"\ndate: \"%s\"\nformat: html\n---\n\n",
titles[input$room], Sys.Date())
writeLines(paste0(front_matter, current_content()), file)
}
)
}
# Clean up when app stops
onStop(function() sync_server$close())
shinyApp(ui, server)Why not use a standard textarea?
You might wonder whether a simpler approach using Shiny’s
textAreaInput() could work for collaborative editing. The
autoedit package does include textarea_ui() and
textarea_server() functions that synchronize text using
Automerge without requiring a sync server. However, this approach has a
fundamental limitation that makes it unsuitable for real-time
collaboration.
The cursor position problem
HTML <textarea> elements have no API for granular
text updates. The only way to update a textarea programmatically is to
replace its entire .value property. This means that when
another user’s changes arrive, the cursor position is lost.
You can demonstrate this problem with the following app:
library(shiny)
library(autoedit)
ui <- fluidPage(
h3("Cursor Reset Demo"),
p("1. Click in the middle of the text below"),
p("2. Click 'Simulate Remote Edit' - watch your cursor jump"),
textarea_ui("notes", height = "200px"),
actionButton("remote_edit", "Simulate Remote Edit")
)
server <- function(input, output, session) {
textarea_server(
"notes",
doc_id = "cursor-test",
initial_text = "Place your cursor HERE in the middle then click the button."
)
# Directly modify the master doc to simulate another user's edit
observeEvent(input$remote_edit, {
master <- autoedit:::.master_docs[["cursor-test"]]
if (!is.null(master)) {
text_obj <- automerge::am_get(master$doc, automerge::AM_ROOT, "text")
content <- automerge::am_text_content(text_obj)
# Append text to simulate remote edit
automerge::am_text_splice(text_obj, nchar(content), 0L, " [REMOTE EDIT]")
automerge::am_commit(master$doc, "remote edit")
master$version <- master$version + 1L
}
})
}
shinyApp(ui, server)When you click the button, your cursor jumps to the end of the text. In a real collaborative scenario, this would happen every time another user makes an edit, making it impossible to type continuously.
Why CodeMirror works
The editor() widget uses CodeMirror 6 with the
automerge-codemirror integration. CodeMirror maintains a
proper document model that can apply operations incrementally. When a
remote change arrives, CodeMirror:
- Applies the change at the correct position
- Adjusts cursor and selection positions to account for the change
- Preserves your editing context
This is why editor() with a sync server is the
recommended approach for collaborative text editing.
Alternatives for serverless collaboration
If you need serverless collaboration without the cursor position issues, consider using discrete UI components instead of free-form text. For example, a collaborative kanban board works well because each action (add item, move item, toggle, delete) is atomic - there’s no cursor position to preserve. See the “Collaborative Kanban Board” vignette for details.