Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
21b2d99
Enhance Slash Menu and Toolbar Components
krijnrijshouwer May 7, 2026
a4804d0
fix
krijnrijshouwer May 8, 2026
322d444
Enhance Slash Menu and Toolbar Components
krijnrijshouwer May 7, 2026
8337873
Merge branch 'feature/web-2661-integrate-input' of https://github.com…
krijnrijshouwer May 8, 2026
1c3edb8
Add headless editor functionality and update pnpm-lock.yaml
krijnrijshouwer May 9, 2026
ded1c97
Implement session diagnostics feature and update dependencies
krijnrijshouwer May 9, 2026
6d394e3
Remove headless collaboration AI waves roadmap document
krijnrijshouwer May 9, 2026
47a087d
Improve editor input backend synchronization
krijnrijshouwer May 11, 2026
4cc0ba3
Cover repeated EditContext selection deletion
krijnrijshouwer May 11, 2026
e75c518
Ignore stale collapsed EditContext DOM selections
krijnrijshouwer May 11, 2026
42a7233
Enhance editor functionality and improve keyboard handling
krijnrijshouwer May 12, 2026
852a9cf
Refactor editor and inline handling for improved clarity and function…
krijnrijshouwer May 13, 2026
e33d5ff
Enhance Slash Menu and Suggestion Menu Integration
krijnrijshouwer May 14, 2026
5077923
Update debounce timing and enhance input range resolution in editor
krijnrijshouwer May 16, 2026
b58c53b
Enhance inline completion handling and selection synchronization
krijnrijshouwer May 16, 2026
d3aa3cd
Enhance AI autocomplete functionality and placeholder behavior
krijnrijshouwer May 17, 2026
4bb7c8b
Enhance inline atom handling and DOM reconciliation
krijnrijshouwer May 18, 2026
fe7951f
Enhance AI session tracking and suggestion handling
krijnrijshouwer May 22, 2026
bbb7e01
Refactor inline completion and AI suggestion handling
krijnrijshouwer May 22, 2026
24015a3
Remove obsolete test files and refactor editor application logic
krijnrijshouwer May 25, 2026
d240d77
Integrate content-ops dependency and enhance AI extension functionality
krijnrijshouwer May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 16 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,25 @@ pnpm add @pen/core
## What It Provides

- `createEditor(...)` to create editor instances
- `createHeadlessEditor(...)` for server-side, worker, and test workflows that need editor semantics without a renderer
- document state, selection, normalization, and mutation orchestration
- the canonical `editor.apply(...)` document mutation boundary

## Headless Usage

```ts
import { createHeadlessEditor } from "@pen/core";
import { yjsAdapter, wrapYjsDocument } from "@pen/crdt-yjs";

const adapter = yjsAdapter();
const editor = createHeadlessEditor({
crdt: adapter,
document: wrapYjsDocument(adapter, ydoc),
});
```

Use this shape for migrations, AI workers, export workers, and tests that should run through Pen's mutation pipeline without mounting a UI.

## Typical Pairing

Most apps use `@pen/core` with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function databaseEditor() {
return editor;
}


