openwriter 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-D0TNu7yx.js +210 -0
- package/dist/client/assets/index-Dze14Bgb.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/x-api/dist/index.js +4 -2
- package/dist/plugins/x-api/package.json +2 -1
- package/dist/server/compact.js +39 -1
- package/dist/server/documents.js +23 -0
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +330 -207
- package/dist/server/state.js +74 -18
- package/dist/server/task-routes.js +38 -0
- package/dist/server/tasks.js +52 -0
- package/dist/server/ws.js +12 -2
- package/package.json +4 -2
- package/skill/SKILL.md +27 -6
- package/dist/client/assets/index-BhlEJsdX.css +0 -1
- package/dist/client/assets/index-XajWsVLO.js +0 -210
package/dist/server/state.js
CHANGED
|
@@ -92,7 +92,7 @@ export function getDocId() {
|
|
|
92
92
|
export function getPlainText() {
|
|
93
93
|
return extractText(state.document.content);
|
|
94
94
|
}
|
|
95
|
-
function extractText(nodes) {
|
|
95
|
+
export function extractText(nodes) {
|
|
96
96
|
if (!nodes)
|
|
97
97
|
return '';
|
|
98
98
|
return nodes
|
|
@@ -170,21 +170,21 @@ function computePartialRange(origContent, newContent) {
|
|
|
170
170
|
let origTo = origEnd >= firstDiff && origEnd < origWords.length ? origWords[origEnd].end : origFrom;
|
|
171
171
|
let newFrom = firstDiff < newWords.length ? newWords[firstDiff].start : newText.length;
|
|
172
172
|
let newTo = newEnd >= firstDiff && newEnd < newWords.length ? newWords[newEnd].end : newFrom;
|
|
173
|
-
// Snap start back to previous sentence boundary (after ". ")
|
|
173
|
+
// Snap start back to previous sentence boundary (after ". " or ".\n")
|
|
174
174
|
const snapBack = (text, pos) => {
|
|
175
175
|
let i = pos - 1;
|
|
176
176
|
while (i > 0) {
|
|
177
|
-
if (text[i] === '.' && i + 1 < text.length && text[i + 1] === ' ')
|
|
177
|
+
if (text[i] === '.' && i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\n'))
|
|
178
178
|
return i + 2;
|
|
179
179
|
i--;
|
|
180
180
|
}
|
|
181
181
|
return 0; // No period found → start of text
|
|
182
182
|
};
|
|
183
|
-
// Snap end forward to next sentence boundary (
|
|
183
|
+
// Snap end forward to next sentence boundary (". " or ".\n" or end of text)
|
|
184
184
|
const snapForward = (text, pos) => {
|
|
185
185
|
let i = pos;
|
|
186
186
|
while (i < text.length) {
|
|
187
|
-
if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' '))
|
|
187
|
+
if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' ' || text[i + 1] === '\n'))
|
|
188
188
|
return i + 1;
|
|
189
189
|
i++;
|
|
190
190
|
}
|
|
@@ -249,31 +249,67 @@ export function getNodesByIds(ids) {
|
|
|
249
249
|
}
|
|
250
250
|
return result;
|
|
251
251
|
}
|
|
252
|
+
/** Pure version of getNodesByIds — takes content array instead of reading state. */
|
|
253
|
+
export function findNodesByIds(docContent, ids) {
|
|
254
|
+
const result = [];
|
|
255
|
+
const idSet = new Set(ids);
|
|
256
|
+
function scan(nodes) {
|
|
257
|
+
if (!nodes)
|
|
258
|
+
return;
|
|
259
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
260
|
+
const node = nodes[i];
|
|
261
|
+
if (node.attrs?.id && idSet.has(node.attrs.id)) {
|
|
262
|
+
result.push(node);
|
|
263
|
+
if (i + 1 < nodes.length && nodes[i + 1].type === 'horizontalRule') {
|
|
264
|
+
result.push(nodes[i + 1]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (node.content)
|
|
268
|
+
scan(node.content);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
scan(docContent);
|
|
272
|
+
if (result.length > 0 && result[result.length - 1].type === 'horizontalRule') {
|
|
273
|
+
result.pop();
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
252
277
|
export function getMetadata() {
|
|
253
278
|
return state.metadata;
|
|
254
279
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Apply contamination guards + deep-merge context keys. Pure function.
|
|
282
|
+
* Returns the merged metadata object, or null if all updates were filtered out.
|
|
283
|
+
*/
|
|
284
|
+
export function mergeMetadataUpdates(existing, updates) {
|
|
285
|
+
// Clone so we don't mutate the caller's object
|
|
286
|
+
updates = { ...updates };
|
|
287
|
+
// Prevent blogContext contamination
|
|
288
|
+
if (updates.blogContext && !updates.blogContext.active && !existing?.blogContext?.active) {
|
|
259
289
|
delete updates.blogContext;
|
|
260
290
|
if (Object.keys(updates).length === 0)
|
|
261
|
-
return;
|
|
291
|
+
return null;
|
|
262
292
|
}
|
|
263
293
|
// Same guard for newsletterContext
|
|
264
|
-
if (updates.newsletterContext && !updates.newsletterContext.active && !
|
|
294
|
+
if (updates.newsletterContext && !updates.newsletterContext.active && !existing?.newsletterContext?.active) {
|
|
265
295
|
delete updates.newsletterContext;
|
|
266
296
|
if (Object.keys(updates).length === 0)
|
|
267
|
-
return;
|
|
297
|
+
return null;
|
|
268
298
|
}
|
|
269
|
-
// Deep-merge known context objects
|
|
299
|
+
// Deep-merge known context objects
|
|
270
300
|
const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
|
|
271
301
|
for (const key of CONTEXT_KEYS) {
|
|
272
|
-
if (updates[key] && typeof updates[key] === 'object' &&
|
|
273
|
-
updates[key] = { ...
|
|
302
|
+
if (updates[key] && typeof updates[key] === 'object' && existing?.[key] && typeof existing[key] === 'object') {
|
|
303
|
+
updates[key] = { ...existing[key], ...updates[key] };
|
|
274
304
|
}
|
|
275
305
|
}
|
|
276
|
-
|
|
306
|
+
return { ...existing, ...updates };
|
|
307
|
+
}
|
|
308
|
+
export function setMetadata(updates) {
|
|
309
|
+
const merged = mergeMetadataUpdates(state.metadata, updates);
|
|
310
|
+
if (!merged)
|
|
311
|
+
return;
|
|
312
|
+
state.metadata = merged;
|
|
277
313
|
if (updates.title)
|
|
278
314
|
state.title = updates.title;
|
|
279
315
|
// Auto-tag based on context metadata
|
|
@@ -599,6 +635,26 @@ function applyChangesToDoc(doc, changes) {
|
|
|
599
635
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
600
636
|
if (!found)
|
|
601
637
|
continue;
|
|
638
|
+
// Tweet thread: hard-delete paragraphs + adjacent HR immediately.
|
|
639
|
+
// Tweet compose view can't handle pending deletes near HRs — hard-delete and resync.
|
|
640
|
+
const delNode = found.parent[found.index];
|
|
641
|
+
if (delNode.type === 'paragraph' && state.metadata?.tweetContext) {
|
|
642
|
+
const idx = found.index;
|
|
643
|
+
if (idx > 0 && found.parent[idx - 1].type === 'horizontalRule') {
|
|
644
|
+
found.parent.splice(idx, 1);
|
|
645
|
+
found.parent.splice(idx - 1, 1);
|
|
646
|
+
}
|
|
647
|
+
else if (idx + 1 < found.parent.length && found.parent[idx + 1].type === 'horizontalRule') {
|
|
648
|
+
found.parent.splice(idx + 1, 1);
|
|
649
|
+
found.parent.splice(idx, 1);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
found.parent.splice(idx, 1);
|
|
653
|
+
}
|
|
654
|
+
// Push a synthetic HR change so ws.ts detects it and sends document-switched
|
|
655
|
+
processed.push({ operation: 'delete', nodeId: change.nodeId, content: [{ type: 'horizontalRule' }] });
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
602
658
|
found.parent[found.index] = {
|
|
603
659
|
...found.parent[found.index],
|
|
604
660
|
attrs: {
|
|
@@ -769,7 +825,7 @@ export function invalidateDocCache(filePath) {
|
|
|
769
825
|
docCache.delete(filePath);
|
|
770
826
|
}
|
|
771
827
|
/** Update the cache entry for a file after writing changes (without cloning the active state). */
|
|
772
|
-
function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
828
|
+
export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
773
829
|
let fileMtime = 0;
|
|
774
830
|
try {
|
|
775
831
|
fileMtime = statSync(filePath).mtimeMs;
|
|
@@ -1320,7 +1376,7 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1320
1376
|
* Returns { title, wordCount, pendingCount } for the response message.
|
|
1321
1377
|
*/
|
|
1322
1378
|
/** Count pending nodes in a document tree. */
|
|
1323
|
-
function countPending(nodes) {
|
|
1379
|
+
export function countPending(nodes) {
|
|
1324
1380
|
let count = 0;
|
|
1325
1381
|
if (!nodes)
|
|
1326
1382
|
return 0;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express routes for task CRUD.
|
|
3
|
+
* Mounted in index.ts to keep the main file lean.
|
|
4
|
+
*/
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
7
|
+
export function createTaskRouter() {
|
|
8
|
+
const router = Router();
|
|
9
|
+
router.get('/api/tasks', (_req, res) => {
|
|
10
|
+
res.json({ tasks: readTasks() });
|
|
11
|
+
});
|
|
12
|
+
router.post('/api/tasks', (req, res) => {
|
|
13
|
+
const { text } = req.body;
|
|
14
|
+
if (!text?.trim()) {
|
|
15
|
+
res.status(400).json({ error: 'text is required' });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
res.json({ task: addTask(text.trim()) });
|
|
19
|
+
});
|
|
20
|
+
router.put('/api/tasks/:id', (req, res) => {
|
|
21
|
+
const { text, completed } = req.body;
|
|
22
|
+
const task = updateTask(req.params.id, { text, completed });
|
|
23
|
+
if (!task) {
|
|
24
|
+
res.status(404).json({ error: 'Task not found' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
res.json({ task });
|
|
28
|
+
});
|
|
29
|
+
router.delete('/api/tasks/:id', (req, res) => {
|
|
30
|
+
const ok = removeTask(req.params.id);
|
|
31
|
+
if (!ok) {
|
|
32
|
+
res.status(404).json({ error: 'Task not found' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.json({ success: true });
|
|
36
|
+
});
|
|
37
|
+
return router;
|
|
38
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-profile task persistence. Stores tasks.json in the profile data dir.
|
|
3
|
+
*/
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { getDataDir, atomicWriteFileSync, generateNodeId } from './helpers.js';
|
|
7
|
+
function tasksPath() {
|
|
8
|
+
return join(getDataDir(), 'tasks.json');
|
|
9
|
+
}
|
|
10
|
+
export function readTasks() {
|
|
11
|
+
const p = tasksPath();
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeTasks(tasks) {
|
|
22
|
+
atomicWriteFileSync(tasksPath(), JSON.stringify(tasks, null, 2));
|
|
23
|
+
}
|
|
24
|
+
export function addTask(text) {
|
|
25
|
+
const tasks = readTasks();
|
|
26
|
+
const maxPos = tasks.reduce((m, t) => Math.max(m, t.position), -1);
|
|
27
|
+
const task = { id: generateNodeId(), text, completed: false, position: maxPos + 1 };
|
|
28
|
+
tasks.push(task);
|
|
29
|
+
writeTasks(tasks);
|
|
30
|
+
return task;
|
|
31
|
+
}
|
|
32
|
+
export function updateTask(id, patch) {
|
|
33
|
+
const tasks = readTasks();
|
|
34
|
+
const task = tasks.find((t) => t.id === id);
|
|
35
|
+
if (!task)
|
|
36
|
+
return null;
|
|
37
|
+
if (patch.text !== undefined)
|
|
38
|
+
task.text = patch.text;
|
|
39
|
+
if (patch.completed !== undefined)
|
|
40
|
+
task.completed = patch.completed;
|
|
41
|
+
writeTasks(tasks);
|
|
42
|
+
return task;
|
|
43
|
+
}
|
|
44
|
+
export function removeTask(id) {
|
|
45
|
+
const tasks = readTasks();
|
|
46
|
+
const idx = tasks.findIndex((t) => t.id === id);
|
|
47
|
+
if (idx === -1)
|
|
48
|
+
return false;
|
|
49
|
+
tasks.splice(idx, 1);
|
|
50
|
+
writeTasks(tasks);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
package/dist/server/ws.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
5
|
+
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
const clients = new Set();
|
|
@@ -61,6 +61,10 @@ export function setupWebSocket(server) {
|
|
|
61
61
|
if (hasHrChange) {
|
|
62
62
|
const doc = getDocument();
|
|
63
63
|
console.log(`[WS] HR detected in tweet thread → sending document-switched (${doc?.content?.length || 0} nodes)`);
|
|
64
|
+
// Re-set agent lock so the 3s window starts NOW, not from the original insert.
|
|
65
|
+
// Tweet thread resyncs recreate all editors which fire onUpdate → stale doc-updates.
|
|
66
|
+
// Without this reset, the lock expires before the browser finishes recreating editors.
|
|
67
|
+
setAgentLock();
|
|
64
68
|
const filePath = getFilePath();
|
|
65
69
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
66
70
|
const msg = JSON.stringify({
|
|
@@ -128,7 +132,13 @@ export function setupWebSocket(server) {
|
|
|
128
132
|
saveDocToFile(msg.filename, msg.document);
|
|
129
133
|
}
|
|
130
134
|
else {
|
|
131
|
-
|
|
135
|
+
// Strip ephemeral imageLoading nodes — they're transient placeholders that should
|
|
136
|
+
// never persist. The browser's doc-update can re-add them after a failed rewrite.
|
|
137
|
+
if (msg.document.content) {
|
|
138
|
+
msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
|
|
139
|
+
}
|
|
140
|
+
const cleanedCount = msg.document.content?.length || 0;
|
|
141
|
+
console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
|
|
132
142
|
updateDocument(msg.document);
|
|
133
143
|
updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
|
|
134
144
|
debouncedSave();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@tiptap/starter-kit": "^3.0.0",
|
|
60
60
|
"@turbodocx/html-to-docx": "^1.20.1",
|
|
61
61
|
"@types/multer": "^2.0.0",
|
|
62
|
+
"@xdevplatform/xdk": "^0.4.0",
|
|
62
63
|
"express": "^4.21.0",
|
|
63
64
|
"gray-matter": "^4.0.3",
|
|
64
65
|
"lowlight": "^3.3.0",
|
|
@@ -72,8 +73,8 @@
|
|
|
72
73
|
"react": "^18.3.1",
|
|
73
74
|
"react-dom": "^18.3.1",
|
|
74
75
|
"trash": "^10.1.0",
|
|
76
|
+
"twitter-text": "^3.1.0",
|
|
75
77
|
"ws": "^8.18.0",
|
|
76
|
-
"@xdevplatform/xdk": "^0.4.0",
|
|
77
78
|
"zod": "^3.25.76"
|
|
78
79
|
},
|
|
79
80
|
"devDependencies": {
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"@types/markdown-it": "^14.1.2",
|
|
82
83
|
"@types/react": "^18.3.0",
|
|
83
84
|
"@types/react-dom": "^18.3.0",
|
|
85
|
+
"@types/twitter-text": "^3.1.10",
|
|
84
86
|
"@types/ws": "^8.5.0",
|
|
85
87
|
"@vitejs/plugin-react": "^4.3.0",
|
|
86
88
|
"typescript": "^5.6.0",
|
package/skill/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: openwriter
|
|
|
3
3
|
description: |
|
|
4
4
|
OpenWriter — the writing surface for AI agents. A markdown-native rich text
|
|
5
5
|
editor where agents write via MCP tools and users accept or reject changes
|
|
6
|
-
in-browser.
|
|
6
|
+
in-browser. 40 core MCP tools for document editing, multi-doc workspaces,
|
|
7
7
|
and organization, plus 21 publish platform tools for newsletter, social
|
|
8
8
|
posting, and scheduling. Tweet compose mode for drafting replies/QTs with
|
|
9
9
|
pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
|
|
@@ -16,7 +16,7 @@ description: |
|
|
|
16
16
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
17
17
|
metadata:
|
|
18
18
|
author: travsteward
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.4.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -98,7 +98,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
98
98
|
|
|
99
99
|
**MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
|
|
100
100
|
|
|
101
|
-
## MCP Tools Reference (
|
|
101
|
+
## MCP Tools Reference (40 core + 21 publish platform)
|
|
102
102
|
|
|
103
103
|
### Document Operations
|
|
104
104
|
|
|
@@ -159,18 +159,28 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
159
159
|
| `get_agent_marks` | `docId?` | Get inline feedback marks left by the user (optional docId — omit for all docs) |
|
|
160
160
|
| `resolve_agent_marks` | `mark_ids` | Remove marks after addressing feedback (pass mark IDs) |
|
|
161
161
|
|
|
162
|
+
### Task Management
|
|
163
|
+
|
|
164
|
+
| Tool | Key Params | Description |
|
|
165
|
+
|------|-----------|-------------|
|
|
166
|
+
| `list_tasks` | — | List all tasks for the current profile |
|
|
167
|
+
| `add_task` | `text` | Add a new task to the checklist |
|
|
168
|
+
| `update_task` | `id`, `text?`, `completed?` | Update a task (text or completion status) |
|
|
169
|
+
| `remove_task` | `id` | Remove a task from the checklist |
|
|
170
|
+
|
|
171
|
+
Call `list_tasks` at session start to check for pending work from previous sessions.
|
|
172
|
+
|
|
162
173
|
### Text Operations
|
|
163
174
|
|
|
164
175
|
| Tool | Key Params | Description |
|
|
165
176
|
|------|-----------|-------------|
|
|
166
|
-
| `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks) |
|
|
177
|
+
| `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks). **`edits` must be a JSON array, not a string.** Example: `edits: [{ find: "old text", replace: "new text" }]` |
|
|
167
178
|
|
|
168
179
|
### Image Generation
|
|
169
180
|
|
|
170
181
|
| Tool | Description |
|
|
171
182
|
|------|-------------|
|
|
172
|
-
| `
|
|
173
|
-
| `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
|
|
183
|
+
| `insert_image` | Generate image via Gemini. Three modes: (1) `docId` + `afterNodeId` → inline insert with pending decoration. (2) `set_cover: true` → set as article cover. (3) Neither → generate to disk only. Requires GEMINI_API_KEY. |
|
|
174
184
|
|
|
175
185
|
### Version Management
|
|
176
186
|
|
|
@@ -397,6 +407,17 @@ Threads are single documents with `horizontalRule` nodes separating each tweet.
|
|
|
397
407
|
}})
|
|
398
408
|
```
|
|
399
409
|
|
|
410
|
+
### Inserting New Tweets into Existing Threads
|
|
411
|
+
|
|
412
|
+
**Insert HR + paragraph as ONE change with a content array.** Two separate calls will fail — the browser resyncs on HR insertion and overwrites the second call.
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
write_to_pad({ docId: "...", changes: [
|
|
416
|
+
{ operation: "insert", afterNodeId: "<last-node-of-previous-tweet>",
|
|
417
|
+
content: [{ type: "horizontalRule" }, { type: "paragraph", content: [{ type: "text", text: "New tweet" }] }] }
|
|
418
|
+
]})
|
|
419
|
+
```
|
|
420
|
+
|
|
400
421
|
### Paragraph Spacing in Tweets
|
|
401
422
|
|
|
402
423
|
Tweet compose uses `<br>` (hardBreak) for line breaks within a paragraph. Double Enter in the browser creates a new `<p>` node (paragraph split) with visual spacing.
|