Full document editor with WYSIWYG, Markdown, and HTML source editing modes. Supports themes, presets, page layout, word count, find/replace, DOCX/ODT import, and export to HTML/Markdown/PDF/DOCX/TXT.
Available themes: light, dark, classic, word. Available presets: classic, wysiwyg, minimal.
<iframe id="wr-demo" src="/online/webapp/writer" width="100%" height="600" frameborder="0" style="border:1px solid #ccc; border-radius:4px;"></iframe>
<script>window._wsConnect('wr-demo', 'wrSocket');</script>
<iframe src="https://sgapps.io/online/webapp/writer"
width="100%" height="600" frameborder="0"></iframe>setHTML / getHTML -- HTML Contentsocket.fire("webapp::instance::request", "setHTML",
"<h1>Hello World</h1><p>This is a <b>document</b>.</p>",
function (err) { console.log(err || "Set"); });
socket.fire("webapp::instance::request", "getHTML",
function (err, html) { console.log("HTML:", html); });<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setHTML','<h1>Welcome to Writer</h1><p>This document was created via the <b>API</b>.</p><h2>Features</h2><ul><li>WYSIWYG editing</li><li>Markdown mode</li><li>Multiple themes</li><li>Export to HTML, Markdown, PDF</li></ul><p>Try the buttons below to control the editor.</p>')">Try: Set Sample HTML</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','getHTML',function(e,h){if(h)alert('HTML length: '+h.length+' chars\n\n'+h.substring(0,300)+'...')})">Try: Get HTML</button>
setMarkdown / getMarkdown -- Markdown Contentsocket.fire("webapp::instance::request", "setMarkdown",
"# My Document\n\nHello **world**!\n\n- Item 1\n- Item 2",
function (err) {});
socket.fire("webapp::instance::request", "getMarkdown",
function (err, md) { console.log("Markdown:", md); });<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setMarkdown','# API Demo\n\nThis content was set via setMarkdown.\n\n## Formatting\n\n- Bold: text\n- Italic: text\n- Code: inline code\n\n## Code Block\n\n
`js\nconsole.log("Hello from Writer!");\n`\n\n> This is a blockquote.\n\nEnd of demo.')">Try: Set Markdown Content</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','getMarkdown',function(e,md){if(md)alert('Markdown:\n\n'+md.substring(0,500))})">Try: Get Markdown</button>getText -- Plain Textsocket.fire("webapp::instance::request", "getText",
function (err, text) { console.log("Text:", text); });<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','getText',function(e,t){if(t)alert('Plain text:\n\n'+t.substring(0,300))})">Try: Get Plain Text</button>
getWordCount -- Word Countsocket.fire("webapp::instance::request", "getWordCount",
function (err, counts) {
console.log(counts); // { words, characters, paragraphs, ... }
});<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','getWordCount',function(e,c){alert(c?JSON.stringify(c,null,2):'No data')})">Try: Get Word Count</button>
setMode -- Switch Editing ModeSwitch between WYSIWYG, Markdown, and Source editing:
socket.fire("webapp::instance::request", "setMode", "wysiwyg"); // rich text
socket.fire("webapp::instance::request", "setMode", "markdown"); // markdown source
socket.fire("webapp::instance::request", "setMode", "source"); // raw HTML<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setMode','wysiwyg')">Try: WYSIWYG Mode</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setMode','markdown')">Try: Markdown Mode</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setMode','source')">Try: Source Mode</button>
setTheme -- Change Themesocket.fire("webapp::instance::request", "setTheme", "light");
socket.fire("webapp::instance::request", "setTheme", "dark");
socket.fire("webapp::instance::request", "setTheme", "classic");
socket.fire("webapp::instance::request", "setTheme", "word");<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setTheme','light')">Try: Light</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setTheme','dark')">Try: Dark</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setTheme','classic')">Try: Classic</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setTheme','word')">Try: Word</button>
usePreset -- Apply PresetEach preset is a one-call configuration of layout, ruler visibility, toolbar, and theme:
| Preset | Layout | Rulers | Toolbar | Theme | Best for |
|---|---|---|---|---|---|
"classic" | Document (paginated) | shown | full | classic | Document authoring, collaboration |
"wysiwyg" | Inline (fluid) | hidden | full | light | CMS, blog editors, forms |
"minimal" | Inline (fluid) | hidden | hidden | light | Textarea replacement, comments |
socket.fire("webapp::instance::request", "usePreset", "classic"); // paginated document layout + ruler
socket.fire("webapp::instance::request", "usePreset", "wysiwyg"); // inline editor, no ruler
socket.fire("webapp::instance::request", "usePreset", "minimal"); // no toolbar, no ruler, no status bar<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','usePreset','classic')">Try: Classic Preset</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','usePreset','wysiwyg')">Try: WYSIWYG Preset</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','usePreset','minimal')">Try: Minimal Preset</button>
setLayoutMode -- Document vs InlinesetLayoutMode is a real layout switch -- not just a CSS class swap. When called it strips auto-break footers/headers from the editable area, lazily creates the page wrapper and vertical ruler if they're missing, restores the cleaned HTML, then re-runs pagination + ruler refresh. Switching back to inline detaches the page wrapper (kept in memory for reuse) and hides the rulers.
socket.fire("webapp::instance::request", "setLayoutMode", "document"); // page layout with margins
socket.fire("webapp::instance::request", "setLayoutMode", "inline"); // compact, no page breaks<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setLayoutMode','document')">Try: Document Layout</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setLayoutMode','inline')">Try: Inline Layout</button>
The toolbar also exposes a Layout group with two buttons (📄 Document / ☰ Inline) that fire the same calls and stay in sync with the active layout. Disable it via the toolbar.layout: false constructor option if you want a fixed layout for an embed.
socket.fire("webapp::instance::request", "setZoom", 1.5); // 150%
socket.fire("webapp::instance::request", "readOnly", true); // toggle read-only
socket.fire("webapp::instance::request", "undo");
socket.fire("webapp::instance::request", "redo");<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setZoom',1.25)">Try: Zoom 125%</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setZoom',1)">Try: Zoom 100%</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','undo')">Try: Undo</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','redo')">Try: Redo</button>
// Insert a page break
socket.fire("webapp::instance::request", "insertBreak", "page");
// Other break types: "line", "paragraph"
// Insert a table
socket.fire("webapp::instance::request", "insertBlock", "table",
{ rows: 3, cols: 4, header: true });
// Insert a code block
socket.fire("webapp::instance::request", "insertBlock", "code",
{ language: "javascript" });
// Insert a heading
socket.fire("webapp::instance::request", "insertBlock", "heading",
{ level: 2 });
// Insert an ordered/task list
socket.fire("webapp::instance::request", "insertBlock", "list",
{ ordered: true });
// Insert an image
socket.fire("webapp::instance::request", "insertBlock", "image",
{ src: "https://example.com/img.png", alt: "Example", width: 400 });
// Insert a callout / alert box
socket.fire("webapp::instance::request", "insertBlock", "callout",
{ calloutType: "info" });"heading", "paragraph", "blockquote", "code", "table", "image", "hr", "callout", "list""info", "warning", "success", "error", "note", "tip"<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','insertBlock','table',{rows:3,cols:3,header:true})">Try: Insert 3x3 Table</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','insertBlock','heading',{level:2})">Try: Insert H2</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','insertBlock','code',{language:'javascript'})">Try: Insert Code Block</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','insertBlock','callout',{calloutType:'info'})">Try: Insert Callout</button>
// Set page to A4 landscape with custom margins
socket.fire("webapp::instance::request", "setPageSetup", {
size: "A4", // "A3" | "A4" | "A5" | "Letter" | "Legal" | "Tabloid" | "B5"
orientation: "landscape", // "portrait" | "landscape"
margins: { top: 20, bottom: 20, left: 15, right: 15 }, // mm
columns: 1
}, function (err) {});
// Read current page setup
socket.fire("webapp::instance::request", "getPageSetup",
function (err, setup) { console.log(setup); });<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setPageSetup',{size:'A4',orientation:'landscape'})">Try: A4 Landscape</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','setPageSetup',{size:'Letter',orientation:'portrait'})">Try: US Letter Portrait</button>
In Document Mode every paginated page gets its own header and footer rendered as inline blocks. They are configured through the headerFooter option of the editor (passed at construction time) or via setPageSetup for the per-document toggles. Each header/footer has three zones (left, center, right) and accepts plain text or HTML with dynamic field tokens that re-resolve on every re-pagination.
// Configure headers/footers for the current document
socket.fire("webapp::instance::request", "setPageSetup", {
headerFooter: {
firstPageDifferent: true,
oddEvenDifferent: false
}
}, function (err) {});These tokens can be placed inside any header or footer zone and are re-evaluated on every re-pagination:
| Token | Resolves to |
|---|---|
{pageNumber} | Current page number (1-based) |
{totalPages} | Total number of pages |
{date} | Today's date in locale format |
{time} | Current time in locale format |
{title} | Document title (from <title> or first heading) |
showPageSetupDialog API event to open an interactive header/footer editor:socket.fire("webapp::instance::request", "showPageSetupDialog");<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','showPageSetupDialog')">Try: Page & Header / Footer Dialog</button>
socket.fire("webapp::instance::request", "setRulerVisible", true);
socket.fire("webapp::instance::request", "setRulerUnit", "cm");
// Units: "mm", "cm", "in", "px", "pt"// Find all matches
socket.fire("webapp::instance::request", "find", "important",
{ matchCase: false, wholeWord: false },
function (err, matches) {
console.log("Found", matches.length, "matches");
});
// Replace all
socket.fire("webapp::instance::request", "replace", "old", "new", {},
function (err, count) {
console.log("Replaced", count, "occurrences");
});// Import a DOCX/ODT/HTML/MD/TXT file (binary import)
var file = /* a File or Blob */;
socket.fire("webapp::instance::request", "importFile", file,
function (err) { if (!err) console.log("Imported"); });// Export as HTML / Markdown / PDF / DOCX / TXT
socket.fire("webapp::instance::request", "export", "html",
function (err, result) { console.log(result); });
socket.fire("webapp::instance::request", "export", "markdown",
function (err, result) { console.log(result); });
socket.fire("webapp::instance::request", "export", "pdf",
function (err, blob) { /* PDF returned as Blob via Promise */ });
socket.fire("webapp::instance::request", "export", "docx",
function (err, blob) { /* DOCX returned as Blob via Promise */ });
// Download as file
socket.fire("webapp::instance::request", "download", "html", "document.html");
socket.fire("webapp::instance::request", "download", "markdown", "document.md");
socket.fire("webapp::instance::request", "download", "pdf", "document.pdf");
socket.fire("webapp::instance::request", "download", "docx", "document.docx");
// Open browser print dialog
socket.fire("webapp::instance::request", "print");"html", "markdown", "text", "pdf", "docx"<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','export','html',function(e,r){if(r)alert('HTML export:\n\n'+(typeof r==='string'?r.substring(0,300):JSON.stringify(r).substring(0,300)))})">Try: Export HTML</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','export','markdown',function(e,r){if(r)alert('Markdown export:\n\n'+(typeof r==='string'?r.substring(0,300):JSON.stringify(r).substring(0,300)))})">Try: Export Markdown</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','download','pdf','document.pdf')">Try: Download PDF</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','print')">Try: Print</button>
The Writer ships with built-in dialogs that can be triggered remotely:
socket.fire("webapp::instance::request", "showPageSetupDialog");
socket.fire("webapp::instance::request", "showInsertTableDialog");
socket.fire("webapp::instance::request", "showInsertImageDialog");
socket.fire("webapp::instance::request", "showInsertLinkDialog");
socket.fire("webapp::instance::request", "showFindReplaceDialog", true); // true = show replace tab
socket.fire("webapp::instance::request", "showFormatParagraphDialog");
socket.fire("webapp::instance::request", "showStylesManagerDialog");
socket.fire("webapp::instance::request", "showInsertEmbedDialog");
socket.fire("webapp::instance::request", "showEmojiPicker");<button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','showFindReplaceDialog',true)">Try: Find & Replace</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','showInsertTableDialog')">Try: Insert Table Dialog</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','showPageSetupDialog')">Try: Page Setup</button><button onclick="window._ws('wrSocket')&&window.wrSocket.fire('webapp::instance::request','showEmojiPicker')">Try: Emoji Picker</button>
Track unsaved changes:
socket.fire("webapp::instance::request", "isDirty",
function (err, dirty) { console.log("Has changes:", dirty); });
socket.fire("webapp::instance::request", "markClean",
function (err) { console.log("Marked as saved"); });The Writer forwards internal editor events to the embedder via api-event::instance:<eventName>:
// Subscribe to content changes
socket.on("api-event::instance:event:change", function (args) {
console.log("Document changed");
});
// Get word count updates
socket.on("api-event::instance:event:wordCount", function (args) {
var stats = args[0];
console.log("Words:", stats.words);
});
// Detect autosave triggers
socket.on("api-event::instance:event:save", function (args) {
console.log("Save triggered");
});event:ready -- editor finished loadingevent:change -- document content changed ({html})event:selectionChange -- text selection changed ({from, to, marks})event:modeChange -- editor mode switched, wysiwyg/markdown/source ({mode})event:layoutChange -- layout mode changed, document/inline ({layoutMode})event:pageChange -- pagination updated, Document Mode ({page, totalPages})event:wordCount -- word count recalculated ({words, chars})event:focus, event:blur -- focus state changedevent:save -- autosave or manual save ({html, json} or string)event:export -- document exported (format)event:import -- file imported (fileName)event:print -- print dialog openedSubscribe viasocket.on("api-event::instance:<eventName>", function (args) { ... }). Theargsparameter is an array of values that the editor emitted (typically one element).
The Writer ships with keyboard shortcuts that work whenever the editor has focus:
| Shortcut | Action |
|---|---|
Ctrl+B / Ctrl+I / Ctrl+U | Bold / Italic / Underline |
Ctrl+Shift+X | Strikethrough |
Ctrl+Shift+7 / 8 / 9 | Ordered / Bullet / Task list |
Ctrl+E / L / R / J | Center / Left / Right / Justify |
Ctrl+] / Ctrl+[ | Increase / Decrease indent |
Ctrl+Z / Ctrl+Y | Undo / Redo |
Ctrl+K | Insert link |
Ctrl+Shift+E | Code block |
Ctrl+Enter | Page break (Document Mode) |
Ctrl+P | |
Ctrl+S | Save (fires event:save) |
Ctrl+F / Ctrl+H | Find / Find & Replace |
/ | Slash command menu (Notion-style block insertion) |
# … ###### | Auto-heading 1–6 |
- / * / 1. | Auto-start list |
> | Auto-blockquote |
--- | Horizontal rule |
Shift+Enter | Soft line break |
<iframe id="doc" src="https://sgapps.io/online/webapp/writer"
width="100%" height="600" frameborder="0"></iframe>
<script src="https://sgapps.io/components/application-prototype/ApplicationPrototype.js"></script>
<script src="https://sgapps.io/components/window-socket/index.js"></script>
<script>
var socket = new WindowSocket();
socket.start();
socket.on("webapp::connection::ping", function () {
socket.fire("webapp::instance::embed-mode", true);
// Apply the classic preset (paginated document with ruler)
socket.fire("webapp::instance::request", "usePreset", "classic");
socket.fire("webapp::instance::request", "setTheme", "light");
// Subscribe to change events
socket.on("api-event::instance:event:change", function () {
console.log("Document edited");
});
// Load content
socket.fire("webapp::instance::request", "setMarkdown",
"# Project Proposal\n\n" +
"## Overview\n\n" +
"This document outlines the **key objectives** for Q2 2026.\n\n" +
"## Goals\n\n" +
"1. Launch v2.0 of the platform\n" +
"2. Onboard 1000 new users\n" +
"3. Reduce page load time by 40%\n\n" +
"## Timeline\n\n" +
"| Phase | Start | End |\n" +
"|-------|-------|-----|\n" +
"| Design | Apr 1 | Apr 15 |\n" +
"| Development | Apr 15 | May 30 |\n" +
"| Testing | Jun 1 | Jun 15 |\n"
);
});
</script>