From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Examples
    • Plate to HTML
    • Export
    • Server-Side
    • Version History
    • Editable Voids
    • Huge Document
    • Hundreds Editors
    • Markdown Streaming
    • Preview Markdown
    • Collaboration Demo
    • Table Nomerge Demo
    • Excalidraw Demo
    • Code Drawing Demo
    • Single Block Demo
    • List Classic Demo
    • Find Replace Demo
    • AI Demo
    • Align Demo
    • Autoformat Demo
    • Basic Nodes Demo
    • Block Menu Demo
    • Node Selection Demo
    • Column Demo
    • Code Block Demo
    • Callout Demo
    • Discussion Demo
    • Cursor Overlay Demo
    • Date Demo
    • Footnote Demo
    • Drag & Drop Demo
    • Emoji Demo
    • Equation Demo
    • Exit Break Demo
    • Floating Toolbar Demo
    • Font Demo
    • Indent Demo
    • List Demo
    • Line Height Demo
    • Link Demo
    • Media Demo
    • Mention Demo
    • Block Placeholder Demo
    • Serializing CSV Demo
    • Serializing Docx Demo
    • Serializing HTML Demo
    • Serializing Markdown Demo
    • Slash Command Demo
    • Plugin Rules Demo
    • Table Demo
    • Table of Contents Demo
    • Toggle Demo

Version History

PreviousNext

Save Plate value snapshots and render a diff against the live editor.

This example stores immutable Plate value snapshots in local React state. It compares the selected revision with the current value through @platejs/diff.

Demo

Loading…
Server-SideEditable Voids Demo

On This Page

DemoSourceRuntime ShapeSnapshot FlowProduct PreviewRelated
Build your editor
Production-ready AI template and reusable components.
Get all-access

Source

'use client';
 