describe("database core operations", () => {
it("insert-block with database type seeds shared grid structures", () => {
const editor = databaseEditor();
Expand Down Expand Up @@ -402,294 +403,4 @@ describe("database core operations", () => {
editor.destroy();
});

it("normalizes invalid database view references on write", () => {
const editor = databaseEditor();

editor.apply([
{
type: "database-insert-row",
blockId: "d1",
rowId: "row-a",
values: {
name: "Alpha",
tags: "todo",
status: "true",
},
},
{
type: "database-update-view",
blockId: "d1",
patch: {
visibleColumnIds: ["name", "missing", "name"],
columnOrder: ["missing", "tags", "name", "tags"],
sort: [
{ columnId: "missing", direction: "asc" },
{ columnId: "tags", direction: "asc" },
{ columnId: "tags", direction: "desc" },
],
filter: {
operator: "and",
conditions: [
{ columnId: "missing", operator: "is", value: "x" },
{ columnId: "tags", operator: "is", value: "todo" },
],
},
groupBy: "missing",
rowPinning: {
top: ["missing-row", "row-a", "row-a"],
bottom: ["row-a", "missing-row"],
},
},
},
]);

const view = editor.getBlock("d1")?.databaseActiveView();
expect(view?.visibleColumnIds).toEqual(["name"]);
expect(view?.columnOrder).toEqual(["tags", "name"]);
expect(view?.sort).toEqual([{ columnId: "tags", direction: "asc" }]);
expect(view?.filter).toEqual({
operator: "and",
conditions: [{ columnId: "tags", operator: "is", value: "todo" }],
});
expect(view?.groupBy).toBeUndefined();
expect(view?.rowPinning).toEqual({
top: ["row-a"],
bottom: undefined,
});

editor.destroy();
});

it("database row and select option ops clean up dependent data", () => {
const editor = databaseEditor();
editor.apply([
{
type: "database-update-view",
blockId: "d1",
patch: {
rowPinning: {
top: ["row-a"],
bottom: ["row-b"],
},
},
},
{
type: "database-update-column",
blockId: "d1",
columnId: "tags",
patch: {
options: [
{ id: "bug", value: "Bug", color: "red" },
{ id: "chore", value: "Chore", color: "gray" },
],
},
},
{
type: "database-convert-column",
blockId: "d1",
columnId: "tags",
toType: "multiSelect",
},
{
type: "database-insert-row",
blockId: "d1",
rowId: "row-a",
values: {
name: "A",
tags: JSON.stringify(["bug", "chore"]),
},
},
{
type: "database-insert-row",
blockId: "d1",
rowId: "row-b",
values: {
name: "B",
tags: JSON.stringify(["bug"]),
},
},
{
type: "database-update-select-options",
blockId: "d1",
columnId: "tags",
action: "remove",
optionId: "bug",
},
{
type: "database-duplicate-row",
blockId: "d1",
rowId: "row-a",
newRowId: "row-c",
},
{
type: "database-delete-rows",
blockId: "d1",
rowIds: ["row-b"],
},
{
type: "database-move-row",
blockId: "d1",
rowId: "row-c",
index: 0,
},
]);

const block = editor.getBlock("d1")!;
expect(block.tableRowCount()).toBe(2);
expect(block.tableRow(0)?.id).toBe("row-a");
expect(block.tableRow(1)?.id).toBe("row-c");
expect(block.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["chore"]));
expect(block.tableCell(1, 1)?.textContent()).toBe(JSON.stringify(["chore"]));
expect(block.tableColumns()[1]?.options).toEqual([
{ id: "chore", value: "Chore", color: "gray" },
]);

editor.apply([
{
type: "database-remove-column",
blockId: "d1",
columnId: "tags",
},
]);

const nextBlock = editor.getBlock("d1")!;
expect(nextBlock.tableColumns().map((column) => column.id)).toEqual([
"name",
"status",
]);
expect(nextBlock.databaseActiveView()?.columnOrder).toEqual([
"name",
"status",
]);
expect(nextBlock.databaseActiveView()?.visibleColumnIds).toEqual([
"name",
"status",
]);
expect(nextBlock.databaseActiveView()?.rowPinning).toBeUndefined();
editor.destroy();
});

it("renaming a select option preserves stored option ids", () => {
const editor = databaseEditor();
editor.apply([
{
type: "database-update-column",
blockId: "d1",
columnId: "tags",
patch: {
options: [{ id: "todo", value: "Todo", color: "gray" }],
},
},
{
type: "database-insert-row",
blockId: "d1",
rowId: "row-1",
values: {
name: "Write docs",
tags: "todo",
},
},
{
type: "database-update-select-options",
blockId: "d1",
columnId: "tags",
action: "rename",
optionId: "todo",
value: "Ready",
},
]);

const block = editor.getBlock("d1")!;
expect(block.tableCell(0, 1)?.textContent()).toBe("todo");
expect(block.tableColumns()[1]?.options).toEqual([
{ id: "todo", value: "Ready", color: "gray" },
]);
editor.destroy();
});

it("rejects column type changes through database-update-column", () => {
const editor = databaseEditor();
editor.apply([{
type: "database-update-column",
blockId: "d1",
columnId: "name",
patch: {
type: "number",
title: "Name field",
},
} as DocumentOp]);

const block = editor.getBlock("d1")!;
expect(block.tableColumns()[0]).toEqual(
expect.objectContaining({
id: "name",
title: "Name field",
type: "text",
}),
);
editor.destroy();
});

it("normalizes typed database row writes and rejects invalid updates", () => {
const editor = databaseEditor();
editor.apply([{
type: "update-table-columns",
blockId: "d1",
columns: [
{ id: "score", title: "Score", type: "number" },
{ id: "done", title: "Done", type: "checkbox" },
{
id: "status",
title: "Status",
type: "select",
options: [{ id: "todo", value: "Todo" }],
},
{
id: "labels",
title: "Labels",
type: "multiSelect",
options: [{ id: "todo", value: "Todo" }],
},
],
}]);

editor.apply([{
type: "database-insert-row",
blockId: "d1",
rowId: "row-typed",
values: {
score: "not-a-number",
done: "yes",
status: "Todo",
labels: JSON.stringify(["Todo"]),
},
}]);

const block = editor.getBlock("d1")!;
expect(block.tableCell(0, 0)?.textContent()).toBe("");
expect(block.tableCell(0, 1)?.textContent()).toBe("true");
expect(block.tableCell(0, 2)?.textContent()).toBe("todo");
expect(block.tableCell(0, 3)?.textContent()).toBe(JSON.stringify(["todo"]));

editor.apply([{
type: "database-update-cell",
blockId: "d1",
rowId: "row-typed",
columnId: "score",
value: "42",
}]);
expect(block.tableCell(0, 0)?.textContent()).toBe("42");

editor.apply([{
type: "database-update-cell",
blockId: "d1",
rowId: "row-typed",
columnId: "score",
value: "still-not-a-number",
}]);
expect(block.tableCell(0, 0)?.textContent()).toBe("42");

editor.destroy();
});

});
Loading
Loading