creo-edit 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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/clipboard/drop.d.ts +17 -0
  4. package/dist/clipboard/htmlParser.d.ts +18 -0
  5. package/dist/clipboard/htmlSerializer.d.ts +9 -0
  6. package/dist/commands/imageCommands.d.ts +32 -0
  7. package/dist/commands/insertCommands.d.ts +33 -0
  8. package/dist/commands/listCommands.d.ts +19 -0
  9. package/dist/commands/markCommands.d.ts +21 -0
  10. package/dist/commands/navigationCommands.d.ts +27 -0
  11. package/dist/commands/structuralCommands.d.ts +22 -0
  12. package/dist/commands/textCommands.d.ts +18 -0
  13. package/dist/controller/history.d.ts +31 -0
  14. package/dist/controller/navigation.d.ts +18 -0
  15. package/dist/controller/selection.d.ts +25 -0
  16. package/dist/controller/wordBoundary.d.ts +25 -0
  17. package/dist/createEditor.d.ts +252 -0
  18. package/dist/dom/anchorMap.d.ts +27 -0
  19. package/dist/index.d.ts +34 -0
  20. package/dist/index.js +7833 -0
  21. package/dist/index.js.map +72 -0
  22. package/dist/input/keymap.d.ts +47 -0
  23. package/dist/input/mobile.d.ts +13 -0
  24. package/dist/input/nativeInput.d.ts +27 -0
  25. package/dist/markdown/serialize.d.ts +2 -0
  26. package/dist/model/blockText.d.ts +42 -0
  27. package/dist/model/cellAccess.d.ts +2 -0
  28. package/dist/model/doc.d.ts +45 -0
  29. package/dist/model/fractional.d.ts +57 -0
  30. package/dist/model/rebalance.d.ts +12 -0
  31. package/dist/model/types.d.ts +133 -0
  32. package/dist/plugin/anchorCodec.d.ts +18 -0
  33. package/dist/plugin/atomic.d.ts +2 -0
  34. package/dist/plugin/builtin.d.ts +10 -0
  35. package/dist/plugin/decorations.d.ts +35 -0
  36. package/dist/plugin/htmlCodec.d.ts +8 -0
  37. package/dist/plugin/keymapMatch.d.ts +2 -0
  38. package/dist/plugin/registry.d.ts +29 -0
  39. package/dist/plugin/runsAt.d.ts +9 -0
  40. package/dist/plugin/serializeCodec.d.ts +6 -0
  41. package/dist/plugin/triggers.d.ts +33 -0
  42. package/dist/plugin/types.d.ts +188 -0
  43. package/dist/plugins/add-block/index.d.ts +17 -0
  44. package/dist/plugins/calendar/index.d.ts +7 -0
  45. package/dist/plugins/calendar/view.d.ts +29 -0
  46. package/dist/plugins/cells/codecs.d.ts +6 -0
  47. package/dist/plugins/cells/commands.d.ts +5 -0
  48. package/dist/plugins/cells/controls.d.ts +3 -0
  49. package/dist/plugins/cells/htmlCodec.d.ts +6 -0
  50. package/dist/plugins/cells/index.d.ts +3 -0
  51. package/dist/plugins/cells/views.d.ts +27 -0
  52. package/dist/plugins/drag-handle/index.d.ts +6 -0
  53. package/dist/plugins/infinite-scroll/index.d.ts +44 -0
  54. package/dist/plugins/md-shortcuts/index.d.ts +2 -0
  55. package/dist/plugins/search/engine.d.ts +33 -0
  56. package/dist/plugins/search/highlight.d.ts +13 -0
  57. package/dist/plugins/search/index.d.ts +5 -0
  58. package/dist/plugins/search/navigate.d.ts +15 -0
  59. package/dist/plugins/search/styles.d.ts +1 -0
  60. package/dist/plugins/search/types.d.ts +73 -0
  61. package/dist/plugins/search/ui.d.ts +2 -0
  62. package/dist/plugins/slash/index.d.ts +12 -0
  63. package/dist/plugins/slash/items.d.ts +21 -0
  64. package/dist/plugins/slash/menu.d.ts +17 -0
  65. package/dist/plugins/styles.css +233 -0
  66. package/dist/render/DocView.d.ts +7 -0
  67. package/dist/render/InlineRunsView.d.ts +7 -0
  68. package/dist/render/blocks/CodeBlockView.d.ts +7 -0
  69. package/dist/render/blocks/HeadingView.d.ts +7 -0
  70. package/dist/render/blocks/ImageView.d.ts +8 -0
  71. package/dist/render/blocks/ListItemView.d.ts +7 -0
  72. package/dist/render/blocks/ParagraphView.d.ts +7 -0
  73. package/dist/virtual/VirtualDoc.d.ts +28 -0
  74. package/dist/virtual/heightIndex.d.ts +36 -0
  75. package/package.json +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nik (xnimorz)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # creo-edit
