Code Monkey home page Code Monkey logo

peritext's Introduction

Peritext

This is a prototype implementation of Peritext, a CRDT for rich text with inline formatting. The algorithm is described in the following publications:

This repo includes:

  • A Typescript implementation of the core Peritext CRDT algorithm
  • A prototype integration with the Prosemirror editor library
  • An interactive demo UI where you can try out the editor
  • A test suite

Try the editor demo

To see a basic interactive demo where you can type rich text into two editors and periodically sync them:

npm install

npm run start

Code tour

Algorithm code: The main algorithm implementation is in src/peritext.ts. Because the goal of this work is to eventually implement a rich text type in Automerge, we implemented Peritext as an extension to a codebase called Micromerge, which is a simplified implementation of Automerge that has mostly the same behavior but is less performance-optimized.

The essay describes the algorithm in three main parts:

  • Generating operations: happens in changeMark
  • Applying operations: happens in applyAddRemoveMark
  • Producing a document: there are two places this logic is defined. getTextWithFormatting is a "batch" approach which iterates over the internal document metadata and produces a Prosemirror document. There is also a codepath that produces incremental patches representing changes (which is actually what powers the editor demo); these patches get emitted directly while applying the op, within applyAddRemoveMark.

Prosemirror integration: src/bridge.ts contains the code for the integration between the CRDT and the Prosemirror library. There are two main pieces to the integration:

  • Prosemirror to CRDT: when a change happens in the editor, Prosemirror emits a Transaction. We turn that transaction into a list of InputOperation commands for the CRDT, inside the applyProsemirrorTransactionToMicromergeDoc function.
  • CRDT to Prosemirror: when a change happens in the Micromerge CRDT, the CRDT emits a Patch object representing what changed. We turn this into a Prosemirror transaction with the extendProsemirrorTransactionWithMicromergePatch function.

Each direction of this transformation is straightforward, because the external interface of InputOperations and Patches provided by the CRDT closesly matches the Prosemirror Transaction format.

Tests

npm run test will run the manual tests defined in test/micromerge.ts. These tests correspond to many of the specific examples explained in the essay.

You can also run a generative fuzz tester using npm run fuzz. This will randomly generate edit traces and check for convergence.

Build demo artifact for essay

This repo also contains a UI that plays back a preset trace of edit actions, which is included in the Ink & Switch essay about Peritext.

To see that UI, you can run npm run start-essay-demo.

To build an artifact for including in the essay, run npx parcel build src/essay-demo.ts, and then copy the resulting ./dist/essay-demo.js file to content/peritext/static/peritext-demo.js in the essays repo. Also copy over any CSS changes from static/essay-demo.css to content/peritext/static/peritext-styles.css if needed.

peritext's People

Contributors

ept avatar geoffreylitt avatar pvh avatar raboof avatar sliminality avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

peritext's Issues

Uncaught TypeError: global is undefined

When I check out the project, npm install, npm run start and open the sample in the browser (tried both Firefox and Chromium), I don't get a functional sample:

peri

And in the javascript console:

Uncaught TypeError: global is undefined
    parcelRequire<["node_modules/node-libs-browser/node_modules/buffer/index.js"]< index.js:43
    newRequire src.f10117fe.js:47
    localRequire src.f10117fe.js:53
    parcelRequire<["node_modules/lodash/lodash.js"]< src.f10117fe.js:2377
    newRequire src.f10117fe.js:47
    localRequire src.f10117fe.js:53
    parcelRequire<["src/micromerge.ts"]< micromerge.ts:3
    newRequire src.f10117fe.js:47
    localRequire src.f10117fe.js:53
    parcelRequire<["src/bridge.ts"]< bridge.ts:5
    newRequire src.f10117fe.js:47
    localRequire src.f10117fe.js:53
    parcelRequire<["src/index.ts"]< index.ts:1
    newRequire src.f10117fe.js:47
    parcelRequire src.f10117fe.js:81
    <anonymous> src.f10117fe.js:120

npm run test is fine, node v14.18.1, npm 6.14.15