import {
  type DiffIntent,
  type DiffUpdate,
  computeDiff,
  excludeDiffFragment,
} from '@platejs/diff';
import type { PropertyJsonValue } from '@platejs/plite';
import { cloneDeep } from 'lodash';
import { property, schema } from 'platejs';
import type { Value } from 'platejs';
import {
  type PlateElementProps,
  type PlateLeafProps,
  type PlateProps,
  type PlateEditor,
  createPlateEditor,
  Plate,
  PlateContent,
  PlateElement,
  PlateLeaf,
  definePlatePlugin,
  useElementSelected,
  usePlateEditor,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { BasicMarksKit } from '@/components/editor/basic-marks';
 
const InlinePlugin = definePlatePlugin('inline', {
  schema: {
    element: {
      content: schema.content.text({ default: 'text', min: 1 }),
      inline: true,
    },
  },
});
 
const InlineVoidPlugin = definePlatePlugin('inlineVoid', {
  schema: { element: { inline: true, void: 'inline' } },
});
 
const diffIntentColors: Record<DiffIntent['type'], string> = {
  delete: 'bg-red-200',
  insert: 'bg-green-200',
  update: 'bg-blue-200',
};
 
type JsonDiffIntent =
  | Exclude<DiffIntent, DiffUpdate>
  | (Omit<DiffUpdate, 'newProperties' | 'properties'> & {
      newProperties: Readonly<Record<string, PropertyJsonValue>>;
      properties: Readonly<Record<string, PropertyJsonValue>>;
    });
 
const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null;
 
const isPropertyJsonValue = (value: unknown): value is PropertyJsonValue => {
  if (
    value === null ||
    typeof value === 'boolean' ||
    typeof value === 'string'
  ) {
    return true;
  }
  if (typeof value === 'number') return Number.isFinite(value);
  if (Array.isArray(value)) return value.every(isPropertyJsonValue);
 
  return isRecord(value) && Object.values(value).every(isPropertyJsonValue);
};
 
const isJsonRecord = (
  value: unknown
): value is Readonly<Record<string, PropertyJsonValue>> =>
  isRecord(value) && Object.values(value).every(isPropertyJsonValue);
 
const isDiffIntent = (value: unknown): value is JsonDiffIntent => {
  if (!isRecord(value)) return false;
 
  const { type } = value;
 
  if (type === 'delete' || type === 'insert') return true;
  if (type !== 'update') return false;
 
  const { newProperties, properties } = value;
 
  return isJsonRecord(newProperties) && isJsonRecord(properties);
};
 
export const formatPropertyValue = (
  value: PropertyJsonValue | undefined
): string => {
  if (typeof value === 'string') return value;
 
  return value === undefined
    ? 'undefined'
    : (JSON.stringify(value) ?? 'undefined');
};
 
const describeUpdate = ({
  newProperties,
  properties,
}: Extract<JsonDiffIntent, { type: 'update' }>) => {
  const addedProps: string[] = [];
  const removedProps: string[] = [];
  const updatedProps: string[] = [];
 
  Object.keys(newProperties).forEach((key) => {
    const oldValue = properties[key];
    const newValue = newProperties[key];
 
    if (oldValue === undefined) {
      addedProps.push(key);
 
      return;
    }
    if (newValue === undefined) {
      removedProps.push(key);
 
      return;
    }
 
    updatedProps.push(key);
  });
 
  const descriptionParts: string[] = [];
 
  if (addedProps.length > 0) {
    descriptionParts.push(`Added ${addedProps.join(', ')}`);
  }
  if (removedProps.length > 0) {
    descriptionParts.push(`Removed ${removedProps.join(', ')}`);
  }
  if (updatedProps.length > 0) {
    updatedProps.forEach((key) => {
      descriptionParts.push(
        `Updated ${key} from ${formatPropertyValue(properties[key])} to ${formatPropertyValue(newProperties[key])}`
      );
    });
  }
 
  return descriptionParts.join('\n');
};
 
const InlineElement = ({
  children,
  ...props
}: PlateElementProps<typeof InlinePlugin>) => (
  <PlateElement {...props} as="span" className="rounded-sm bg-slate-200/50 p-1">
    {children}
  </PlateElement>
);
 
const InlineVoidElement = ({
  children,
  ...props
}: PlateElementProps<typeof InlineVoidPlugin>) => {
  const selected = useElementSelected();
 
  return (
    <PlateElement {...props} as="span">
      <span
        className={cn(
          'rounded-sm bg-slate-200/50 p-1',
          selected && 'bg-blue-500 text-white'
        )}
        contentEditable={false}
      >
        Inline void
      </span>
      {children}
    </PlateElement>
  );
};
 
const DiffPlugin = definePlatePlugin('diff', {
  schema: {
    mark: property.boolean({ default: false, omitDefault: true }),
    properties: {
      diffIntent: schema.textProperty(
        property.json({
          validate: isDiffIntent,
          validationVersion: 1,
        })
      ),
    },
  },
})
  .extend(excludeDiffFragment())
  .extend({
    render: {
      aboveNodes:
        () =>
        ({ children, editor, element }) => {
          if (!element.diff) return children;
 
          const { diffIntent } = element;
 
          if (!isDiffIntent(diffIntent)) return children;
 
          const label = (
            {
              delete: 'deletion',
              insert: 'insertion',
              update: 'update',
            } as const
          )[diffIntent.type];
 
          const Component = editor.read.schema.isInline(element)
            ? 'span'
            : 'div';
 
          return (
            <Component
              className={diffIntentColors[diffIntent.type]}
              title={
                diffIntent.type === 'update'
                  ? describeUpdate(diffIntent)
                  : undefined
              }
              aria-label={label}
            >
              {children}
            </Component>
          );
        },
    },
  });
 
function DiffLeaf({ children, ...props }: PlateLeafProps<typeof DiffPlugin>) {
  const { diffIntent } = props.leaf;
 
  if (!diffIntent) return <PlateLeaf {...props}>{children}</PlateLeaf>;
 
  return (
    <PlateLeaf
      {...props}
      // as={Component}
      className={diffIntentColors[diffIntent.type]}
      attributes={{
        ...props.attributes,
        title:
          diffIntent.type === 'update' ? describeUpdate(diffIntent) : undefined,
      }}
    >
      {children}
    </PlateLeaf>
  );
}
 
const initialValue: Value = [
  {
    children: [{ text: 'This is a version history demo.' }],
    type: 'paragraph',
  },
  {
    children: [
      { text: 'Try editing the ' },
      { bold: true, text: 'text and see what' },
      { text: ' happens.' },
    ],
    type: 'paragraph',
  },
  {
    children: [
      { text: 'This is an ' },
      { children: [{ text: '' }], type: 'inlineVoid' },
      { text: '. Try removing it.' },
    ],
    type: 'paragraph',
  },
  {
    children: [
      { text: 'This is an ' },
      { children: [{ text: 'editable inline' }], type: 'inline' },
      { text: '. Try editing it.' },
    ],
    type: 'paragraph',
  },
];
 
export const createVersionSnapshot = (value: Value): Value => cloneDeep(value);
 
const basePlugins = [
  ...BasicMarksKit,
  InlinePlugin.configure({ component: InlineElement }),
  InlineVoidPlugin.configure({ component: InlineVoidElement }),
];
 
const diffPlugins = [
  ...basePlugins,
  DiffPlugin.configure({ component: DiffLeaf }),
];
 
function VersionHistoryPlate<E extends PlateEditor>(
  props: Omit<PlateProps<E>, 'children'>
) {
  return (
    <Plate<E> {...props}>
      <PlateContent className="rounded-md border p-3" />
    </Plate>
  );
}
 
type DiffProps = {
  current: Value;
  previous: Value;
};
 
function Diff({ current, previous }: DiffProps) {
  const diffValue = React.useMemo(() => {
    const editor = createPlateEditor({
      plugins: diffPlugins,
    });
 
    return computeDiff(
      createVersionSnapshot(previous),
      createVersionSnapshot(current),
      {
        isInline: (node) => editor.read.schema.isInline(node),
        lineBreakChar: '¶',
      }
    ) as Value;
  }, [previous, current]);
 
  const editor = usePlateEditor(
    {
      plugins: diffPlugins,
      initialValue: diffValue,
    },
    [diffValue]
  );
 
  return (
    <>
      <VersionHistoryPlate
        key={JSON.stringify(diffValue)}
        readOnly
        editor={editor}
      />
 
      {/* <pre>{JSON.stringify(diffValue, null, 2)}</pre> */}
    </>
  );
}
 
export default function VersionHistoryDemo() {
  const [revisions, setRevisions] = React.useState<Value[]>(() => [
    createVersionSnapshot(initialValue),
  ]);
  const [selectedRevisionIndex, setSelectedRevisionIndex] =
    React.useState<number>(0);
  const [value, setValue] = React.useState<Value>(() =>
    createVersionSnapshot(initialValue)
  );
 
  const selectedRevisionValue = React.useMemo(
    () => revisions[selectedRevisionIndex],
    [revisions, selectedRevisionIndex]
  );
 
  const saveRevision = () => {
    setRevisions([...revisions, createVersionSnapshot(value)]);
  };
 
  const editor = usePlateEditor({
    plugins: basePlugins,
    initialValue: createVersionSnapshot(initialValue),
  });
 
  const editorRevision = usePlateEditor(
    {
      plugins: basePlugins,
      initialValue: selectedRevisionValue,
    },
    [selectedRevisionValue]
  );
 
  return (
    <div className="flex flex-col gap-3 p-3">
      <Button onClick={saveRevision}>Save revision</Button>
 
      <VersionHistoryPlate
        onValueChange={({ value: innerValue }) => {
          setValue(createVersionSnapshot(innerValue.children));
        }}
        editor={editor}
      />
 
      <label>
        Revision to compare:
        <select
          className="rounded-md border p-1"
          onChange={(e) => {
            setSelectedRevisionIndex(Number(e.target.value));
          }}
        >
          {revisions.map((_, i) => (
            // oxlint-disable-next-line react-doctor/no-array-index-as-key -- [P1 local-invariant] Revisions are append-only snapshots, so creation order is their stable identity in this selector.
            <option key={i} value={i}>
              Revision {i + 1}
            </option>
          ))}
        </select>
      </label>
 
      <div className="grid gap-3 md:grid-cols-2">
        <div>
          <h2>Revision {selectedRevisionIndex + 1}</h2>
          <VersionHistoryPlate
            key={selectedRevisionIndex}
            readOnly
            editor={editorRevision}
          />
        </div>
 
        <div>
          <h2>Diff</h2>
          <Diff current={value} previous={selectedRevisionValue} />
        </div>
      </div>
    </div>
  );
}
'use client';
 
import {
  type DiffIntent,
  type DiffUpdate,
  computeDiff,
  excludeDiffFragment,
} from '@platejs/diff';
import type { PropertyJsonValue } from '@platejs/plite';
import { cloneDeep } from 'lodash';
import { property, schema } from 'platejs';
import type { Value } from 'platejs';
import {
  type PlateElementProps,
  type PlateLeafProps,
  type PlateProps,
  type PlateEditor,
  createPlateEditor,
  Plate,
























































































































































































































































































































































































































Runtime Shape

PieceOwnerRole
createVersionSnapshotregistry exampleClones each Value with cloneDeep before saving or diffing.
revisionsregistry exampleStores the saved Plate values and feeds the revision picker.
computeDiff@platejs/diffCompares the selected revision and live value, then returns diff-marked nodes.
DiffPluginregistry exampleRenders diff leaves and wraps diff elements with insert, delete, or update styling.
InlinePlugin and InlineVoidPluginregistry exampleKeep inline and inline-void nodes in the demo value so the diff path covers mixed content.
excludeDiffFragment@platejs/diffRemoves diff and diffOperation metadata from copied fragments.

Snapshot Flow

  1. The editor starts from initialValue and keeps the live value in local state.
  2. Save revision clones the live value and appends it to revisions.
  3. The select menu chooses one saved revision for comparison.
  4. computeDiff receives the selected revision, current value, state.schema.isInline, and lineBreakChar: '¶'.
  5. The read-only diff editor renders the returned value with the same inline plugins plus DiffPlugin.

Product Preview

Potion uses the same document-history idea in a full app surface with stored versions, restore actions, and visual comparison.

Get the code
Loading…

Related

  • Controlled Value for owning editor value from React state.
  • Plate Plugin for component binding and render hooks such as render.aboveNodes.
PlateContent,
PlateElement,
PlateLeaf,
definePlatePlugin,
useElementSelected,
usePlateEditor,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { BasicMarksKit } from '@/components/editor/basic-marks';
const InlinePlugin = definePlatePlugin('inline', {
schema: {
element: {
content: schema.content.text({ default: 'text', min: 1 }),
inline: true,
},
},
});
const InlineVoidPlugin = definePlatePlugin('inlineVoid', {
schema: { element: { inline: true, void: 'inline' } },
});
const diffIntentColors: Record<DiffIntent['type'], string> = {
delete: 'bg-red-200',
insert: 'bg-green-200',
update: 'bg-blue-200',
};
type JsonDiffIntent =
| Exclude<DiffIntent, DiffUpdate>
| (Omit<DiffUpdate, 'newProperties' | 'properties'> & {
newProperties: Readonly<Record<string, PropertyJsonValue>>;
properties: Readonly<Record<string, PropertyJsonValue>>;
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isPropertyJsonValue = (value: unknown): value is PropertyJsonValue => {
if (
value === null ||
typeof value === 'boolean' ||
typeof value === 'string'
) {
return true;
}
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isPropertyJsonValue);
return isRecord(value) && Object.values(value).every(isPropertyJsonValue);
};
const isJsonRecord = (
value: unknown
): value is Readonly<Record<string, PropertyJsonValue>> =>
isRecord(value) && Object.values(value).every(isPropertyJsonValue);
const isDiffIntent = (value: unknown): value is JsonDiffIntent => {
if (!isRecord(value)) return false;
const { type } = value;
if (type === 'delete' || type === 'insert') return true;
if (type !== 'update') return false;
const { newProperties, properties } = value;
return isJsonRecord(newProperties) && isJsonRecord(properties);
};
export const formatPropertyValue = (
value: PropertyJsonValue | undefined
): string => {
if (typeof value === 'string') return value;
return value === undefined
? 'undefined'
: (JSON.stringify(value) ?? 'undefined');
};
const describeUpdate = ({
newProperties,
properties,
}: Extract<JsonDiffIntent, { type: 'update' }>) => {
const addedProps: string[] = [];
const removedProps: string[] = [];
const updatedProps: string[] = [];
Object.keys(newProperties).forEach((key) => {
const oldValue = properties[key];
const newValue = newProperties[key];
if (oldValue === undefined) {
addedProps.push(key);
return;
}
if (newValue === undefined) {
removedProps.push(key);
return;
}
updatedProps.push(key);
});
const descriptionParts: string[] = [];
if (addedProps.length > 0) {
descriptionParts.push(`Added ${addedProps.join(', ')}`);
}
if (removedProps.length > 0) {
descriptionParts.push(`Removed ${removedProps.join(', ')}`);
}
if (updatedProps.length > 0) {
updatedProps.forEach((key) => {
descriptionParts.push(
`Updated ${key} from ${formatPropertyValue(properties[key])} to ${formatPropertyValue(newProperties[key])}`
);
});
}
return descriptionParts.join('\n');
};
const InlineElement = ({
children,
...props
}: PlateElementProps<typeof InlinePlugin>) => (
<PlateElement {...props} as="span" className="rounded-sm bg-slate-200/50 p-1">
{children}
</PlateElement>
);
const InlineVoidElement = ({
children,
...props
}: PlateElementProps<typeof InlineVoidPlugin>) => {
const selected = useElementSelected();
return (
<PlateElement {...props} as="span">
<span
className={cn(
'rounded-sm bg-slate-200/50 p-1',
selected && 'bg-blue-500 text-white'
)}
contentEditable={false}
>
Inline void
</span>
{children}
</PlateElement>
);
};
const DiffPlugin = definePlatePlugin('diff', {
schema: {
mark: property.boolean({ default: false, omitDefault: true }),
properties: {
diffIntent: schema.textProperty(
property.json({
validate: isDiffIntent,
validationVersion: 1,
})
),
},
},
})
.extend(excludeDiffFragment())
.extend({
render: {
aboveNodes:
() =>
({ children, editor, element }) => {
if (!element.diff) return children;
const { diffIntent } = element;
if (!isDiffIntent(diffIntent)) return children;
const label = (
{
delete: 'deletion',
insert: 'insertion',
update: 'update',
} as const
)[diffIntent.type];
const Component = editor.read.schema.isInline(element)
? 'span'
: 'div';
return (
<Component
className={diffIntentColors[diffIntent.type]}
title={
diffIntent.type === 'update'
? describeUpdate(diffIntent)
: undefined
}
aria-label={label}
>
{children}
</Component>
);
},
},
});
function DiffLeaf({ children, ...props }: PlateLeafProps<typeof DiffPlugin>) {
const { diffIntent } = props.leaf;
if (!diffIntent) return <PlateLeaf {...props}>{children}</PlateLeaf>;
return (
<PlateLeaf
{...props}
// as={Component}
className={diffIntentColors[diffIntent.type]}
attributes={{
...props.attributes,
title:
diffIntent.type === 'update' ? describeUpdate(diffIntent) : undefined,
}}
>
{children}
</PlateLeaf>
);
}
const initialValue: Value = [
{
children: [{ text: 'This is a version history demo.' }],
type: 'paragraph',
},
{
children: [
{ text: 'Try editing the ' },
{ bold: true, text: 'text and see what' },
{ text: ' happens.' },
],
type: 'paragraph',
},
{
children: [
{ text: 'This is an ' },
{ children: [{ text: '' }], type: 'inlineVoid' },
{ text: '. Try removing it.' },
],
type: 'paragraph',
},
{
children: [
{ text: 'This is an ' },
{ children: [{ text: 'editable inline' }], type: 'inline' },
{ text: '. Try editing it.' },
],
type: 'paragraph',
},
];
export const createVersionSnapshot = (value: Value): Value => cloneDeep(value);
const basePlugins = [
...BasicMarksKit,
InlinePlugin.configure({ component: InlineElement }),
InlineVoidPlugin.configure({ component: InlineVoidElement }),
];
const diffPlugins = [
...basePlugins,
DiffPlugin.configure({ component: DiffLeaf }),
];
function VersionHistoryPlate<E extends PlateEditor>(
props: Omit<PlateProps<E>, 'children'>
) {
return (
<Plate<E> {...props}>
<PlateContent className="rounded-md border p-3" />
</Plate>
);
}
type DiffProps = {
current: Value;
previous: Value;
};
function Diff({ current, previous }: DiffProps) {
const diffValue = React.useMemo(() => {
const editor = createPlateEditor({
plugins: diffPlugins,
});
return computeDiff(
createVersionSnapshot(previous),
createVersionSnapshot(current),
{
isInline: (node) => editor.read.schema.isInline(node),
lineBreakChar: '¶',
}
) as Value;
}, [previous, current]);
const editor = usePlateEditor(
{
plugins: diffPlugins,
initialValue: diffValue,
},
[diffValue]
);
return (
<>
<VersionHistoryPlate
key={JSON.stringify(diffValue)}
readOnly
editor={editor}
/>
{/* <pre>{JSON.stringify(diffValue, null, 2)}</pre> */}
</>
);
}
export default function VersionHistoryDemo() {
const [revisions, setRevisions] = React.useState<Value[]>(() => [
createVersionSnapshot(initialValue),
]);
const [selectedRevisionIndex, setSelectedRevisionIndex] =
React.useState<number>(0);
const [value, setValue] = React.useState<Value>(() =>
createVersionSnapshot(initialValue)
);
const selectedRevisionValue = React.useMemo(
() => revisions[selectedRevisionIndex],
[revisions, selectedRevisionIndex]
);
const saveRevision = () => {
setRevisions([...revisions, createVersionSnapshot(value)]);
};
const editor = usePlateEditor({
plugins: basePlugins,
initialValue: createVersionSnapshot(initialValue),
});
const editorRevision = usePlateEditor(
{
plugins: basePlugins,
initialValue: selectedRevisionValue,
},
[selectedRevisionValue]
);
return (
<div className="flex flex-col gap-3 p-3">
<Button onClick={saveRevision}>Save revision</Button>
<VersionHistoryPlate
onValueChange={({ value: innerValue }) => {
setValue(createVersionSnapshot(innerValue.children));
}}
editor={editor}
/>
<label>
Revision to compare:
<select
className="rounded-md border p-1"
onChange={(e) => {
setSelectedRevisionIndex(Number(e.target.value));
}}
>
{revisions.map((_, i) => (
// oxlint-disable-next-line react-doctor/no-array-index-as-key -- [P1 local-invariant] Revisions are append-only snapshots, so creation order is their stable identity in this selector.
<option key={i} value={i}>
Revision {i + 1}
</option>
))}
</select>
</label>
<div className="grid gap-3 md:grid-cols-2">
<div>
<h2>Revision {selectedRevisionIndex + 1}</h2>
<VersionHistoryPlate
key={selectedRevisionIndex}
readOnly
editor={editorRevision}
/>
</div>
<div>
<h2>Diff</h2>
<Diff current={value} previous={selectedRevisionValue} />
</div>
</div>
</div>
);
}