2
+
3
+ A text editor framework, based on [Creo](https://github.com/xnim/creo). Row-based rich-text editing on a controlled `contentEditable`. Mountable inside any framework that gives you a DOM ref (React, Vue, Svelte, Solid) — see [Hosting inside other frameworks](#hosting-inside-other-frameworks).
4
+
5
+ ## Highlights
6
+
7
+ - **Cursor lives outside the document state** — typing into a block doesn't dirty selection subscribers, and vice versa.
8
+ - **CRDT-friendly row ordering** via base-62 fractional indexing — insert-between is O(log n), no renumber.
9
+ - **Controlled `contentEditable`** — native browser selection and IME, with every `beforeinput` intercepted and translated into a command. The model is the source of truth.
10
+ - **Per-keystroke render cost is O(1) blocks** — block immutability + `shouldUpdate` identity checks make the keyed reconciler skip every untouched block.
11
+ - **Optional virtualization** — only blocks intersecting the viewport are mounted; documents with hundreds of thousands of blocks remain responsive.
12
+ - **First-class mobile support** — native long-press OS menu, native selection handles, IME composition reconciled into a single undo step, `visualViewport`-aware caret-keeping.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ bun add creo creo-edit
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { createApp, HtmlRender } from "creo";
24
+ import { createEditor } from "creo-edit";
25
+
26
+ const editor = createEditor({
27
+ initial: {
28
+ blocks: [
29
+ { type: "h1", runs: [{ text: "Hello" }] },
30
+ { type: "p", runs: [{ text: "Type here." }] },
31
+ ],
32
+ },
33
+ });
34
+
35
+ createApp(
36
+ () => editor.EditorView(),
37
+ new HtmlRender(document.querySelector("#app")!),
38
+ ).mount();
39
+ ```
40
+
41
+ ## Hosting inside other frameworks
42
+
43
+ The editor renders through Creo's `HtmlRender`, which mounts into any DOM element. Embed it inside a host framework by giving Creo a container element managed by that framework. There is no React/Vue/Svelte wrapper package — you write the ten-line bridge once.
44
+
45
+ **React**
46
+
47
+ ```tsx
48
+ import { useEffect, useRef } from "react";
49
+ import { createApp, HtmlRender } from "creo";
50
+ import { createEditor } from "creo-edit";
51
+
52
+ export function CreoEdit() {
53
+ const ref = useRef<HTMLDivElement>(null);
54
+ useEffect(() => {
55
+ if (!ref.current) return;
56
+ const editor = createEditor();
57
+ const app = createApp(
58
+ () => editor.EditorView(),
59
+ new HtmlRender(ref.current),
60
+ ).mount();
61
+ return () => app.unmount?.();
62
+ }, []);
63
+ return <div ref={ref} />;
64
+ }
65
+ ```
66
+
67
+ **Vue 3**
68
+
69
+ ```vue
70
+ <script setup lang="ts">
71
+ import { onMounted, onBeforeUnmount, ref } from "vue";
72
+ import { createApp as creoApp, HtmlRender } from "creo";
73
+ import { createEditor } from "creo-edit";
74
+
75
+ const host = ref<HTMLElement | null>(null);
76
+ let app: ReturnType<typeof creoApp> | null = null;
77
+ onMounted(() => {
78
+ const editor = createEditor();
79
+ app = creoApp(() => editor.EditorView(), new HtmlRender(host.value!)).mount();
80
+ });
81
+ onBeforeUnmount(() => app?.unmount?.());
82
+ </script>
83
+
84
+ <template>
85
+ <div ref="host" />
86
+ </template>
87
+ ```
88
+
89
+ **Svelte 5 / Solid / anything with a DOM ref**: same pattern — wait for the container to be mounted, call `createApp(...).mount()`, call `app.unmount()` on teardown. The `editor` handle (`createEditor()` return value) is plain JS; exposing `editor.dispatch`, `editor.toJSON`, `editor.docStore`, etc. from inside a hook / composable is straightforward.
90
+
91
+ ## Editor API
92
+
93
+ ```ts
94
+ type Editor = {
95
+ docStore: Store<DocState>;
96
+ selStore: Store<Selection>;
97
+ dispatch(cmd: Command): void;
98
+ undo(): void;
99
+ redo(): void;
100
+ EditorView: PublicView<EditorViewProps, void>;
101
+ setDocFromHTML(html: string): void;
102
+ toJSON(): SerializedDoc;
103
+ focus(): void;
104
+ blur(): void;
105
+ };
106
+ ```
107
+
108
+ ### Commands
109
+
110
+ - `insertText`, `deleteBackward`, `deleteForward` — text editing
111
+ - `splitBlock`, `mergeBackward`, `mergeForward` — structural
112
+ - `setBlockType` — promote/demote between paragraph, headings, list items
113
+ - `toggleMark` — bold, italic, underline, strikethrough, code
114
+ - `toggleList`, `indentList`, `outdentList`
115
+ - `insertImage`, `insertTable`
116
+ - `tableInsertRow/Col`, `tableRemoveRow/Col`
117
+ - `moveCursor`
118
+
119
+ ### Default keybindings
120
+
121
+ | Chord | Action |
122
+ |---|---|
123
+ | `Cmd/Ctrl+B` / `+I` / `+U` | Toggle bold / italic / underline |
124
+ | `Cmd/Ctrl+Shift+S` | Strikethrough |
125
+ | `Cmd/Ctrl+Z` / `+Shift+Z` | Undo / redo |
126
+ | `Cmd/Ctrl+Alt+1..6` | Heading levels |
127
+ | `Cmd/Ctrl+Alt+0` | Paragraph |
128
+ | `Tab` / `Shift+Tab` | List indent / outdent (or table cell nav) |
129
+ | `Enter` / `Backspace` / `Delete` | Split / merge blocks |
130
+ | Arrows / `Home` / `End` | Caret navigation (extend with `Shift`) |
131
+
132
+ ### Virtualization (large documents)
133
+
134
+ ```ts
135
+ const editor = createEditor({
136
+ initial: { blocks: [...] },
137
+ virtualized: true,
138
+ virtualEstimatedHeight: 32,
139
+ });
140
+ ```
141
+
142
+ Only blocks intersecting `[scrollTop − overscan, scrollTop + viewport + overscan]` are mounted, with measured per-block heights stored in a Fenwick tree for O(log n) viewport resolution.
143
+
144
+ ### Image upload
145
+
146
+ ```ts
147
+ const editor = createEditor({
148
+ uploadImage: async (file) => {
149
+ const fd = new FormData();
150
+ fd.append("file", file);
151
+ const res = await fetch("/upload", { method: "POST", body: fd });
152
+ return (await res.json()).url;
153
+ },
154
+ });
155
+ ```
156
+
157
+ Without `uploadImage`, dropped/pasted images use `URL.createObjectURL`.
158
+
159
+ ### Mobile
160
+
161
+ `creo-edit` ships first-class mobile support. The editor root is a `contentEditable`, so the OS long-press menu, native selection handles, IME composition, and autocorrect work out of the box. `visualViewport` tracking exposes `--creo-vv-height` and `--creo-vv-top` as CSS custom properties so host pages can position floating UI above the soft keyboard, and scrolls the caret into the upper third of visible space when the keyboard opens.
162
+
163
+ ## Architecture
164
+
165
+ See `AGENTS.md` in the repo root for engine-level notes. The editor is a pure consumer of Creo's public API — `view`, `use`, `store`, primitive functions — and uses raw DOM listeners only for events Creo's event map doesn't expose (composition, drag/drop, clipboard, visualViewport).
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,17 @@
1
+ import type { Store } from "creo";
2
+ import { type UploadFn } from "../commands/imageCommands";
3
+ import type { DocState, Selection } from "../model/types";
4
+ export type DropStores = {
5
+ docStore: Store<DocState>;
6
+ selStore: Store<Selection>;
7
+ };
8
+ export type DropHandle = {
9
+ destroy: () => void;
10
+ };
11
+ /**
12
+ * Wire dragover + drop on the editor root so dragged image files become
13
+ * image blocks. We only swallow the drop when at least one image file is
14
+ * present; other drops fall through to the browser's default behaviour
15
+ * (so dragging text from elsewhere keeps working).
16
+ */
17
+ export declare function attachDrop(root: HTMLElement, stores: DropStores, upload?: UploadFn): DropHandle;
@@ -0,0 +1,18 @@
1
+ import type { BlockSpec } from "../model/types";
2
+ /**
3
+ * Parse a fragment of HTML into a sanitized list of BlockSpec.
4
+ *
5
+ * Sanitization rules:
6
+ * - `<script>`, `<style>`, `<link>`, `<meta>` content is dropped entirely.
7
+ * - All `on*` attributes are stripped.
8
+ * - `javascript:` URLs become "#".
9
+ * - Unknown elements become inline (their text content is preserved, marks
10
+ * of any ancestor mark elements still apply).
11
+ * - Block elements outside our model (article, section, etc.) become
12
+ * paragraphs (recursing into their children when those are themselves
13
+ * block-level).
14
+ *
15
+ * Falls back to a single paragraph if no block-level structure is present.
16
+ */
17
+ export declare function parseHTML(html: string): BlockSpec[];
18
+ export declare function parsePlainText(text: string): BlockSpec[];
@@ -0,0 +1,9 @@
1
+ import type { DocState, Selection } from "../model/types";
2
+ /** Whole-doc serialization (used by toJSON-ish helpers and copy-all). */
3
+ export declare function docToHtml(doc: DocState): string;
4
+ export declare function docToPlain(doc: DocState): string;
5
+ export type ClipboardPayload = {
6
+ html: string;
7
+ plain: string;
8
+ };
9
+ export declare function selectionToClipboard(doc: DocState, sel: Selection): ClipboardPayload;
@@ -0,0 +1,32 @@
1
+ import type { Store } from "creo";
2
+ import type { DocState, Selection } from "../model/types";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ export type UploadFn = (file: File) => Promise<string>;
8
+ /**
9
+ * Pick an image source for a File. If `upload` is provided, await its URL;
10
+ * otherwise fall back to `URL.createObjectURL`.
11
+ */
12
+ export declare function fileToImageSrc(file: File, upload?: UploadFn): Promise<string>;
13
+ /**
14
+ * Drop / paste a single File into the editor as an image block. Async
15
+ * (must await an upload when configured).
16
+ */
17
+ export declare function insertImageFile(stores: Stores, file: File, upload?: UploadFn): Promise<boolean>;
18
+ /**
19
+ * Process a FileList (from paste or drop). Each image becomes its own block;
20
+ * non-image files are ignored.
21
+ */
22
+ export declare function insertImageFiles(stores: Stores, files: FileList | File[], upload?: UploadFn): Promise<boolean>;
23
+ /**
24
+ * Delete the atomic block currently under the caret. Backspace / Delete
25
+ * routes here whenever the caret sits on any atomic block (image, calendar,
26
+ * or any plugin block flagged `isAtomic`). The caret lands at the start of
27
+ * whatever block survives in that slot — preferring the next sibling, then
28
+ * the previous, then the first remaining block.
29
+ */
30
+ export declare function deleteSelectedAtomic(stores: Stores): boolean;
31
+ /** Backwards-compatible alias — img used to be the only atomic block. */
32
+ export declare const deleteSelectedImage: typeof deleteSelectedAtomic;
@@ -0,0 +1,33 @@
1
+ import type { Store } from "creo";
2
+ import type { BlockSpec, DocState, Selection } from "../model/types";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ /**
8
+ * Insert a list of pre-parsed blocks at the current caret. If the selection
9
+ * is a range, it is collapsed first.
10
+ *
11
+ * Splice rules (matches every consumer rich-text editor I'm aware of):
12
+ * - Single text-bearing block: inline-merge into the current block.
13
+ * - Multiple blocks: split the current block at the caret; the first
14
+ * pasted block's runs join the LEFT half, the last pasted block's runs
15
+ * are followed by the RIGHT half (in a new block of the last one's
16
+ * type), and any blocks in between are inserted as new blocks.
17
+ * - Non-text blocks (img / table): always become a separate block, with
18
+ * the surrounding paragraph split if needed.
19
+ */
20
+ export declare function insertBlocks(stores: Stores, blocks: BlockSpec[]): boolean;
21
+ export declare function insertImage(stores: Stores, payload: {
22
+ src: string;
23
+ alt?: string;
24
+ width?: number;
25
+ height?: number;
26
+ }): boolean;
27
+ export declare function insertColumns(stores: Stores, payload: {
28
+ cols: number;
29
+ }): boolean;
30
+ export declare function insertTable(stores: Stores, payload: {
31
+ rows: number;
32
+ cols: number;
33
+ }): boolean;
@@ -0,0 +1,19 @@
1
+ import type { Store } from "creo";
2
+ import type { DocState, Selection } from "../model/types";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ /**
8
+ * Toggle whether the touched blocks are list items of the given `ordered`
9
+ * kind.
10
+ * - If every touched text block is already a list item with that ordering,
11
+ * convert them back to paragraphs (and reset depth to 0).
12
+ * - Otherwise convert them to `li ordered=...` (preserving depth where
13
+ * possible, defaulting to 0).
14
+ */
15
+ export declare function toggleList({ docStore, selStore }: Stores, ordered: boolean): boolean;
16
+ /** Tab — increase list depth (max 3). No-op for non-list blocks. */
17
+ export declare function indentList({ docStore, selStore }: Stores): boolean;
18
+ /** Shift+Tab — decrease list depth. At depth 0, convert back to paragraph. */
19
+ export declare function outdentList({ docStore, selStore }: Stores): boolean;
@@ -0,0 +1,21 @@
1
+ import type { Store } from "creo";
2
+ import type { DocState, Mark, Selection } from "../model/types";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ /**
8
+ * Toggle a mark over the current selection.
9
+ *
10
+ * Behaviour:
11
+ * - Caret-only: no-op (real editors track a "pending mark" that biases the
12
+ * next character; we don't ship that in M6 to keep the API minimal).
13
+ * - Single-block range: if every character in [start, end) already has the
14
+ * mark, REMOVE it; otherwise ADD it everywhere in the range.
15
+ * - Cross-block range: same rule applied per-block to the slice that
16
+ * intersects the range.
17
+ *
18
+ * Run merging happens via `normalizeRuns`, so toggling repeatedly never
19
+ * fragments runs unbounded.
20
+ */
21
+ export declare function toggleMark({ docStore, selStore }: Stores, mark: Mark): boolean;
@@ -0,0 +1,27 @@
1
+ import type { Store } from "creo";
2
+ import { blockAbove, blockBelow, endOfBlock, homeOfBlock, nextAnchor, nextWord, prevAnchor, prevWord } from "../controller/navigation";
3
+ import type { Anchor, DocState, Selection } from "../model/types";
4
+ export type NavStores = {
5
+ docStore: Store<DocState>;
6
+ selStore: Store<Selection>;
7
+ };
8
+ export type AnchorStep = (doc: DocState, a: Anchor) => Anchor;
9
+ /**
10
+ * Move the cursor by applying `step` to the current focus side.
11
+ * - `extend === false` → collapse to the new anchor.
12
+ * - `extend === true` → keep the original anchor, move the focus.
13
+ */
14
+ export declare function moveBy({ docStore, selStore }: NavStores, step: AnchorStep, extend: boolean): void;
15
+ export declare function moveTo({ selStore }: NavStores, anchor: Anchor, extend: boolean): void;
16
+ export declare const STEP: {
17
+ next: typeof nextAnchor;
18
+ prev: typeof prevAnchor;
19
+ nextWord: typeof nextWord;
20
+ prevWord: typeof prevWord;
21
+ up: typeof blockAbove;
22
+ down: typeof blockBelow;
23
+ home: typeof homeOfBlock;
24
+ end: typeof endOfBlock;
25
+ docHome: (doc: DocState, _a: Anchor) => Anchor;
26
+ docEnd: (doc: DocState, _a: Anchor) => Anchor;
27
+ };
@@ -0,0 +1,22 @@
1
+ import type { Store } from "creo";
2
+ import type { BlockType, DocState, Selection } from "../model/types";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ /**
8
+ * Split the current block at the caret. The right half becomes a NEW block
9
+ * placed immediately after the current one. Headings split into a paragraph
10
+ * (the convention every consumer-grade editor uses — pressing Enter at the
11
+ * end of a heading shouldn't create another heading by default). List items
12
+ * split into another list item of the same kind & depth.
13
+ */
14
+ export declare function splitBlock({ docStore, selStore }: Stores): boolean;
15
+ export declare function mergeBackward({ docStore, selStore }: Stores): boolean;
16
+ export declare function mergeForward({ docStore, selStore }: Stores): boolean;
17
+ export type SetBlockTypePayload = {
18
+ type: BlockType;
19
+ ordered?: boolean;
20
+ depth?: 0 | 1 | 2 | 3;
21
+ };
22
+ export declare function setBlockType({ docStore, selStore }: Stores, payload: SetBlockTypePayload): boolean;
@@ -0,0 +1,18 @@
1
+ import type { DocState, Selection } from "../model/types";
2
+ import type { Store } from "creo";
3
+ export type Stores = {
4
+ docStore: Store<DocState>;
5
+ selStore: Store<Selection>;
6
+ };
7
+ /**
8
+ * Insert plain text at the current selection. If the selection is a range,
9
+ * the range is deleted first and the text is inserted at the start.
10
+ *
11
+ * Cross-block ranges are deferred to mergeBackward (M5) — this only handles
12
+ * the single-cell / single-block case.
13
+ */
14
+ export declare function insertText({ docStore, selStore }: Stores, text: string): boolean;
15
+ /** Delete one character backward, or collapse a range. */
16
+ export declare function deleteBackward({ docStore, selStore }: Stores): boolean;
17
+ /** Delete one character forward, or collapse a range. */
18
+ export declare function deleteForward({ docStore, selStore }: Stores): boolean;
@@ -0,0 +1,31 @@
1
+ import type { Store } from "creo";
2
+ import type { DocState, Selection } from "../model/types";
3
+ /**
4
+ * Snapshot-based undo/redo. We don't synthesize inverse commands — we just
5
+ * stash the previous (doc, sel) before each mutation and let undo restore
6
+ * it.
7
+ *
8
+ * Coalescing rule: consecutive `insertText` / `deleteBackward` ops within
9
+ * 500ms collapse into a single undo entry. This matches Notion / Google
10
+ * Docs UX — typing a sentence then hitting Cmd+Z removes the whole sentence,
11
+ * not the last character.
12
+ */
13
+ export type HistoryEntry = {
14
+ doc: DocState;
15
+ sel: Selection;
16
+ tag: string;
17
+ ts: number;
18
+ };
19
+ export type HistoryStores = {
20
+ docStore: Store<DocState>;
21
+ selStore: Store<Selection>;
22
+ };
23
+ export declare const COALESCE_MS = 500;
24
+ export declare const HISTORY_CAP = 200;
25
+ export declare function createHistory(stores: HistoryStores): {
26
+ record: (tag: string) => void;
27
+ undo: () => boolean;
28
+ redo: () => boolean;
29
+ reset: () => void;
30
+ };
31
+ export type History = ReturnType<typeof createHistory>;
@@ -0,0 +1,18 @@
1
+ import type { Anchor, DocState } from "../model/types";
2
+ /**
3
+ * Move one character to the right. Crosses block boundaries: from the end of
4
+ * block i to the start of block i+1. At end-of-doc, returns the same anchor.
5
+ */
6
+ export declare function nextAnchor(doc: DocState, a: Anchor): Anchor;
7
+ /**
8
+ * Move one character to the left. Crosses block boundaries the same way.
9
+ */
10
+ export declare function prevAnchor(doc: DocState, a: Anchor): Anchor;
11
+ export declare function homeOfBlock(doc: DocState, a: Anchor): Anchor;
12
+ export declare function endOfBlock(doc: DocState, a: Anchor): Anchor;
13
+ export declare function homeOfDoc(doc: DocState): Anchor;
14
+ export declare function endOfDocAnchor(doc: DocState): Anchor;
15
+ export declare function blockAbove(doc: DocState, a: Anchor): Anchor;
16
+ export declare function blockBelow(doc: DocState, a: Anchor): Anchor;
17
+ export declare function nextWord(doc: DocState, a: Anchor): Anchor;
18
+ export declare function prevWord(doc: DocState, a: Anchor): Anchor;
@@ -0,0 +1,25 @@
1
+ import type { Anchor, BlockId, DocState, Selection } from "../model/types";
2
+ export declare function caretAt(blockId: BlockId, offset: number): Anchor;
3
+ export declare function caret(at: Anchor): Selection;
4
+ export declare function range(anchor: Anchor, focus: Anchor): Selection;
5
+ export declare function anchorOffset(a: Anchor): number;
6
+ export declare function withCharOffset(a: Anchor, offset: number): Anchor;
7
+ /** Returns true when the selection is a collapsed caret. */
8
+ export declare function isCaret(s: Selection): s is {
9
+ kind: "caret";
10
+ at: Anchor;
11
+ };
12
+ /** Same anchor & focus → effectively a caret. */
13
+ export declare function selectionStart(s: Selection): Anchor;
14
+ export declare function selectionEnd(s: Selection): Anchor;
15
+ /** Compares two anchors that point into the same doc. Returns -1/0/+1. */
16
+ export declare function compareAnchors(doc: DocState, a: Anchor, b: Anchor): number;
17
+ /** Anchor in document-order start..end ascending. */
18
+ export declare function orderedRange(doc: DocState, s: Selection): {
19
+ start: Anchor;
20
+ end: Anchor;
21
+ };
22
+ /** Clamp an anchor so it points at a valid offset inside its block. */
23
+ export declare function clampAnchor(doc: DocState, a: Anchor): Anchor;
24
+ export declare function clampSelection(doc: DocState, s: Selection): Selection;
25
+ export declare function endOfDoc(doc: DocState): Anchor;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Word-boundary helpers — compute "next/previous word" offsets within a
3
+ * single string. Used by Cmd/Ctrl+Arrow navigation and double-click word
4
+ * selection.
5
+ *
6
+ * Definition of a word — same as the OS conventions every consumer editor
7
+ * follows:
8
+ * - A word run is one-or-more "word chars" (Unicode letters + digits +
9
+ * underscore + apostrophes embedded inside a run).
10
+ * - Whitespace and punctuation are treated as separators.
11
+ * - "Next word from here" = scan forward over (separators), then over
12
+ * (word chars), and land on the first non-word-char.
13
+ * - "Prev word from here" = scan backward over (separators), then over
14
+ * (word chars), and land on the first word-char.
15
+ */
16
+ /** Offset of the start of the next word at or after `offset` in `text`. */
17
+ export declare function nextWordOffset(text: string, offset: number): number;
18
+ /** Offset of the start of the previous word strictly before `offset`. */
19
+ export declare function prevWordOffset(text: string, offset: number): number;
20
+ /**
21
+ * Find the word that contains `offset` in `text`. Returns [start, end).
22
+ * If `offset` is on a non-word char, returns [offset, offset+1) so callers
23
+ * still get a usable selection (single char).
24
+ */
25
+ export declare function wordRangeAt(text: string, offset: number): [number, number];