From 2d47cb095864fe307da158e3b99629fa8303f4d9 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 12 Jul 2023 16:17:45 +0200 Subject: [PATCH 01/20] [WIP] Editor blocks --- .../components/sandbox/mdx-editor/index.tsx | 39 +++- .../sandbox/mdx-editor/mdx-editor.tsx | 195 ++++++++++++------ 2 files changed, 162 insertions(+), 72 deletions(-) diff --git a/app/scripts/components/sandbox/mdx-editor/index.tsx b/app/scripts/components/sandbox/mdx-editor/index.tsx index b32145872..bcb16ea50 100644 --- a/app/scripts/components/sandbox/mdx-editor/index.tsx +++ b/app/scripts/components/sandbox/mdx-editor/index.tsx @@ -1,20 +1,23 @@ import React from 'react'; -import MDXEditor, { MDXBlockWithError } from './mdx-editor'; +import MDXEditor from './mdx-editor'; import { PageMainContent } from '$styles/page'; import ContentBlockFigure from '$components/common/blocks/figure'; import { ContentBlockProse } from '$styles/content-block'; import Image, { Caption } from '$components/common/blocks/images'; import { Chapter } from '$components/common/blocks/scrollytelling/chapter'; import { NotebookConnectCalloutBlock } from '$components/common/notebook-connect'; -import { LazyMap, LazyScrollyTelling, LazyChart, LazyCompareImage} from '$components/common/blocks/lazy-components'; +import { + LazyMap, + LazyScrollyTelling, + LazyChart, + LazyCompareImage +} from '$components/common/blocks/lazy-components'; import SmartLink from '$components/common/smart-link'; - const components = { h1: (props) =>

, Test: () =>
Test
, - Block: MDXBlockWithError, Prose: ContentBlockProse, Figure: ContentBlockFigure, Caption, @@ -28,15 +31,25 @@ const components = { Link: SmartLink }; -export const MDX_LOCAL_STORAGE_KEY = "MDX_EDITOR"; -export const MDX_SOURCE_DEFAULT = ` +export const MDX_LOCAL_STORAGE_KEY = 'MDX_EDITOR'; +export const MDX_SOURCE_DEFAULT = [ + ` ### Your markdown header Your markdown contents comes here. - - +` +, +` + + ### Hello + + Your markdown contents comes here. + +` +, + `
[Explore How COVID-19 Is Affecting Earth's Climate](/covid19/discoveries/climate/climate-change-and-covid "Explore How COVID-19 Is Affecting Earth's Climate") - -`; +` +]; const savedSource = localStorage.getItem(MDX_LOCAL_STORAGE_KEY); @@ -74,7 +87,11 @@ export default function MDXEditorWrapper() { return (
- +
); diff --git a/app/scripts/components/sandbox/mdx-editor/mdx-editor.tsx b/app/scripts/components/sandbox/mdx-editor/mdx-editor.tsx index 2cae8552b..90e14ea8b 100644 --- a/app/scripts/components/sandbox/mdx-editor/mdx-editor.tsx +++ b/app/scripts/components/sandbox/mdx-editor/mdx-editor.tsx @@ -4,8 +4,10 @@ import React, { createContext, useContext, useCallback, - useEffect + useEffect, + useRef } from 'react'; +import { createPortal } from 'react-dom'; import * as runtime from 'react/jsx-runtime'; import { evaluate } from '@mdx-js/mdx'; import { useMDXComponents, MDXProvider } from '@mdx-js/react'; @@ -74,30 +76,39 @@ const GlobalErrorWrapper = styled.div` `; interface useMDXReturnProps { - source: string; + source: string[]; result: MDXContent | null; error: any; } -function useMDX(source) { +function useMDX(initialSource: string[]) { + const [currentEditingBlockIndex, setCurrentEditingBlockIndex] = + useState(0); const [state, setState] = useState({ - source, + source: initialSource, result: null, error: null }); useEffect(() => { - localStorage.setItem(MDX_LOCAL_STORAGE_KEY, state.source); + // localStorage.setItem(MDX_LOCAL_STORAGE_KEY, state.source); }, [state.source]); - async function setSource(source) { + async function setSource(source: string[]) { const remarkPlugins = [remarkGfm]; let result: MDXContent | null = null; + // const mergedSource = source.join('\n
\n'); + const mergedSource = source.reduce((acc, curr, currentIndex) => { + return currentIndex === 0 + ? curr + : acc + `\n
\n` + curr; + }, ''); + try { result = ( - await evaluate(source, { + await evaluate(mergedSource, { ...runtime, useMDXComponents, useDynamicImport: true, @@ -116,20 +127,40 @@ function useMDX(source) { } } - useMemo(() => setSource(source), [source]); + useMemo(() => setSource(initialSource), [initialSource]); - return { ...state, setSource }; + return { + ...state, + setSource, + currentEditingBlockIndex, + setCurrentEditingBlockIndex + }; } const MDXContext = createContext(null); interface MDXEditorProps { - initialSource: string; + initialSource: string[]; components: any; } const MDXEditor = ({ initialSource, components = null }: MDXEditorProps) => { - const { result, error, setSource } = useMDX(initialSource); + const { + result, + error, + source, + setSource, + currentEditingBlockIndex, + setCurrentEditingBlockIndex + } = useMDX(initialSource); + + const componentsWithBlock = useMemo(() => { + if (!components) return null; + return { + ...components, + Block: MDXBlockForEditor + }; + }, [components]); const extensions = useMemo( () => [basicSetup, oneDark, langMarkdown()], @@ -158,56 +189,84 @@ const MDXEditor = ({ initialSource, components = null }: MDXEditorProps) => { [editorView] ); + useEffect(() => { + if (!editorView) return; + writeToEditor(source[currentEditingBlockIndex]); + }, [editorView, currentEditingBlockIndex]); + + const currentEditingBlockIndexRef = useRef(); + currentEditingBlockIndexRef.current = currentEditingBlockIndex; + + const onEditorUpdated = useCallback( + (v) => { + if (v.docChanged) { + let blockSource = v.state.doc.toString(); + // This is a hack to prevent the editor from being cleared + // when the user deletes all the text. + // because when that happens, React throws an order of hooks error + blockSource = blockSource ? blockSource : DEFAULT_CONTENT; + const allSource = [ + ...source.slice(0, currentEditingBlockIndexRef.current), + blockSource, + ...source.slice(currentEditingBlockIndexRef.current + 1) + ]; + setSource(allSource); + } + }, + [source, setSource] + ); + return ( - - - - - - MDX Editor{' '} - { - writeToEditor(MDX_SOURCE_DEFAULT); - setSource(MDX_SOURCE_DEFAULT); - }} - > - reset with default content - - { - writeToEditor(DEFAULT_CONTENT); - setSource(DEFAULT_CONTENT); - }} - > - clear - - - {error && {errorHumanReadable}} - - setEditorView(editorView)} - onUpdate={(v) => { - if (v.docChanged) { - let source = v.state.doc.toString(); - - // This is a hack to prevent the editor from being cleared - // when the user deletes all the text. - // because when that happens, React throws an order of hooks error - source = source ? source : DEFAULT_CONTENT; - setSource(source); - } - }} - extensions={extensions} - /> - - - - - - + <> + {Array.from(Array(source.length)).map((_, i) => { + if (document.getElementById(`block${i}`)) { + return createPortal( +

setCurrentEditingBlockIndex(i)}>edit {i}

, + document.getElementById(`block${i}`)! + ); + } + })} + + + + + + + MDX Editor{' '} + {/* { + writeToEditor(MDX_SOURCE_DEFAULT); + setSource(MDX_SOURCE_DEFAULT); + }} + > + reset with default content + + { + writeToEditor(DEFAULT_CONTENT); + setSource(DEFAULT_CONTENT); + }} + > + clear + */} + + {error && {errorHumanReadable}} + + setEditorView(editorView)} + onUpdate={onEditorUpdated} + extensions={extensions} + /> + + + + + + + ); }; @@ -272,8 +331,9 @@ const MDXBlockError = ({ error }: any) => { ); }; -export const MDXBlockWithError = (props) => { +const MDXBlockWithError = (props) => { const result = useContext(MDXContext); + return ( { ); }; +const MDXBlockForEditorUI = styled.div` + border: 1px solid #f00; +`; + +const MDXBlockForEditor = (props) => { + console.log(props) + return ( + + + + ); +}; + export default MDXEditor; From 322a497b6c34f21a7693c1dbd58eeaa18dfeaba9 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Tue, 25 Jul 2023 15:43:49 +0200 Subject: [PATCH 02/20] Integrate publication tool in overall navigation --- app/scripts/components/common/page-header.tsx | 10 ++ .../publication-tool/data-story-editor.tsx | 12 ++ .../components/publication-tool/index.tsx | 153 ++++++++++++++++++ .../components/publication-tool/types.d.ts | 12 ++ app/scripts/main.tsx | 7 +- package.json | 3 +- yarn.lock | 5 + 7 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 app/scripts/components/publication-tool/data-story-editor.tsx create mode 100644 app/scripts/components/publication-tool/index.tsx create mode 100644 app/scripts/components/publication-tool/types.d.ts diff --git a/app/scripts/components/common/page-header.tsx b/app/scripts/components/common/page-header.tsx index e1ab51eee..5918478b0 100644 --- a/app/scripts/components/common/page-header.tsx +++ b/app/scripts/components/common/page-header.tsx @@ -424,6 +424,16 @@ function PageHeader() { Data Stories + {process.env.NODE_ENV !== 'production' && ( +
  • + + Publication Tool + +
  • + )} diff --git a/app/scripts/components/publication-tool/data-story-editor.tsx b/app/scripts/components/publication-tool/data-story-editor.tsx new file mode 100644 index 000000000..029b38b95 --- /dev/null +++ b/app/scripts/components/publication-tool/data-story-editor.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { PageMainContent } from '$styles/page'; + +export default function DataStoryEditor() { + return ( + +
    + Hello +
    +
    + ); +} diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx new file mode 100644 index 000000000..1d8d81b46 --- /dev/null +++ b/app/scripts/components/publication-tool/index.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { Route, Routes, useParams } from 'react-router'; +import { useAtomValue } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { Button, ButtonGroup } from '@devseed-ui/button'; +import { DataStory } from './types'; +import DataStoryEditor from './data-story-editor'; +import { LayoutProps } from '$components/common/layout-root'; +import { resourceNotFound } from '$components/uhoh'; +import PageHero from '$components/common/page-hero'; +import { + Fold, + FoldHeader, + FoldProse, + FoldTitle +} from '$components/common/fold'; +import { PageMainContent } from '$styles/page'; + +const DataStoriesAtom = atomWithStorage('dataStories', [ + { + frontmatter: { + id: 'example-data-story', + name: 'Example Data Story', + description: 'This is an example data story', + sources: [], + thematics: [], + pubDate: '2023-01-01' + }, + blocks: [ + { + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + ` + } + ] + }, + { + frontmatter: { + id: 'example-data-story-2', + name: 'Example Data Story 2', + description: 'This is an example data story', + sources: [], + thematics: [], + pubDate: '2023-01-01' + }, + blocks: [ + { + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + ` + } + ] + } +]); + +function DataStoryEditorLayout() { + const { pId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + + const page = dataStories.find((p) => p.frontmatter.id === pId); + if (!page) throw resourceNotFound(); + + const items = useMemo( + () => + dataStories.map((dataStory) => ({ + id: dataStory.frontmatter.id, + name: dataStory.frontmatter.name, + to: `/publication-tool/${dataStory.frontmatter.id}` + })), + [dataStories] + ); + + return ( + <> + + + + + + + + ); +} + +function PublicationTool() { + const dataStories = useAtomValue(DataStoriesAtom); + return ( + + } /> + + + + + + Data Stories + + + + + + + + + + + {dataStories.map((dataStory) => ( + + + + + ))} + +
    TitleActions
    {dataStory.frontmatter.name} + + + + +
    +
    +
    + + } + /> +
    + ); +} + +export default PublicationTool; diff --git a/app/scripts/components/publication-tool/types.d.ts b/app/scripts/components/publication-tool/types.d.ts new file mode 100644 index 000000000..98cfb10a3 --- /dev/null +++ b/app/scripts/components/publication-tool/types.d.ts @@ -0,0 +1,12 @@ +import { DiscoveryData } from "veda"; + +export interface DataStoryBlock { + tag: 'Block' | 'ScrollyTellingBlock'; + blockType?: 'wide' | 'full'; + mdx: string; +} + +export interface DataStory { + frontmatter: DiscoveryData; + blocks: DataStoryBlock[]; +} \ No newline at end of file diff --git a/app/scripts/main.tsx b/app/scripts/main.tsx index 788fe414b..6149e45ce 100644 --- a/app/scripts/main.tsx +++ b/app/scripts/main.tsx @@ -38,6 +38,8 @@ const Sandbox = lazy(() => import('$components/sandbox')); const UserPagesComponent = lazy(() => import('$components/user-pages')); +const PublicationTool = lazy(() => import('$components/publication-tool')); + // Handle wrong types from devseed-ui. const DevseedUiThemeProvider = DsTp as any; @@ -110,7 +112,10 @@ function Root() { } /> {process.env.NODE_ENV !== 'production' && ( - } /> + <> + } /> + } /> + )} {/* Legacy: Routes related to thematic areas redirect. */} diff --git a/package.json b/package.json index d7a7daac0..08e68804f 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "history": "^5.1.0", "intersection-observer": "^0.12.0", "jest-environment-jsdom": "^28.1.3", + "jotai": "^2.2.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mapbox-gl": "^2.11.0", @@ -149,9 +150,9 @@ "react": "^18.2.0", "react-compare-image": "^3.1.0", "react-cool-dimensions": "^2.0.7", + "react-dom": "^18.2.0", "react-draggable": "^4.4.5", "react-error-boundary": "^4.0.10", - "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-indiana-drag-scroll": "^2.2.0", "react-lazyload": "^3.2.0", diff --git a/yarn.lock b/yarn.lock index 5fe5e7773..c4f5e5876 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8067,6 +8067,11 @@ jest@^28.1.3: import-local "^3.0.2" jest-cli "^28.1.3" +jotai@^2.2.2: + version "2.2.2" + resolved "http://verdaccio.ds.io:4873/jotai/-/jotai-2.2.2.tgz#1e181789dcc01ced8240b18b95d9fa10169f9366" + integrity sha512-Cn8hnBg1sc5ppFwEgVGTfMR5WSM0hbAasd/bdAwIwTaum0j3OUPqBSC4tyk3jtB95vicML+RRWgKFOn6gtfN0A== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "http://verdaccio.ds.io:4873/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From bf25eec8b1151df472173b8a9f1009604336c9c3 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Tue, 25 Jul 2023 17:44:55 +0200 Subject: [PATCH 03/20] Add submenu --- .../components/common/page-local-nav.js | 58 +++++++++++++------ .../components/publication-tool/index.tsx | 22 ++++++- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/scripts/components/common/page-local-nav.js b/app/scripts/components/common/page-local-nav.js index 9101f9885..c94c30269 100644 --- a/app/scripts/components/common/page-local-nav.js +++ b/app/scripts/components/common/page-local-nav.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import T from 'prop-types'; import styled from 'styled-components'; import { Link, NavLink, useMatch } from 'react-router-dom'; @@ -91,7 +91,7 @@ const NavBlock = styled(ShadowScrollbar)` } `; -const LocalMenu = styled.ul` +const LocalMenuWrapper = styled.ul` ${listReset()} display: flex; flex-flow: row nowrap; @@ -172,7 +172,6 @@ const pagePath = '/datasets/:dataId/:page'; function PageLocalNav(props) { const { localMenuCmp, parentName, parentLabel, parentTo, items, currentId } = props; - // Keep the url structure on dataset pages const datasetPageMatch = useMatch(pagePath); const currentPage = datasetPageMatch ? datasetPageMatch.params.page : ''; @@ -247,24 +246,49 @@ PageLocalNav.propTypes = { export function DatasetsLocalMenu(props) { const { dataset } = props; + const options = useMemo(() => { + const datasetPath = getDatasetPath(dataset.data); + const datasetExplorePath = getDatasetExplorePath(dataset.data); + return [ + { + label: 'Overview', + to: datasetPath + }, + { + label: 'Exploration', + to: datasetExplorePath + } + ]; + }, [dataset]); + + return ; +} + +DatasetsLocalMenu.propTypes = { + dataset: T.object +}; + +export function LocalMenu({ options }) { return ( - -
  • - - Overview - -
  • -
  • - - Exploration - -
  • -
    + + {options.map((option) => ( +
  • + + {option.label} + +
  • + ))} +
    ); } -DatasetsLocalMenu.propTypes = { - dataset: T.object +LocalMenu.propTypes = { + options: T.arrayOf( + T.shape({ + label: T.string, + to: T.string + }) + ) }; diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index 1d8d81b46..9d723adda 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -8,6 +8,7 @@ import DataStoryEditor from './data-story-editor'; import { LayoutProps } from '$components/common/layout-root'; import { resourceNotFound } from '$components/uhoh'; import PageHero from '$components/common/page-hero'; +import { LocalMenu } from '$components/common/page-local-nav'; import { Fold, FoldHeader, @@ -77,6 +78,7 @@ function DataStoryEditorLayout() { })), [dataStories] ); + console.log('items', items); return ( <> @@ -87,7 +89,25 @@ function DataStoryEditorLayout() { parentLabel: 'Publication Tool', parentTo: '/publication-tool', items, - currentId: pId + currentId: pId, + localMenuCmp: ( + + ) }} /> From bc7d840a640e03a7cfce540700452bfcf42dedfc Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 26 Jul 2023 11:14:33 +0200 Subject: [PATCH 04/20] Render MDX blocks --- .../publication-tool/block-with-error.tsx | 36 +++++++++++ .../publication-tool/data-story-editor.tsx | 12 ---- .../publication-tool/data-story.tsx | 24 +++++++ .../publication-tool/editor-block.tsx | 62 +++++++++++++++++++ .../components/publication-tool/index.tsx | 14 ++++- .../publication-tool/mdx-renderer.tsx | 44 +++++++++++++ 6 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 app/scripts/components/publication-tool/block-with-error.tsx delete mode 100644 app/scripts/components/publication-tool/data-story-editor.tsx create mode 100644 app/scripts/components/publication-tool/data-story.tsx create mode 100644 app/scripts/components/publication-tool/editor-block.tsx create mode 100644 app/scripts/components/publication-tool/mdx-renderer.tsx diff --git a/app/scripts/components/publication-tool/block-with-error.tsx b/app/scripts/components/publication-tool/block-with-error.tsx new file mode 100644 index 000000000..fd0a96ea3 --- /dev/null +++ b/app/scripts/components/publication-tool/block-with-error.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { generalErrorMessage } from '../common/blocks/block-constant'; +import { HintedErrorDisplay, docsMessage } from '$utils/hinted-error'; +import { BlockComponent } from '$components/common/blocks'; + +const MDXBlockError = ({ error }: any) => { + return ( + + ); +}; + +class ErrorBoundaryWithCRAReset extends ErrorBoundary { + static getDerivedStateFromError(error: Error) { + (error as any).CRAOverlayIgnore = true; + return { didCatch: true, error }; + } +} + +export const MDXBlockWithError = (props) => { + // const result = useContext(MDXContext); + + return ( + + + + ); +}; \ No newline at end of file diff --git a/app/scripts/components/publication-tool/data-story-editor.tsx b/app/scripts/components/publication-tool/data-story-editor.tsx deleted file mode 100644 index 029b38b95..000000000 --- a/app/scripts/components/publication-tool/data-story-editor.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { PageMainContent } from '$styles/page'; - -export default function DataStoryEditor() { - return ( - -
    - Hello -
    -
    - ); -} diff --git a/app/scripts/components/publication-tool/data-story.tsx b/app/scripts/components/publication-tool/data-story.tsx new file mode 100644 index 000000000..c788ccef9 --- /dev/null +++ b/app/scripts/components/publication-tool/data-story.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useAtom } from 'jotai'; +import { useParams } from 'react-router'; +import EditorBlock from './editor-block'; +import { DataStoriesAtom } from '.'; +import { PageMainContent } from '$styles/page'; + +export default function DataStoryEditor() { + const { pId } = useParams(); + const [dataStories, setDataStories] = useAtom(DataStoriesAtom); + const story = dataStories.find((s) => s.frontmatter.id === pId); + + return ( + +
    + {story?.blocks.map((block) => { + return ( + + ); + })} +
    +
    + ); +} diff --git a/app/scripts/components/publication-tool/editor-block.tsx b/app/scripts/components/publication-tool/editor-block.tsx new file mode 100644 index 000000000..0434c6a3b --- /dev/null +++ b/app/scripts/components/publication-tool/editor-block.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { evaluate } from '@mdx-js/mdx'; +import { MDXContent } from 'mdx/types'; +import remarkGfm from 'remark-gfm'; +import * as runtime from 'react/jsx-runtime'; +import { useMDXComponents } from '@mdx-js/react'; +import MDXRenderer from './mdx-renderer'; + + +interface useMDXReturnProps { + source: string; + result: MDXContent | null; + error: any; +} + + +const useMDX = (source: string) => { + const remarkPlugins = [remarkGfm]; + const [state, setState] = useState({ + source, + result: null, + error: null + }); + + async function renderMDX() { + let result: MDXContent | null = null; + try { + const wrappedSource = `${source}`; + result = ( + await evaluate(wrappedSource, { + ...runtime, + useMDXComponents, + useDynamicImport: true, + remarkPlugins + } as any) + ).default; + setState((oldState) => { + return { ...oldState, source, result, error: null }; + }); + } catch (error) { + console.log(error); + setState((oldState) => { + return { ...oldState, source, result: null, error }; + }); + } + } + + useEffect(() => { + renderMDX(); + }, [source]); + + return state; +}; + +export default function EditorBlock({ mdx }: { mdx: string }) { + const { result, error } = useMDX(mdx); + console.log(error) + return ( + + ); +} + diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index 9d723adda..5f2fca1eb 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { Button, ButtonGroup } from '@devseed-ui/button'; import { DataStory } from './types'; -import DataStoryEditor from './data-story-editor'; +import DataStoryEditor from './data-story'; import { LayoutProps } from '$components/common/layout-root'; import { resourceNotFound } from '$components/uhoh'; import PageHero from '$components/common/page-hero'; @@ -17,7 +17,7 @@ import { } from '$components/common/fold'; import { PageMainContent } from '$styles/page'; -const DataStoriesAtom = atomWithStorage('dataStories', [ +export const DataStoriesAtom = atomWithStorage('dataStories', [ { frontmatter: { id: 'example-data-story', @@ -36,6 +36,15 @@ const DataStoriesAtom = atomWithStorage('dataStories', [ Your markdown contents comes here. ` + }, + { + tag: 'Block', + mdx: ` + + ### Second header + + Let's tell a story of _data_. + ` } ] }, @@ -78,7 +87,6 @@ function DataStoryEditorLayout() { })), [dataStories] ); - console.log('items', items); return ( <> diff --git a/app/scripts/components/publication-tool/mdx-renderer.tsx b/app/scripts/components/publication-tool/mdx-renderer.tsx new file mode 100644 index 000000000..bb20194cd --- /dev/null +++ b/app/scripts/components/publication-tool/mdx-renderer.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { MDXProvider } from '@mdx-js/react'; +import { MDXContent } from 'mdx/types'; +import { MDXBlockWithError } from './block-with-error'; +import ContentBlockFigure from '$components/common/blocks/figure'; +import { ContentBlockProse } from '$styles/content-block'; +import Image, { Caption } from '$components/common/blocks/images'; +import { Chapter } from '$components/common/blocks/scrollytelling/chapter'; +import { NotebookConnectCalloutBlock } from '$components/common/notebook-connect'; +import { + LazyMap, + LazyScrollyTelling, + LazyChart, + LazyCompareImage +} from '$components/common/blocks/lazy-components'; +import SmartLink from '$components/common/smart-link'; + +const COMPONENTS = { + Block: MDXBlockWithError, + Prose: ContentBlockProse, + Figure: ContentBlockFigure, + Caption, + Chapter, + Image, + Map: LazyMap, + ScrollytellingBlock: LazyScrollyTelling, + Chart: LazyChart, + CompareImage: LazyCompareImage, + NotebookConnectCallout: NotebookConnectCalloutBlock, + Link: SmartLink +}; + + +interface MDXRendererProps { + result: MDXContent | null; +} + +export default function MDXRenderer({ result }: MDXRendererProps) { + return ( + + {result && result({ components: COMPONENTS })} + + ); +} From e00ced347cdad80e60c014385174d1abb6effbf0 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 26 Jul 2023 12:41:21 +0200 Subject: [PATCH 05/20] Added error handling --- .../publication-tool/block-with-error.tsx | 2 +- .../publication-tool/editor-block.tsx | 15 ++++++++------ .../components/publication-tool/index.tsx | 20 +++++++++++++++++-- .../publication-tool/mdx-renderer.tsx | 1 - .../components/sandbox/mdx-editor/index.tsx | 6 ++++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/scripts/components/publication-tool/block-with-error.tsx b/app/scripts/components/publication-tool/block-with-error.tsx index fd0a96ea3..759f2e434 100644 --- a/app/scripts/components/publication-tool/block-with-error.tsx +++ b/app/scripts/components/publication-tool/block-with-error.tsx @@ -4,7 +4,7 @@ import { generalErrorMessage } from '../common/blocks/block-constant'; import { HintedErrorDisplay, docsMessage } from '$utils/hinted-error'; import { BlockComponent } from '$components/common/blocks'; -const MDXBlockError = ({ error }: any) => { +export const MDXBlockError = ({ error }: any) => { return ( { return { ...oldState, source, result, error: null }; }); } catch (error) { - console.log(error); setState((oldState) => { return { ...oldState, source, result: null, error }; }); @@ -54,9 +54,12 @@ const useMDX = (source: string) => { export default function EditorBlock({ mdx }: { mdx: string }) { const { result, error } = useMDX(mdx); - console.log(error) - return ( - - ); + const errorHumanReadable = useMemo(() => { + if (!error) return null; + const { line, message } = JSON.parse(JSON.stringify(error)); + return { message: `At line ${line - 1}: ${message}` }; + }, [error]); + + return error ? : ; } diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index 5f2fca1eb..834095c64 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -31,19 +31,35 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ { tag: 'Block', mdx: ` - + < ### Your markdown header Your markdown contents comes here. + + + + Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. + ` }, { tag: 'Block', mdx: ` - + ### Second header Let's tell a story of _data_. + ` } ] diff --git a/app/scripts/components/publication-tool/mdx-renderer.tsx b/app/scripts/components/publication-tool/mdx-renderer.tsx index bb20194cd..5f024a6af 100644 --- a/app/scripts/components/publication-tool/mdx-renderer.tsx +++ b/app/scripts/components/publication-tool/mdx-renderer.tsx @@ -30,7 +30,6 @@ const COMPONENTS = { Link: SmartLink }; - interface MDXRendererProps { result: MDXContent | null; } diff --git a/app/scripts/components/sandbox/mdx-editor/index.tsx b/app/scripts/components/sandbox/mdx-editor/index.tsx index bcb16ea50..535ef5d39 100644 --- a/app/scripts/components/sandbox/mdx-editor/index.tsx +++ b/app/scripts/components/sandbox/mdx-editor/index.tsx @@ -65,6 +65,12 @@ export const MDX_SOURCE_DEFAULT = [ > Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. + + Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. +
    ## Seeing Rebounds in NO2 From eff22c66359c6b508837d5d109d18f9f439eccc7 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 26 Jul 2023 13:31:46 +0200 Subject: [PATCH 06/20] Select currently editing --- .../publication-tool/data-story.tsx | 43 +++++++--- .../publication-tool/editor-block.tsx | 79 +++++++++++++++++-- .../components/publication-tool/index.tsx | 16 ++-- .../components/publication-tool/types.d.ts | 2 + 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/app/scripts/components/publication-tool/data-story.tsx b/app/scripts/components/publication-tool/data-story.tsx index c788ccef9..f2cdc7935 100644 --- a/app/scripts/components/publication-tool/data-story.tsx +++ b/app/scripts/components/publication-tool/data-story.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; import { useAtom } from 'jotai'; import { useParams } from 'react-router'; import EditorBlock from './editor-block'; @@ -6,19 +7,37 @@ import { DataStoriesAtom } from '.'; import { PageMainContent } from '$styles/page'; export default function DataStoryEditor() { - const { pId } = useParams(); + const { storyId } = useParams(); const [dataStories, setDataStories] = useAtom(DataStoriesAtom); - const story = dataStories.find((s) => s.frontmatter.id === pId); + const story = dataStories.find((s) => s.frontmatter.id === storyId); + const [currentHighlightedBlockId, setCurrentHighlightedBlockId] = + useState(); return ( - -
    - {story?.blocks.map((block) => { - return ( - - ); - })} -
    -
    + <> + {story?.currentBlockId && + document.getElementById(`block${story?.currentBlockId}`) && + createPortal( +
    Insert editor here
    , + document.getElementById( + `block${story?.currentBlockId}` + ) as HTMLElement + )} + +
    + {story?.blocks.map((block) => { + return ( + + ); + })} +
    +
    + ); } diff --git a/app/scripts/components/publication-tool/editor-block.tsx b/app/scripts/components/publication-tool/editor-block.tsx index 15762721b..de8c968b6 100644 --- a/app/scripts/components/publication-tool/editor-block.tsx +++ b/app/scripts/components/publication-tool/editor-block.tsx @@ -1,12 +1,16 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSetAtom } from 'jotai'; import { evaluate } from '@mdx-js/mdx'; +import { Button, ButtonGroup } from '@devseed-ui/button'; +import styled from 'styled-components'; import { MDXContent } from 'mdx/types'; import remarkGfm from 'remark-gfm'; import * as runtime from 'react/jsx-runtime'; import { useMDXComponents } from '@mdx-js/react'; import MDXRenderer from './mdx-renderer'; import { MDXBlockWithError } from './block-with-error'; - +import { DataStoriesAtom } from '.'; +import { useParams } from 'react-router'; interface useMDXReturnProps { source: string; @@ -14,7 +18,6 @@ interface useMDXReturnProps { error: any; } - const useMDX = (source: string) => { const remarkPlugins = [remarkGfm]; const [state, setState] = useState({ @@ -52,7 +55,40 @@ const useMDX = (source: string) => { return state; }; -export default function EditorBlock({ mdx }: { mdx: string }) { +const MDXRendererControls = styled.div<{ highlighted: boolean }>` + background: ${(props) => + props.highlighted + ? `repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + #eee 10px, + #eee 20px + )` + : 'transparent'}; + position: relative; +`; + +const MDXRendererActions = styled.div` + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + padding: 1rem; +`; + +export default function EditorBlock({ + mdx, + id, + onHighlight, + highlighted +}: { + mdx: string; + id: string; + onHighlight: (id: string) => void; + highlighted: boolean; +}) { + const { storyId } = useParams(); const { result, error } = useMDX(mdx); const errorHumanReadable = useMemo(() => { if (!error) return null; @@ -60,6 +96,37 @@ export default function EditorBlock({ mdx }: { mdx: string }) { return { message: `At line ${line - 1}: ${message}` }; }, [error]); - return error ? : ; + const setDataStories = useSetAtom(DataStoriesAtom); + const onEditClick = useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const storyIndex = newDataStories.findIndex( + (s) => s.frontmatter.id === storyId + ); + newDataStories[storyIndex].currentBlockId = id; + return newDataStories; + }); + }, [id, setDataStories, storyId]); + return error ? ( + + ) : ( + onHighlight(id)} + > + + {highlighted && ( + + + + + + + + + + )} +
    + + ); } - diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index 834095c64..df9963dc7 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -27,11 +27,13 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ thematics: [], pubDate: '2023-01-01' }, + currentBlockId: '1', blocks: [ { + id: '1', tag: 'Block', mdx: ` - < + ### Your markdown header Your markdown contents comes here. @@ -53,9 +55,10 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ ` }, { + id: '2', tag: 'Block', mdx: ` - + ### Second header Let's tell a story of _data_. @@ -75,6 +78,7 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ }, blocks: [ { + id: '1', tag: 'Block', mdx: ` @@ -88,10 +92,10 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ ]); function DataStoryEditorLayout() { - const { pId } = useParams(); + const { storyId } = useParams(); const dataStories = useAtomValue(DataStoriesAtom); - const page = dataStories.find((p) => p.frontmatter.id === pId); + const page = dataStories.find((p) => p.frontmatter.id === storyId); if (!page) throw resourceNotFound(); const items = useMemo( @@ -113,7 +117,7 @@ function DataStoryEditorLayout() { parentLabel: 'Publication Tool', parentTo: '/publication-tool', items, - currentId: pId, + currentId: storyId, localMenuCmp: ( - } /> + } /> Date: Wed, 26 Jul 2023 15:33:28 +0200 Subject: [PATCH 07/20] Allow MDX edition --- .../components/publication-tool/atoms.ts | 132 ++++++++++++++++++ .../publication-tool/data-story.tsx | 19 ++- .../publication-tool/editor-block.tsx | 22 +-- .../components/publication-tool/editor.tsx | 93 ++++++++++++ .../components/publication-tool/index.tsx | 77 +--------- 5 files changed, 239 insertions(+), 104 deletions(-) create mode 100644 app/scripts/components/publication-tool/atoms.ts create mode 100644 app/scripts/components/publication-tool/editor.tsx diff --git a/app/scripts/components/publication-tool/atoms.ts b/app/scripts/components/publication-tool/atoms.ts new file mode 100644 index 000000000..c5d856369 --- /dev/null +++ b/app/scripts/components/publication-tool/atoms.ts @@ -0,0 +1,132 @@ +import { atomWithStorage } from 'jotai/utils'; +import { useParams } from 'react-router'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback, useMemo } from 'react'; +import { DataStory } from './types'; + +export const DataStoriesAtom = atomWithStorage('dataStories', [ + { + frontmatter: { + id: 'example-data-story', + name: 'Example Data Story', + description: 'This is an example data story', + sources: [], + thematics: [], + pubDate: '2023-01-01' + }, + currentBlockId: '1', + blocks: [ + { + id: '1', + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + + + + Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. + + ` + }, + { + id: '2', + tag: 'Block', + mdx: ` + + ### Second header + + Let's tell a story of _data_. + + ` + } + ] + }, + { + frontmatter: { + id: 'example-data-story-2', + name: 'Example Data Story 2', + description: 'This is an example data story', + sources: [], + thematics: [], + pubDate: '2023-01-01' + }, + blocks: [ + { + id: '1', + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + ` + } + ] + } +]); + +export const useCurrentDataStory = () => { + const { storyId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + const currentDataStory = useMemo( + () => dataStories.find((p) => p.frontmatter.id === storyId), + [dataStories, storyId] + ); + return currentDataStory; +}; + +export const useCurrentBlockId = () => { + const currentDataStory = useCurrentDataStory(); + const currentBlockId = currentDataStory?.currentBlockId; + return currentBlockId; +}; + +export const useSetCurrentBlock = (blockId: string) => { + const { storyId } = useParams(); + const setDataStories = useSetAtom(DataStoriesAtom); + return useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const storyIndex = newDataStories.findIndex( + (s) => s.frontmatter.id === storyId + ); + newDataStories[storyIndex].currentBlockId = blockId; + return newDataStories; + }); + }, [blockId, setDataStories, storyId]); +}; + +export const useSetBlockMDX = (blockId?: string) => { + const { storyId } = useParams(); + const setDataStories = useSetAtom(DataStoriesAtom); + const callback = useCallback( + (mdx: string) => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const storyIndex = newDataStories.findIndex( + (s) => s.frontmatter.id === storyId + ); + const blockIndex = newDataStories[storyIndex].blocks.findIndex( + (b) => b.id === blockId + ); + newDataStories[storyIndex].blocks[blockIndex].mdx = mdx; + return newDataStories; + }); + }, + [blockId, setDataStories, storyId] + ); + return blockId ? callback : undefined; +}; diff --git a/app/scripts/components/publication-tool/data-story.tsx b/app/scripts/components/publication-tool/data-story.tsx index f2cdc7935..d5cc63065 100644 --- a/app/scripts/components/publication-tool/data-story.tsx +++ b/app/scripts/components/publication-tool/data-story.tsx @@ -1,31 +1,28 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; -import { useAtom } from 'jotai'; -import { useParams } from 'react-router'; import EditorBlock from './editor-block'; -import { DataStoriesAtom } from '.'; +import { useCurrentDataStory } from './atoms'; +import Editor from './editor'; import { PageMainContent } from '$styles/page'; export default function DataStoryEditor() { - const { storyId } = useParams(); - const [dataStories, setDataStories] = useAtom(DataStoriesAtom); - const story = dataStories.find((s) => s.frontmatter.id === storyId); + const currentDataStory = useCurrentDataStory(); const [currentHighlightedBlockId, setCurrentHighlightedBlockId] = useState(); return ( <> - {story?.currentBlockId && - document.getElementById(`block${story?.currentBlockId}`) && + {currentDataStory?.currentBlockId && + document.getElementById(`block${currentDataStory?.currentBlockId}`) && createPortal( -
    Insert editor here
    , + , document.getElementById( - `block${story?.currentBlockId}` + `block${currentDataStory?.currentBlockId}` ) as HTMLElement )}
    - {story?.blocks.map((block) => { + {currentDataStory?.blocks.map((block) => { return ( void; highlighted: boolean; }) { - const { storyId } = useParams(); const { result, error } = useMDX(mdx); const errorHumanReadable = useMemo(() => { if (!error) return null; @@ -96,17 +93,8 @@ export default function EditorBlock({ return { message: `At line ${line - 1}: ${message}` }; }, [error]); - const setDataStories = useSetAtom(DataStoriesAtom); - const onEditClick = useCallback(() => { - setDataStories((oldDataStories) => { - const newDataStories = [...oldDataStories]; - const storyIndex = newDataStories.findIndex( - (s) => s.frontmatter.id === storyId - ); - newDataStories[storyIndex].currentBlockId = id; - return newDataStories; - }); - }, [id, setDataStories, storyId]); + const onEditClick = useSetCurrentBlock(id); + return error ? ( ) : ( @@ -118,7 +106,7 @@ export default function EditorBlock({ {highlighted && ( - + diff --git a/app/scripts/components/publication-tool/editor.tsx b/app/scripts/components/publication-tool/editor.tsx new file mode 100644 index 000000000..34df9d830 --- /dev/null +++ b/app/scripts/components/publication-tool/editor.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import CodeMirror from 'rodemirror'; +import { EditorView, basicSetup } from 'codemirror'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { Extension } from '@codemirror/state'; +import { markdown as langMarkdown } from '@codemirror/lang-markdown'; +import { useCurrentBlockId, useCurrentDataStory, useSetBlockMDX } from './atoms'; + +const DEFAULT_CONTENT = '(your content here)'; + +const EditorWrapper = styled.div` + margin: 0 3rem 1rem; +`; + +const CodeMirrorWrapper = styled.div` + overflow: scroll; + max-height: 500px; +`; + +const TitleBar = styled.div` + background-color: #1e1e1e; + color: #fff; + padding: 10px; + font-size: 14px; + font-weight: bold; + border-bottom: 1px solid #000; + cursor: move; +`; + +export default function Editor() { + const extensions = useMemo( + () => [basicSetup, oneDark, langMarkdown()], + [] + ); + + const [editorView, setEditorView] = useState(null); + const currentDataStory = useCurrentDataStory(); + + // Update editor on current editing change + useEffect(() => { + if (!editorView) return; + const currentBlock = currentDataStory?.blocks.find( + (block) => block.id === currentDataStory?.currentBlockId + ); + editorView.dispatch({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: currentBlock?.mdx + } + }); + }, [editorView, currentDataStory?.currentBlockId]); + + const currentBlockId = useCurrentBlockId(); + const setMDX = useSetBlockMDX(currentBlockId); + const onEditorUpdated = useCallback( + (v) => { + if (v.docChanged) { + let blockSource = v.state.doc.toString(); + // This is a hack to prevent the editor from being cleared + // when the user deletes all the text. + // because when that happens, React throws an order of hooks error + blockSource = blockSource || DEFAULT_CONTENT; + + + if (setMDX) setMDX(blockSource); + // const allSource = [ + // ...source.slice(0, currentEditingBlockIndexRef.current), + // blockSource, + // ...source.slice(currentEditingBlockIndexRef.current + 1) + // ]; + // setSource(allSource); + } + }, + [setMDX] + ); + + return ( + + MDX Editor + + + setEditorView(editorView)} + onUpdate={onEditorUpdated} + extensions={extensions} + /> + + + ); +} diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index df9963dc7..bed7fba64 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -1,10 +1,9 @@ import React, { useMemo } from 'react'; import { Route, Routes, useParams } from 'react-router'; import { useAtomValue } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; import { Button, ButtonGroup } from '@devseed-ui/button'; -import { DataStory } from './types'; import DataStoryEditor from './data-story'; +import { DataStoriesAtom } from './atoms'; import { LayoutProps } from '$components/common/layout-root'; import { resourceNotFound } from '$components/uhoh'; import PageHero from '$components/common/page-hero'; @@ -17,80 +16,6 @@ import { } from '$components/common/fold'; import { PageMainContent } from '$styles/page'; -export const DataStoriesAtom = atomWithStorage('dataStories', [ - { - frontmatter: { - id: 'example-data-story', - name: 'Example Data Story', - description: 'This is an example data story', - sources: [], - thematics: [], - pubDate: '2023-01-01' - }, - currentBlockId: '1', - blocks: [ - { - id: '1', - tag: 'Block', - mdx: ` - - ### Your markdown header - - Your markdown contents comes here. - - - - Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. - - ` - }, - { - id: '2', - tag: 'Block', - mdx: ` - - ### Second header - - Let's tell a story of _data_. - - ` - } - ] - }, - { - frontmatter: { - id: 'example-data-story-2', - name: 'Example Data Story 2', - description: 'This is an example data story', - sources: [], - thematics: [], - pubDate: '2023-01-01' - }, - blocks: [ - { - id: '1', - tag: 'Block', - mdx: ` - - ### Your markdown header - - Your markdown contents comes here. - ` - } - ] - } -]); - function DataStoryEditorLayout() { const { storyId } = useParams(); const dataStories = useAtomValue(DataStoriesAtom); From 8207d0dfa594d0d73cdfc937a0eaa045d2bf8e88 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 26 Jul 2023 16:48:49 +0200 Subject: [PATCH 08/20] Add/remove blocks --- .../components/publication-tool/atoms.ts | 87 +++++++++++++++---- .../publication-tool/editor-block.tsx | 14 +-- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/app/scripts/components/publication-tool/atoms.ts b/app/scripts/components/publication-tool/atoms.ts index c5d856369..3309b9616 100644 --- a/app/scripts/components/publication-tool/atoms.ts +++ b/app/scripts/components/publication-tool/atoms.ts @@ -94,39 +94,96 @@ export const useCurrentBlockId = () => { return currentBlockId; }; -export const useSetCurrentBlock = (blockId: string) => { +export const useStoryIndex = () => { const { storyId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + const storyIndex = useMemo( + () => dataStories.findIndex((p) => p.frontmatter.id === storyId), + [dataStories, storyId] + ); + return storyIndex; +}; + +export const useBlockIndex = (blockId: string) => { + const currentDataStory = useCurrentDataStory(); + const blockIndex = useMemo(() => { + const blockIndex = currentDataStory?.blocks.findIndex( + (b) => b.id === blockId + ); + return blockIndex ?? -1; + }, [blockId, currentDataStory]); + return blockIndex; +}; + +const useCRUDUtils = (blockId: string) => { const setDataStories = useSetAtom(DataStoriesAtom); + const storyIndex = useStoryIndex(); + const blockIndex = useBlockIndex(blockId); + return { setDataStories, storyIndex, blockIndex }; +}; + +export const useSetCurrentBlockId = (blockId: string) => { + const { setDataStories, storyIndex } = useCRUDUtils(blockId); return useCallback(() => { setDataStories((oldDataStories) => { const newDataStories = [...oldDataStories]; - const storyIndex = newDataStories.findIndex( - (s) => s.frontmatter.id === storyId - ); newDataStories[storyIndex].currentBlockId = blockId; return newDataStories; }); - }, [blockId, setDataStories, storyId]); + }, [blockId, setDataStories, storyIndex]); }; -export const useSetBlockMDX = (blockId?: string) => { - const { storyId } = useParams(); - const setDataStories = useSetAtom(DataStoriesAtom); +export const useRemoveBlock = (blockId: string) => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); + return useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + newDataStories[storyIndex].blocks = [ + ...newDataStories[storyIndex].blocks.slice(0, blockIndex), + ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + ]; + return newDataStories; + }); + }, [setDataStories, storyIndex, blockIndex]); +}; + +export const useAddBlock = (afterBlockId: string) => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(afterBlockId); + return useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const newBlockId = new Date().getTime().toString(); + newDataStories[storyIndex].currentBlockId = newBlockId; + newDataStories[storyIndex].blocks = [ + ...newDataStories[storyIndex].blocks.slice(0, blockIndex + 1), + { + id: newBlockId, + tag: 'Block', + mdx: ` +### Hello, block! + +Let's tell a story of _data_. + +` + }, + ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + ]; + return newDataStories; + }); + }, [setDataStories, storyIndex, blockIndex]); +}; + +export const useSetBlockMDX = (blockId: string) => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); const callback = useCallback( (mdx: string) => { setDataStories((oldDataStories) => { const newDataStories = [...oldDataStories]; - const storyIndex = newDataStories.findIndex( - (s) => s.frontmatter.id === storyId - ); - const blockIndex = newDataStories[storyIndex].blocks.findIndex( - (b) => b.id === blockId - ); newDataStories[storyIndex].blocks[blockIndex].mdx = mdx; return newDataStories; }); }, - [blockId, setDataStories, storyId] + [setDataStories, storyIndex, blockIndex] ); return blockId ? callback : undefined; }; diff --git a/app/scripts/components/publication-tool/editor-block.tsx b/app/scripts/components/publication-tool/editor-block.tsx index b86ec6d5b..7cd8ebddf 100644 --- a/app/scripts/components/publication-tool/editor-block.tsx +++ b/app/scripts/components/publication-tool/editor-block.tsx @@ -8,7 +8,7 @@ import * as runtime from 'react/jsx-runtime'; import { useMDXComponents } from '@mdx-js/react'; import MDXRenderer from './mdx-renderer'; import { MDXBlockWithError } from './block-with-error'; -import { useSetCurrentBlock } from './atoms'; +import { useAddBlock, useRemoveBlock, useSetCurrentBlockId } from './atoms'; interface useMDXReturnProps { source: string; @@ -93,7 +93,9 @@ export default function EditorBlock({ return { message: `At line ${line - 1}: ${message}` }; }, [error]); - const onEditClick = useSetCurrentBlock(id); + const onEditClick = useSetCurrentBlockId(id); + const onRemoveClick = useRemoveBlock(id); + const onAddClick = useAddBlock(id); return error ? ( @@ -107,10 +109,10 @@ export default function EditorBlock({ - - - - + + + + )} From a3584ff445097682af633ffed1c531be424caf34 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Thu, 3 Aug 2023 10:45:42 +0200 Subject: [PATCH 09/20] [WIP] Set blocks order --- .../components/publication-tool/atoms.ts | 59 ++++++++++++++++--- .../publication-tool/editor-block.tsx | 30 ++++++++-- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/app/scripts/components/publication-tool/atoms.ts b/app/scripts/components/publication-tool/atoms.ts index 3309b9616..08ff763a3 100644 --- a/app/scripts/components/publication-tool/atoms.ts +++ b/app/scripts/components/publication-tool/atoms.ts @@ -136,14 +136,16 @@ export const useSetCurrentBlockId = (blockId: string) => { export const useRemoveBlock = (blockId: string) => { const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); return useCallback(() => { - setDataStories((oldDataStories) => { - const newDataStories = [...oldDataStories]; - newDataStories[storyIndex].blocks = [ - ...newDataStories[storyIndex].blocks.slice(0, blockIndex), - ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) - ]; - return newDataStories; - }); + if (window.confirm('Are you sure you want to delete this block?')) { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + newDataStories[storyIndex].blocks = [ + ...newDataStories[storyIndex].blocks.slice(0, blockIndex), + ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + ]; + return newDataStories; + }); + } }, [setDataStories, storyIndex, blockIndex]); }; @@ -160,7 +162,7 @@ export const useAddBlock = (afterBlockId: string) => { id: newBlockId, tag: 'Block', mdx: ` -### Hello, block! +### Hello, new block! Let's tell a story of _data_. @@ -187,3 +189,42 @@ export const useSetBlockMDX = (blockId: string) => { ); return blockId ? callback : undefined; }; + +export const useSetBlockOrder = (blockId: string, direction: 'up' | 'down') => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); + const currentDataStory = useCurrentDataStory(); + + const isAvailable = useMemo(() => { + const canGoUp = blockIndex > 0; + const canGoDown = currentDataStory + ? blockIndex < currentDataStory.blocks.length - 1 + : false; + return direction === 'up' ? canGoUp : canGoDown; + }, [blockIndex, currentDataStory, direction]); + + + const setBlockOrder = useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const block = newDataStories[storyIndex].blocks[blockIndex]; + // const newBlockIndex = + // direction === 'up' ? blockIndex - 1 : blockIndex + 1; + // newDataStories[storyIndex].blocks = [ + // ...newDataStories[storyIndex].blocks.slice(0, blockIndex), + // ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + // ]; + + if (direction === 'up') { + newDataStories[storyIndex].blocks[blockIndex] = + newDataStories[storyIndex].blocks[blockIndex - 1]; + newDataStories[storyIndex].blocks[blockIndex - 1] = block; + } else { + newDataStories[storyIndex].blocks[blockIndex] = + newDataStories[storyIndex].blocks[blockIndex + 1]; + newDataStories[storyIndex].blocks[blockIndex + 1] = block; + } + return newDataStories; + }); + }, [setDataStories, storyIndex, blockIndex, direction]); + return { isAvailable, setBlockOrder }; +}; diff --git a/app/scripts/components/publication-tool/editor-block.tsx b/app/scripts/components/publication-tool/editor-block.tsx index 7cd8ebddf..e88bd5b47 100644 --- a/app/scripts/components/publication-tool/editor-block.tsx +++ b/app/scripts/components/publication-tool/editor-block.tsx @@ -8,7 +8,13 @@ import * as runtime from 'react/jsx-runtime'; import { useMDXComponents } from '@mdx-js/react'; import MDXRenderer from './mdx-renderer'; import { MDXBlockWithError } from './block-with-error'; -import { useAddBlock, useRemoveBlock, useSetCurrentBlockId } from './atoms'; +import { + useAddBlock, + useCurrentDataStory, + useRemoveBlock, + useSetBlockOrder, + useSetCurrentBlockId +} from './atoms'; interface useMDXReturnProps { source: string; @@ -96,6 +102,16 @@ export default function EditorBlock({ const onEditClick = useSetCurrentBlockId(id); const onRemoveClick = useRemoveBlock(id); const onAddClick = useAddBlock(id); + const { isAvailable: canGoUp, setBlockOrder: onUpClick } = useSetBlockOrder( + id, + 'up' + ); + const { isAvailable: canGoDown, setBlockOrder: onDownClick } = + useSetBlockOrder(id, 'down'); + + const currentDataStory = useCurrentDataStory(); + + const editing = id === currentDataStory?.currentBlockId; return error ? ( @@ -108,10 +124,16 @@ export default function EditorBlock({ {highlighted && ( - + - - + + From 2d853295c1f93b018bb1ecf147f86644e7fc3d02 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Mon, 7 Aug 2023 17:26:13 +0200 Subject: [PATCH 10/20] [WIP] Allow importing full MDX documents --- .../components/publication-tool/atoms.ts | 25 ++++++--- .../components/publication-tool/index.tsx | 37 +++++++++++-- .../components/publication-tool/types.d.ts | 10 ++-- .../components/publication-tool/utils.tsx | 52 +++++++++++++++++++ 4 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 app/scripts/components/publication-tool/utils.tsx diff --git a/app/scripts/components/publication-tool/atoms.ts b/app/scripts/components/publication-tool/atoms.ts index 08ff763a3..c37558b41 100644 --- a/app/scripts/components/publication-tool/atoms.ts +++ b/app/scripts/components/publication-tool/atoms.ts @@ -2,17 +2,17 @@ import { atomWithStorage } from 'jotai/utils'; import { useParams } from 'react-router'; import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useMemo } from 'react'; -import { DataStory } from './types'; +import { EditorDataStory } from './types'; +import { toEditorDataStory, toMDXDocument } from './utils'; -export const DataStoriesAtom = atomWithStorage('dataStories', [ +export const DataStoriesAtom = atomWithStorage('dataStories', [ { frontmatter: { id: 'example-data-story', name: 'Example Data Story', description: 'This is an example data story', - sources: [], - thematics: [], - pubDate: '2023-01-01' + pubDate: '2023-01-01', + taxonomy: [], }, currentBlockId: '1', blocks: [ @@ -59,8 +59,7 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ id: 'example-data-story-2', name: 'Example Data Story 2', description: 'This is an example data story', - sources: [], - thematics: [], + taxonomy: [], pubDate: '2023-01-01' }, blocks: [ @@ -78,6 +77,18 @@ export const DataStoriesAtom = atomWithStorage('dataStories', [ } ]); +export const useCreateEditorDataStoryFromMDXDocument = () => { + const setDataStories = useSetAtom(DataStoriesAtom); + return useCallback((mdxDocument: string) => { + const editorDataStory = toEditorDataStory(mdxDocument); + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories, editorDataStory]; + return newDataStories; + }); + return editorDataStory; + }, [setDataStories]); +}; + export const useCurrentDataStory = () => { const { storyId } = useParams(); const dataStories = useAtomValue(DataStoriesAtom); diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx index bed7fba64..c7f6e3661 100644 --- a/app/scripts/components/publication-tool/index.tsx +++ b/app/scripts/components/publication-tool/index.tsx @@ -1,9 +1,12 @@ -import React, { useMemo } from 'react'; -import { Route, Routes, useParams } from 'react-router'; +import React, { useMemo, useState } from 'react'; +import { Route, Routes, useNavigate, useParams } from 'react-router'; import { useAtomValue } from 'jotai'; import { Button, ButtonGroup } from '@devseed-ui/button'; import DataStoryEditor from './data-story'; -import { DataStoriesAtom } from './atoms'; +import { + DataStoriesAtom, + useCreateEditorDataStoryFromMDXDocument +} from './atoms'; import { LayoutProps } from '$components/common/layout-root'; import { resourceNotFound } from '$components/uhoh'; import PageHero from '$components/common/page-hero'; @@ -74,6 +77,17 @@ function DataStoryEditorLayout() { function PublicationTool() { const dataStories = useAtomValue(DataStoriesAtom); + const [newStory, setNewStory] = useState(''); + const createEditorDataStoryFromMDXDocument = + useCreateEditorDataStoryFromMDXDocument(); + const navigate = useNavigate(); + + const onCreate = () => { + const { frontmatter } = createEditorDataStoryFromMDXDocument(newStory); + setNewStory(''); + navigate(`/publication-tool/${frontmatter.id}`); + }; + return ( } /> @@ -114,6 +128,23 @@ function PublicationTool() { ))} +
    +

    Create new Story

    +
    +

    From existing MDX document:

    +