+ );
+ },
+};
+
+/**
+ * The lists plugin keeps nesting under keyboard control: `Tab` and `Shift+Tab` change the depth of
+ * the current list item, `Enter` splits it, and `Enter` on an empty item lifts it out of the list.
+ */
+export const Nesting: Story = {
+ args: {
+ defaultValue: [
+ {
+ type: ElementType.ORDERED_LIST,
+ children: [
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'Press Tab on the item below' }] }],
+ },
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'This one' }] }],
+ },
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'Then Shift+Tab to move it back' }] }],
+ },
+ ],
+ },
+ ],
+ },
+ render: (args, context) => {
+ return ;
+ },
+};
+
+/**
+ * A list may nest one of the other type. Changing a type is scoped to the lists the selection sits
+ * in, so switching the sublist below leaves the list around it alone, and vice versa.
+ */
+export const MixedNesting: Story = {
+ args: {
+ defaultValue: [
+ {
+ type: ElementType.UNORDERED_LIST,
+ children: [
+ {
+ type: ElementType.LIST_ITEM,
+ children: [
+ { type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'A bulleted item' }] },
+ {
+ type: ElementType.ORDERED_LIST,
+ children: [
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'A numbered sub item' }] }],
+ },
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'Another numbered sub item' }] }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ElementType.LIST_ITEM,
+ children: [{ type: ElementType.LIST_ITEM_TEXT, children: [{ text: 'A last bulleted item' }] }],
+ },
+ ],
+ },
+ ],
+ },
+ render: (args, context) => {
+ return ;
+ },
+};
+
+/**
+ * Attaching a file inserts a void block holding nothing but a reference. Use the toolbar button, or drop
+ * a file onto the editable.
+ *
+ * Watch the value below while it uploads. The file name and the progress never appear in it — they live
+ * in the entity store beside the document — and the value stays still until the upload finishes, at which
+ * point the whole response lands on the node — which is what lets a saved document be rendered again.
+ */
+export const Uploads: Story = {
+ args: {
+ defaultValue: [{ type: ElementType.PARAGRAPH, children: [{ text: 'Attach a file, or drop one here.' }] }],
+ },
+ render: (args, context) => {
+ const [value, setValue] = useState(args.defaultValue ?? []);
+
+ return (
+
+
+
{JSON.stringify(value, null, 2)}
+
+ );
+ },
+};
+
+/**
+ * The same, with an uploader that always fails. The block reports the failure and offers to try again,
+ * while the value below never gains an `uploaded`: there is nothing to record until an upload succeeds.
+ */
+export const UploadFailure: Story = {
+ args: {
+ defaultValue: [{ type: ElementType.PARAGRAPH, children: [{ text: 'Attach a file to watch it fail.' }] }],
+ },
+ render: (args, context) => {
+ const [value, setValue] = useState(args.defaultValue ?? []);
+
+ return (
+
+
+
{JSON.stringify(value, null, 2)}
+
+ );
+ },
+};
+
+/**
+ * The round trip the arrangement exists for. Attach a file, then press the button: the value is put
+ * through `JSON.stringify` and a brand new editor is built from it, with an empty store.
+ *
+ * The file still renders, because the response is on the node and `restoreEntities` puts it back into the
+ * store on load. Nothing re-uploads, and the view reads the store either way — it never needs to know
+ * whether a file arrived this session or last.
+ */
+export const Restore: Story = {
+ args: {
+ defaultValue: [{ type: ElementType.PARAGRAPH, children: [{ text: 'Attach a file, then reload below.' }] }],
+ },
+ render: (args, context) => {
+ const [value, setValue] = useState(args.defaultValue ?? []);
+ const [loaded, setLoaded] = useState(args.defaultValue ?? []);
+ const [generation, setGeneration] = useState(0);
+
+ return (
+
+ {/* A new key builds a new editor, and with it a new store — the same as a page load. */}
+
+
+
+
+
{JSON.stringify(value, null, 2)}
+
+ );
+ },
+};
diff --git a/packages/editor/src/demo/Editor.tsx b/packages/editor/src/demo/Editor.tsx
new file mode 100644
index 000000000..b0f7d70e4
--- /dev/null
+++ b/packages/editor/src/demo/Editor.tsx
@@ -0,0 +1,422 @@
+import { ChangeEvent, MouseEvent, ReactNode, useMemo, useRef, useState } from 'react';
+
+import { BaseSelection, createEditor, Descendant } from 'slate';
+import { withHistory } from 'slate-history';
+import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, Slate, useSlate, withReact } from 'slate-react';
+
+import { DEFAULT_LABELS, EditorLabels } from './Editor.labels';
+import { ElementFile } from './ElementFile';
+
+import { BASE_SCHEMA, ElementType, ENTITIES_SCHEMA, LINKS_SCHEMA, LISTS_SCHEMA, UploadedFile } from '../testing';
+import {
+ BaseEditor,
+ composeKeyDown,
+ createEntityStore,
+ EntitiesEditor,
+ EntityState,
+ EntityUploader,
+ isElementType,
+ isValidHttpUrl,
+ LinksEditor,
+ ListsEditor,
+ onBaseKeyDown,
+ onListsKeyDown,
+ withBase,
+ withEntities,
+ withLinks,
+ withLists,
+ withNodeId,
+} from '..';
+
+import {
+ Button,
+ Divider,
+ IconAttachmentLineW500,
+ IconFormatBoldFillW500,
+ IconFormatClearFillW500,
+ IconFormatHeader1FillW500,
+ IconFormatHeader2FillW500,
+ IconFormatHeader3FillW500,
+ IconFormatItalicFillW500,
+ IconFormatListBulletedLineW500,
+ IconFormatListNumberedLineW500,
+ IconFormatStrikethroughFillW500,
+ IconFormatUnderlineFillW500,
+ IconLinkLineW500,
+ IconLinkOffLineW500,
+ TextField,
+ Tooltip,
+ useEvent,
+} from '@esfront/react';
+
+export interface EditorProps {
+ /** The initial document. Slate requires at least one node. */
+ defaultValue?: Descendant[];
+ /** Overrides for the toolbar labels. Every string the demo renders comes from here. */
+ labels?: Partial;
+ /** Called with the document whenever it changes. Selection-only changes are filtered out. */
+ onChange?: (value: Descendant[]) => void;
+ /** Uploads an attached file. Without one, attachments stay pending. */
+ upload?: EntityUploader;
+}
+
+const EMPTY_VALUE: Descendant[] = [{ type: ElementType.PARAGRAPH, children: [{ text: '' }] }];
+
+// Pressing a toolbar button must not move focus out of the editor, otherwise the selection the
+// transform is about to act on is gone by the time the click lands.
+const preventDefault = (event: MouseEvent) => {
+ event.preventDefault();
+};
+
+const createRenderElement = (labels: EditorLabels) =>
+ function RenderElement({ attributes, children, element }: RenderElementProps) {
+ switch (element.type) {
+ case ElementType.H1:
+ return
{children}
;
+ case ElementType.H2:
+ return
{children}
;
+ case ElementType.H3:
+ return
{children}
;
+ case ElementType.H4:
+ return
{children}
;
+ case ElementType.H5:
+ return
{children}
;
+ case ElementType.H6:
+ return
{children}
;
+ case ElementType.ORDERED_LIST:
+ return {children};
+ case ElementType.UNORDERED_LIST:
+ return