diff --git a/services/madoc-ts/src/extensions/projects/editors/README.md b/services/madoc-ts/src/extensions/projects/editors/README.md new file mode 100644 index 000000000..f672305be --- /dev/null +++ b/services/madoc-ts/src/extensions/projects/editors/README.md @@ -0,0 +1,13 @@ +# Project editors + +At the moment there is only one project editor: Capture Model UI. + +This will have a number of different editors for different types of capture models. + +Elements of an editor: +- Editor +- Preview +- Review +- Metadata + + diff --git a/services/madoc-ts/src/extensions/projects/editors/default-editor.tsx b/services/madoc-ts/src/extensions/projects/editors/default-editor.tsx new file mode 100644 index 000000000..1e20d5d75 --- /dev/null +++ b/services/madoc-ts/src/extensions/projects/editors/default-editor.tsx @@ -0,0 +1,8 @@ +export const defaultEditor = { + metadata: { + label: 'Capture model editor', + }, + // UI Elements. + // ReadOnlyViewer + // Editor +}; diff --git a/services/madoc-ts/src/extensions/projects/editors/null-editor.tsx b/services/madoc-ts/src/extensions/projects/editors/null-editor.tsx new file mode 100644 index 000000000..bc5b2255c --- /dev/null +++ b/services/madoc-ts/src/extensions/projects/editors/null-editor.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { EditorRenderingConfig } from '../../../frontend/shared/capture-models/new/components/EditorSlots'; + +export const nullEditor = { + metadata: { + label: 'Null editor', + }, + + editor: { + sidebar: { + enabled: true, + components: { + TopLevelEditor: () =>
Top level
, + SubmitButton: () =>
, + } as Partial, + }, + viewer: { + enabled: true, + component: () =>
Viewer
, + controls: () =>
controls
, + }, + }, + // UI Elements. +}; diff --git a/services/madoc-ts/src/extensions/projects/types.ts b/services/madoc-ts/src/extensions/projects/types.ts index 7acfd0e61..4aada51ea 100644 --- a/services/madoc-ts/src/extensions/projects/types.ts +++ b/services/madoc-ts/src/extensions/projects/types.ts @@ -28,6 +28,11 @@ export type JsonProjectTemplate = { defaults?: Partial; immutable?: Array; frozen?: boolean; + // Editor shown on the frontend to the user. + editor?: { + type: string; + options?: any; + }; captureModels?: ModelEditorConfig; tasks?: { generateOnCreate?: boolean; diff --git a/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-model.ts b/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-model.ts index 2c71d90a9..e086bcb3e 100644 --- a/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-model.ts +++ b/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-model.ts @@ -153,7 +153,7 @@ export type RevisionsModel = { // Field instances (for allowMultiple=true) createNewFieldInstance: Action< RevisionsModel, - { path: Array<[string, string]>; revisionId?: string; property: string } + { path: Array<[string, string]>; revisionId?: string; property: string; withId?: string } >; createNewEntityInstance: Action< RevisionsModel, diff --git a/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-store.tsx b/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-store.tsx index c7eb19375..e3798ec6a 100644 --- a/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-store.tsx +++ b/services/madoc-ts/src/frontend/shared/capture-models/editor/stores/revisions/revisions-store.tsx @@ -612,7 +612,7 @@ export const revisionStore: RevisionsModel = { } }), - createNewFieldInstance: action((state, { property, path, revisionId }) => { + createNewFieldInstance: action((state, { property, path, revisionId, withId }) => { // Grab the parent entity where we want to add a new field. const entity = getRevisionFieldFromPath(state, path, revisionId); if (!entity) { @@ -620,7 +620,7 @@ export const revisionStore: RevisionsModel = { } // Fork a new field from what already exists. - const newField = createNewFieldInstance(debug(entity), property); + const newField = createNewFieldInstance(debug(entity), property, false, null, withId); if (newField) { newField.revision = state.currentRevisionId || undefined; diff --git a/services/madoc-ts/src/frontend/shared/capture-models/helpers/create-new-field-instance.ts b/services/madoc-ts/src/frontend/shared/capture-models/helpers/create-new-field-instance.ts index 42373f506..46a5ba469 100644 --- a/services/madoc-ts/src/frontend/shared/capture-models/helpers/create-new-field-instance.ts +++ b/services/madoc-ts/src/frontend/shared/capture-models/helpers/create-new-field-instance.ts @@ -8,7 +8,8 @@ export function createNewFieldInstance( entity: CaptureModel['document'], property: string, multipleOverride = false, - revisionId?: string | null + revisionId?: string | null, + withId?: string ): BaseField { // Grab the property itself from the entity. const prop = entity.properties[property]; @@ -30,7 +31,7 @@ export function createNewFieldInstance( } // Modify the new field with defaults form the plugin store - newField.id = generateId(); + newField.id = withId || generateId(); newField.value = copy(description.defaultValue); if (newField.selector) { newField.selector.id = generateId(); diff --git a/services/madoc-ts/src/frontend/shared/capture-models/utility/capture-model-fn.ts b/services/madoc-ts/src/frontend/shared/capture-models/utility/capture-model-fn.ts new file mode 100644 index 000000000..16c6c2354 --- /dev/null +++ b/services/madoc-ts/src/frontend/shared/capture-models/utility/capture-model-fn.ts @@ -0,0 +1,212 @@ +import { Store } from 'easy-peasy'; +import { useMemo } from 'react'; +import { Revisions, RevisionsModel } from '../editor/stores/revisions/index'; +import { generateId } from '../helpers/generate-id'; +import { getRevisionFieldFromPath } from '../helpers/get-revision-field-from-path'; +import { useSelector } from '../plugin-api/hooks/use-selector'; +import { CaptureModel } from '../types/capture-model'; +import { BaseField } from '../types/field-types'; + +interface CaptureModelFnInstance { + get(): T; + set(value: T): void; + update(fn: (value: T) => T): void; +} + +interface CaptureModelFnList extends CaptureModelFnInstance { + push(value: T): void; + remove(value: T): void; + removeAt(index: number): void; + at(index: number): CaptureModelFnInstance; + // Future? + // insertAt(index: number, value: T): void; + // move(from: number, to: number): void; +} + +type UpdateFieldValueContext = RevisionsModel['updateFieldValue']['payload']; + +class BaseFieldWrapper implements CaptureModelFnInstance { + store: Store; + path: [string, string][]; + constructor(store: Store, path: [string, string][]) { + this.store = store; + this.path = path; + } + + get(): T { + const field = getRevisionFieldFromPath(this.store.getState(), this.path); + return field ? field.value : undefined; + } + + set(value: T) { + this.store.getActions().updateFieldValue({ + path: this.path, + value, + }); + } + + update(fn: (value: T) => any) { + this.store.getActions().updateFieldValue({ + path: this.path, + value: fn(this.get()), + }); + } +} + +class JsonFieldWrapper implements CaptureModelFnInstance { + store: Store; + path: [string, string][]; + constructor(store: Store, path: [string, string][]) { + this.store = store; + this.path = path; + } + + get() { + const field = getRevisionFieldFromPath(this.store.getState(), this.path); + try { + return field ? JSON.parse(field.value) : undefined; + } catch (err) { + return undefined; + } + } + + set(value: T | undefined) { + this.store.getActions().updateFieldValue({ + path: this.path, + value: value ? JSON.stringify(value) : value, + }); + } + + update(fn: (value: T | undefined) => any) { + this.store.getActions().updateFieldValue({ + path: this.path, + value: fn(this.get()), + }); + } +} + +// Wraps a structure like: +// { 'property-name': [BaseFieldWrapper, BaseFieldWrapper, BaseFieldWrapper] } +class FieldListWrapper implements CaptureModelFnList { + path: [string, string][]; + property: string; + store: Store; + fields: BaseFieldWrapper[] = []; + constructor(store: Store, path: [string, string][], property: string) { + this.store = store; + this.property = property; + this.path = path; + const entity = getRevisionFieldFromPath(store.getState(), path); + if (!entity) { + throw new Error('Could not find entity'); + } + + const propertyList = entity.properties[property]; + for (const field of propertyList) { + if (field.type === 'entity') { + // TODO + } else { + this.fields.push(new BaseFieldWrapper(store, [...path, [property, field.id]])); + } + } + } + + get(): T[] { + return this.fields.map(f => f.get()); + } + + at(index: number): CaptureModelFnInstance { + return this.fields[index]; + } + + push(value: T) { + const actions = this.store.getActions(); + const id = generateId(); + actions.createNewFieldInstance({ + path: this.path, + property: this.property, + withId: id, + }); + + actions.updateFieldValue({ + path: [...this.path, [this.property, id]], + value, + }); + } + + remove(value: T) { + const index = this.fields.findIndex(f => f.get() === value); + if (index !== -1) { + this.removeAt(index); + } + } + + removeAt(index: number) { + const actions = this.store.getActions(); + const field = this.fields[index]; + actions.removeInstance({ + path: field.path, + }); + } + + set(): never { + throw new Error('Not implemented'); + } + + update(fn: (value: T[]) => any): never { + throw new Error('Not implemented'); + } +} + +export class BaseEntityWrapper implements CaptureModelFnInstance { + store: Store; + path: [string, string][]; + properties: Map> = new Map(); + + constructor(store: Store, path: [string, string][]) { + this.store = store; + this.path = path; + + const entity = getRevisionFieldFromPath(store.getState(), path); + if (!entity) { + throw new Error('Could not find entity'); + } + + for (const property of Object.keys(entity.properties)) { + this.properties.set(property, new FieldListWrapper(store, path, property)); + } + } + + getProperty(property: string): CaptureModelFnList { + return this.properties.get(property) as any; + } + + get(): CaptureModel['document'] { + const entity = getRevisionFieldFromPath(this.store.getState(), this.path); + if (!entity) { + throw new Error('Could not find entity'); + } + return entity; + } + + set(): never { + throw new Error('Cant overwrite entity'); + } + + update(fn: (value: CaptureModel['document']) => any): void { + throw new Error('Cant overwrite entity'); + } +} + +function useModelInstance() { + const currentRevisionId = Revisions.useStoreState(s => s.currentRevisionId); + const store = Revisions.useStore(); + + return useMemo(() => { + if (!store.getState().currentRevision) { + return null; + } + + return new BaseEntityWrapper(store, []); + }, [currentRevisionId]); +} diff --git a/services/madoc-ts/src/frontend/shared/viewers/Annotorious.lazy.tsx b/services/madoc-ts/src/frontend/shared/viewers/Annotorious.lazy.tsx new file mode 100644 index 000000000..1b961b761 --- /dev/null +++ b/services/madoc-ts/src/frontend/shared/viewers/Annotorious.lazy.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +export const Annotorious = React.lazy(() => import('./Annotorious')); diff --git a/services/madoc-ts/src/frontend/shared/viewers/Annotorious.tsx b/services/madoc-ts/src/frontend/shared/viewers/Annotorious.tsx new file mode 100644 index 000000000..8fd827311 --- /dev/null +++ b/services/madoc-ts/src/frontend/shared/viewers/Annotorious.tsx @@ -0,0 +1,71 @@ +import React, { useMemo, useRef } from 'react'; +import * as Annotorious from '@recogito/annotorious-openseadragon'; +import '@recogito/annotorious-openseadragon/dist/annotorious.min.css'; +import { OpenSeadragonViewer } from '../../site/features/OpenSeadragonViewer.lazy'; +import { Revisions } from '../capture-models/editor/stores/revisions/index'; +import { BaseEntityWrapper } from '../capture-models/utility/capture-model-fn'; + +interface AnnotoriousOptions { + allowEmpty?: boolean; + disableEditor?: boolean; + disableSelect?: boolean; + drawOnSingleClick?: boolean; + formatters?: any[] | any; + fragmentUnit?: string; + gigapixelMode?: boolean; + handleRadius?: number; + hotkey?: any; + locale?: string; + messages?: any; + readOnly?: boolean; + widgets?: any[]; +} + +interface AnnotoriousInstance { + disableEditor: boolean; + disableSelect: boolean; + formatters: any[]; + readOnly: boolean; + widgets: any[]; +} + +export default function AnnotoriousViewer() { + const osd = useRef(); + const annotorious = useRef(); + + const revision = Revisions.useStoreState(s => s.currentRevision); + const currentRevisionId = Revisions.useStoreState(s => s.currentRevisionId); + const actions = Revisions.useStoreActions(a => a); + + const store = Revisions.useStore(); + + const wrapper = useMemo(() => { + if (!store.getState().currentRevision) { + return null; + } + + return new BaseEntityWrapper(store, []); + }, [currentRevisionId]); + + console.log('wrapper', wrapper); + + if (wrapper) { + // wrapper + // .getProperty('Date') + // .at(0) + // .set('Testing value'); + // console.log(wrapper.getProperty('Date').get()); + } + + const setupAnnotorious = (viewer: any) => { + console.log('viewer.element', viewer.element); + + setTimeout(() => { + annotorious.current = Annotorious(viewer, { + // config? + } as AnnotoriousOptions); + }, 1000); + }; + + return ; +} diff --git a/services/madoc-ts/src/frontend/site/features/OpenSeadragonViewer.tsx b/services/madoc-ts/src/frontend/site/features/OpenSeadragonViewer.tsx index 3515855cb..7e938e325 100644 --- a/services/madoc-ts/src/frontend/site/features/OpenSeadragonViewer.tsx +++ b/services/madoc-ts/src/frontend/site/features/OpenSeadragonViewer.tsx @@ -1,22 +1,32 @@ -import React, { forwardRef, useImperativeHandle } from 'react'; -import { useCanvas, useRenderingStrategy } from 'react-iiif-vault'; +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import { useCanvas, useImageService, useRenderingStrategy } from 'react-iiif-vault'; import { useOpenSeadragon } from 'use-open-seadragon'; -export const OpenSeadragonViewer = forwardRef(function OpenSeadragonViewer(props, ref) { +export const OpenSeadragonViewer = forwardRef(function OpenSeadragonViewer(props: OSDViewerProps, ref) { const canvas = useCanvas(); const [strategy] = useRenderingStrategy(); + const { data: service } = useImageService(); if (strategy.type !== 'images' || !strategy.image) { return null; } - return ( - - ); + if (!service) { + return null; + } + + return ; }); -const InnerViewer = forwardRef(function InnerViewer(props: any, fwdRef) { - const [ref, viewer] = useOpenSeadragon(props.source); +export interface OSDViewerProps { + onReady?: (osd: any) => void; +} + +const InnerViewer = forwardRef(function InnerViewer(props: OSDViewerProps & { source: any }, fwdRef) { + const [ref, viewer] = useOpenSeadragon(props.source, { + id: 'openseadragon', + }); + const initialised = useRef(false); useImperativeHandle(fwdRef, () => ({ zoomIn() { @@ -34,8 +44,14 @@ const InnerViewer = forwardRef(function InnerViewer(props: any, fwdRef) { }, })); + if (viewer.isReady && !initialised.current) { + initialised.current = true; + props.onReady?.(viewer?.viewer); + } + return (
diff --git a/services/madoc-ts/stories/capture-models/ModelMustache.stories.tsx b/services/madoc-ts/stories/capture-models/ModelMustache.stories.tsx new file mode 100644 index 000000000..5ccd69fc6 --- /dev/null +++ b/services/madoc-ts/stories/capture-models/ModelMustache.stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +export default { title: 'Capture models / Mustache' }; + +import model1 from '../../fixtures/97-bugs/05-chain.json'; +import model2 from '../../fixtures/03-revisions/02-single-field-with-multiple-revisions.json'; +import model3 from '../../fixtures/96-jira/MAD-1076.json'; +import { serialiseCaptureModel } from '../../src/frontend/shared/capture-models/helpers/serialise-capture-model'; +import { mustache } from '../../src/utility/mustache'; + +export const Default = () => { + // 1. Find a capture model + + const serialised = serialiseCaptureModel(model3.document, { + addSelectors: true, + normalisedValueLists: true, + }); + + const text = mustache( + ` +
+ {{#ocr-correction.properties}} + {{#properties.lines}} +
+ {{#properties.text}} + {{value}} + {{/properties.text}} +
+ {{/properties.lines}} + {{/ocr-correction}} +
+ `, + serialised + ); + + return ( +
+

Mustache.

+

This demo will have 3 tabs

+
    +
  • The capture model editor
  • +
  • The capture model preview
  • +
  • Mustache/Markdown editor + preview
  • +
+
+
{JSON.stringify(serialised, null, 2)}
+
+ ); +}; diff --git a/services/madoc-ts/stories/components/canvas-layout.stories.tsx b/services/madoc-ts/stories/components/canvas-layout.stories.tsx new file mode 100644 index 000000000..493e8aac8 --- /dev/null +++ b/services/madoc-ts/stories/components/canvas-layout.stories.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import styled from 'styled-components'; +import { AtlasViewer } from '../../src/frontend/shared/capture-models/editor/content-types/Atlas/Atlas'; + +export default { title: 'Components / Canvas layout' }; + +const Breadcrumbs = styled.div` + background: red; + padding: 0.5em; +`; + +const TitleNavigation = styled.div` + background: blue; + height: 4em; + display: flex; + align-items: center; + padding: 0 1em; + position: sticky; + top: 0; + z-index: 1; +`; + +const Navigation = styled.div` + background: #2aabd2; + margin-left: auto; +`; + +const Messaging = styled.div` + background: green; +`; + +const Contributions = styled.div` + background: mediumvioletred; + display: flex; + align-items: flex-start; +`; + +const ContributionLeftMenu = styled.div` + background: #2aabd2; + position: sticky; + top: 4em; + width: 4em; + height: calc(100vh - 4em); +`; + +const ContributionLeftPanel = styled.div` + background: #2b542c; + position: sticky; + top: 4em; + width: 300px; + height: calc(100vh - 4em); +`; + +const ContributionViewer = styled.div` + background: #985f0d; + position: sticky; + top: 4em; + flex: 1; + height: calc(100vh - 4em); +`; + +const ContributionModel = styled.div` + background: #31708f; + min-width: 0; + width: 380px; +`; + +const FooterContent = styled.div` + background: pink; + height: 800px; +`; + +const LayoutContainer = styled.div` + background: white; +`; + +const Viewer = styled.div` + background: red; +`; + +const LeftMenuGrid = styled.div` + display: grid; + grid-template-columns: 3em 1fr; + grid-template-rows: repeat(auto-fit, 3em); + //grid-auto-rows: minmax(3em, auto); + grid-gap: 0.5em; + background: green; + align-self: stretch; +`; + +const Icon = styled.button` + background: blue; + height: 3em; + width: 3em; + grid-column: 1; +`; + +const Sidebar = styled.div` + background: yellow; + grid-area: sidebar; + width: 300px; + grid-column: 2; + grid-row: auto / span 5; +`; + +const SaveButton = styled.div` + background: red; + position: sticky; + bottom: 0; + padding: 1em; +`; + +export const Default = () => { + // Contains: + // - Canvas title + // - Actions/navigation + // - sidebar + // - canvas/viewer + // - contributions panel + // - footer/extra panel content + + // Features + // - Canvas title sticks to top along with actions/navigation + // - The viewer is always in focus, so for long forms you can scroll the page and the viewer will stay in place. + // - The width of the sidebar can be switch from narrow, normal and wide. + // - The submit section will be sticky to the bottom of the page. + // - Once you scroll past the canvas and annotations, you can still see content under + + return ( + + Breadcrumbs + +

Title

+ Test +
+ Messaging + + + +

Panel sidebar

+
+ +

Viewer

+
+ +

Model

+
+ Save changes + + + Footer + + ); +};