@owomark/core 0.1.0

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/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # @owomark/core
2
+
3
+ Framework-agnostic Markdown editor engine. Provides document model, incremental rendering, input handling, commands, and selection management for a single-layer `contenteditable` editing experience.
4
+
5
+ Official package: `@owomark/core`. The flat name `owomark-core` is reserved only as a compatibility redirect and should not be used for new installs.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @owomark/core
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### Pure Logic Core (no DOM)
16
+
17
+ ```ts
18
+ import { createOwoMarkCore } from '@owomark/core';
19
+
20
+ const core = createOwoMarkCore({ initialMarkdown: '# Hello' });
21
+
22
+ // Intent-driven input API
23
+ const result = core.applyBeforeInput(
24
+ { inputType: 'insertText', data: 'X' },
25
+ { anchor: 7, focus: 7 },
26
+ );
27
+ // result.action: 'handled' | 'allow-native' | 'composition-sync'
28
+
29
+ console.log(core.getMarkdown()); // '# HelloX'
30
+ core.destroy();
31
+ ```
32
+
33
+ ### With DOM (use @owomark/view)
34
+
35
+ ```ts
36
+ import { createOwoMarkCore } from '@owomark/core';
37
+ import { createOwoMarkView } from '@owomark/view';
38
+
39
+ const core = createOwoMarkCore({ initialMarkdown: '# Hello\n\nStart typing...' });
40
+ const view = createOwoMarkView(core, document.getElementById('editor')!);
41
+
42
+ core.onChange((markdown) => {
43
+ console.log('Content changed:', markdown);
44
+ });
45
+
46
+ // Cleanup
47
+ view.destroy();
48
+ core.destroy();
49
+ ```
50
+
51
+ For backward compatibility, a legacy standalone editor API is also available:
52
+
53
+ ```ts
54
+ import { createOwoMarkVanillaEditor } from '@owomark/view';
55
+
56
+ const editor = createOwoMarkVanillaEditor();
57
+ editor.setMarkdown('# Hello');
58
+ editor.mount(document.getElementById('editor')!);
59
+ editor.destroy();
60
+ ```
61
+
62
+ ## Core API
63
+
64
+ ### State
65
+
66
+ | Method | Description |
67
+ |--------|-------------|
68
+ | `getMarkdown(): string` | Get current Markdown content |
69
+ | `getDocument(): OwoMarkDocument` | Get immutable document model |
70
+ | `getSelection(): OwoMarkSelection \| null` | Current selection as linear offsets |
71
+ | `getVirtualSelection()` | Current selection as `{ blockId, offset }` pairs |
72
+ | `getComposition()` | Current IME composition state |
73
+ | `getSnapshot()` | Full state snapshot |
74
+
75
+ ### Input Handling
76
+
77
+ | Method | Description |
78
+ |--------|-------------|
79
+ | `applyBeforeInput(intent, selection)` | Handle `beforeinput` events |
80
+ | `applyKeyDown(intent, selection)` | Handle `keydown` events |
81
+ | `applyPaste(intent, selection)` | Handle `paste` events |
82
+ | `handleCompositionStart(selection)` | Enter IME composition mode |
83
+ | `handleCompositionEnd(selection)` | Exit IME composition mode |
84
+ | `syncText(text, selection)` | Sync DOM text back to model (IME fallback) |
85
+
86
+ ### Content Mutation
87
+
88
+ | Method | Description |
89
+ |--------|-------------|
90
+ | `setMarkdown(md: string)` | Replace content (resets history) |
91
+ | `replaceMarkdown(md, selection)` | Replace content with explicit selection (supports undo) |
92
+ | `dispatch(command, payload?)` | Dispatch a named command |
93
+
94
+ ### Events
95
+
96
+ | Method | Description |
97
+ |--------|-------------|
98
+ | `onChange(cb)` | Subscribe to content changes |
99
+ | `onSelectionChange(cb)` | Subscribe to selection changes |
100
+ | `onCompositionStateChange(cb)` | Subscribe to IME composition state |
101
+ | `onDocumentChange(cb)` | Subscribe to document model changes (sovereign mode) |
102
+ | `onStateChange(cb)` | Subscribe to any state change |
103
+
104
+ ## Pure Function Exports
105
+
106
+ The package also exports pure functions that can be used independently:
107
+
108
+ ### Commands
109
+
110
+ ```ts
111
+ import {
112
+ handleMarkdownEnter,
113
+ applyMarkdownIndent,
114
+ handleCharInput,
115
+ handleSmartBackspace,
116
+ toggleBold,
117
+ toggleItalic,
118
+ insertLink,
119
+ insertCodeFence,
120
+ } from '@owomark/core';
121
+ ```
122
+
123
+ ### Parser
124
+
125
+ ```ts
126
+ import { tokenizeBlock, tokenizeInline } from '@owomark/core';
127
+ ```
128
+
129
+ ### Model
130
+
131
+ ```ts
132
+ import {
133
+ parseMarkdownToDocument,
134
+ serializeDocument,
135
+ getBlockAtOffset,
136
+ } from '@owomark/core';
137
+ ```
138
+
139
+ ### Clipboard
140
+
141
+ ```ts
142
+ import { normalizeMarkdownPaste } from '@owomark/core';
143
+ ```
144
+
145
+ ## Supported Markdown
146
+
147
+ - Headings (`# ` through `###### `)
148
+ - Bold (`**text**`) and italic (`*text*`)
149
+ - Inline code (`` `code` ``)
150
+ - Links (`[text](url)`)
151
+ - Unordered and ordered lists
152
+ - Blockquotes (`> `)
153
+ - Fenced code blocks (`` ``` ``)
154
+ - Thematic breaks (`---`)
155
+
156
+ ## Preview Projection
157
+
158
+ Convert a parsed document into preview-ready blocks for incremental rendering:
159
+
160
+ ```ts
161
+ import {
162
+ createSharedStateStore,
163
+ projectToPreviewBlocks,
164
+ computePreviewDirtyRange,
165
+ deriveBlockId,
166
+ } from '@owomark/core';
167
+ ```
168
+
169
+ ### Shared State Store
170
+
171
+ ```ts
172
+ const store = createSharedStateStore({ initialMarkdown: '# Hello' });
173
+
174
+ // Connect an editor instance
175
+ const disconnect = store.connectEditor(editorInstance);
176
+
177
+ // Subscribe to state changes (consumed by preview engines)
178
+ const unsubscribe = store.subscribe((state) => {
179
+ console.log('Version:', state.version);
180
+ console.log('Preview blocks:', state.previewBlocks.length);
181
+ });
182
+
183
+ // Cleanup
184
+ unsubscribe();
185
+ disconnect();
186
+ ```
187
+
188
+ ### Block Projection
189
+
190
+ ```ts
191
+ const blocks = projectToPreviewBlocks(document, 'vitesse-light');
192
+ const dirtyRange = computePreviewDirtyRange(previousBlocks, blocks);
193
+ ```
194
+
195
+ - Adjacent same-type container blocks (lists, blockquotes) are grouped into a single `PreviewBlock`
196
+ - Each block has a stable `blockId` (`L{startLine}-{endLine}`) for DOM reconciliation
197
+ - `renderKey` (djb2 hash of `kind:theme:raw`) drives cache invalidation
198
+
199
+ ### Preview Types
200
+
201
+ - `PreviewBlock` — block identity, kind, raw content, line range, render key
202
+ - `PreviewBlockKind` — `'paragraph' | 'heading' | 'code-fence' | ...`
203
+ - `PreviewDirtyReason` — `'text-edit' | 'structure-edit' | 'theme-change' | 'renderer-change' | 'custom-block-change' | 'full-rebuild'`
204
+ - `OwoMarkSharedState` — full shared state snapshot (document, preview blocks, selection, version)
205
+ - `OwoMarkSharedStateStore` — subscribe/getState store interface
206
+ - `OwoMarkSharedStateController` — store + `setMarkdown()`, `connectEditor()`, `updateVisibleBlockIds()`, `setThemeKey()`, `notifyRendererChange()`, `notifyCustomBlockChange()`
207
+
208
+ ## Design Principles
209
+
210
+ - **Input-first**: IME composition is never interrupted by rendering. Patches only run after `compositionend`.
211
+ - **Incremental**: Only dirty blocks are re-parsed and re-rendered. No full DOM rebuilds on each keystroke.
212
+ - **Framework-free**: Zero runtime dependencies. Works with any UI framework or vanilla JS.
@@ -0,0 +1 @@
1
+ export { v as DocumentChangeCallback, w as DomAdapterInstance, a6 as createDomAdapter } from '../dom-adapter-IZEW91gZ.js';
@@ -0,0 +1,261 @@
1
+ import {
2
+ createOwoMarkCore,
3
+ detectAndRenderDirty,
4
+ fullRender,
5
+ readSelection,
6
+ restoreSelection
7
+ } from "../chunk-RYTHPR7H.js";
8
+
9
+ // src/adapter/dom-adapter.ts
10
+ function createDomAdapter() {
11
+ const core = createOwoMarkCore();
12
+ let root = null;
13
+ let compositionJustEnded = false;
14
+ let documentChangeCallback = null;
15
+ function syncEmptyAttr() {
16
+ if (!root) return;
17
+ const doc = core.getDocument();
18
+ const empty = doc.blocks.length === 0 || doc.blocks.length === 1 && doc.blocks[0].raw === "";
19
+ if (empty) {
20
+ root.setAttribute("data-owo-empty", "true");
21
+ } else {
22
+ root.removeAttribute("data-owo-empty");
23
+ }
24
+ }
25
+ function syncFromDOM() {
26
+ if (!root) return;
27
+ const textContent = extractTextFromRoot(root);
28
+ const sel = readSelection(root);
29
+ const oldMarkdown = core.getMarkdown();
30
+ if (textContent !== oldMarkdown) {
31
+ const oldDoc = core.getDocument();
32
+ core.syncText(textContent, sel);
33
+ if (!documentChangeCallback && root) {
34
+ const newDoc = core.getDocument();
35
+ detectAndRenderDirty(root, oldDoc, newDoc, true);
36
+ if (sel) {
37
+ restoreSelection(root, sel);
38
+ }
39
+ }
40
+ syncEmptyAttr();
41
+ }
42
+ }
43
+ function onBeforeInput(e) {
44
+ if (core.getComposition().active) return;
45
+ const sel = readSelection(root);
46
+ const oldDoc = core.getDocument();
47
+ const result = core.applyBeforeInput(
48
+ { inputType: e.inputType, data: e.data },
49
+ sel
50
+ );
51
+ if (result.action === "handled") {
52
+ e.preventDefault();
53
+ if (!documentChangeCallback && root) {
54
+ const newDoc = core.getDocument();
55
+ if (oldDoc !== newDoc) {
56
+ detectAndRenderDirty(root, oldDoc, newDoc);
57
+ const linearSel = core.getSelection();
58
+ if (linearSel) restoreSelection(root, linearSel);
59
+ }
60
+ }
61
+ syncEmptyAttr();
62
+ }
63
+ }
64
+ function onInput() {
65
+ if (core.getComposition().active) return;
66
+ if (compositionJustEnded) {
67
+ compositionJustEnded = false;
68
+ syncFromDOM();
69
+ }
70
+ }
71
+ function onKeyDown(e) {
72
+ if (core.getComposition().active) return;
73
+ const sel = readSelection(root);
74
+ const oldDoc = core.getDocument();
75
+ const result = core.applyKeyDown(
76
+ {
77
+ key: e.key,
78
+ ctrlKey: e.ctrlKey,
79
+ metaKey: e.metaKey,
80
+ altKey: e.altKey,
81
+ shiftKey: e.shiftKey
82
+ },
83
+ sel
84
+ );
85
+ if (result.action === "handled") {
86
+ e.preventDefault();
87
+ if (!documentChangeCallback && root) {
88
+ const newDoc = core.getDocument();
89
+ if (oldDoc !== newDoc) {
90
+ detectAndRenderDirty(root, oldDoc, newDoc);
91
+ const linearSel = core.getSelection();
92
+ if (linearSel) restoreSelection(root, linearSel);
93
+ }
94
+ }
95
+ syncEmptyAttr();
96
+ }
97
+ }
98
+ function onPaste(e) {
99
+ e.preventDefault();
100
+ const raw = e.clipboardData?.getData("text/plain") ?? "";
101
+ const sel = readSelection(root);
102
+ const oldDoc = core.getDocument();
103
+ const result = core.applyPaste({ text: raw }, sel);
104
+ if (result.action === "handled" && !documentChangeCallback && root) {
105
+ const newDoc = core.getDocument();
106
+ if (oldDoc !== newDoc) {
107
+ detectAndRenderDirty(root, oldDoc, newDoc);
108
+ const linearSel = core.getSelection();
109
+ if (linearSel) restoreSelection(root, linearSel);
110
+ }
111
+ }
112
+ syncEmptyAttr();
113
+ }
114
+ function onCompositionStart() {
115
+ const sel = readSelection(root);
116
+ core.handleCompositionStart(sel);
117
+ }
118
+ function onCompositionUpdate() {
119
+ core.handleCompositionUpdate();
120
+ }
121
+ function onCompositionEnd(_e) {
122
+ const sel = readSelection(root);
123
+ core.handleCompositionEnd(sel);
124
+ compositionJustEnded = true;
125
+ requestAnimationFrame(() => {
126
+ if (compositionJustEnded) {
127
+ compositionJustEnded = false;
128
+ if (root) syncFromDOM();
129
+ }
130
+ });
131
+ }
132
+ function onSelectionChange() {
133
+ if (!root) return;
134
+ const sel = readSelection(root);
135
+ if (sel) {
136
+ core.updateSelection(sel);
137
+ }
138
+ }
139
+ let selectionObserver = null;
140
+ function mount(element) {
141
+ root = element;
142
+ root.contentEditable = "true";
143
+ root.setAttribute("role", "textbox");
144
+ root.setAttribute("aria-multiline", "true");
145
+ root.setAttribute("aria-label", "Markdown editor");
146
+ root.classList.add("owo-editor-root");
147
+ root.style.whiteSpace = "pre-wrap";
148
+ root.style.wordBreak = "break-word";
149
+ root.style.outline = "none";
150
+ if (!documentChangeCallback) {
151
+ fullRender(root, core.getDocument());
152
+ }
153
+ syncEmptyAttr();
154
+ root.addEventListener("beforeinput", onBeforeInput);
155
+ root.addEventListener("input", onInput);
156
+ root.addEventListener("compositionstart", onCompositionStart);
157
+ root.addEventListener("compositionupdate", onCompositionUpdate);
158
+ root.addEventListener("compositionend", onCompositionEnd);
159
+ root.addEventListener("keydown", onKeyDown);
160
+ root.addEventListener("paste", onPaste);
161
+ const onDocSelectionChange = () => onSelectionChange();
162
+ root.ownerDocument.addEventListener("selectionchange", onDocSelectionChange);
163
+ selectionObserver = () => {
164
+ root?.ownerDocument.removeEventListener("selectionchange", onDocSelectionChange);
165
+ };
166
+ }
167
+ function destroy() {
168
+ if (!root) return;
169
+ root.removeEventListener("beforeinput", onBeforeInput);
170
+ root.removeEventListener("input", onInput);
171
+ root.removeEventListener("compositionstart", onCompositionStart);
172
+ root.removeEventListener("compositionupdate", onCompositionUpdate);
173
+ root.removeEventListener("compositionend", onCompositionEnd);
174
+ root.removeEventListener("keydown", onKeyDown);
175
+ root.removeEventListener("paste", onPaste);
176
+ selectionObserver?.();
177
+ selectionObserver = null;
178
+ core.destroy();
179
+ root = null;
180
+ }
181
+ function focus() {
182
+ root?.focus();
183
+ }
184
+ function setMarkdown(markdown) {
185
+ core.setMarkdown(markdown);
186
+ if (!documentChangeCallback && root) {
187
+ fullRender(root, core.getDocument());
188
+ const linearSel = core.getSelection();
189
+ if (linearSel) restoreSelection(root, linearSel);
190
+ }
191
+ syncEmptyAttr();
192
+ }
193
+ function replaceMarkdown(markdown, selection) {
194
+ const oldDoc = core.getDocument();
195
+ core.replaceMarkdown(markdown, selection);
196
+ if (!documentChangeCallback && root) {
197
+ detectAndRenderDirty(root, oldDoc, core.getDocument());
198
+ restoreSelection(root, selection);
199
+ }
200
+ syncEmptyAttr();
201
+ }
202
+ function dispatch(command, payload) {
203
+ const oldDoc = core.getDocument();
204
+ core.dispatch(command, payload);
205
+ if (!documentChangeCallback && root) {
206
+ const newDoc = core.getDocument();
207
+ if (oldDoc !== newDoc) {
208
+ if (command === "undo" || command === "redo") {
209
+ fullRender(root, newDoc);
210
+ } else {
211
+ detectAndRenderDirty(root, oldDoc, newDoc);
212
+ }
213
+ const linearSel = core.getSelection();
214
+ if (linearSel) restoreSelection(root, linearSel);
215
+ }
216
+ }
217
+ syncEmptyAttr();
218
+ }
219
+ return {
220
+ mount,
221
+ destroy,
222
+ getMarkdown: () => core.getMarkdown(),
223
+ setMarkdown,
224
+ replaceMarkdown,
225
+ getSelection: () => core.getSelection(),
226
+ getDocument: () => core.getDocument(),
227
+ focus,
228
+ dispatch,
229
+ setIndentMode: (mode) => core.setIndentMode(mode),
230
+ onChange: (callback) => core.onChange(callback),
231
+ onSelectionChange: (callback) => core.onSelectionChange(callback),
232
+ onCompositionStateChange: (callback) => core.onCompositionStateChange(callback),
233
+ onDocumentChange(callback) {
234
+ documentChangeCallback = callback;
235
+ const unsub = core.onDocumentChange(callback);
236
+ return () => {
237
+ unsub();
238
+ if (documentChangeCallback === callback) {
239
+ documentChangeCallback = null;
240
+ }
241
+ };
242
+ },
243
+ getCore: () => core
244
+ };
245
+ }
246
+ function extractTextFromRoot(root) {
247
+ const parts = [];
248
+ for (const child of root.childNodes) {
249
+ if (child.nodeType === Node.ELEMENT_NODE) {
250
+ const el = child;
251
+ if (el.hasAttribute("data-owo-block")) {
252
+ if (parts.length > 0) parts.push("\n");
253
+ parts.push(el.textContent ?? "");
254
+ }
255
+ }
256
+ }
257
+ return parts.join("");
258
+ }
259
+ export {
260
+ createDomAdapter
261
+ };