the global seems to have been removed from buffer between 4.x and 5.0 (in feross/buffer@8746070#diff-e727e4bdf3657fd1d798edcd6b099d6e092f8573cba266154583a746bba0f346R41), but node-libs-browser is still on 4.x and not interested in updating.

Peritext algorithm may attach deleted mark to new inserted text

The Peritext algorithm may attach a non-exist ghost mark to a newly inserted string. It is also reproducible in Automerge's implementation. But it can be fixed without affecting the validity of the historical data.

CleanShot 2023-05-18 at 00 54 25@2x

Steps to reproduce the issue in this repo

This can be replicated by adding this micromerge test

    it("doesn't create ghost style", () => {
      testConcurrentWrites({
        inputOps1: [],
        inputOps2: [
          {
            action: "addMark",
            startIndex: 4,
            endIndex: 12,
            markType: "link",
            attrs: {
              url: "inkandswitch.com",
            },
          },
          {
            action: "addMark",
            startIndex: 8,
            endIndex: 12,
            markType: "strong",
          },
          {
            action: "delete",
            index: 3,
            count: 8,
          },
          {
            action: "insert",
            index: 3,
            values: ["!"],
          },
        ],
        //
        expectedResult: [
          { marks: {}, text: "The!editor" },
        ],
      });
    });

Why

The problem is caused by this fix in the paper. In this example, when Peritext inserts the new char '!', it prefers the positions after the tombstone with the end of the link. And the tombstone is after the beginning of the bold mark. So '!' turns out to be bold.

In my implementation, I've fixed this by adding a new rule for choosing the insertion position among tombstones:

  1. Insertions occur before tombstones that contain the beginning of new marks. (new)
  2. Insertions occur before tombstones that contain the end of bold-like marks
  3. Insertions occur after tombstones that contain the end of link-like marks

Rule 1 should be satisfied before rules 2 and 3 to avoid this problem.

This solution can clearly fix the example given above and make it satisfy all of the rules. The only downside is it might make a "link-like mark" grow forward unexpectedly a bit more often. But it seems to be more tolerable.

How to make Peritext agnostic to the underlying plain text CRDT

In the current implementation, Peritext needs a special behavior to fix an issue related to tombstones, as mentioned in the article. It requires the plain text CRDT to insert text after the tombstone with a special property (a tombstone that has an “after” anchor on it) to make the span expansion behavior intuitive.

peritext/src/micromerge.ts

Lines 775 to 800 in 89c162d

if (options?.lookAfterTombstones) {
// Normally in Automerge we insert new characters before any tombstones at the insertion position.
// But when formatting is involved, we sometimes want to insert after some of the tombstones.
// We peek ahead and see if there are any tombstones that have a nonempty markOpsAfter;
// If there are, we want to put this new character after the last such tombstone.
// This ensures that if there are non-growing marks which end at this insertion position,
// this new character is inserted after the span-end.
// See the test case labeled "handles growth behavior for spans where the boundary is a tombstone"
// for a motivating exapmle of why this behavior is needed.
let elemIndex = metaIndex
let peekIndex = metaIndex + 1
let latestIndexAfterTombstone: number | undefined
while (meta[peekIndex] && meta[peekIndex].deleted) {
if (meta[peekIndex].markOpsAfter !== undefined) {
latestIndexAfterTombstone = peekIndex
}
peekIndex++
}
if (latestIndexAfterTombstone) {
elemIndex = latestIndexAfterTombstone
}
return meta[elemIndex].elemId
} else {
return element.elemId
}

And there still are cases that this approach cannot fix. Normally, if we have a rich text span like

<bold><link>a</link></bold>

and insert x after it, the intuitive outcome would be

<bold><link>a</link>x</bold>

However, it’s not always the case if there are tombstones. Imagine this case

<link><bold>1</bold>234</link>5

then we delete 234 and insert an x character after 1.

The image below illustrates the dilemma: no matter where we insert x, we cannot meet the requirements of making x bold and non-link.

image

The current Peritext implementation chooses the most intuitive position: after 4, which makes x non-bold and non-link. While in situations without tombstones, it would be bold and non-link. Thus the same view and operation to the user might produce different results in the current Peritext implementation.

What could be the direction for making Peritext support block elements

This is not an issue. Just want to pick the team's brain on what's the perspective on dealing with rich text elements with "layouts" like tables and lists. Kudos to the team, Peritext has come up with an efficient representation for holding both character data as well as formatting boundaries.

Handling block elements is a different beast altogether. I'll take tables for clarity as it's the most common block element, yet supports most complex operations.
Here are the options I see with Peritext's current representation.

  1. Encode blocks as special control character. For example a table will have control characters for <table-start>, <table-end>, <row-start>, <column-start>, .... etc.
[

  { char: "T", opId: "9@B", deleted: false, markOpsBefore: [
    {
      action: "addMark",
      opId: "19@A",
      start: { type: "before", opId: "9@B" },
      end:   { type: "before", opId: "10@B" },
      markType: "bold"
    }
  ] },
  { char: "t", opId: "1@A", deleted: true },
  { char: "h", opId: "2@A", deleted: false },
  { char: "e", opId: "3@A", deleted: false },
  { char: " ", opId: "4@A", deleted: false },
  { char: "f", opId: "5@A", deleted: false },
  { char: "o", opId: "6@A", deleted: false },
  { char: "x", opId: "7@A", deleted: false },
  { char: " ", opId: "10@B", deleted: false, markOpsBefore: [] },
  
  { char: "<table-start-char>", opId: "11@A", deleted: false },
   //..... (rest of the table body with content interweaving table control characters)
  { char: "<table-end-char>", opId: "12@A", deleted: false },

]
  • Makes formatting model play along nicely. But as quoted in the original article it takes significant complexity to tailor it for our desired outcomes. Keeps the base model simple but shifts the complexity to handling outcomes.
  1. Encode tables as trees (i.e semantic JSON) and use automerge's/custom JSON capabilities.
[

  { char: "T", opId: "9@B", deleted: false, markOpsBefore: [
    {
      action: "addMark",
      opId: "19@A",
      start: { type: "before", opId: "9@B" },
      end:   { type: "before", opId: "10@B" },
      markType: "bold"
    }
  ] },
  { char: "t", opId: "1@A", deleted: true },
  { char: "h", opId: "2@A", deleted: false },
  { char: "e", opId: "3@A", deleted: false },
  { char: " ", opId: "4@A", deleted: false },
  { char: "f", opId: "5@A", deleted: false },
  { char: "o", opId: "6@A", deleted: false },
  { char: "x", opId: "7@A", deleted: false },
  { char: " ", opId: "10@B", deleted: false, markOpsBefore: [] },
  
  {layout: "table", opId: "11@B", deleted: false, rows: [/* other table components */]} // special layout JSON
]
  • Adds additional basic units to the model but plays along well with existing CRDTs that support trees/JSON. Doesn't mean handling outcomes is any easier. Might take significant complexity to deal with operations that might result in an un-renderable table structure.

None of the options might make sense actually. It's highly possible there are other better directions to take that handles semantic tree elements like tables and lists, which the team might already have a hang on. I'd be happy to wrap my head around what's cooking and maybe help in someway.

CRDTs for working with non-schematic JSON is fairly straightforward. CRDTs for schematic JSON like a SQL database is slightly tricky but the operation types (insert row, insert column, update cell) are too simple to warrant a complex solution. But tables in a WYIWYG editors are the most complex of them all with operations like cell-merge and cell-splitting. As far as I've studied none of the existing CRDT literature touches on this particular topic. Given Peritext's direction this project might be the only one, that throws light in this area.

Uncaught TypeError: Cannot read properties of undefined (reading 'TYPED_ARRAY_SUPPORT')

index.js:43 Uncaught TypeError: Cannot read properties of undefined (reading 'TYPED_ARRAY_SUPPORT')
    at Object.parcelRequire.../../node_modules/node-libs-browser/node_modules/buffer/index.js.base64-js (index.js:43)
    at newRequire (src.f10117fe.js:47)
    at localRequire (src.f10117fe.js:53)
    at Object.parcelRequire.../../node_modules/lodash/lodash.js.buffer (index.js:1790)
    at newRequire (src.f10117fe.js:47)
    at localRequire (src.f10117fe.js:53)
    at Object.parcelRequire.src/micromerge.ts.uuid (micromerge.ts:3)
    at newRequire (src.f10117fe.js:47)
    at localRequire (src.f10117fe.js:53)
    at Object.parcelRequire.src/bridge.ts../micromerge (bridge.ts:5)
parcelRequire.../../node_modules/node-libs-browser/node_modules/buffer/index.js.base64-js @ index.js:43
newRequire @ src.f10117fe.js:47
localRequire @ src.f10117fe.js:53
parcelRequire.../../node_modules/lodash/lodash.js.buffer @ index.js:1790
newRequire @ src.f10117fe.js:47
localRequire @ src.f10117fe.js:53
parcelRequire.src/micromerge.ts.uuid @ micromerge.ts:3
newRequire @ src.f10117fe.js:47
localRequire @ src.f10117fe.js:53
parcelRequire.src/bridge.ts../micromerge @ bridge.ts:5
newRequire @ src.f10117fe.js:47
localRequire @ src.f10117fe.js:53
parcelRequire.src/index.ts../bridge @ index.ts:1
newRequire @ src.f10117fe.js:47
(anonymous) @ src.f10117fe.js:81
(anonymous) @ src.f10117fe.js:120

Seeing this error after cloning and starting the latest.

Snapshots and opportunity for distributions

Please take with a grain of salt my words, I'm not an expert in CRDT.
Feel free to point me in the right direction to let me learn more.

If I understood correctly the specification, peritext relays on the following mechanism:

  • The creation of a sequence, named trough ids
  • The micro split in log fashion, for each typed (and "clicked") input
  • The concept of actions, with descriptors such a selection

As emacs user and not per-se the average user, I see too many challenges to consume it.
peritext is builded around how the user provides the input and less how files changes over time.

And since seems to solve Conflict Resolution trough UX.

I imagine that peritext rather than be hooked to an editor, could be run as service like a Language Server Protocol, which runs in background while I'm editing a file.

I will then expect to solve the following:

  • understands the action that I'm doing to it, by just saving the file and compare the in-memory difference.
  • allows me to specifically "publish" my changes.
  • notifies when someone pushes changes and allows me to merge them, interactively when ambiguous.

A bit like git, but without an actual user input, beside saving a file.

If the concept of "publish" exists, snapshot would be cheaper to share.
And if peritext would leverage the LSP architecture could take advantage at the integration with existing editors.

Hoping this wasn't a waste of your time.

Plans for Automerge binary encoding?

Hi! I don't know if this is the right place to ask this kind of questions, but I don't know any better :)

As far as I understand there's a plan to bring Peritext into Automerge at some point. I wonder how the data model of Peritext is going to fit into the Automerge's binary encoding format? As marks in Peritext could have arbitrary properties, like url, color, and so on, is Automerge binary encoding flexible enough to support that, or are you planning to introduce changes to the binary format?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.