From 66d7e76933661fd772d2cc83b4f380c302287dd8 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Mon, 11 Dec 2023 14:57:34 -0800 Subject: [PATCH] feat: add headless richtext schema field (#223) --- .changeset/spotty-coats-love.md | 6 + examples/blog/collections/BlogPosts.schema.ts | 5 + examples/blog/root-cms.d.ts | 236 +++++++++--------- examples/blog/routes/blog/[blog-post].tsx | 7 +- .../root-cms/cli/commands/generate-types.ts | 42 +++- packages/root-cms/core/schema.ts | 13 +- packages/root-cms/core/tsup.config.ts | 2 + packages/root-cms/package.json | 12 + packages/root-cms/richtext/richtext.tsx | 181 ++++++++++++++ packages/root-cms/richtext/tsconfig.json | 34 +++ packages/root-cms/richtext/types.ts | 9 + .../ui/components/DocEditor/DocEditor.tsx | 134 +++------- .../LocalizationModal/LocalizationModal.tsx | 17 +- .../RichTextEditor/RichTextEditor.css | 67 +++++ .../RichTextEditor/RichTextEditor.tsx | 216 ++++++++++++++++ .../RichTextEditor/tools/Superscript.ts | 80 ++++++ packages/root-cms/ui/utils/gcs.ts | 116 +++++++++ packages/root/src/core/types.ts | 2 +- pnpm-lock.yaml | 86 +++++++ 19 files changed, 1028 insertions(+), 237 deletions(-) create mode 100644 .changeset/spotty-coats-love.md create mode 100644 packages/root-cms/richtext/richtext.tsx create mode 100644 packages/root-cms/richtext/tsconfig.json create mode 100644 packages/root-cms/richtext/types.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts create mode 100644 packages/root-cms/ui/utils/gcs.ts diff --git a/.changeset/spotty-coats-love.md b/.changeset/spotty-coats-love.md new file mode 100644 index 00000000..fb15ed45 --- /dev/null +++ b/.changeset/spotty-coats-love.md @@ -0,0 +1,6 @@ +--- +'@blinkk/root-cms': minor +'@blinkk/root': minor +--- + +feat: add richtext schema field diff --git a/examples/blog/collections/BlogPosts.schema.ts b/examples/blog/collections/BlogPosts.schema.ts index 4f46c0c7..a0748709 100644 --- a/examples/blog/collections/BlogPosts.schema.ts +++ b/examples/blog/collections/BlogPosts.schema.ts @@ -57,6 +57,11 @@ export default schema.collection({ id: 'content', label: 'Content', fields: [ + schema.richtext({ + id: 'richtext', + label: 'Blog content', + translate: true, + }), schema.string({ id: 'body', label: 'Body copy', diff --git a/examples/blog/root-cms.d.ts b/examples/blog/root-cms.d.ts index 21f71a1b..3bfe8c86 100644 --- a/examples/blog/root-cms.d.ts +++ b/examples/blog/root-cms.d.ts @@ -1,5 +1,25 @@ /** Root.js CMS types. This file is autogenerated. */ +export interface RootCMSImage { + src: string; + width?: number; + height?: number; + alt?: string; +} + +export type RootCMSOneOf = T & { + _type: string; +} + +export interface RootCMSRichTextBlock { + type: string; + data: any; +} + +export interface RootCMSRichText { + blocks: RootCMSRichTextBlock[]; +} + export interface RootCMSDoc { /** The id of the doc, e.g. "Pages/foo-bar". */ id: string; @@ -25,45 +45,42 @@ export interface RootCMSDoc { /** Generated from `/collections/BlogPosts.schema.ts`. */ export interface BlogPostsFields { - /** Internal Description. Use this field to leave internal notes, etc. */ - internalDesc?: string; - /** Meta */ - meta?: { - /** Title */ - title?: string; - /** Description. Description for SEO and social shares. */ - description?: string; - /** Image. Meta image for social shares. Recommended size: 1200x600. */ - image?: { - src: string; - width: number; - height: number; - alt?: string; - }; - /** Featured?. Check the box to mark the blog post as a featured blog post. */ - featured?: boolean; - /** Tags. Category tags for searching and filtering. */ - tags?: string[]; - }; - /** Content */ - content?: { - /** Body copy. Markdown supported. */ - body?: string; - }; - /** Advanced */ - advanced?: { - /** - * Custom CSS. Optional CSS to inject into the page. - * @deprecated - */ - customCss?: string; - /** PDF. PDF version of the post. */ - pdf?: { - src: string; - }; - /** Published Date Override. Override for the "Published" date. */ - publishedAtOverride?: number; + /** Internal Description. Use this field to leave internal notes, etc. */ + internalDesc?: string; + /** Meta */ + meta?: { + /** Title */ + title?: string; + /** Description. Description for SEO and social shares. */ + description?: string; + /** Image. Meta image for social shares. Recommended size: 1200x600. */ + image?: RootCMSImage; + /** Featured?. Check the box to mark the blog post as a featured blog post. */ + featured?: boolean; + /** Tags. Category tags for searching and filtering. */ + tags?: string[]; + }; + /** Content */ + content?: { + /** Blog content */ + richtext?: RootCMSRichText; + /** Body copy. Markdown supported. */ + body?: string; + }; + /** Advanced */ + advanced?: { + /** + * Custom CSS. Optional CSS to inject into the page. + * @deprecated + */ + customCss?: string; + /** PDF. PDF version of the post. */ + pdf?: { + src: string; }; + /** Published Date Override. Override for the "Published" date. */ + publishedAtOverride?: number; + }; } /** Generated from `/collections/BlogPosts.schema.ts`. */ @@ -71,45 +88,42 @@ export type BlogPostsDoc = RootCMSDoc; /** Generated from `/collections/BlogPostsSandbox.schema.ts`. */ export interface BlogPostsSandboxFields { - /** Internal Description. Use this field to leave internal notes, etc. */ - internalDesc?: string; - /** Meta */ - meta?: { - /** Title */ - title?: string; - /** Description. Description for SEO and social shares. */ - description?: string; - /** Image. Meta image for social shares. Recommended size: 1200x600. */ - image?: { - src: string; - width: number; - height: number; - alt?: string; - }; - /** Featured?. Check the box to mark the blog post as a featured blog post. */ - featured?: boolean; - /** Tags. Category tags for searching and filtering. */ - tags?: string[]; - }; - /** Content */ - content?: { - /** Body copy. Markdown supported. */ - body?: string; - }; - /** Advanced */ - advanced?: { - /** - * Custom CSS. Optional CSS to inject into the page. - * @deprecated - */ - customCss?: string; - /** PDF. PDF version of the post. */ - pdf?: { - src: string; - }; - /** Published Date Override. Override for the "Published" date. */ - publishedAtOverride?: number; + /** Internal Description. Use this field to leave internal notes, etc. */ + internalDesc?: string; + /** Meta */ + meta?: { + /** Title */ + title?: string; + /** Description. Description for SEO and social shares. */ + description?: string; + /** Image. Meta image for social shares. Recommended size: 1200x600. */ + image?: RootCMSImage; + /** Featured?. Check the box to mark the blog post as a featured blog post. */ + featured?: boolean; + /** Tags. Category tags for searching and filtering. */ + tags?: string[]; + }; + /** Content */ + content?: { + /** Blog content */ + richtext?: RootCMSRichText; + /** Body copy. Markdown supported. */ + body?: string; + }; + /** Advanced */ + advanced?: { + /** + * Custom CSS. Optional CSS to inject into the page. + * @deprecated + */ + customCss?: string; + /** PDF. PDF version of the post. */ + pdf?: { + src: string; }; + /** Published Date Override. Override for the "Published" date. */ + publishedAtOverride?: number; + }; } /** Generated from `/collections/BlogPostsSandbox.schema.ts`. */ @@ -117,32 +131,27 @@ export type BlogPostsSandboxDoc = RootCMSDoc; /** Generated from `/collections/Pages.schema.ts`. */ export interface PagesFields { - /** [INTERNAL] Description. Internal-only field. Used for notes, etc. */ - internalDesc?: string; - /** Meta */ - meta?: { - /** Title. Page title. */ - title?: string; - /** Description. Description for SEO and social shares. */ - description?: string; - /** Image. Meta image for social shares. Recommended: 1400x600 JPG. */ - image?: { - src: string; - width: number; - height: number; - alt?: string; - }; - }; - /** Content */ - content?: { - /** Modules. Compose the page by adding one or more modules. */ - modules?: unknown[]; - }; - /** Advanced */ - advanced?: { - /** Analytics. HTML injected into the page for custom analytics. */ - analtyics?: string; - }; + /** [INTERNAL] Description. Internal-only field. Used for notes, etc. */ + internalDesc?: string; + /** Meta */ + meta?: { + /** Title. Page title. */ + title?: string; + /** Description. Description for SEO and social shares. */ + description?: string; + /** Image. Meta image for social shares. Recommended: 1400x600 JPG. */ + image?: RootCMSImage; + }; + /** Content */ + content?: { + /** Modules. Compose the page by adding one or more modules. */ + modules?: RootCMSOneOf[]; + }; + /** Advanced */ + advanced?: { + /** Analytics. HTML injected into the page for custom analytics. */ + analtyics?: string; + }; } /** Generated from `/collections/Pages.schema.ts`. */ @@ -161,12 +170,7 @@ export interface SpacerFields { /** Generated from `/templates/Template5050/5050assets/ImageAsset.schema.ts`. */ export interface ImageAssetFields { /** Image to embed. Optional. If not provided, the default YT thumbnail is used. */ - image?: { - src: string; - width: number; - height: number; - alt?: string; - }; + image?: RootCMSImage; } /** Generated from `/templates/Template5050/5050assets/YouTubeAsset.schema.ts`. */ @@ -174,12 +178,7 @@ export interface YouTubeAssetFields { /** YouTube URL */ youtubeUrl?: string; /** Thumbnail image. Optional. If not provided, the default YT thumbnail is used. */ - thumbnail?: { - src: string; - width: number; - height: number; - alt?: string; - }; + thumbnail?: RootCMSImage; } /** Generated from `/templates/Template5050/Template5050.schema.ts`. */ @@ -191,7 +190,7 @@ export interface Template5050Fields { /** Body */ body?: string; /** Asset */ - asset?: unknown; + asset?: RootCMSOneOf; } /** Generated from `/templates/TemplateFeaturedBlogPosts/TemplateFeaturedBlogPosts.schema.ts`. */ @@ -213,10 +212,5 @@ export interface TemplateHeroFields { /** Title */ title?: string; /** Image */ - image?: { - src: string; - width: number; - height: number; - alt?: string; - }; + image?: RootCMSImage; } \ No newline at end of file diff --git a/examples/blog/routes/blog/[blog-post].tsx b/examples/blog/routes/blog/[blog-post].tsx index da1e2e75..7de66b5d 100644 --- a/examples/blog/routes/blog/[blog-post].tsx +++ b/examples/blog/routes/blog/[blog-post].tsx @@ -1,6 +1,6 @@ import {Handler, HandlerContext, Request} from '@blinkk/root'; import {getDoc} from '@blinkk/root-cms'; - +import {RichText} from '@blinkk/root-cms/richtext'; import {Container} from '@/components/Container/Container.js'; import {BaseLayout} from '@/layouts/BaseLayout.js'; import {BlogPostsDoc} from '@/root-cms.js'; @@ -16,6 +16,11 @@ export default function Page(props: Props) {

Blog Post

+
+ {fields.content?.richtext && ( + + )} +
{JSON.stringify(props, null, 2)}
diff --git a/packages/root-cms/cli/commands/generate-types.ts b/packages/root-cms/cli/commands/generate-types.ts index cb9df9b7..37f8dd14 100644 --- a/packages/root-cms/cli/commands/generate-types.ts +++ b/packages/root-cms/cli/commands/generate-types.ts @@ -29,6 +29,26 @@ export function generateTypes(rootConfig: RootConfig) { const TEMPLATE = `/** Root.js CMS types. This file is autogenerated. */ +export interface RootCMSImage { + src: string; + width?: number; + height?: number; + alt?: string; +} + +export type RootCMSOneOf = T & { + _type: string; +} + +export interface RootCMSRichTextBlock { + type: string; + data: any; +} + +export interface RootCMSRichText { + blocks: RootCMSRichTextBlock[]; +} + export interface RootCMSDoc { /** The id of the doc, e.g. "Pages/foo-bar". */ id: string; @@ -109,16 +129,8 @@ function fieldType(field: Field): dom.Type { return dom.type.number; } if (field.type === 'image') { - return dom.create.objectType([ - dom.create.property('src', dom.type.string), - dom.create.property('width', dom.type.number), - dom.create.property('height', dom.type.number), - dom.create.property( - 'alt', - dom.type.string, - dom.DeclarationFlags.Optional - ), - ]); + const imageType = dom.create.namedTypeReference('RootCMSImage'); + return imageType; } if (field.type === 'file') { return dom.create.objectType([dom.create.property('src', dom.type.string)]); @@ -126,6 +138,14 @@ function fieldType(field: Field): dom.Type { if (field.type === 'multiselect') { return dom.type.array(dom.type.string); } + if (field.type === 'oneof') { + const oneofType = dom.create.namedTypeReference('RootCMSOneOf'); + return oneofType; + } + if (field.type === 'richtext') { + const richtextType = dom.create.namedTypeReference('RootCMSRichText'); + return richtextType; + } if (field.type === 'select') { return dom.type.string; } @@ -171,7 +191,7 @@ function renderSchema(fileId: string, schema: Schema): string { const docTypeOutput = dom .emit(docType, {singleLineJsDocComments: true}) .trim(); - return fieldsTypeOutput + '\n\n' + docTypeOutput; + return reindent(fieldsTypeOutput + '\n\n' + docTypeOutput); } return reindent(fieldsTypeOutput); } diff --git a/packages/root-cms/core/schema.ts b/packages/root-cms/core/schema.ts index c2a81058..7af950c2 100644 --- a/packages/root-cms/core/schema.ts +++ b/packages/root-cms/core/schema.ts @@ -166,6 +166,16 @@ export function oneOf(field: Omit): OneOfField { return {...field, type: 'oneof'}; } +export type RichTextField = CommonFieldProps & { + type: 'richtext'; + translate?: boolean; + placeholder?: string; +}; + +export function richtext(field: Omit): RichTextField { + return {...field, type: 'richtext'}; +} + export type Field = | StringField | NumberField @@ -178,7 +188,8 @@ export type Field = | FileField | ObjectField | ArrayField - | OneOfField; + | OneOfField + | RichTextField; /** * Similar to {@link Field} but with a required `id`. diff --git a/packages/root-cms/core/tsup.config.ts b/packages/root-cms/core/tsup.config.ts index 83d0c728..84935c49 100644 --- a/packages/root-cms/core/tsup.config.ts +++ b/packages/root-cms/core/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ functions: './core/functions.ts', plugin: './core/plugin.ts', project: './core/project.ts', + richtext: './richtext/richtext.tsx', }, sourcemap: 'inline', target: 'node16', @@ -21,6 +22,7 @@ export default defineConfig({ './core/functions.ts', './core/plugin.ts', './core/project.ts', + './richtext/richtext.tsx', ], }, format: ['esm'], diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index 7c88e648..988d8a07 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -44,6 +44,10 @@ "./project": { "types": "./dist/project.d.ts", "import": "./dist/project.js" + }, + "./richtext": { + "types": "./dist/richtext.d.ts", + "import": "./dist/richtext.js" } }, "scripts": { @@ -73,6 +77,14 @@ "devDependencies": { "@babel/core": "^7.17.9", "@blinkk/root": "workspace:*", + "@editorjs/editorjs": "^2.28.2", + "@editorjs/header": "^2.8.1", + "@editorjs/image": "^2.9.0", + "@editorjs/list": "^1.9.0", + "@editorjs/nested-list": "^1.4.2", + "@editorjs/raw": "^2.5.0", + "@editorjs/table": "^2.3.0", + "@editorjs/underline": "^1.1.0", "@emotion/react": "^11.10.5", "@firebase/app-compat": "^0.2.19", "@firebase/app-types": "^0.9.0", diff --git a/packages/root-cms/richtext/richtext.tsx b/packages/root-cms/richtext/richtext.tsx new file mode 100644 index 00000000..a41a1f4b --- /dev/null +++ b/packages/root-cms/richtext/richtext.tsx @@ -0,0 +1,181 @@ +import {useTranslations} from '@blinkk/root'; +import {FunctionalComponent} from 'preact'; +import {RichTextData} from './types.js'; + +export type RichTextBlockComponent = FunctionalComponent; + +export interface RichTextProps { + data: RichTextData; + components?: Record; +} + +/** Renders data from the "richtext" field. */ +export function RichText(props: RichTextProps) { + const components: Record = { + heading: RichText.HeadingBlock, + html: RichText.HtmlBlock, + image: RichText.ImageBlock, + orderedList: RichText.ListBlock, + paragraph: RichText.ParagraphBlock, + unorderedList: RichText.ListBlock, + ...props.components, + }; + const blocks = (props.data?.blocks || []).filter((block) => { + const blockType = block?.type; + if (!blockType) { + return false; + } + if (!(blockType in components)) { + console.warn(`ignoring unknown richtext type: "${blockType}"`); + return false; + } + return true; + }); + return ( + <> + {blocks.map((block) => { + const Block = components[block.type]; + return ; + })} + + ); +} + +export interface RichTextParagraphBlockProps { + type: 'paragraph'; + data?: { + text?: string; + }; +} + +RichText.ParagraphBlock = (props: RichTextParagraphBlockProps) => { + if (!props.data?.text) { + return null; + } + const t = useTranslations(); + return

; +}; + +export interface RichTextHeadingBlockProps { + type: 'heading'; + data?: { + level?: number; + text?: string; + }; +} + +type HeadingComponent = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +RichText.HeadingBlock = (props: RichTextHeadingBlockProps) => { + if (!props.data?.text) { + return null; + } + const t = useTranslations(); + const level = props.data.level || 2; + const Component = `h${level}` as HeadingComponent; + return ; +}; + +interface ListItem { + content?: string; + items?: ListItem[]; +} + +export interface RichTextListBlockProps { + type: 'orderedList' | 'unorderedList'; + data?: { + style?: 'ordered' | 'unordered'; + items?: ListItem[]; + }; +} + +RichText.ListBlock = (props: RichTextListBlockProps) => { + if (!props.data?.items?.length) { + return null; + } + + let style = props.data?.style; + if (!style) { + style = props.type === 'orderedList' ? 'ordered' : 'unordered'; + } + const Component = style === 'ordered' ? 'ol' : 'ul'; + const items = props.data.items; + const t = useTranslations(); + return ( + + {items.map((item) => { + if (item.content || item.items?.length) { + return ( +

  • + {item.content && ( + + )} + {item.items && item.items.length > 0 && ( + + )} +
  • + ); + } + return null; + })} + + ); +}; + +export interface RichTextImageBlockProps { + type: 'image'; + data?: { + file?: { + url: string; + width: string | number; + height: string | number; + alt: string; + }; + }; +} + +RichText.ImageBlock = (props: RichTextImageBlockProps) => { + const imageUrl = props.data?.file?.url; + if (!imageUrl) { + return null; + } + const width = toNumber(props.data?.file?.width); + const height = toNumber(props.data?.file?.height); + const alt = props.data?.file?.alt || ''; + return {alt}; +}; + +export interface RichTextHtmlBlockProps { + type: 'html'; + data?: { + html?: string; + }; +} + +RichText.HtmlBlock = (props: RichTextHtmlBlockProps) => { + const html = props.data?.html || ''; + if (!html) { + return null; + } + return
    ; +}; + +function toNumber(input?: string | number): number { + if (input === undefined) { + return 0; + } + if (typeof input === 'number') { + return input; + } + const parsedNumber = parseFloat(input); + if (isNaN(parsedNumber)) { + return 0; + } + return parsedNumber; +} diff --git a/packages/root-cms/richtext/tsconfig.json b/packages/root-cms/richtext/tsconfig.json new file mode 100644 index 00000000..428be5d8 --- /dev/null +++ b/packages/root-cms/richtext/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["esnext"], + "module": "nodenext", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "pretty": true, + "sourceMap": true, + "strict": true, + "target": "es2020", + "outDir": "dist", + "types": [ + "node", + "vite/client" + ], + "useUnknownInCatchVariables": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "moduleResolution": "nodenext", + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "include": [ + "*.ts", + "**/*.ts", + "*.tsx", + "**/*.tsx", + ] +} diff --git a/packages/root-cms/richtext/types.ts b/packages/root-cms/richtext/types.ts new file mode 100644 index 00000000..2289aafd --- /dev/null +++ b/packages/root-cms/richtext/types.ts @@ -0,0 +1,9 @@ +export interface RichTextBlock { + type: string; + data?: any; +} + +export interface RichTextData { + [key: string]: any; + blocks: any[]; +} diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 76e802b2..26712538 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -28,7 +28,6 @@ import { IconTriangleFilled, } from '@tabler/icons-preact'; import {Timestamp} from 'firebase/firestore'; -import {ref as storageRef, updateMetadata, uploadBytes} from 'firebase/storage'; import {ChangeEvent} from 'preact/compat'; import {useEffect, useReducer, useRef, useState} from 'preact/hooks'; import {route} from 'preact-router'; @@ -39,9 +38,10 @@ import { SaveState, UseDraftHook, } from '../../hooks/useDraft.js'; +import {joinClassNames} from '../../utils/classes.js'; +import {uploadFileToGCS} from '../../utils/gcs.js'; import {flattenNestedKeys} from '../../utils/objects.js'; import {getPlaceholderKeys, strFormat} from '../../utils/str-format.js'; -import './DocEditor.css'; import { DocActionEvent, DocActionsMenu, @@ -50,7 +50,11 @@ import {DocStatusBadges} from '../DocStatusBadges/DocStatusBadges.js'; import {useEditJsonModal} from '../EditJsonModal/EditJsonModal.js'; import {useLocalizationModal} from '../LocalizationModal/LocalizationModal.js'; import {usePublishDocModal} from '../PublishDocModal/PublishDocModal.js'; -import {joinClassNames} from '../../utils/classes.js'; +import './DocEditor.css'; +import { + RichTextData, + RichTextEditor, +} from '../RichTextEditor/RichTextEditor.js'; interface DocEditorProps { docId: string; @@ -193,6 +197,8 @@ DocEditor.Field = (props: FieldProps) => { ) : field.type === 'oneof' ? ( + ) : field.type === 'richtext' ? ( + ) : field.type === 'select' ? ( ) : field.type === 'string' ? ( @@ -253,107 +259,35 @@ DocEditor.StringField = (props: FieldProps) => { ); }; -async function sha1(file: File) { - const buffer = await file.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest('SHA-1', buffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return hashHex; -} - -interface UploadFileOptions { - preserveFilename?: boolean; -} - -async function uploadFileToGCS(file: File, options?: UploadFileOptions) { - const projectId = window.__ROOT_CTX.rootConfig.projectId; - const hashHex = await sha1(file); - const ext = normalizeExt(file.name.split('.').at(-1) || ''); - const filename = options?.preserveFilename - ? `${hashHex}/${file.name}` - : `${hashHex}.${ext}`; - const filePath = `${projectId}/uploads/${filename}`; - const gcsRef = storageRef(window.firebase.storage, filePath); - await uploadBytes(gcsRef, file); - console.log(`uploaded ${filePath}`); - const meta: Record = {}; - meta.filename = file.name; - meta.uploadedBy = window.firebase.user.email || 'unknown'; - meta.uploadedAt = String(Math.floor(new Date().getTime())); - const gcsPath = `/${gcsRef.bucket}/${gcsRef.fullPath}`; - let imageSrc = `https://storage.googleapis.com${gcsPath}`; - if (ext === 'jpg' || ext === 'png' || ext === 'svg') { - const dimens = await getImageDimensions(file); - meta.width = String(dimens.width); - meta.height = String(dimens.height); - - if (ext === 'jpg' || ext === 'png') { - const gciUrl = await getGciUrl(gcsPath); - if (gciUrl) { - meta.gcsPath = gcsPath; - imageSrc = gciUrl; - } - } - } - // Since the files are stored by their hash, we should be able to set a long - // cache control header, i.e. 1 year. - const cacheControl = 'public, max-age=31536000'; - await updateMetadata(gcsRef, {cacheControl, customMetadata: meta}); - console.log('updated meta data: ', meta); - return { - ...meta, - src: imageSrc, - }; -} +DocEditor.RichTextField = (props: FieldProps) => { + const field = props.field as schema.RichTextField; + const [value, setValue] = useState({ + blocks: [{type: 'paragraph', data: {}}], + }); -/** - * Normalizes file extensions like `.PNG` to `.png` and `.JPEG` to `.jpg`. - */ -function normalizeExt(ext: string) { - let output = String(ext).toLowerCase(); - if (output === '.jpeg') { - output = '.jpg'; + function onChange(newValue: RichTextData) { + setValue(newValue); + props.draft.updateKey(props.deepKey, newValue); } - return output; -} -async function getGciUrl(gcsPath: string) { - const gciDomain = window.__ROOT_CTX.rootConfig.gci; - if (!gciDomain) { - return ''; - } - const params = new URLSearchParams({gcs: gcsPath}); - const url = `${gciDomain}/_/serving_url?${params.toString()}`; - const res = await window.fetch(url); - if (res.status !== 200) { - const text = await res.text(); - console.error(`failed to get gci url: ${url}`); - console.error(text); - throw new Error('failed to get gci url'); - } - const resData = await res.json(); - return resData.servingUrl; -} + useEffect(() => { + const unsubscribe = props.draft.subscribe( + props.deepKey, + (newValue: RichTextData) => { + setValue(newValue); + } + ); + return unsubscribe; + }, []); -async function getImageDimensions( - file: File -): Promise<{width: number; height: number}> { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const img = new Image(); - img.onload = () => { - resolve({width: img.width, height: img.height}); - }; - img.onerror = reject; - img.src = String(reader.result); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} + return ( + + ); +}; const IMAGE_MIMETYPES = [ 'image/png', diff --git a/packages/root-cms/ui/components/LocalizationModal/LocalizationModal.tsx b/packages/root-cms/ui/components/LocalizationModal/LocalizationModal.tsx index e28ded22..2120ad46 100644 --- a/packages/root-cms/ui/components/LocalizationModal/LocalizationModal.tsx +++ b/packages/root-cms/ui/components/LocalizationModal/LocalizationModal.tsx @@ -35,6 +35,7 @@ import { } from '../../utils/l10n.js'; import {Heading} from '../Heading/Heading.js'; import './LocalizationModal.css'; +import {extractRichTextStrings} from '../RichTextEditor/RichTextEditor.js'; const MODAL_ID = 'LocalizationModal'; @@ -547,6 +548,14 @@ function extractField( if (!fieldValue) { return; } + + function addString(text: string) { + const str = normalizeString(text); + if (str) { + strings.add(str); + } + } + if (field.type === 'object') { extractFields(strings, field.fields || [], fieldValue); } else if (field.type === 'array') { @@ -556,12 +565,12 @@ function extractField( } } else if (field.type === 'string' || field.type === 'select') { if (field.translate) { - strings.add(normalizeString(fieldValue)); + addString(fieldValue); } } else if (field.type === 'multiselect') { if (field.translate && Array.isArray(fieldValue)) { for (const value of fieldValue) { - strings.add(normalizeString(value)); + addString(value); } } } else if (field.type === 'oneof') { @@ -570,6 +579,10 @@ function extractField( if (fieldValueType) { extractFields(strings, fieldValueType.fields || [], fieldValue); } + } else if (field.type === 'richtext') { + if (field.translate) { + extractRichTextStrings(strings, fieldValue); + } } else { console.log(`extract: ignoring field, id=${field.id}, type=${field.type}`); } diff --git a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css new file mode 100644 index 00000000..1aafef00 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css @@ -0,0 +1,67 @@ +.RichTextEditor { + border: 1px solid #ced4da; + padding: 4px 10px; +} + +.RichTextEditor .codex-editor__redactor { + padding-bottom: 150px !important; +} + +.RichTextEditor .ce-inline-toolbar { + min-width: 155px; +} + +.RichTextEditor .ce-header { + margin-top: 20px; +} + +.RichTextEditor .ce-block:first-of-type .ce-header { + margin-top: 0; +} + +.RichTextEditor .cdx-list__item { + padding-top: 0; + padding-bottom: 0; +} + +.RichTextEditor .image-tool__image { + margin-bottom: 0; +} + +.RichTextEditor .image-tool__image-picture { + background: #f5f5f5; + border: 1px solid #dedede; + aspect-ratio: 16/9; + width: 100%; + padding: 10px; + object-fit: contain; + object-position: center; +} + +.RichTextEditor .image-tool__image::after { + content: 'Image alt text'; + display: block; + margin-top: 6px; + font-weight: 500; +} + +.RichTextEditor .image-tool__caption { + font-family: inherit; + border: 1px solid #ced4da; + padding: 8px 10px; + margin-top: 4px; +} + +.RichTextEditor .ce-rawtool::before { + content: 'WARNING: Use raw HTML with caution.'; + display: block; + color: red; + font-weight: 500; + margin-bottom: 4px; + font-size: 10px; + font-style: italic; +} + +.RichTextEditor .ce-inline-tool[data-tool="superscript"] svg { + padding: 2px; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx new file mode 100644 index 00000000..7e05f0d3 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx @@ -0,0 +1,216 @@ +import EditorJS from '@editorjs/editorjs'; +import Header from '@editorjs/header'; +import ImageTool from '@editorjs/image'; +// import List from '@editorjs/list'; +import NestedList from '@editorjs/nested-list'; +import RawHtmlTool from '@editorjs/raw'; +// import Table from '@editorjs/table'; +import {useEffect, useRef, useState} from 'preact/hooks'; +import {joinClassNames} from '../../utils/classes.js'; +import './RichTextEditor.css'; +import {uploadFileToGCS} from '../../utils/gcs.js'; +import {normalizeString} from '../../utils/l10n.js'; +import {isObject} from '../../utils/objects.js'; +import Superscript from './tools/Superscript.js'; + +export interface RichTextEditorProps { + className?: string; + placeholder?: string; + value?: any; + onChange?: (data: any) => void; +} + +export type RichTextData = { + [key: string]: any; + blocks: any[]; + time?: number; +}; + +export function RichTextEditor(props: RichTextEditorProps) { + const editorRef = useRef(null); + const [editor, setEditor] = useState(null); + const [currentValue, setCurrentValue] = useState({ + blocks: [{type: 'paragraph', data: {}}], + }); + + const placeholder = props.placeholder || 'Start typing...'; + + useEffect(() => { + const newValue = props.value; + if (editor && currentValue?.time !== newValue?.time) { + const currentTime = currentValue?.time || 0; + const newValueTime = newValue?.time || 0; + if (newValueTime > currentTime && validateRichTextData(newValue)) { + editor.render(newValue); + setCurrentValue(newValue); + } + } + }, [props.value]); + + useEffect(() => { + const holder = editorRef.current!; + // TODO(stevenle): fix type issues. + const EditorJSClass = EditorJS as any; + const editor = new EditorJSClass({ + holder: holder, + placeholder: placeholder, + inlineToolbar: ['bold', 'italic', 'superscript', 'link'], + tools: { + heading: { + class: Header, + config: { + placeholder: 'Enter a header', + levels: [2, 3, 4, 5], + defaultLevel: 2, + }, + }, + superscript: { + class: Superscript, + }, + image: { + class: ImageTool, + config: { + uploader: gcsUploader(), + captionPlaceholder: 'Alt text', + }, + }, + unorderedList: { + class: NestedList, + inlineToolbar: true, + config: { + defaultStyle: 'unordered', + }, + toolbox: { + name: 'unorderedList', + title: 'Bulleted List', + icon: '', + }, + }, + orderedList: { + class: NestedList, + inlineToolbar: true, + config: { + defaultStyle: 'ordered', + }, + toolbox: { + name: 'orderedList', + title: 'Numbered List', + icon: '', + }, + }, + html: { + class: RawHtmlTool, + toolbox: { + name: 'HTML', + }, + }, + // TODO(stevenle): issue with Table because firestore doesn't support + // nested arrays. + // table: Table, + }, + onReady: () => { + setEditor(editor); + }, + onChange: () => { + editor + .save() + .then((richTextData: RichTextData) => { + setCurrentValue(richTextData); + if (props.onChange) { + props.onChange(richTextData); + } + }) + .catch((err: any) => { + console.error('richtext error: ', err); + }); + }, + }); + return () => editor.destroy(); + }, []); + + return ( +
    + ); +} + +export function validateRichTextData(data: RichTextData) { + return isObject(data) && Array.isArray(data.blocks) && data.blocks.length > 0; +} + +function gcsUploader() { + return { + uploadByFile: async (file: File) => { + try { + const imageMeta = await uploadFileToGCS(file); + let imageUrl = imageMeta.src; + if (isGciUrl(imageUrl)) { + imageUrl = `${imageUrl}=s0-e365`; + } + console.log(imageMeta); + return {success: 1, file: {...imageMeta, url: imageUrl}}; + } catch (err) { + console.error(err); + return {success: 0, error: err}; + } + }, + uploadByUrl: async (url: string) => { + return {success: 0, error: 'upload by url not currently supported'}; + }, + }; +} + +function isGciUrl(url: string) { + return url.startsWith('https://lh3.googleusercontent.com/'); +} + +export function extractRichTextStrings( + strings: Set, + data: RichTextData +) { + const blocks = data?.blocks || []; + blocks.forEach((block) => { + extractBlockStrings(strings, block); + }); +} + +interface ListItemData { + content?: string; + items?: ListItemData[]; +} + +function extractBlockStrings(strings: Set, block: any) { + if (!block?.type) { + return; + } + + function addString(text?: string) { + if (!text) { + return; + } + const str = normalizeString(text); + if (str) { + strings.add(str); + } + } + + function extractList(items?: ListItemData[]) { + if (!items) { + return; + } + items.forEach((item) => { + addString(item.content); + extractList(item.items); + }); + } + + if (block.type === 'heading' || block.type === 'paragraph') { + addString(block.data?.text); + } else if (block.type === 'orderedList' || block.type === 'unorderedList') { + extractList(block.data?.items); + } else if (block.type === 'html') { + addString(block.data?.html); + } +} diff --git a/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts b/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts new file mode 100644 index 00000000..4cb403fb --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts @@ -0,0 +1,80 @@ +const TAG = 'SUP'; + +export class Superscript { + private api: any; + private button: HTMLButtonElement; + private iconClasses: Record; + + static get CSS() { + return ''; + } + + constructor(options: any) { + this.api = options.api; + this.button = document.createElement('button'); + this.iconClasses = { + base: this.api.styles.inlineToolButton, + active: this.api.styles.inlineToolButtonActive, + }; + } + + static get isInline() { + return true; + } + + render() { + this.button.type = 'button'; + this.button.classList.add(this.iconClasses.base); + this.button.innerHTML = this.toolboxIcon; + return this.button; + } + + surround(range: Range) { + if (!range) { + return; + } + + const termWrapper = this.api.selection.findParentTag(TAG, Superscript.CSS); + + if (termWrapper) { + this.unwrap(termWrapper); + } else { + this.wrap(range); + } + } + + wrap(range: Range) { + const supElement = document.createElement(TAG); + supElement.appendChild(range.extractContents()); + range.insertNode(supElement); + this.api.selection.expandToTag(supElement); + } + + unwrap(termWrapper: HTMLElement) { + this.api.selection.expandToTag(termWrapper); + const sel = window.getSelection()!; + const range = sel.getRangeAt(0); + const unwrappedContent = range.extractContents(); + termWrapper.parentNode!.removeChild(termWrapper); + range.insertNode(unwrappedContent); + sel.removeAllRanges(); + sel.addRange(range); + } + + checkState() { + const termTag = this.api.selection.findParentTag(TAG, Superscript.CSS); + this.button.classList.toggle(this.iconClasses.active, !!termTag); + } + + get toolboxIcon() { + return ''; + } + + static get sanitize() { + return { + sup: {}, + }; + } +} + +export default Superscript; diff --git a/packages/root-cms/ui/utils/gcs.ts b/packages/root-cms/ui/utils/gcs.ts new file mode 100644 index 00000000..ded2aacb --- /dev/null +++ b/packages/root-cms/ui/utils/gcs.ts @@ -0,0 +1,116 @@ +import {ref as storageRef, updateMetadata, uploadBytes} from 'firebase/storage'; + +export interface UploadFileOptions { + preserveFilename?: boolean; +} + +export async function uploadFileToGCS(file: File, options?: UploadFileOptions) { + const projectId = window.__ROOT_CTX.rootConfig.projectId; + const hashHex = await sha1(file); + const ext = normalizeExt(file.name.split('.').at(-1) || ''); + const filename = options?.preserveFilename + ? `${hashHex}/${file.name}` + : `${hashHex}.${ext}`; + const filePath = `${projectId}/uploads/${filename}`; + const gcsRef = storageRef(window.firebase.storage, filePath); + await uploadBytes(gcsRef, file); + console.log(`uploaded ${filePath}`); + + const meta: Record = {}; + meta.filename = file.name; + meta.uploadedBy = window.firebase.user.email || 'unknown'; + meta.uploadedAt = String(Math.floor(new Date().getTime())); + const gcsPath = `/${gcsRef.bucket}/${gcsRef.fullPath}`; + let imageSrc = `https://storage.googleapis.com${gcsPath}`; + if (ext === 'jpg' || ext === 'png' || ext === 'svg') { + const dimens = await getImageDimensions(file); + meta.width = dimens.width; + meta.height = dimens.height; + if (ext === 'jpg' || ext === 'png') { + const gciUrl = await getGciUrl(gcsPath); + if (gciUrl) { + meta.gcsPath = gcsPath; + imageSrc = gciUrl; + } + } + } + + // Since the files are stored by their hash, we should be able to set a long + // cache control header, i.e. 1 year. + const cacheControl = 'public, max-age=31536000'; + await updateMetadata(gcsRef, { + cacheControl, + customMetadata: normalizeGcsMeta(meta), + }); + console.log('updated meta data: ', meta); + return { + ...meta, + src: imageSrc, + }; +} + +/** + * Normalizes file extensions like `.PNG` to `.png` and `.JPEG` to `.jpg`. + */ +function normalizeExt(ext: string) { + let output = String(ext).toLowerCase(); + if (output === 'jpeg') { + output = 'jpg'; + } + return output; +} + +async function getGciUrl(gcsPath: string) { + const gciDomain = window.__ROOT_CTX.rootConfig.gci; + if (!gciDomain) { + return ''; + } + const params = new URLSearchParams({gcs: gcsPath}); + const url = `${gciDomain}/_/serving_url?${params.toString()}`; + const res = await window.fetch(url); + if (res.status !== 200) { + const text = await res.text(); + console.error(`failed to get gci url: ${url}`); + console.error(text); + throw new Error('failed to get gci url'); + } + const resData = await res.json(); + return resData.servingUrl; +} + +async function getImageDimensions( + file: File +): Promise<{width: number; height: number}> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + resolve({width: img.width, height: img.height}); + }; + img.onerror = reject; + img.src = String(reader.result); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +async function sha1(file: File) { + const buffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-1', buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return hashHex; +} + +/** Stringifies all values in a file metadata object. */ +function normalizeGcsMeta(meta: Record): Record { + const result: Record = {}; + Object.entries(meta).forEach(([key, value]) => { + meta[key] = String(value).trim(); + }); + return result; +} diff --git a/packages/root/src/core/types.ts b/packages/root/src/core/types.ts index 5189256c..8a49ae07 100644 --- a/packages/root/src/core/types.ts +++ b/packages/root/src/core/types.ts @@ -44,7 +44,7 @@ export type GetStaticProps = (ctx: { /** * The `getStaticPaths()` is used by the SSG build to determine all of the - * paths that should exist for a given route. This should be used alongside a + * paths that exist for a given route. This should be used alongside a * parameterized route, e.g. `/routes/blog/[slug].tsx`. */ export type GetStaticPaths = () => Promise<{ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68592b84..3447675a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,30 @@ importers: '@blinkk/root': specifier: workspace:* version: link:../root + '@editorjs/editorjs': + specifier: ^2.28.2 + version: 2.28.2 + '@editorjs/header': + specifier: ^2.8.1 + version: 2.8.1 + '@editorjs/image': + specifier: ^2.9.0 + version: 2.9.0 + '@editorjs/list': + specifier: ^1.9.0 + version: 1.9.0 + '@editorjs/nested-list': + specifier: ^1.4.2 + version: 1.4.2 + '@editorjs/raw': + specifier: ^2.5.0 + version: 2.5.0 + '@editorjs/table': + specifier: ^2.3.0 + version: 2.3.0 + '@editorjs/underline': + specifier: ^1.1.0 + version: 1.1.0 '@emotion/react': specifier: ^11.10.5 version: 11.10.5(@babel/core@7.17.9)(@preact/compat@17.1.2) @@ -818,6 +842,22 @@ packages: prettier: 2.8.7 dev: true + /@codexteam/icons@0.0.2: + resolution: {integrity: sha512-KdeKj3TwaTHqM3IXd5YjeJP39PBUZTb+dtHjGlf5+b0VgsxYD4qzsZkb11lzopZbAuDsHaZJmAYQ8LFligIT6Q==} + dev: true + + /@codexteam/icons@0.0.4: + resolution: {integrity: sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q==} + dev: true + + /@codexteam/icons@0.0.5: + resolution: {integrity: sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==} + dev: true + + /@codexteam/icons@0.0.6: + resolution: {integrity: sha512-L7Q5PET8PjKcBT5wp7VR+FCjwCi5PUp7rd/XjsgQ0CI5FJz0DphyHGRILMuDUdCW2MQT9NHbVr4QP31vwAkS/A==} + dev: true + /@colors/colors@1.5.0: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -838,6 +878,52 @@ packages: kuler: 2.0.0 dev: true + /@editorjs/editorjs@2.28.2: + resolution: {integrity: sha512-g6V0Nd3W9IIWMpvxDNTssQ6e4kxBp1Y0W4GIf8cXRlmcBp3TUjrgCYJQmNy3l2a6ZzhyBAoVSe8krJEq4g7PQw==} + dev: true + + /@editorjs/header@2.8.1: + resolution: {integrity: sha512-y0HVXRP7m2W617CWo3fsb5HhXmSLaRpb9GzFx0Vkp/HEm9Dz5YO1s8tC7R8JD3MskwoYh7V0hRFQt39io/r6hA==} + dependencies: + '@codexteam/icons': 0.0.5 + dev: true + + /@editorjs/image@2.9.0: + resolution: {integrity: sha512-xItihKJFiWJ06SMtLWQZvzHv4LRPNAFZYaHAXesBFzXvWwUrtVaVMcNSf0eNnw3InrPO3Po1vZRRgpsT+Ya3Bg==} + dependencies: + '@codexteam/icons': 0.0.6 + dev: true + + /@editorjs/list@1.9.0: + resolution: {integrity: sha512-BQEvZW4vi0O0dBvGNljiKxiE89vMSHoM2Tu2OzKUndoj7pY9AxqpgCh1qvwIVsJAlG4Lbt/vBFQilnoStMmI6A==} + dependencies: + '@codexteam/icons': 0.0.4 + dev: true + + /@editorjs/nested-list@1.4.2: + resolution: {integrity: sha512-qb1dAoJ+bihqmlR3822TC2GuIxEjTCLTZsZVWNces3uJIZ+W4019G3IJKBt/MOOgz4Evzad/RvUEKwPCPe6YOQ==} + dependencies: + '@codexteam/icons': 0.0.2 + dev: true + + /@editorjs/raw@2.5.0: + resolution: {integrity: sha512-ZOYKgR/sutOXHQ4uaWg8KzZ6yFJZCMTndezlN257GGsQ3Sa3ERcK9wZHKgAMNXaDhdKx9fDuojxRRcYvz6l9aQ==} + dependencies: + '@codexteam/icons': 0.0.4 + dev: true + + /@editorjs/table@2.3.0: + resolution: {integrity: sha512-/dKE6A5FkukX/FpvIyQGsewwXglZk8YbXpEp9vkB6Ec4ETLeGJMxObCcYqbXqVod+IU/a1tFH+j3lsD8ASD41g==} + dependencies: + '@codexteam/icons': 0.0.6 + dev: true + + /@editorjs/underline@1.1.0: + resolution: {integrity: sha512-vQj2ROW1KreD31QHlhaPikmDJGWYzRBusN4Zyfwl9nIIQCByt4S8fZQpsrRvH4sct5mkirsHllNT00rJlqHK7Q==} + dependencies: + '@codexteam/icons': 0.0.6 + dev: true + /@emotion/babel-plugin@11.10.5(@babel/core@7.17.9): resolution: {integrity: sha512-xE7/hyLHJac7D2Ve9dKroBBZqBT7WuPQmWcq7HSGb84sUuP4mlOWoB8dvVfD9yk5DHkU1m6RW7xSoDtnQHNQeA==} peerDependencies: