From 932d1601f7ac98ecd204ef02627df22008b84d0f Mon Sep 17 00:00:00 2001 From: Per-Kristian Nordnes Date: Tue, 31 Oct 2023 12:08:59 +0100 Subject: [PATCH] wip --- dev/test-studio/sanity.config.ts | 2 + .../src/editor/Editable.tsx | 9 +- .../src/editor/PortableTextEditor.tsx | 6 +- .../editor/plugins/createWithEditableAPI.ts | 3 + .../portable-text-editor/src/types/editor.ts | 4 +- packages/sanity/exports/scratchPad.ts | 1 + packages/sanity/package.json | 15 ++ .../form/inputs/PortableText/Compositor.tsx | 10 +- .../core/form/types/definitionExtensions.ts | 2 +- .../sanity/src/scratchPad/components/Form.tsx | 190 ++++++++++++++++++ .../src/scratchPad/components/Layout.tsx | 27 +++ .../sanity/src/scratchPad/components/Root.tsx | 15 ++ .../components/assistant/Assistant.tsx | 77 +++++++ .../components/editor/AssistanceRange.tsx | 19 ++ .../scratchPad/components/editor/Editable.tsx | 164 +++++++++++++++ .../scratchPad/components/editor/Input.tsx | 44 ++++ .../components/rendering/renderBlock.tsx | 65 ++++++ .../sanity/src/scratchPad/config/index.ts | 38 ++++ .../scratchPad/context/ScratchPadProvider.tsx | 166 +++++++++++++++ .../src/scratchPad/hooks/useScratchPad.tsx | 12 ++ packages/sanity/src/scratchPad/index.ts | 1 + .../sanity/src/scratchPad/scratchPadTool.ts | 32 +++ .../src/scratchPad/utils/toAssistantText.ts | 27 +++ 23 files changed, 909 insertions(+), 20 deletions(-) create mode 100644 packages/sanity/exports/scratchPad.ts create mode 100644 packages/sanity/src/scratchPad/components/Form.tsx create mode 100644 packages/sanity/src/scratchPad/components/Layout.tsx create mode 100644 packages/sanity/src/scratchPad/components/Root.tsx create mode 100644 packages/sanity/src/scratchPad/components/assistant/Assistant.tsx create mode 100644 packages/sanity/src/scratchPad/components/editor/AssistanceRange.tsx create mode 100644 packages/sanity/src/scratchPad/components/editor/Editable.tsx create mode 100644 packages/sanity/src/scratchPad/components/editor/Input.tsx create mode 100644 packages/sanity/src/scratchPad/components/rendering/renderBlock.tsx create mode 100644 packages/sanity/src/scratchPad/config/index.ts create mode 100644 packages/sanity/src/scratchPad/context/ScratchPadProvider.tsx create mode 100644 packages/sanity/src/scratchPad/hooks/useScratchPad.tsx create mode 100644 packages/sanity/src/scratchPad/index.ts create mode 100644 packages/sanity/src/scratchPad/scratchPadTool.ts create mode 100644 packages/sanity/src/scratchPad/utils/toAssistantText.ts diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 12c708ed827..8f0aba9936a 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -17,6 +17,7 @@ import {schemaTypes} from './schema' import {defaultDocumentNode, newDocumentOptions, structure} from './structure' import {workshopTool} from './workshop' import {presenceTool} from './plugins/presence' +import {scratchPadTool} from 'sanity/scratchPad' import { CustomLayout, CustomLogo, @@ -85,6 +86,7 @@ const sharedSettings = definePlugin({ structure, defaultDocumentNode, }), + scratchPadTool(), languageFilter({ defaultLanguages: ['nb'], supportedLanguages: [ diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index b90f1017d67..faffa2465b8 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -1,12 +1,5 @@ import {BaseRange, Transforms, Text, NodeEntry, Range as SlateRange} from 'slate' -import React, { - forwardRef, - KeyboardEvent, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' +import React, {forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useState} from 'react' import { Editable as SlateEditable, ReactEditor, diff --git a/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx b/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx index 2a9c922d65f..5cd15d7a645 100644 --- a/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx @@ -103,7 +103,7 @@ export class PortableTextEditor extends React.Component super(props) if (!props.schemaType) { - throw new Error('PortableTextEditor: missing "type" property') + throw new Error('PortableTextEditor: missing "schemaType" property') } if (props.incomingPatches$) { @@ -271,4 +271,8 @@ export class PortableTextEditor extends React.Component debug(`Host toggling mark`, mark) editor.editable?.toggleMark(mark) } + static getFragment = (editor: PortableTextEditor): PortableTextBlock[] | undefined => { + debug(`Host getting fragment`) + return editor.editable?.getFragment() + } } diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts index a7b6a1b444a..086818f35f2 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithEditableAPI.ts @@ -484,6 +484,9 @@ export function createWithEditableAPI( editor.insertBreak() editor.onChange() }, + getFragment: () => { + return fromSlateValue(editor.getFragment(), types.block.name) + }, }) return editor } diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index 8dc8a1e0369..4830aab6b0f 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -18,7 +18,8 @@ import { import {Subject, Observable} from 'rxjs' import {Descendant, Node as SlateNode, Operation as SlateOperation} from 'slate' import {ReactEditor} from 'slate-react' -import {FocusEvent} from 'react' +import {FocusEvent, PropsWithChildren, ReactElement} from 'react' +import {DOMNode} from 'slate-react/dist/utils/dom' import type {Patch} from '../types/patch' import {PortableTextEditor} from '../editor/PortableTextEditor' @@ -42,6 +43,7 @@ export interface EditableAPI { focusBlock: () => PortableTextBlock | undefined focusChild: () => PortableTextChild | undefined getSelection: () => EditorSelection + getFragment: () => PortableTextBlock[] | undefined getValue: () => PortableTextBlock[] | undefined hasBlockStyle: (style: string) => boolean hasListStyle: (listStyle: string) => boolean diff --git a/packages/sanity/exports/scratchPad.ts b/packages/sanity/exports/scratchPad.ts new file mode 100644 index 00000000000..d5ef8baa155 --- /dev/null +++ b/packages/sanity/exports/scratchPad.ts @@ -0,0 +1 @@ +export * from '../src/scratchPad' diff --git a/packages/sanity/package.json b/packages/sanity/package.json index cb04a1e2115..4d86ff71162 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -77,6 +77,17 @@ "import": "./lib/router.esm.js", "default": "./lib/router.esm.js" }, + "./scratchPad": { + "types": "./lib/exports/scratchPad.d.ts", + "source": "./exports/scratchPad.ts", + "require": "./lib/scratchPad.js", + "node": { + "import": "./lib/scratchPad.cjs.mjs", + "require": "./lib/scratchPad.js" + }, + "import": "./lib/scratchPad.esm.js", + "default": "./lib/scratchPad.esm.js" + }, "./package.json": "./package.json" }, "main": "./lib/index.js", @@ -94,6 +105,9 @@ "desk": [ "./lib/exports/desk.d.ts" ], + "scratchPad": [ + "./lib/exports/scratchPad.d.ts" + ], "router": [ "./lib/exports/router.d.ts" ] @@ -107,6 +121,7 @@ "bin", "cli.js", "desk.js", + "scratchPad.js", "lib", "router.js", "src", diff --git a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx index 392dfc16d4e..0f50f3f5de6 100644 --- a/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx +++ b/packages/sanity/src/core/form/inputs/PortableText/Compositor.tsx @@ -163,6 +163,7 @@ export function Compositor(props: Omit field?: ComponentType inlineBlock?: ComponentType - input?: ComponentType + input?: ComponentType item?: ComponentType preview?: ComponentType } diff --git a/packages/sanity/src/scratchPad/components/Form.tsx b/packages/sanity/src/scratchPad/components/Form.tsx new file mode 100644 index 00000000000..d19d8e5852c --- /dev/null +++ b/packages/sanity/src/scratchPad/components/Form.tsx @@ -0,0 +1,190 @@ +import {Path, Schema, ValidationContext, ValidationMarker} from '@sanity/types' +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {validateDocument} from '../../core/validation' +import {applyAll} from '../../core/form/patch/applyPatch' +import {useScratchPad} from '../hooks/useScratchPad' +import { + EMPTY_ARRAY, + FormBuilder, + FormBuilderProps, + getExpandOperations, + noop, + PatchEvent, + setAtPath, + StateTree, + useFormState, +} from 'sanity' + +export function ScratchPadForm({schema}: {schema: Schema}) { + const [validation, setValidation] = useState([]) + const [openPath, onSetOpenPath] = useState([]) + const [fieldGroupState, onSetFieldGroupState] = useState>() + const [collapsedPaths, onSetCollapsedPath] = useState>() + const [collapsedFieldSets, onSetCollapsedFieldSets] = useState>() + const {patchChannel, document, setDocument} = useScratchPad() + const [focusPath, setFocusPath] = useState(() => []) + + useEffect(() => { + patchChannel.publish({ + type: 'mutation', + patches: [], + snapshot: document, + }) + }, [document, patchChannel]) + + const schemaType = schema.get('scratchPadDocument') + + if (!schemaType) { + throw new Error('missing schema type') + } + + if (schemaType.jsonType !== 'object') { + throw new Error('schema type is not an object') + } + + useEffect(() => { + validateStaticDocument(document, schema, (result) => setValidation(result)) + }, [document, schema]) + + const formState = useFormState(schemaType, { + focusPath, + collapsedPaths, + collapsedFieldSets, + comparisonValue: null, + fieldGroupState, + openPath, + presence: EMPTY_ARRAY, + validation, + value: document, + }) + + const formStateRef = useRef(formState) + formStateRef.current = formState + + const handleFocus = useCallback( + (nextFocusPath: Path) => { + setFocusPath(nextFocusPath) + }, + [setFocusPath], + ) + + const handleBlur = useCallback(() => { + setFocusPath([]) + }, [setFocusPath]) + + const patchRef = useRef<(event: PatchEvent) => void>(() => { + throw new Error('Nope') + }) + + patchRef.current = (event: PatchEvent) => { + setDocument((currentDocumentValue) => applyAll(currentDocumentValue, event.patches)) + } + + const handleChange = useCallback((event: any) => patchRef.current(event), []) + + const handleOnSetCollapsedPath = useCallback((path: Path, collapsed: boolean) => { + onSetCollapsedPath((prevState) => setAtPath(prevState, path, collapsed)) + }, []) + + const handleOnSetCollapsedFieldSet = useCallback((path: Path, collapsed: boolean) => { + onSetCollapsedFieldSets((prevState) => setAtPath(prevState, path, collapsed)) + }, []) + + const handleSetActiveFieldGroup = useCallback( + (path: Path, groupName: string) => + onSetFieldGroupState((prevState) => setAtPath(prevState, path, groupName)), + [], + ) + + const setOpenPath = useCallback( + (path: Path) => { + const ops = getExpandOperations(formStateRef.current!, path) + ops.forEach((op) => { + if (op.type === 'expandPath') { + onSetCollapsedPath((prevState) => setAtPath(prevState, op.path, false)) + } + if (op.type === 'expandFieldSet') { + onSetCollapsedFieldSets((prevState) => setAtPath(prevState, op.path, false)) + } + if (op.type === 'setSelectedGroup') { + onSetFieldGroupState((prevState) => setAtPath(prevState, op.path, op.groupName)) + } + }) + onSetOpenPath(path) + }, + [formStateRef], + ) + + const formBuilderProps: FormBuilderProps = useMemo( + () => ({ + // eslint-disable-next-line camelcase + __internal_patchChannel: patchChannel, + changed: false, + changesOpen: false, + collapsedFieldSets: undefined, + collapsedPaths: undefined, + focused: formState?.focused, + focusPath: formState?.focusPath || EMPTY_ARRAY, + groups: formState?.groups || EMPTY_ARRAY, + id: formState?.id || '', + level: formState?.level || 0, + members: formState?.members || EMPTY_ARRAY, + onChange: handleChange, + onFieldGroupSelect: noop, + onPathBlur: handleBlur, + onPathFocus: handleFocus, + onPathOpen: setOpenPath, + onSelectFieldGroup: handleSetActiveFieldGroup, + onSetFieldSetCollapsed: handleOnSetCollapsedFieldSet, + onSetPathCollapsed: handleOnSetCollapsedPath, + path: EMPTY_ARRAY, + presence: EMPTY_ARRAY, + schemaType: formState?.schemaType || schemaType, + validation, + value: formState?.value, + }), + [ + formState?.focusPath, + formState?.focused, + formState?.groups, + formState?.id, + formState?.level, + formState?.members, + formState?.schemaType, + formState?.value, + handleBlur, + handleChange, + handleFocus, + handleOnSetCollapsedFieldSet, + handleOnSetCollapsedPath, + handleSetActiveFieldGroup, + patchChannel, + schemaType, + setOpenPath, + validation, + ], + ) + + return +} + +async function validateStaticDocument( + document: any, + schema: any, + setCallback: (result: ValidationMarker[]) => void, +) { + const result = await validateDocument(getClient, document, schema) + setCallback(result) +} + +const client = createMockSanityClient() as any as ReturnType +const getClient = (options: {apiVersion: string}) => client + +export function createMockSanityClient() { + const _client = { + fetch: (query: string) => Promise.resolve(null) as Promise, + withConfig: () => _client, + } + + return _client +} diff --git a/packages/sanity/src/scratchPad/components/Layout.tsx b/packages/sanity/src/scratchPad/components/Layout.tsx new file mode 100644 index 00000000000..0210df35a67 --- /dev/null +++ b/packages/sanity/src/scratchPad/components/Layout.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import {Card} from '@sanity/ui' +import {Pane, PaneContent, PaneLayout} from '../../desk/components' +import {schema} from '../config' +import {ScratchPadAssistant} from './assistant/Assistant' +import {ScratchPadForm} from './Form' + +export default function ScratchPadLayout() { + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/sanity/src/scratchPad/components/Root.tsx b/packages/sanity/src/scratchPad/components/Root.tsx new file mode 100644 index 00000000000..7dfd5535927 --- /dev/null +++ b/packages/sanity/src/scratchPad/components/Root.tsx @@ -0,0 +1,15 @@ +import React, {useRef} from 'react' +import {PortableTextEditor} from '@sanity/portable-text-editor' +import {ScratchPadProvider} from '../context/ScratchPadProvider' +import ScratchPadLayout from './Layout' + +export default function ScratchPadRoot() { + const editorRef = useRef(null) + const assistantPromptRef = useRef(null) + + return ( + + + + ) +} diff --git a/packages/sanity/src/scratchPad/components/assistant/Assistant.tsx b/packages/sanity/src/scratchPad/components/assistant/Assistant.tsx new file mode 100644 index 00000000000..cbf688c8b82 --- /dev/null +++ b/packages/sanity/src/scratchPad/components/assistant/Assistant.tsx @@ -0,0 +1,77 @@ +import {Box, Card, TextArea} from '@sanity/ui' +import React, {useEffect, useState} from 'react' +import {PortableTextBlock} from '@sanity/types' +import {keyGenerator} from '@sanity/portable-text-editor' +import {useScratchPad} from '../../hooks/useScratchPad' +import {fragmentToAssistantText} from '../../utils/toAssistantText' + +export interface AssistantResponse { + key: string + response: string + fragment: { + text: string | undefined + portableText: PortableTextBlock[] | undefined + } | null +} + +const DESCRIPTIONS = [ + 'swell', + 'not that good to be honest', + 'marvelous', + 'really hitting the nail', + 'something else', + 'excellent', + 'just right', + 'brave', + 'boring', + 'explaining this too complicated', + 'too full of buzzwords', +] + +const FIRST_RESPONSE: AssistantResponse = { + key: 'first', + response: "Hey, I'm a AI assistant and here to help you with your content.", + fragment: null, +} + +export function ScratchPadAssistant() { + const {assistanceFragment, assistantPromptRef} = useScratchPad() + const [assistantResponses, setAssistantResponses] = useState([ + FIRST_RESPONSE, + ]) + + useEffect(() => { + const text = fragmentToAssistantText(assistanceFragment) + if (text) { + setAssistantResponses((prevState) => [ + ...prevState, + { + key: keyGenerator(), + response: `Your text "${text}" is ${ + DESCRIPTIONS[Math.floor(Math.random() * DESCRIPTIONS.length)] + }.`, + fragment: {portableText: assistanceFragment, text}, + }, + ]) + } + }, [assistanceFragment]) + + return ( + <> + + +
    + {assistantResponses.map((item) => ( +
  • + {item.response} +
  • + ))} +
+
+
+ +