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
+
+ );
+};