runlist 0.0.0 → 0.94.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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,381 @@
1
+ import {desktop,request,copyText,mountDesktop,desktopDisconnected} from './transport.mjs';
2
+ import {documentCreation} from './document-create.mjs';
3
+ import {quickNavigation} from './quick-navigation.mjs';
4
+ import {recoveryCenter} from './recovery-center.mjs';
5
+ import {appSettings} from './settings.mjs';
6
+ import {documentLifecycle} from './document-lifecycle.mjs';
7
+ import { recordNavigation } from './record-navigation.mjs';
8
+ import { libraryNavigation } from './library-navigation.mjs';
9
+ import { gitChanges } from './git-changes.mjs';
10
+ import { gitCommit } from './git-commit.mjs';
11
+ import { editorNavigation } from './editor-navigation.mjs';
12
+ import { templateEditor } from './template-editor.mjs';
13
+ import { BlockEditor } from './block-editor.mjs';
14
+ import { documentViews } from './document-views.mjs';
15
+ import { documentStages } from './document-stages.mjs';
16
+ import { documentYardstick } from './document-yardstick.mjs';
17
+ import { filingNavigation } from './filing-navigation.mjs';
18
+ import { escapeHtml as esc, splitSource, editedSource, markdownHtml, lineDiff, diffHtml, readSourceStage, replaceSourceStage, rebaseSourceStage } from './shared.mjs';
19
+ const $ = id => document.getElementById(id);
20
+ const state = { doc:null, base:null, body:'', csrf:null, mode:'read', dirty:false, draft:null, recovery:null, conflict:null, pending:null, lastSave:null, busy:false, plans:[] };
21
+ const clientId = sessionStorage.runlistClientId ||= crypto.randomUUID();
22
+ let checkoutKey = '', draftTimer, persistence = Promise.resolve();
23
+ let editorPath;
24
+ const blockEditor = new BlockEditor($('block-editor'),{onChange:body=>bodyChanged(body),resolveLink:linked});
25
+ const navigation = editorNavigation({state,editor:blockEditor,checkout:()=>checkoutKey,$,view,notice});
26
+ templateEditor({api,$,checkout:()=>checkoutKey,notice});
27
+ const library = libraryNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
28
+ const settings=appSettings({$,api,checkout:()=>checkoutKey,library,notice});
29
+ const lifecycle=documentLifecycle({state,$,api,checkout:()=>checkoutKey,open,library,notice});
30
+ const yardstick=documentYardstick({state,$,api,checkout:()=>checkoutKey,open,library,notice});
31
+ const filing=filingNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
32
+ const records = recordNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,preferences:()=>settings.read(),notice,create:template=>creation.show({template})});
33
+ const changes=gitChanges({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice,returnToDraft:()=>{view(state.doc.editable?'edit':'read');const url=new URL(location.href);url.searchParams.delete('view');url.searchParams.set('path',state.doc.path);history.replaceState(null,'',url);}});
34
+ const commits=gitCommit({$,api,checkout:()=>checkoutKey,selection:changes.selectedPaths,changed:()=>changes.invalidate(),notice});
35
+ $('changes-commit').onclick=()=>{if(!$('changes-commit').disabled)return commits.show().catch(error=>notice(error.message,true));};
36
+ const views = documentViews({$,view});
37
+ const stages = documentStages({state,$,changed:word=>{state.draftSource=replaceSourceStage(candidate(),word);bodyChanged(state.body);}});
38
+ const creation=documentCreation({$,api,checkout:()=>checkoutKey,state,beforeNavigate:beforeDesktopLeave,open,library,notice,createdRecord:record=>records.show(record.type==='flag'?'flags':'decisions',record.id)});
39
+ const recoveryCenterView=recoveryCenter({$,api,checkout:()=>checkoutKey,beforeNavigate:beforeDesktopLeave,resume:resumeRecovery,notice});
40
+ const quick=quickNavigation({$,api,state,library,open,jump:section=>navigation.jumpTo(section),notice,actions:()=>[
41
+ {title:'New document…',run:()=>creation.show()},
42
+ {title:'New flag…',detail:'Record a finding for triage',run:()=>creation.show({template:'flag'})},
43
+ {title:'New decision…',detail:'Compare alternatives before ruling',run:()=>creation.show({template:'decision'})},
44
+ {title:'Recovery',detail:'Drafts and interrupted operations',run:()=>recoveryCenterView.show()},
45
+ {title:'Changes',detail:'Review saved documents against local Git history',run:()=>changes.show()},
46
+ {title:'Filing',detail:'Find plans without a home or with competing homes',run:()=>filing.show()},
47
+ {title:'Library',run:showLibrary},{title:'Active plans',run:()=>library.applyView('active-plans')},{title:'Hubs',run:()=>library.applyView('hubs')},
48
+ {title:'Flags needing attention',run:()=>records.show('flags')},{title:'Decisions needing attention',run:()=>records.show('decisions')},
49
+ {title:'Templates',run:()=>$('open-templates').click()},{title:'Settings',run:()=>$('open-settings').click()},
50
+ ...(library.savedViews?.()??[]).map(v=>({title:`View: ${v.name}`,detail:'Saved library filters',run:()=>library.applyView(v.id)})),
51
+ ]});
52
+ function notice(message, error = false) { $('notice').textContent = message; $('notice').classList.toggle('error', error); }
53
+ async function api(route,body) {const result=await request(route,body,state.csrf);if(body!==undefined&&['save','undo','create/commit','lifecycle/commit','yardstick/commit','native/action','flags/triage'].includes(route)){changes.invalidate();filing.invalidate();}return result;}
54
+ function guarded(fn) { return async () => { try { await fn(); } catch (error) { notice(error.message, true); } }; }
55
+ function key(path = state.doc?.path) { return `runlist:recovery:${checkoutKey}:${encodeURIComponent(path)}:${clientId}`; }
56
+ function recoverySource() {
57
+ if (state.pending?.kind === 'undo') return state.pending.source;
58
+ try { return candidate(); } catch { return splitSource(state.draftSource ?? state.base.source).envelope + state.body; }
59
+ }
60
+ function storeLocal() {
61
+ if (!state.doc || !state.base) return;
62
+ const record = { path:state.doc.path, baseSource:state.base.source, baseRevision:state.base.revision, source:recoverySource(), draft:state.draft, pending:state.pending, lastSave:state.lastSave, conflicted:!!state.conflict, at:Date.now() };
63
+ try { localStorage.setItem(key(), JSON.stringify(record)); state.recoveryStored = true; } catch { state.recoveryStored = false; notice(`${desktop?'Runlist':'Browser'} recovery storage is full. Keep this tab open until the draft is saved to disk.`, true); }
64
+ }
65
+ function findRecovery(path) {
66
+ const prefix = `runlist:recovery:${checkoutKey}:${encodeURIComponent(path)}:`;
67
+ const records = [];
68
+ try { for (let i = 0; i < localStorage.length; i++) {
69
+ const location = localStorage.key(i); if (!location.startsWith(prefix)) continue;
70
+ try { records.push({ ...JSON.parse(localStorage.getItem(location)), location }); } catch { /* Ignore unreadable browser entries, not disk state. */ }
71
+ }
72
+ } catch { /* Disk recovery remains available when renderer storage cannot be read. */ }
73
+ return records.find(r => r.location === key(path)) ?? records.sort((a,b) => b.at - a.at)[0];
74
+ }
75
+ function linked(href) {
76
+ const target=href.split('#')[0];
77
+ if(!target.endsWith('.md')||/^(?:[a-z][a-z0-9+.-]*:|\\|\/\/)/i.test(target))return null;
78
+ return `?ref=${encodeURIComponent(href)}&from=${encodeURIComponent(state.doc?.path??'')}`;
79
+ }
80
+
81
+ function candidate() { return state.pending?.kind === 'undo' ? state.pending.source : editedSource(state.draftSource ?? state.base.source, state.body); }
82
+ function updateDirty() {
83
+ state.dirty = candidate() !== state.base.source;
84
+ const rows = lineDiff(state.base.source, candidate()), adds = rows.filter(r => r.kind === 'add').length, removes = rows.filter(r => r.kind === 'remove').length;
85
+ $('change-count').textContent = state.dirty ? `${adds + removes}` : '';
86
+ $('review-summary').textContent = `${adds} added ${adds === 1 ? 'line' : 'lines'} · ${removes} removed ${removes === 1 ? 'line' : 'lines'} · ${state.doc.path}`;
87
+ const claimed = state.doc.claim?.state === 'owned' || state.doc.claim?.corrupt;
88
+ $('save').disabled = !state.dirty || !state.doc.editable || claimed || !!state.conflict || state.busy;
89
+ $('edit').disabled = !state.doc.editable || state.busy;
90
+ $('tab-edit').disabled = !state.doc.editable || state.busy;
91
+ $('undo').disabled = !state.lastSave || state.dirty || claimed || state.busy;
92
+ $('body-editor').readOnly = !!state.pending;
93
+ blockEditor.setReadOnly(!!state.pending || !state.doc.editable);
94
+ $('toggle-markdown').disabled = !!state.pending;
95
+ $('pending-note').hidden = !state.pending;
96
+ stages.update(candidate());
97
+ yardstick.update();
98
+ $('save').textContent = state.pending ? state.pending.kind === 'undo' ? 'Retry undo' : 'Retry save' : 'Save changes';
99
+ }
100
+ function view(mode) {
101
+ if (!state.doc) return;
102
+ state.mode = mode;
103
+ $('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
104
+ $('workspace').classList.remove('library-start');$('empty').hidden=true;$('document').hidden=false;
105
+ $('doc-title').textContent=state.doc.title;$('doc-path').textContent=state.doc.path;$('doc-status').textContent=state.doc.status??'';
106
+ views.update(mode);
107
+ const source = candidate();
108
+ $('reading').innerHTML = markdownHtml(state.dirty ? source : state.doc.source, linked);
109
+ $('full-source').textContent = state.doc.source;
110
+ $('diff').innerHTML = diffHtml(state.base.source, source);
111
+ updateDirty();
112
+ if (mode === 'read' && state.dirty) notice('Previewing your draft. It has not been saved to the file.');
113
+ if (mode === 'edit' && (editorPath !== state.doc.path || blockEditor.source() !== state.body)) { blockEditor.set(state.body,{readOnly:!!state.pending || !state.doc.editable}); editorPath=state.doc.path; }
114
+ if (mode !== 'edit') blockEditor.hideTools();
115
+ navigation.update();library.render();
116
+ }
117
+ function renderList() { library.render(); }
118
+
119
+ function renderDocumentSummary() {
120
+ $('doc-title').textContent=state.doc.title;
121
+ const plan=state.plans.find(p=>p.path===state.doc.path);
122
+ if(plan){plan.title=state.doc.title;plan.status=state.doc.status;const selected=readSourceStage(state.doc.source);plan.stage=selected.word;plan.stageInvalid=selected.invalid;}library.opened();
123
+ }
124
+ function renderEvidence() {
125
+ const doc = state.doc;
126
+ renderConnections();
127
+ $('file-info').innerHTML = `<dl><dt>Source file</dt><dd>${esc(doc.path)}</dd><dt>Revision</dt><dd><code>${esc(doc.revision.slice(0,23))}…</code></dd><dt>Actor</dt><dd>${esc($('identity').textContent)}</dd></dl><p class="muted">Source is the record. Save changes the file; derived views read it again.</p>`;
128
+ $('flags').innerHTML = doc.flags.length ? doc.flags.map(flag => `<section class="flag"><span class="flag-tag">${esc(flag.id)} · ${esc(flag.severity)} · ${esc(flag.state)}</span><h3>${esc(flag.text)}</h3><h4>ORIGINAL OBSERVATION</h4><small>${esc(flag.original.file)}${flag.original.line ? `:${flag.original.line}` : ''}<br>${esc(flag.original.at ?? '')}</small><pre>${esc(flag.original.quote || 'File-level observation; no original quote was recorded.')}</pre><small>${esc(flag.original.revisionReason)}</small><h4>CURRENT LOCATION · ${esc(flag.current.status.toUpperCase())}</h4><pre class="current-quote">${esc(flag.current.quote ?? 'Current evidence is unavailable or ambiguous.')}</pre><small>${flag.current.line ? `Line ${flag.current.line} · ` : ''}${esc(flag.current.revision?.slice(0,23) ?? 'No current revision')}<br>Original evidence is retained.</small></section>`).join('') : '<p class="muted">No related flags recorded for this document.</p>';
129
+ }
130
+ function renderConnections() {
131
+ const root=$('document-connections'),relations=state.doc.relationships??[];root.hidden=!relations.length;root.replaceChildren();
132
+ for(const label of [...new Set(relations.map(r=>r.label))]) {
133
+ const details=document.createElement('details'),items=relations.filter(r=>r.label===label),summary=document.createElement('summary');summary.textContent=`${label} · ${items.length}`;details.append(summary);let visible=0;
134
+ const more=document.createElement('button');more.className='quiet small';more.textContent='Show more';const append=()=>{for(const relation of items.slice(visible,visible+20)){const button=document.createElement('button');button.className='connection-link quiet';button.disabled=!relation.target;button.textContent=relation.target?`${relation.target.title} · ${relation.target.status}`:`${relation.ref} · unavailable`;button.title=relation.target?.path??relation.ref;if(relation.target)button.onclick=guarded(()=>open(relation.target.path));details.insertBefore(button,more);}visible+=20;more.hidden=visible>=items.length;};details.append(more);more.onclick=append;append();root.append(details);
135
+ }
136
+ }
137
+ async function open(path,selection={}) {
138
+ if (state.busy) throw new Error('Wait for the current operation before switching plans.');
139
+ await yardstick.beforeLeave();
140
+ clearTimeout(draftTimer);
141
+ if (state.doc && (state.dirty || state.pending)) { storeLocal(); await persistDraft(); if(!state.recoveryStored)throw new Error('Preserve your current draft before switching documents.'); }
142
+ notice('Opening document…');
143
+ path=await lifecycle.resolveInitial(path);
144
+ path=await yardstick.resolveInitial(path);
145
+ const doc = await api(`document?path=${encodeURIComponent(path)}`);
146
+ state.doc = doc; state.draftSource = doc.source; state.base = { source:doc.source, revision:doc.revision }; state.body = splitSource(doc.source).body.replaceAll('\r\n','\n');
147
+ state.dirty = false; state.conflict = null; state.pending = null; state.lastSave = null; state.draft = null;
148
+ let recovery = selection.location ? {...JSON.parse(localStorage.getItem(selection.location)),location:selection.location} : findRecovery(doc.path);
149
+ if(selection.draftId||!recovery||recovery.source===recovery.baseSource&&!recovery.pending) {
150
+ const draftId=selection.draftId??(await api(`recovery?${new URLSearchParams({path:doc.path,limit:'200'})}`)).items.find(item=>item.kind==='draft'&&item.available)?.draftId;
151
+ if(draftId){const disk=await api('draft/read',{path:doc.path,draftId});if(disk)recovery={...disk,draft:{id:disk.id,revision:disk.revision},location:key(doc.path),pending:disk.pendingOperation?{...disk.pendingOperation,path:doc.path,source:disk.source}:null,at:Date.parse(disk.at)};}
152
+ }
153
+ state.recovery = recovery;
154
+ if (recovery?.draft) {
155
+ const disk = await api('draft/read',{ path:doc.path, draftId:recovery.draft.id });
156
+ if (disk && (!recovery.source || recovery.source === recovery.baseSource)) { recovery.source = disk.source; recovery.baseSource = disk.baseSource; recovery.baseRevision = disk.baseRevision; recovery.draft.revision = disk.revision; }
157
+ }
158
+ if (recovery?.lastSave) state.lastSave = recovery.lastSave;
159
+ const recoverable = recovery && (recovery.source !== recovery.baseSource || recovery.pending);
160
+ $('recovery').hidden = !recoverable;
161
+ $('recovery-label').textContent = recoverable ? 'A locally preserved draft is available. Recover it to review changes; the Markdown file is unchanged.' : '';
162
+ $('draft-status').textContent = recoverable ? 'Draft available' : 'Saved source';
163
+ $('claim').hidden = !(doc.claim?.corrupt || doc.claim?.state === 'owned' || !doc.editable);
164
+ $('claim').textContent = doc.claim?.corrupt ? 'Ownership is corrupt. Repair it through the CLI before saving.' : doc.claim?.state === 'owned' ? `Owned by session ${doc.claim.sessionId}. You can keep a draft; coordinate or release the claim through the CLI before saving.` : !doc.editable ? 'This source is read only. Its format or metadata needs repair before saving.' : '';
165
+ $('conflict').hidden = true; $('operation-repair').hidden = true; $('empty').hidden = true; $('document').hidden = false;$('workspace').classList.remove('library-start');
166
+ $('doc-title').textContent = doc.title; $('doc-path').textContent = doc.path; $('doc-status').textContent = doc.status ?? '';
167
+ $('body-editor').value = state.body; $('markdown-fallback').hidden=true; $('block-editor').hidden=false; $('toggle-markdown').textContent='Edit Markdown'; $('toggle-markdown').setAttribute('aria-expanded','false'); renderList(); renderEvidence(); view(doc.editable?'edit':'read');
168
+ const url = new URL(location.href); url.searchParams.delete('ref');url.searchParams.delete('from');url.searchParams.delete('view');url.searchParams.delete('record');url.searchParams.set('path',doc.path); history.replaceState(null,'',url);
169
+ library.opened();
170
+ await yardstick.opened();
171
+ let sourceNotice=null;
172
+ if(selection.line) {
173
+ view('source');
174
+ if(selection.expectedRevision&&selection.expectedRevision!==doc.revision)sourceNotice='This hub changed since the filing report. Current source is open; refresh Filing before using its recorded line.';
175
+ else {
176
+ const line=Math.floor(Number(selection.line)),lines=doc.source.split('\n');
177
+ if(line>=1&&line<=lines.length){const mark=document.createElement('mark');mark.className='source-location';mark.tabIndex=-1;mark.setAttribute('aria-label',`Source line ${line}`);mark.textContent=lines[line-1];$('full-source').replaceChildren(document.createTextNode(lines.slice(0,line-1).join('\n')+(line>1?'\n':'')),mark,document.createTextNode((line<lines.length?'\n':'')+lines.slice(line).join('\n')));mark.focus();mark.scrollIntoView({block:'center'});}
178
+ else sourceNotice='The recorded line is unavailable. Current hub source is open.';
179
+ }
180
+ }
181
+ $('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');if(!selection.line)window.scrollTo(0,0); notice(sourceNotice??(doc.editable?'Your document is ready to edit. Changes are kept as a draft until you save.':'This document is read only. Its source is preserved.'),!!sourceNotice);
182
+ }
183
+ async function persistDraft() {
184
+ if (!state.doc || !state.base || (!state.dirty && !state.pending)) return;
185
+ state.draft ||= { id:crypto.randomUUID(), revision:null };
186
+ storeLocal();
187
+ const request = { path:state.doc.path, draftId:state.draft.id, expectedDraftRevision:state.draft.revision, baseSource:state.base.source, baseRevision:state.base.revision, source:recoverySource(),pendingOperation:state.pending?{operationId:state.pending.operationId,kind:state.pending.kind??'save',expectedRevision:state.pending.expectedRevision,...(state.pending.kind==='undo'?{undoOf:state.pending.undoOf}:{})}:null };
188
+ const path = state.doc.path, id = state.draft.id;
189
+ const run = async () => {
190
+ try {
191
+ // Queued calls must use the most recently acknowledged draft revision.
192
+ if (state.doc?.path !== path || state.draft?.id !== id) return;
193
+ request.expectedDraftRevision = state.draft.revision;
194
+ const stored = await api('draft/write', request);
195
+ if (state.doc?.path === path && state.draft?.id === id) { state.draft.revision = stored.revision; storeLocal(); state.recoveryStored=true; $('draft-status').textContent = 'Draft preserved locally · not saved to Markdown'; }
196
+ } catch (error) { $('draft-status').textContent = desktop?'Draft kept in Runlist':'Draft kept in browser'; notice(error.code === 'draft-conflict' ? 'Another editor updated the disk draft. Your text is kept locally; recover or copy it before proceeding.' : `Draft kept ${desktop?'in Runlist':'in browser'}. ${error.message}`, true); }
197
+ };
198
+ persistence = persistence.then(run,run); await persistence;
199
+ }
200
+ function conflict(error) {
201
+ state.conflict = error.details;
202
+ $('conflict').hidden = false;
203
+ $('current-diff').innerHTML = diffHtml(state.base.source, error.details.currentSource);
204
+ $('draft-diff').innerHTML = diffHtml(state.base.source, candidate());
205
+ $('current-source').textContent = error.details.currentSource; $('base-source').textContent = state.base.source;
206
+ storeLocal();notice('Save stopped: another edit changed the file. Your draft is kept.',true); view('edit'); $('conflict').scrollIntoView({ block:'start', behavior:'auto' });
207
+ }
208
+ async function recover() {
209
+ const r = state.recovery; if (!r) return;
210
+ state.base = { source:r.baseSource, revision:r.baseRevision }; state.draftSource = r.source; state.body = splitSource(r.source).body.replaceAll('\r\n','\n');
211
+ state.draft = r.location === key() ? r.draft : null; state.pending = r.pending; state.lastSave = r.lastSave;
212
+ $('body-editor').value = state.body; $('recovery').hidden = true; updateDirty(); storeLocal(); view('edit');
213
+ if (state.pending) notice('An interrupted save is retained. Review changes and retry to learn its original outcome.');
214
+ else notice('Draft recovered. Review changes before saving.');
215
+ if (state.base.revision !== state.doc.revision && !state.pending) conflict({ details:{ currentRevision:state.doc.revision, currentSource:state.doc.source } });
216
+ }
217
+ async function clearDraft() {
218
+ if (state.draft?.revision) await api('draft/discard', { path:state.doc.path, draftId:state.draft.id, expectedDraftRevision:state.draft.revision });
219
+ state.draft = null;
220
+ }
221
+ async function save() {
222
+ clearTimeout(draftTimer); await persistDraft();
223
+ const source = candidate();
224
+ if (state.pending && (state.pending.source !== source || state.pending.expectedRevision !== state.base.revision)) throw new Error('An interrupted save has a different payload. Recover or settle that operation before creating another save.');
225
+ state.pending ||= { path:state.doc.path, operationId:crypto.randomUUID(), expectedRevision:state.base.revision, source };
226
+ // Retain the original request identity on disk before publication, even if renderer storage is later lost.
227
+ await persistDraft();
228
+ if(!state.recoveryStored)throw new Error('Preserve the draft and operation identity before saving.');
229
+ storeLocal(); state.busy = true; $('workspace').inert = true; updateDirty(); notice('Saving the reviewed source…');
230
+ try {
231
+ const result = await api(state.pending.kind === 'undo' ? 'undo' : 'save',state.pending), pending = state.pending;
232
+ const current = await api(`document?path=${encodeURIComponent(state.doc.path)}`);
233
+ if (result.state !== 'committed') throw new Error('Save outcome is not committed. The draft remains available.');
234
+ state.lastSave = pending.kind === 'undo' ? null : { operationId:result.operationId, revision:result.revision };
235
+ state.pending = null;
236
+ if (current.revision !== result.revision) {
237
+ state.doc = current;
238
+ // Historical retry success is not authority to throw away a later edit.
239
+ state.base = { source:pending.source, revision:result.revision }; state.draftSource = pending.source;
240
+ state.body = splitSource(pending.source).body.replaceAll('\r\n','\n'); storeLocal();
241
+ conflict({ details:{ currentRevision:current.revision, currentSource:current.source } });
242
+ return;
243
+ }
244
+ state.doc = current; state.draftSource = current.source; state.base = { source:current.source, revision:current.revision }; state.body = splitSource(current.source).body.replaceAll('\r\n','\n');
245
+ state.dirty = false; storeLocal(); let cleanupError = null;
246
+ try { await clearDraft(); } catch (error) { cleanupError = error; }
247
+ $('body-editor').value = state.body; $('draft-status').textContent = 'Saved source'; storeLocal(); renderDocumentSummary(); renderEvidence(); view('edit');
248
+ notice(cleanupError ? `Saved to Markdown. Recovery draft retained: ${cleanupError.message}` : pending.kind === 'undo' ? 'Undid the save. The CLI reads the same file.' : 'Saved to Markdown. The CLI reads the same file.', !!cleanupError);
249
+ } catch (error) {
250
+ if (error.code === 'revision-conflict') { state.pending = null; storeLocal(); conflict(error); }
251
+ else if (['repair-required','operation-pending'].includes(error.code)) {
252
+ await inspectOperation(error.details.operationId); notice(error.message,true);
253
+ } else if (['claim-conflict','managed-fields','invalid-record','invalid-stage','forbidden'].includes(error.code)) {
254
+ try { if (!await api('operation/inspect',{ operationId:state.pending.operationId })) { state.pending = null; storeLocal(); } } catch { /* Keep uncertain evidence when authority prevents inspection. */ }
255
+ notice(error.message,true);
256
+ }
257
+ else { notice(`Save outcome is uncertain. Your draft and operation ID are kept; retry the same save. ${error.message}`,true); }
258
+ } finally { state.busy = false; $('workspace').inert = false; updateDirty(); }
259
+ }
260
+ async function inspectOperation(operationId) {
261
+ const operation = await api('operation/inspect',{ operationId });
262
+ if (!operation) throw new Error('The retained operation could not be found. Keep your draft for manual review.');
263
+ state.repair = operation; $('operation-repair').hidden = false;
264
+ $('repair-label').textContent = `Operation ${operation.id} is ${operation.state}. Inspect its images. Settlement keeps the current file unchanged.`;
265
+ $('repair-evidence').textContent = `BEFORE\n${operation.before}\n\nAFTER\n${operation.after}\n\nCURRENT SOURCE\n${operation.currentSource}\n\nCURRENT REVISION\n${operation.currentRevision}`;
266
+ }
267
+ async function bootstrap(token) {
268
+ const session = token ? await api('session',{ token }) : await api('session');
269
+ state.csrf = session.csrf; $('identity').textContent = `${session.actor.label ?? 'Local user'} · human`;
270
+ $('disconnect').hidden = false; $('connect-panel').hidden = true;
271
+ for(const id of ['new-document','quick-open','open-recovery'])$(id).hidden=false;
272
+ const info = await api('library?limit=1');
273
+ checkoutKey = `${session.actor.id}:${encodeURIComponent(info.checkoutPath)}`;
274
+ library.restore();await library.preferences(settings.read(),false);const list=await library.load({reset:true});
275
+ $('checkout').textContent = list.checkout; $('checkout').title = list.checkoutPath; $('workspace').hidden = false;
276
+ renderList();
277
+ const params = new URLSearchParams(location.search), fragment = new URLSearchParams(location.hash.slice(1));
278
+ const resolved=params.has('ref')?await api(`link?${new URLSearchParams({ref:params.get('ref'),from:params.get('from')??''})}`):null;
279
+ const initial = params.has('ref')?resolved?.path:params.get('path') || fragment.get('path') || list.initialPath;
280
+ history.replaceState(null,'',location.pathname + location.search);
281
+ if(params.get('view')==='changes')await changes.show();else if(params.get('view')==='filing')await filing.show();else if (['flags','decisions'].includes(params.get('view'))) await records.show(params.get('view'),params.get('record')); else if (initial) await open(initial); else await showLibrary();
282
+ records.counts().catch(()=>{});
283
+ }
284
+ async function showLibrary() {
285
+ if(state.busy)throw new Error('Wait for the current operation before returning to the library.');
286
+ await yardstick.beforeLeave();
287
+ clearTimeout(draftTimer);
288
+ if(state.doc&&(state.dirty||state.pending)){storeLocal();await persistDraft();if(!state.recoveryStored)throw new Error('Preserve your current draft before leaving the document.');}
289
+ state.mode='home';blockEditor.hideTools();$('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
290
+ $('workspace').classList.add('library-start');$('evidence').classList.remove('outline-open');
291
+ $('document').hidden=true;$('empty').hidden=false;$('doc-path').textContent='';
292
+ $('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');
293
+ const url=new URL(location.href);for(const key of ['path','ref','from','view','record'])url.searchParams.delete(key);history.replaceState(null,'',url);
294
+ library.render();window.scrollTo(0,0);
295
+ notice(state.doc&&(state.dirty||state.pending)?'Library ready. Your document draft stays available when you reopen it.':'Library ready.');
296
+ }
297
+ $('document-changes').onclick=guarded(()=>changes.show());
298
+ $('library-home').onclick=guarded(showLibrary);
299
+ $('back-library').onclick=guarded(showLibrary);
300
+ $('toggle-plans').onclick = () => $('toggle-plans').setAttribute('aria-expanded',String($('sidebar').classList.toggle('open')));
301
+ for (const name of ['read','edit','review','source']) $('tab-' + name).onclick = guarded(() => view(name));
302
+ $('edit').onclick = guarded(() => view(state.dirty ? 'review' : 'edit')); $('review').onclick = guarded(() => view('review')); $('back-edit').onclick = () => view('edit');
303
+ function bodyChanged(body) {
304
+ state.body = body;
305
+ navigation.update();
306
+ $('body-editor').value = body;
307
+ storeLocal();
308
+ try { updateDirty(); storeLocal(); $('draft-status').textContent = state.dirty ? 'Saving draft…' : 'Saved source'; clearTimeout(draftTimer); if(state.dirty) draftTimer = setTimeout(() => persistDraft(),500); }
309
+ catch (error) { $('save').disabled = true; notice(error.message,true); }
310
+ }
311
+ $('body-editor').oninput = () => bodyChanged($('body-editor').value);
312
+ $('toggle-markdown').onclick = () => {
313
+ const showing = $('markdown-fallback').hidden;
314
+ $('markdown-fallback').hidden = !showing; $('block-editor').hidden = showing;
315
+ $('toggle-markdown').textContent = showing ? 'Back to document' : 'Edit Markdown';
316
+ $('toggle-markdown').setAttribute('aria-expanded',String(showing));
317
+ if (showing) { $('body-editor').value=state.body; $('body-editor').focus(); }
318
+ else { blockEditor.set(state.body,{readOnly:!!state.pending}); editorPath=state.doc.path; }
319
+ };
320
+ $('save').onclick = guarded(save);
321
+ $('cancel').onclick = guarded(async () => { await persistDraft(); view('read'); notice(state.dirty ? 'Reading your draft preview. Changes are not saved to the file.' : 'Reading saved source.'); });
322
+ $('discard').onclick = guarded(async () => { if (state.pending) throw new Error('Inspect or retry the interrupted save before discarding its recovery data.'); await clearDraft(); state.body = splitSource(state.doc.source).body.replaceAll('\r\n','\n'); state.base = { source:state.doc.source, revision:state.doc.revision }; state.draftSource = state.doc.source; state.pending = null; state.conflict = null; $('conflict').hidden = true; $('body-editor').value = state.body; storeLocal(); $('draft-status').textContent = 'Saved source'; view('edit'); notice('Changes discarded. The Markdown file is unchanged.'); });
323
+ $('recover').onclick = guarded(recover);
324
+ $('discard-recovery').onclick = guarded(async () => {
325
+ const r = state.recovery;
326
+ if (r?.pending) throw new Error('This draft contains an interrupted save. Recover it and inspect the outcome before discarding.');
327
+ if (r?.draft?.revision) await api('draft/discard',{ path:state.doc.path,draftId:r.draft.id,expectedDraftRevision:r.draft.revision });
328
+ if (r) localStorage.removeItem(r.location); state.recovery = null; $('recovery').hidden = true; notice('Recovery draft discarded. The file is unchanged.');
329
+ });
330
+ $('review-merge').onclick = guarded(async () => { const c = state.conflict; if (!c) return; state.draftSource = rebaseSourceStage(state.base.source,state.draftSource ?? state.base.source,c.currentSource); state.base = { source:c.currentSource, revision:c.currentRevision }; state.pending = null; state.conflict = null; $('conflict').hidden = true; storeLocal(); await persistDraft(); view('review'); notice('Review the merged draft against the current file before saving.'); });
331
+ $('use-current').onclick = guarded(async () => { await persistDraft(); state.doc = await api(`document?path=${encodeURIComponent(state.doc.path)}`); $('reading').innerHTML = markdownHtml(state.doc.source,linked); view('source'); notice('Current source is shown. Your draft and conflict review are retained.'); });
332
+ $('inspect-pending').onclick = guarded(() => inspectOperation(state.pending.operationId));
333
+ $('settle-operation').onclick = guarded(async () => {
334
+ const r = state.repair; if (!r) return;
335
+ const outcome = await api('operation/settle',{ operationId:r.id,expectedRevision:r.currentRevision,disposition:'leave-current' });
336
+ $('operation-repair').hidden = true;
337
+ if (outcome.state === 'committed' && state.pending?.operationId === r.id) { await save(); return; }
338
+ state.pending = null; storeLocal(); updateDirty();
339
+ const current = await api(`document?path=${encodeURIComponent(state.doc.path)}`);
340
+ if (current.revision !== state.base.revision) conflict({ details:{ currentRevision:current.revision,currentSource:current.source } });
341
+ notice(`Operation settled as ${outcome.state}. The current file was left unchanged; review your draft before a new save.`);
342
+ });
343
+ $('undo').onclick = guarded(async () => {
344
+ const previous = state.lastSave;
345
+ state.busy = true; $('workspace').inert = true;
346
+ try {
347
+ const record = await api('operation/inspect',{ operationId:previous.operationId });
348
+ if (!record) throw new Error('The original save receipt is unavailable. Undo cannot overwrite the file.');
349
+ state.base = { source:record.after, revision:previous.revision }; state.draftSource = record.before;
350
+ state.body = splitSource(record.before).body.replaceAll('\r\n','\n'); $('body-editor').value = state.body;
351
+ state.pending = { path:state.doc.path,operationId:crypto.randomUUID(),undoOf:previous.operationId,expectedRevision:previous.revision,kind:'undo',source:record.before };
352
+ storeLocal(); updateDirty();
353
+ await save(); // The same persisted retry and conflict machinery as Save.
354
+ } finally { state.busy = false; $('workspace').inert = false; updateDirty(); }
355
+ });
356
+ $('reading').addEventListener('click',event=>{const anchor=event.target.closest('a');if(!anchor||event.metaKey||event.ctrlKey||event.shiftKey)return;const url=new URL(anchor.href,location.href);if(url.origin!==location.origin||!url.searchParams.has('ref'))return;event.preventDefault();guarded(async()=>{const target=await api(`link?${new URLSearchParams({ref:url.searchParams.get('ref'),from:url.searchParams.get('from')??''})}`);if(!target)throw new Error('This linked document is unavailable or outside this library.');await open(target.path);})();});
357
+ $('copy-source').onclick = guarded(async () => { await copyText(state.doc.source); notice('Canonical Markdown source copied.'); });
358
+ $('disconnect').onclick = guarded(async () => { await beforeDesktopLeave(); await api('logout',{}); $('workspace').hidden = true; $('connect-panel').hidden = false; for(const id of ['disconnect','new-document','quick-open','open-recovery'])$(id).hidden=true; notice('Disconnected. Private drafts are retained locally.'); await desktopDisconnected(); });
359
+ $('connect-form').onsubmit = event => { event.preventDefault(); guarded(async () => { const value = $('access').value.trim(); let token = value; try { token = new URLSearchParams(new URL(value).hash.slice(1)).get('connect') ?? value; } catch { /* A raw token is also accepted. */ } await bootstrap(token); $('access').value = ''; })(); };
360
+ window.addEventListener('beforeunload', event => { if (state.dirty || state.pending) { storeLocal(); if (!state.recoveryStored) { event.preventDefault(); event.returnValue = ''; } } });
361
+ document.addEventListener('keydown', event => { if (document.querySelector('dialog[open]')) return; if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); if (state.doc && state.dirty) guarded(() => view('review'))(); } });
362
+ async function beforeDesktopLeave() {
363
+ if(state.busy)throw new Error('Wait for the current operation before switching folders or quitting.');
364
+ await yardstick.beforeLeave();
365
+ if($('template-dialog').open||$('lifecycle-dialog')?.open||$('create-dialog').open||$('git-commit-dialog').open||$('filter-dialog').open||$('record-review')&&!$('record-review').hidden)throw new Error('Close the current review or template editor before switching folders or quitting.');
366
+ clearTimeout(draftTimer);
367
+ if(state.doc&&(state.dirty||state.pending)){storeLocal();await persistDraft();if(!state.recoveryStored)throw new Error('Recovery storage is unavailable. Save or preserve your draft before leaving.');}
368
+ await persistence;
369
+ }
370
+ async function resumeRecovery(row) {
371
+ if(row.kind==='yardstick'){await yardstick.resume(row);return;}
372
+ if(row.kind==='git-commit'){await commits.show({operationId:row.operationId});return;}
373
+ if(row.kind==='creation'){await creation.show(row.operationId?{operationId:row.operationId}:{});return;}
374
+ if(row.kind==='lifecycle'){await lifecycle.resume(row.operationId);return;}
375
+ if(row.kind==='record'){await records.show(row.recordKind,row.recordKey);return;}
376
+ if(row.kind==='operation'){await open(row.path);await inspectOperation(row.operationId);return;}
377
+ await open(row.path,row.location?{location:row.location}:{draftId:row.draftId});await recover();
378
+ if(state.pending)await inspectOperation(state.pending.operationId);
379
+ }
380
+ try { if(!await mountDesktop({beforeLeave:beforeDesktopLeave,bootstrap,notice}))await bootstrap(new URLSearchParams(location.hash.slice(1)).get('connect')); }
381
+ catch (error) { if(!desktop)$('connect-panel').hidden = false; notice(error.message,true); }
@@ -0,0 +1,182 @@
1
+ import { escapeHtml as esc, markdownHtml } from './shared.mjs';
2
+ import { parseBlocks, blocksSource, rewriteBlock, convertBlock, blockPrefixes } from './block-model.mjs';
3
+ const escapeText = s => s.replace(/[\\`*_\[\]~]/g,'\\$&');
4
+ const allowedLink = href => !/^(?:[a-z][a-z0-9+.-]*:|\/\/|\\)/i.test(href) || /^(https?:|mailto:)/i.test(href);
5
+ function inlineHTML(text) {
6
+ const re=/`([^`\n]+)`|\[([^\]\n]+)\]\(([^\s)]+)\)|\*\*([^\n]+?)\*\*|__([^\n]+?)__|~~([^\n]+?)~~|\*([^*\n]+)\*|_([^_\n]+)_|(?<escaped>\\[\\`*_{}[\]()#+.!>~-])/g;
7
+ let html='',cursor=0;
8
+ for(const m of text.matchAll(re)) {
9
+ html+=esc(text.slice(cursor,m.index)); let tag,inner,attrs='';
10
+ if(m.groups.escaped){tag='span';inner=esc(m.groups.escaped.slice(1));}
11
+ else if(m[1]!==undefined){tag='code';inner=esc(m[1]);}
12
+ else if(m[2]!==undefined){tag='a';inner=inlineHTML(m[2]);attrs=` data-href="${esc(m[3])}"${allowedLink(m[3])?` href="${esc(m[3])}"`:''}`;}
13
+ else {tag=m[4]!==undefined||m[5]!==undefined?'strong':m[6]!==undefined?'s':'em';inner=inlineHTML(m[4]??m[5]??m[6]??m[7]??m[8]);}
14
+ html+=`<${tag}${attrs} data-raw="${esc(m[0])}">${inner}</${tag}>`;cursor=m.index+m[0].length;
15
+ }
16
+ return html+esc(text.slice(cursor));
17
+ }
18
+ function rememberMarkup(root) { for(const e of root.querySelectorAll('[data-raw]')) e._originalHTML=e.innerHTML; }
19
+ const markText=(text,mark)=>{const [,lead,body,tail]=text.match(/^(\s*)([\s\S]*?)(\s*)$/);return lead+(body?mark+body+mark:'')+tail;};
20
+ function markdownDOM(node) {
21
+ if(node.nodeType===3)return escapeText(node.data);
22
+ if(node.nodeType!==1)return '';
23
+ if(node.dataset.raw!==undefined&&node.innerHTML===node._originalHTML)return node.dataset.raw;
24
+ if(node.tagName==='BR')return '\n';
25
+ const text=[...node.childNodes].map(markdownDOM).join('');
26
+ switch(node.tagName){
27
+ case 'STRONG': case 'B':return markText(text,'**');
28
+ case 'EM': case 'I':return markText(text,'*');
29
+ case 'S': case 'STRIKE':return markText(text,'~~');
30
+ case 'CODE': {const raw=node.textContent;let fence='`';while(raw.includes(fence))fence+='`';return fence+(raw.startsWith('`')||raw.endsWith('`')?' ':'')+raw+(raw.startsWith('`')||raw.endsWith('`')?' ':'')+fence;}
31
+ case 'A': {const href=node.dataset.href??node.getAttribute('href')??'';return allowedLink(href)?`[${text}](${href.replace(/[\s()]/g,c=>encodeURIComponent(c))})`:text;}
32
+ case 'DIV': case 'P':return text+'\n';
33
+ default:return text;
34
+ }
35
+ }
36
+ const commands=[['paragraph','Text','Plain paragraph'],['h1','Heading 1','Large heading'],['h2','Heading 2','Section heading'],['h3','Heading 3','Small heading'],['bullet','Bulleted list','A simple list'],['number','Numbered list','A numbered step'],['task','To-do list','Track a task'],['quote','Quote','Set a passage apart'],['code','Code','A code block'],['divider','Divider','A horizontal rule']];
37
+ const MAX_UNDO_ENTRIES=100,MAX_UNDO_BYTES=32*1024*1024;
38
+ // The common edit keeps block boundaries. Compare raw fragments without
39
+ // allocating two complete Unicode document strings on every keystroke.
40
+ const sameSource=(before,after)=>{
41
+ if(before.length===after.length){
42
+ let changed=0;
43
+ for(let i=0;i<before.length;i++)if(before[i].raw!==after[i].raw)changed++;
44
+ if(changed<2)return changed===0;
45
+ }
46
+ return blocksSource(before)===blocksSource(after);
47
+ };
48
+ export class BlockEditor {
49
+ constructor(root,{onChange,resolveLink}={}) {
50
+ this.root=root;this.onChange=onChange;this.resolveLink=resolveLink;this.blocks=[];this.history=[];this.future=[];this.readOnly=false;
51
+ root.classList.add('block-document');root.setAttribute('role','group');root.setAttribute('aria-label','Editable document');
52
+ root.addEventListener('input',e=>this.input(e));root.addEventListener('keydown',e=>this.keydown(e));
53
+ root.addEventListener('paste',e=>this.paste(e));root.addEventListener('drop',e=>{e.preventDefault();const el=e.target.closest('[data-editable]');if(!el||this.readOnly)return;const range=document.caretRangeFromPoint?.(e.clientX,e.clientY);if(range&&el.contains(range.startContainer)){getSelection().removeAllRanges();getSelection().addRange(range);this.paste({target:el,preventDefault(){},clipboardData:e.dataTransfer});}});root.addEventListener('click',e=>this.click(e));
54
+ root.addEventListener('focusin',e=>{const el=e.target.closest('[data-editable]');if(el){this.activeId=el.closest('[data-block]').dataset.block;this.showTools();}});
55
+ document.addEventListener('selectionchange',()=>this.selectionTools());window.addEventListener('scroll',()=>{this.tools.hidden=true;},true);
56
+ document.addEventListener('pointerdown',e=>{if(!root.contains(e.target)&&!this.tools?.contains(e.target)&&!this.menu?.contains(e.target))this.closeMenu();});
57
+ this.tools=document.createElement('div');this.tools.className='block-format';this.tools.hidden=true;this.tools.setAttribute('role','toolbar');this.tools.setAttribute('aria-label','Text formatting');
58
+ this.tools.innerHTML=`<button data-style="strong" aria-label="Bold" title="Bold · ⌘B"><b>B</b></button><button data-style="em" aria-label="Italic" title="Italic · ⌘I"><i>I</i></button><button data-style="code" aria-label="Inline code">&lt;/&gt;</button><button data-style="s" aria-label="Strikethrough"><s>S</s></button><button data-style="link" aria-label="Add link">Link</button>`;
59
+ this.tools.onpointerdown=e=>e.preventDefault();this.tools.onclick=e=>{const b=e.target.closest('[data-style]');if(b)this.format(b.dataset.style);};document.body.append(this.tools);
60
+ this.menu=document.createElement('div');this.menu.className='block-menu';this.menu.hidden=true;this.menu.setAttribute('role','menu');document.body.append(this.menu);
61
+ this.menu.onpointerdown=e=>e.preventDefault();this.menu.onclick=e=>{const b=e.target.closest('[data-kind]');if(b)this.choose(b.dataset.kind);};
62
+ }
63
+ set(body,{readOnly=false}={}) {this.closeMenu();this.tools.hidden=true;this.readOnly=readOnly;this.blocks=parseBlocks(body);this.history=[];this.future=[];this.typing=null;this.render();}
64
+ source(){return blocksSource(this.blocks);}
65
+ hideTools(){this.closeMenu();this.tools.hidden=true;}
66
+ setReadOnly(value){this.readOnly=value;for(const el of this.root.querySelectorAll('[data-editable]')){el.setAttribute('contenteditable',String(!value));el.setAttribute('aria-readonly',String(value));}for(const el of this.root.querySelectorAll('button,input'))el.disabled=value||!!this.blocks.find(b=>b.id===el.closest('[data-block]')?.dataset.block)?.locked;for(const el of this.root.querySelectorAll('textarea'))el.readOnly=value;if(value)this.hideTools();}
67
+ editable(){return this.root.querySelector(`[data-block="${this.activeId}"] [data-editable]`);}
68
+ selected(){const s=getSelection();return s?.rangeCount&&this.root.contains(s.anchorNode)&&this.root.contains(s.focusNode)?s:null;}
69
+ position(){const el=this.editable(),s=this.selected();if(!el||!s||!el.contains(s.focusNode))return {id:this.activeId,offset:0};const range=document.createRange();range.selectNodeContents(el);range.setEnd(s.focusNode,s.focusOffset);return {id:this.activeId,offset:range.toString().length};}
70
+ focus(id,offset=0){this.activeId=id;const el=this.editable();if(!el)return;el.focus({preventScroll:true});let remaining=offset;const walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let text;while((text=walker.nextNode())){if(remaining<=text.length){const r=document.createRange();r.setStart(text,remaining);r.collapse(true);getSelection().removeAllRanges();getSelection().addRange(r);return;}remaining-=text.length;}const r=document.createRange();r.selectNodeContents(el);r.collapse(false);getSelection().removeAllRanges();getSelection().addRange(r);}
71
+ snapshot(){
72
+ // Blocks contain only primitive fields. Copy their mutable objects while
73
+ // sharing immutable strings; structuredClone duplicates all document text.
74
+ const blocks=this.blocks.map(block=>({...block}));
75
+ const bytes=blocks.reduce((sum,block)=>sum+128+Object.values(block).reduce((n,value)=>n+(typeof value==='string'?value.length*2:8),0),0);
76
+ return {blocks,position:this.position(),bytes};
77
+ }
78
+ trimUndo(){
79
+ let bytes=[...this.history,...this.future].reduce((sum,snapshot)=>sum+snapshot.bytes,0);
80
+ // Count shared strings conservatively. Keep at least the nearest action,
81
+ // even if that single document snapshot exceeds the history byte budget.
82
+ while(this.history.length+this.future.length>1&&(bytes>MAX_UNDO_BYTES||this.history.length+this.future.length>MAX_UNDO_ENTRIES)){
83
+ const stack=this.history.length>=this.future.length?this.history:this.future;
84
+ bytes-=stack.shift().bytes;
85
+ }
86
+ }
87
+ commit(previous,{typing=false}={}){
88
+ if(previous&&!sameSource(previous.blocks,this.blocks)){
89
+ const now=performance.now(),group=typing&&this.typing?.id===previous.position.id&&now-this.typing.at<800;
90
+ if(!group)this.history.push(previous);
91
+ this.typing=typing?{id:previous.position.id,at:now}:null;this.future=[];this.trimUndo();this.onChange?.(this.source());
92
+ }
93
+ }
94
+ mutate(fn){const previous=this.snapshot();fn();this.commit(previous);}
95
+ render(){
96
+ this.root.replaceChildren();
97
+ for(const b of this.blocks){
98
+ if(['gap','comment'].includes(b.kind))continue;
99
+ const row=document.createElement('div');row.className=`doc-block block-${b.kind}`;row.dataset.block=b.id;
100
+ const handle=document.createElement('button');handle.className='block-handle';handle.type='button';handle.textContent='+';handle.title='Add a block below';handle.setAttribute('aria-label','Add block below');handle.dataset.add=b.id;handle.disabled=this.readOnly||b.locked;row.append(handle);
101
+ if(b.locked||b.kind==='divider') {const content=document.createElement('div');content.innerHTML=markdownHtml(b.raw,this.resolveLink);if(b.locked){row.classList.add('locked-block');const note=document.createElement('div');note.className='block-lock-label';note.textContent='Lifecycle history · read only';row.append(note);}row.append(content);this.root.append(row);continue;}
102
+ if(b.kind==='source') {const details=document.createElement('details');details.className='source-block';const summary=document.createElement('summary');summary.textContent='Edit this block as Markdown';const preview=document.createElement('div');preview.innerHTML=markdownHtml(b.raw,this.resolveLink);const textarea=document.createElement('textarea');textarea.value=b.raw;textarea.dataset.rawEditor=b.id;textarea.setAttribute('aria-label','Block Markdown');textarea.readOnly=this.readOnly;details.append(summary,textarea);row.append(preview,details);this.root.append(row);continue;}
103
+ const marker=document.createElement(b.kind==='task'?'input':'span');marker.className='block-marker';
104
+ if(b.kind==='task'){marker.type='checkbox';marker.checked=b.checked;marker.disabled=this.readOnly;marker.dataset.check=b.id;marker.setAttribute('aria-label',`Complete task: ${b.text}`);row.append(marker);}
105
+ else if(b.kind==='bullet'||b.kind==='number'){marker.textContent=b.kind==='bullet'?'•':b.prefix.trim();row.append(marker);}
106
+ const el=document.createElement(/^h[1-6]$/.test(b.kind)?b.kind:b.kind==='code'?'pre':'div');el.className='block-text';el.dataset.editable='true';el.contentEditable=String(!this.readOnly);el.setAttribute('role','textbox');el.setAttribute('aria-multiline','true');el.setAttribute('aria-label',b.kind==='paragraph'?'Paragraph':b.kind==='task'?'Task text':b.kind==='code'?'Code':b.kind.startsWith('h')?'Heading':`${b.kind} text`);el.dataset.placeholder=b.kind==='paragraph'?"Type '/' for blocks":'Type here…';
107
+ el.tabIndex=0;el.setAttribute('aria-readonly',String(this.readOnly));if(/^h[1-6]$/.test(b.kind))el.setAttribute('aria-description',`Heading level ${b.kind.slice(1)}`);
108
+ if(b.kind==='code')el.textContent=b.text;else {el.innerHTML=inlineHTML(b.text);rememberMarkup(el);}
109
+ row.append(el);this.root.append(row);
110
+ }
111
+ const add=document.createElement('button');add.className='append-block';add.textContent='+ Add a block';add.disabled=this.readOnly;add.dataset.append='true';this.root.append(add);
112
+ }
113
+ input(e){if(this.readOnly)return;const textarea=e.target.closest('[data-raw-editor]');if(textarea){const b=this.blocks.find(b=>b.id===textarea.dataset.rawEditor);this.mutate(()=>{b.raw=textarea.value;});return;}
114
+ const el=e.target.closest('[data-editable]');if(!el)return;this.activeId=el.closest('[data-block]').dataset.block;const b=this.blocks.find(b=>b.id===this.activeId);const previous=this.snapshot();previous.position={id:b.id,offset:Math.max(0,this.position().offset-1)};rewriteBlock(b,b.kind==='code'?el.innerText:[...el.childNodes].map(markdownDOM).join(''));this.commit(previous,{typing:['insertText','deleteContentBackward','deleteContentForward'].includes(e.inputType)});
115
+ if(b.kind==='paragraph'&&/^\/[^\n]*$/.test(el.textContent))this.openMenu(b.id,{slash:true,query:el.textContent.slice(1)});else if(!this.menu.hidden&&this.menuSlash)this.closeMenu();
116
+ }
117
+ click(e){const link=e.target.closest('a');if(link){e.preventDefault();if(e.metaKey||e.ctrlKey){const href=link.dataset.href??link.getAttribute('href');if(allowedLink(href))window.open(this.resolveLink?.(href)??href,'_blank','noopener');}return;}
118
+ if(e.target.dataset.check){const b=this.blocks.find(b=>b.id===e.target.dataset.check);this.mutate(()=>{b.checked=e.target.checked;b.prefix=b.prefix.replace(/\[[ xX]\]/,b.checked?'[x]':'[ ]');rewriteBlock(b,b.text);});return;}
119
+ if(e.target.dataset.add){this.activeId=e.target.dataset.add;this.openMenu(this.activeId,{insert:true});}
120
+ if(e.target.dataset.append)this.openMenu(this.blocks.filter(b=>!b.locked&&!['gap','comment'].includes(b.kind)).at(-1)?.id,{insert:true});
121
+ }
122
+ closeMenu(){this.menu.hidden=true;this.menuSlash=false;}
123
+ openMenu(id,{slash=false,insert=false,query=''}={}){
124
+ if(this.readOnly)return;this.menuId=id;this.menuSlash=slash;this.menuInsert=insert;this.menuIndex=0;
125
+ const matches=commands.filter(c=>`${c[1]} ${c[2]}`.toLowerCase().includes(query.toLowerCase()));this.menuCommands=matches;
126
+ this.menu.innerHTML='<div class="block-menu-title">'+(insert?'ADD A BLOCK':'TURN INTO')+'</div>'+matches.map((c,i)=>`<button role="menuitem" data-kind="${c[0]}" class="${i===0?'selected':''}"><span>${esc(c[1])}</span><small>${esc(c[2])}</small></button>`).join('');
127
+ if(!matches.length)this.menu.innerHTML+='<p>No matching block types</p>';
128
+ this.menu.hidden=false;const row=this.root.querySelector(`[data-block="${id}"]`),rect=(row??this.root).getBoundingClientRect();this.menu.style.left=Math.max(8,Math.min(rect.left,innerWidth-260))+'px';this.menu.style.top=Math.max(8,Math.min(rect.bottom+4,innerHeight-Math.min(420,this.menu.offsetHeight)-8))+'px';
129
+ }
130
+ choose(kind){this.mutate(()=>{const id=this.menuId;let b=this.blocks.find(b=>b.id===id);if(this.menuInsert){b=this.insertAfter(b,kind,'');}
131
+ else if(b){if(this.menuSlash)b.text='';if(kind==='code'){b.kind='code';b.prefix='```\n';b.suffix='\n```'+(b.raw.endsWith('\n')?'\n':'');rewriteBlock(b,b.text);}else if(kind==='divider'){b.kind='divider';b.raw='---'+(b.raw.endsWith('\n')?'\n':'');}else convertBlock(b,kind);}this.closeMenu();this.render();this.focus(b?.id);});}
132
+ insertAfter(block,kind,text){let index=block?this.blocks.indexOf(block)+1:this.blocks.findIndex(b=>b.locked);if(index<0)index=this.blocks.length;
133
+ const next=this.blocks[index];if(block&&!block.raw.endsWith('\n'))block.raw+='\n';
134
+ const fresh={id:crypto.randomUUID(),kind,prefix:kind==='code'?'```\n':blockPrefixes[kind]??'',text,suffix:kind==='code'?'\n```\n':'\n',checked:false,raw:''};rewriteBlock(fresh,text);if(kind==='divider')fresh.raw='---\n';
135
+ const parts=[];if(!['bullet','number','task'].includes(kind))parts.push({id:crypto.randomUUID(),kind:'gap',raw:'\n'});parts.push(fresh);if(next?.kind!=='gap'&&next)parts.push({id:crypto.randomUUID(),kind:'gap',raw:'\n'});
136
+ this.blocks.splice(index,0,...parts);return fresh;
137
+ }
138
+ selectionTools(){const s=this.selected();if(!s||s.isCollapsed||this.readOnly){this.tools.hidden=true;return;}const start=s.anchorNode.parentElement.closest('[data-editable]'),end=s.focusNode.parentElement.closest('[data-editable]');if(!start||start!==end||start.closest('.block-code')){this.tools.hidden=true;return;}
139
+ this.savedRange=s.getRangeAt(0).cloneRange();const r=this.savedRange.getBoundingClientRect();this.tools.hidden=false;this.tools.style.left=Math.max(8,Math.min(r.left,innerWidth-this.tools.offsetWidth-8))+'px';this.tools.style.top=Math.max(8,r.top-this.tools.offsetHeight-8)+'px';
140
+ }
141
+ showTools(){this.selectionTools();}
142
+ format(tag){let s=this.selected(),range=s?.rangeCount?s.getRangeAt(0):this.savedRange;if(!range||range.collapsed||!this.root.contains(range.commonAncestorContainer))return;
143
+ const el=(range.startContainer.nodeType===1?range.startContainer:range.startContainer.parentElement).closest('[data-editable]');if(!el||!el.contains(range.endContainer)||el.closest('.block-code'))return;
144
+ this.activeId=el.closest('[data-block]').dataset.block;let href;
145
+ if(tag==='link'){href=window.prompt('Link URL');if(!href)return;if(!allowedLink(href)||/[\s]/.test(href)){window.alert('Use a web, mail, fragment, or relative link without spaces.');return;}tag='a';}
146
+ this.mutate(()=>{
147
+ const parent=(range.startContainer.nodeType===1?range.startContainer:range.startContainer.parentElement).closest(tag);
148
+ if(parent&&el.contains(parent)&&parent.contains(range.endContainer)) {
149
+ const before=document.createRange();before.selectNodeContents(parent);before.setEnd(range.startContainer,range.startOffset);
150
+ const after=document.createRange();after.selectNodeContents(parent);after.setStart(range.endContainer,range.endOffset);
151
+ const left=parent.cloneNode(false),right=parent.cloneNode(false),middle=range.cloneContents();
152
+ left.removeAttribute('data-raw');right.removeAttribute('data-raw');left.append(before.cloneContents());right.append(after.cloneContents());
153
+ const replacement=document.createDocumentFragment();if(left.textContent)replacement.append(left);replacement.append(middle);if(right.textContent)replacement.append(right);
154
+ const offset=this.position().offset;parent.replaceWith(replacement);
155
+ rewriteBlock(this.blocks.find(b=>b.id===this.activeId),[...el.childNodes].map(markdownDOM).join(''));this.focus(this.activeId,offset);return;
156
+ }
157
+ const wrapper=document.createElement(tag);if(href){wrapper.dataset.href=href;wrapper.setAttribute('href',href);}wrapper.append(range.extractContents());range.insertNode(wrapper);const r=document.createRange();r.selectNodeContents(wrapper);s=getSelection();s.removeAllRanges();s.addRange(r);rewriteBlock(this.blocks.find(b=>b.id===this.activeId),[...el.childNodes].map(markdownDOM).join(''));
158
+ });this.selectionTools();
159
+ }
160
+ paste(e){const el=e.target.closest('[data-editable]');if(!el||this.readOnly)return;e.preventDefault();const s=this.selected();if(!s)return;const r=s.getRangeAt(0);if(!el.contains(r.endContainer))return;const text=e.clipboardData.getData('text/plain');r.deleteContents();const node=document.createTextNode(text.replaceAll('\r\n','\n'));r.insertNode(node);r.setStartAfter(node);r.collapse(true);s.removeAllRanges();s.addRange(r);this.input({target:el});}
161
+ undo(redo=false){this.typing=null;const from=redo?this.future:this.history,to=redo?this.history:this.future;if(!from.length)return;const target=from.pop();to.push(this.snapshot());this.trimUndo();this.blocks=target.blocks;this.closeMenu();this.render();this.focus(target.position.id,target.position.offset);this.onChange?.(this.source());}
162
+ keydown(e){if(this.readOnly||e.isComposing)return;const el=e.target.closest('[data-editable]');if(!el)return;this.activeId=el.closest('[data-block]').dataset.block;const b=this.blocks.find(b=>b.id===this.activeId),mod=e.metaKey||e.ctrlKey;
163
+ if(mod&&e.key.toLowerCase()==='z'){e.preventDefault();this.undo(e.shiftKey);return;}if(mod&&e.key.toLowerCase()==='y'){e.preventDefault();this.undo(true);return;}
164
+ if(mod&&['b','i'].includes(e.key.toLowerCase())){e.preventDefault();this.format(e.key.toLowerCase()==='b'?'strong':'em');return;}
165
+ if(!this.menu.hidden){if(e.key==='Escape'){e.preventDefault();this.closeMenu();return;}if(['ArrowDown','ArrowUp'].includes(e.key)){e.preventDefault();this.menuIndex=(this.menuIndex+(e.key==='ArrowDown'?1:-1)+this.menuCommands.length)%this.menuCommands.length;[...this.menu.querySelectorAll('[data-kind]')].forEach((b,i)=>b.classList.toggle('selected',i===this.menuIndex));return;}if(e.key==='Enter'){e.preventDefault();const cmd=this.menuCommands[this.menuIndex];if(cmd)this.choose(cmd[0]);return;}}
166
+ const s=this.selected();if(!s||!el.contains(s.anchorNode)||!el.contains(s.focusNode))return;
167
+ if(e.key==='Enter'&&e.shiftKey&&b.kind!=='code'){e.preventDefault();const r=s.getRangeAt(0);r.deleteContents();const br=document.createElement('br');r.insertNode(br);r.setStartAfter(br);r.collapse(true);s.removeAllRanges();s.addRange(r);this.input({target:el});return;}
168
+ if(['ArrowUp','ArrowDown'].includes(e.key)&&s.isCollapsed&&!mod){const offset=this.position().offset;if((e.key==='ArrowUp'&&offset===0)||(e.key==='ArrowDown'&&offset===el.textContent.length)){const elements=[...this.root.querySelectorAll('[data-editable]')],index=elements.indexOf(el),next=elements[index+(e.key==='ArrowUp'?-1:1)];if(next){e.preventDefault();this.focus(next.closest('[data-block]').dataset.block,e.key==='ArrowUp'?next.textContent.length:0);}}}
169
+ if(e.key==='Enter'&&!e.shiftKey&&b.kind!=='code'){
170
+ e.preventDefault();this.mutate(()=>{const r=s.getRangeAt(0);r.deleteContents();const tail=document.createRange();tail.selectNodeContents(el);tail.setStart(r.startContainer,r.startOffset);const frag=tail.extractContents();const head=[...el.childNodes].map(markdownDOM).join(''),text=[...frag.childNodes].map(markdownDOM).join('');
171
+ if(['bullet','number','task','quote'].includes(b.kind)&&!head&&!text){rewriteBlock(b,'');convertBlock(b,'paragraph');this.render();this.focus(b.id);return;}
172
+ rewriteBlock(b,head);const kind=['bullet','number','task','quote'].includes(b.kind)?b.kind:'paragraph',next=this.insertAfter(b,kind,text);if(kind==='number'){next.prefix=b.prefix.replace(/\d+/,n=>String(Number(n)+1));rewriteBlock(next,next.text);}this.render();this.focus(next.id);});return;
173
+ }
174
+ if(e.key==='Enter'&&b.kind==='code'){e.preventDefault();const r=s.getRangeAt(0);r.deleteContents();const n=document.createTextNode('\n');r.insertNode(n);r.setStartAfter(n);r.collapse(true);s.removeAllRanges();s.addRange(r);this.input({target:el});return;}
175
+ if(e.key==='Backspace'&&s.isCollapsed&&this.position().offset===0){
176
+ if(b.kind!=='paragraph'&&b.kind in blockPrefixes){e.preventDefault();this.mutate(()=>{convertBlock(b,'paragraph');this.render();this.focus(b.id);});return;}
177
+ const index=this.blocks.indexOf(b);let p=index-1;while(this.blocks[p]?.kind==='gap')p--;const prev=this.blocks[p];if(!prev||prev.locked||!['paragraph','bullet','number','task','quote','h1','h2','h3','h4','h5','h6'].includes(prev.kind))return;
178
+ e.preventDefault();const prevEl=this.root.querySelector(`[data-block="${prev.id}"] [data-editable]`),offset=prevEl?.textContent.length??0;this.mutate(()=>{rewriteBlock(prev,prev.text+b.text);this.blocks.splice(p+1,index-p);this.render();this.focus(prev.id,offset);});
179
+ }
180
+ if(e.key==='Tab'&&!mod&&!e.shiftKey&&b.kind==='code'){e.preventDefault();const r=s.getRangeAt(0),n=document.createTextNode(' ');r.deleteContents();r.insertNode(n);r.setStartAfter(n);r.collapse(true);s.removeAllRanges();s.addRange(r);this.input({target:el});}
181
+ }
182
+ }