From cf67310fea6eb1724f4144ae403113a223d358a6 Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 10 Dec 2024 11:54:50 +0100 Subject: [PATCH 01/54] refactor(image-plugin): Throw more errors, show more toasts in old upload --- .../src/plugins/image/utils/upload-file.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/plugins/image/utils/upload-file.ts b/packages/editor/src/plugins/image/utils/upload-file.ts index d75a114107..4737873f74 100644 --- a/packages/editor/src/plugins/image/utils/upload-file.ts +++ b/packages/editor/src/plugins/image/utils/upload-file.ts @@ -60,16 +60,31 @@ async function uploadFile({ handleError(errorMessage) }) - const data = (await result?.json()) as { + if (result && !result.ok) { + const error = new Error('Failed to get signed URL') + handleError(error.message) + return Promise.reject(error) + } + + const data = (await result?.json().catch(() => null)) as { signedUrl: string fileUrl: string + } | null + if (!data) { + const error = new Error('Failed to get signed URL') + handleError(error.message) + + return Promise.reject(error) } - if (!data) return Promise.reject(new Error('Could not get signed URL')) const { signedUrl, fileUrl } = data const success = await uploadToBucket({ file, signedUrl }) - if (!success) return Promise.reject(new Error('Could not upload file')) + if (!success) { + const error = new Error('Failed to upload file') + handleError(error.message) + return Promise.reject(error) + } return Promise.resolve(fileUrl) } From 0599ec189d93673381f18822a89f64383cc9e79d Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 10 Dec 2024 15:40:31 +0100 Subject: [PATCH 02/54] refactor(image): Simplify code and show toast in old upload --- .../image-with-testing-config.ts | 107 ++++++++++-------- 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/packages/editor/src/editor-integration/image-with-testing-config.ts b/packages/editor/src/editor-integration/image-with-testing-config.ts index da9f1a7cb2..da9d5efde3 100644 --- a/packages/editor/src/editor-integration/image-with-testing-config.ts +++ b/packages/editor/src/editor-integration/image-with-testing-config.ts @@ -80,62 +80,77 @@ function createUploadImageHandler(secret: string) { return async function uploadImageHandler(file: File): Promise { const validation = validateFile(file) if (!validation.valid) { - onError(validation.errors) + showErrorToast(validation.errors) // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(validation.errors) } - return (await readFile(file)).dataUrl + try { + const result = await readFile(file) + return result.dataUrl + } catch (error) { + // eslint-disable-next-line no-console + console.error('Upload failed:', error) + const errors = handleErrors([FileErrorCode.UPLOAD_FAILED]) + showErrorToast(errors) + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(errors) + } } } export function createReadFile(secret: string) { return async function readFile(file: File): Promise { - return new Promise((resolve, reject) => { - async function runFetch() { - const endpoint = 'https://api.serlo-staging.dev/graphql' - const response = await fetch(endpoint, { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-SERLO-EDITOR-TESTING': secret, - }, - method: 'POST', - body: JSON.stringify({ - query: uploadUrlQuery, - variables: { - mediaType: mimeTypesToMediaType[file.type as SupportedMimeType], - }, - }), - }) - const { data } = (await response.json()) as { data: MediaUploadQuery } - const reader = new FileReader() - - reader.onload = async function (e: ProgressEvent) { - if (!e.target) return - - try { - const response = await fetch(data.media.newUpload.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type }, - body: file, - }) - - if (response.status !== 200) reject() - resolve({ - file, - dataUrl: data.media.newUpload.urlAfterUpload, - }) - } catch { - reject() - } - } - - reader.readAsDataURL(file) - } + if (!secret) { + throw new Error('Missing secret for image plugin!') + } - void runFetch() + const endpoint = 'https://api.serlo-staging.dev/graphql' + const response = await fetch(endpoint, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-SERLO-EDITOR-TESTING': secret, + }, + method: 'POST', + body: JSON.stringify({ + query: uploadUrlQuery, + variables: { + mediaType: mimeTypesToMediaType[file.type as SupportedMimeType], + }, + }), }) + + if (!response.ok) { + throw new Error(`Failed to get upload URL: ${response.status}`) + } + + const { data } = (await response.json()) as { data: MediaUploadQuery } + + if (!data?.media?.newUpload) { + // eslint-disable-next-line no-console + console.error('Server responded with following invalid data: ', data) + throw new Error('Invalid response format from server') + } + + const uploadResponse = await fetch(data.media.newUpload.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }) + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status: ${uploadResponse.status}`) + } + + if (!data?.media?.newUpload?.urlAfterUpload) { + throw new Error('Invalid response format from server') + } + + return { + file, + dataUrl: data.media.newUpload.urlAfterUpload, + } } } @@ -151,7 +166,7 @@ function handleErrors(errors: FileErrorCode[]): FileError[] { })) } -function onError(errors: FileError[]): void { +function showErrorToast(errors: FileError[]): void { showToastNotice(errors.map((error) => error.message).join('\n'), 'warning') } From 17580936770caab936962034722799289f86b557 Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 10 Dec 2024 15:42:48 +0100 Subject: [PATCH 03/54] refactor(image): Rename readFile fn to readAndUploadFile --- .../src/editor-integration/image-with-testing-config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/editor-integration/image-with-testing-config.ts b/packages/editor/src/editor-integration/image-with-testing-config.ts index da9d5efde3..b1caba51b9 100644 --- a/packages/editor/src/editor-integration/image-with-testing-config.ts +++ b/packages/editor/src/editor-integration/image-with-testing-config.ts @@ -76,7 +76,7 @@ export const createTestingImagePlugin = (secret: string) => { } function createUploadImageHandler(secret: string) { - const readFile = createReadFile(secret) + const readAndUploadFile = createReadAndUploadFile(secret) return async function uploadImageHandler(file: File): Promise { const validation = validateFile(file) if (!validation.valid) { @@ -86,7 +86,7 @@ function createUploadImageHandler(secret: string) { } try { - const result = await readFile(file) + const result = await readAndUploadFile(file) return result.dataUrl } catch (error) { // eslint-disable-next-line no-console @@ -99,8 +99,8 @@ function createUploadImageHandler(secret: string) { } } -export function createReadFile(secret: string) { - return async function readFile(file: File): Promise { +export function createReadAndUploadFile(secret: string) { + return async function readAndUploadFile(file: File): Promise { if (!secret) { throw new Error('Missing secret for image plugin!') } From 45c0d35e7a1ebe44cb0eaa4eb2ecbea2994cb4e3 Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 10 Dec 2024 15:50:39 +0100 Subject: [PATCH 04/54] refactor(image): Streamline error handling --- .../src/editor-integration/image-with-testing-config.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/editor-integration/image-with-testing-config.ts b/packages/editor/src/editor-integration/image-with-testing-config.ts index b1caba51b9..35e8c0aa7a 100644 --- a/packages/editor/src/editor-integration/image-with-testing-config.ts +++ b/packages/editor/src/editor-integration/image-with-testing-config.ts @@ -127,7 +127,10 @@ export function createReadAndUploadFile(secret: string) { const { data } = (await response.json()) as { data: MediaUploadQuery } - if (!data?.media?.newUpload) { + if ( + !data?.media?.newUpload?.uploadUrl || + !data.media.newUpload.urlAfterUpload + ) { // eslint-disable-next-line no-console console.error('Server responded with following invalid data: ', data) throw new Error('Invalid response format from server') @@ -143,10 +146,6 @@ export function createReadAndUploadFile(secret: string) { throw new Error(`Upload failed with status: ${uploadResponse.status}`) } - if (!data?.media?.newUpload?.urlAfterUpload) { - throw new Error('Invalid response format from server') - } - return { file, dataUrl: data.media.newUpload.urlAfterUpload, From fd09dcff1ed01390120721d7911b99bdb1b637ea Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 10 Dec 2024 16:27:54 +0100 Subject: [PATCH 05/54] refactor(image): Add more error codes and display exact error instead of just generic "upload failed" --- .../image-with-testing-config.ts | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/editor-integration/image-with-testing-config.ts b/packages/editor/src/editor-integration/image-with-testing-config.ts index 35e8c0aa7a..bc14f327f7 100644 --- a/packages/editor/src/editor-integration/image-with-testing-config.ts +++ b/packages/editor/src/editor-integration/image-with-testing-config.ts @@ -45,6 +45,10 @@ enum FileErrorCode { BAD_EXTENSION, FILE_TOO_BIG, UPLOAD_FAILED, + UNAUTHORIZED, + SECRET_MISSING, + INVALID_RESPONSE, + NETWORK_ERROR, } export interface FileError { @@ -91,7 +95,12 @@ function createUploadImageHandler(secret: string) { } catch (error) { // eslint-disable-next-line no-console console.error('Upload failed:', error) - const errors = handleErrors([FileErrorCode.UPLOAD_FAILED]) + const errorCode = + error instanceof Error + ? Number(error.message) || FileErrorCode.UPLOAD_FAILED + : FileErrorCode.UPLOAD_FAILED + + const errors = handleErrors([errorCode]) showErrorToast(errors) // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(errors) @@ -99,10 +108,20 @@ function createUploadImageHandler(secret: string) { } } +interface GraphQlResponse { + data: MediaUploadQuery | null + errors?: Array<{ + message: string + extensions?: { + code?: string + } + }> +} + export function createReadAndUploadFile(secret: string) { return async function readAndUploadFile(file: File): Promise { if (!secret) { - throw new Error('Missing secret for image plugin!') + throw new Error(FileErrorCode.SECRET_MISSING.toString()) } const endpoint = 'https://api.serlo-staging.dev/graphql' @@ -122,18 +141,28 @@ export function createReadAndUploadFile(secret: string) { }) if (!response.ok) { - throw new Error(`Failed to get upload URL: ${response.status}`) + throw new Error(FileErrorCode.NETWORK_ERROR.toString()) } - const { data } = (await response.json()) as { data: MediaUploadQuery } + const { data, errors } = (await response.json()) as GraphQlResponse + + if (errors?.length) { + // eslint-disable-next-line no-console + console.error('GraphQL errors:', errors) + if (errors[0]?.extensions?.code === 'UNAUTHENTICATED') { + throw new Error(FileErrorCode.UNAUTHORIZED.toString()) + } + throw new Error(FileErrorCode.UPLOAD_FAILED.toString()) + } if ( + !data || !data?.media?.newUpload?.uploadUrl || !data.media.newUpload.urlAfterUpload ) { // eslint-disable-next-line no-console console.error('Server responded with following invalid data: ', data) - throw new Error('Invalid response format from server') + throw new Error(FileErrorCode.INVALID_RESPONSE.toString()) } const uploadResponse = await fetch(data.media.newUpload.uploadUrl, { @@ -143,7 +172,7 @@ export function createReadAndUploadFile(secret: string) { }) if (!uploadResponse.ok) { - throw new Error(`Upload failed with status: ${uploadResponse.status}`) + throw new Error(FileErrorCode.UPLOAD_FAILED.toString()) } return { @@ -181,6 +210,14 @@ function errorCodeToMessage(error: FileErrorCode) { return 'Filesize is too big' case FileErrorCode.UPLOAD_FAILED: return 'Error while uploading' + case FileErrorCode.UNAUTHORIZED: + return 'You are not authorized to upload images. Ensure the testingSecret is correct!' + case FileErrorCode.SECRET_MISSING: + return 'Missing authentication credentials (testingSecret)!' + case FileErrorCode.INVALID_RESPONSE: + return 'Server returned invalid data' + case FileErrorCode.NETWORK_ERROR: + return 'Network error while uploading' } } From c20facf8708a94d2724f369bfabdd39602039dee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Dec 2024 10:09:38 +0000 Subject: [PATCH 06/54] chore(deps): bump nanoid from 3.3.7 to 3.3.8 Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../nanoid-npm-3.3.7-98824ba130-d36c427e53.zip | Bin 15074 -> 0 bytes .../nanoid-npm-3.3.8-d22226208b-dfe0adbc0c.zip | Bin 0 -> 28579 bytes yarn.lock | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 .yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip create mode 100644 .yarn/cache/nanoid-npm-3.3.8-d22226208b-dfe0adbc0c.zip diff --git a/.yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip b/.yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip deleted file mode 100644 index 7b2fd6e1b557870a3b3c70a12edc5ca32ef937e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15074 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 diff --git a/.yarn/cache/nanoid-npm-3.3.8-d22226208b-dfe0adbc0c.zip b/.yarn/cache/nanoid-npm-3.3.8-d22226208b-dfe0adbc0c.zip new file mode 100644 index 0000000000000000000000000000000000000000..7b4819b1f116143a2f6bfec0c278b019f1570d97 GIT binary patch literal 28579 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 literal 0 HcmV?d00001 diff --git a/yarn.lock b/yarn.lock index 5a509448df..7dd86cf82c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14536,11 +14536,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": - version: 3.3.7 - resolution: "nanoid@npm:3.3.7" + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" bin: nanoid: bin/nanoid.cjs - checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2 + checksum: dfe0adbc0c77e9655b550c333075f51bb28cfc7568afbf3237249904f9c86c9aaaed1f113f0fddddba75673ee31c758c30c43d4414f014a52a7a626efc5958c9 languageName: node linkType: hard From b99e26b37c674d6f03d4a194a0574aa0a4fd5b45 Mon Sep 17 00:00:00 2001 From: Vitomir Budimir Date: Sun, 15 Dec 2024 17:08:07 +0100 Subject: [PATCH 07/54] feat(bildungsraum-share): add endpoint + enable article copy + adjust paste handler --- .../user-tools/share/share-modal.tsx | 52 +++++++++ apps/web/src/data/en/index.ts | 5 +- .../pages/api/frontend/bildungsraum-share.ts | 100 ++++++++++++++++++ .../hooks/use-editable-key-down-handler.tsx | 11 +- .../text/hooks/use-editable-paste-handler.tsx | 66 ++++++------ .../{insert-plugin.ts => insert-plugins.ts} | 62 ++++++----- 6 files changed, 233 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/pages/api/frontend/bildungsraum-share.ts rename packages/editor/src/plugins/text/utils/{insert-plugin.ts => insert-plugins.ts} (54%) diff --git a/apps/web/src/components/user-tools/share/share-modal.tsx b/apps/web/src/components/user-tools/share/share-modal.tsx index ef1d2295a3..88f87e9b4e 100644 --- a/apps/web/src/components/user-tools/share/share-modal.tsx +++ b/apps/web/src/components/user-tools/share/share-modal.tsx @@ -1,3 +1,4 @@ +import type { AnyEditorDocument } from '@editor/types/editor-plugins' import { faFacebookSquare, faGoogle, @@ -8,6 +9,7 @@ import { faCopy, faDownload, faEnvelope, + faFileText, } from '@fortawesome/free-solid-svg-icons' import { QRCodeSVG } from 'qrcode.react' import { MouseEvent, useState, useEffect } from 'react' @@ -36,6 +38,16 @@ interface EntryData { onClick?: (event: MouseEvent) => void } +function getBase(currentHost: string) { + if (currentHost.endsWith('serlo-staging.dev')) + return 'https://de.serlo-staging.dev' + if (currentHost.endsWith('serlo.org')) return 'https://' + currentHost + + return process.env.NODE_ENV === 'development' + ? 'http://localhost:3000' + : 'https://de.serlo.org' +} + export function ShareModal({ isOpen, setIsOpen, @@ -70,10 +82,48 @@ export function ShareModal({ } } + async function copyContentToClipboard(text?: string) { + try { + if (!pathOrId) { + throw new Error('No path or entity id provided.') + } + const base = getBase(window.location.host) + const url = `${base}/api/frontend/bildungsraum-share?href=${encodeURIComponent(pathOrId)}` + const res = await fetch(url) + const data = (await res.json()) as string | AnyEditorDocument[] + if (!res.ok) { + throw new Error( + 'injection-content API call failed with error: ' + data.toString() + ) + } + console.log('bildungsraum-share endpoint data: ', data) + await navigator.clipboard.writeText(JSON.stringify(data)) + showToastNotice( + '👌 ' + (text ? text : strings.share.copyContentSuccess), + 'success' + ) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + showToastNotice( + '❌ ' + (text ? text : strings.share.copyContentFailed), + 'warning' + ) + } + } + const shareUrl = `${window.location.protocol}//${window.location.host}/${pathOrId}` const urlEncoded = encodeURIComponent(shareUrl) const titleEncoded = encodeURIComponent(document.title) + const contentCopy = [ + { + title: strings.share.copyContent, + icon: faFileText, + onClick: () => copyContentToClipboard('Content copied to clipboard!'), + }, + ] + const socialShare = [ { title: 'E-Mail', @@ -147,6 +197,8 @@ export function ShareModal({ {renderShareInput()}
+ {renderButtons(contentCopy)} +
{renderButtons(lmsData)}
{renderButtons(socialShare)} diff --git a/apps/web/src/data/en/index.ts b/apps/web/src/data/en/index.ts index 69195e52f1..0ae1e60322 100644 --- a/apps/web/src/data/en/index.ts +++ b/apps/web/src/data/en/index.ts @@ -97,6 +97,9 @@ export const instanceData = { copyLink: 'Copy link', copySuccess: 'Link copied!', copyFailed: 'Error copying link!', + copyContent: 'Copy content', + copyContentSuccess: 'Content copied!', + copyContentFailed: 'Error copying content!', close: 'Close', pdf: 'Download as PDF', pdfNoSolutions: 'PDF without solutions', @@ -401,7 +404,7 @@ export const instanceData = { 'The provided authentication code is invalid, please try again.', code4000010: 'Have you already verified your email address?.%break% %verificationLinkText%', - code4000032: "You inserted less than 8 characters.", + code4000032: 'You inserted less than 8 characters.', code4060004: 'The recovery link is not valid or has already been used. Please try requesting an email again', code4070001: diff --git a/apps/web/src/pages/api/frontend/bildungsraum-share.ts b/apps/web/src/pages/api/frontend/bildungsraum-share.ts new file mode 100644 index 0000000000..2989e07bfa --- /dev/null +++ b/apps/web/src/pages/api/frontend/bildungsraum-share.ts @@ -0,0 +1,100 @@ +import { parseDocumentString } from '@editor/static-renderer/helper/parse-document-string' +import { EditorArticleDocument } from '@editor/types/editor-plugins' +import { gql } from 'graphql-request' +import type { NextApiRequest, NextApiResponse } from 'next' + +import { endpoint } from '@/api/endpoint' +import { InjectionOnlyContentQuery } from '@/fetcher/graphql-types/operations' +import { isProduction } from '@/helper/is-production' + +/** + * Allows frontend to copy Serlo content to the clipboard. + * The content is unpacked for consistent pasting in the Editor. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const href = decodeURIComponent(String(req.query.href)) + + const [base] = href.split('#') + const path = base.startsWith('/') ? base : `/${base}` + + if (!path) { + return res.status(401).json('no path provided') + } + + try { + void fetch(endpoint, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ query, variables: { path } }), + }) + .then((res) => res.json()) + .then((data: { data: InjectionOnlyContentQuery }) => { + if (!data.data?.uuid) { + return res.status(404).json('not found') + } + + const uuid = data.data.uuid + + if (!Object.hasOwn(uuid, 'currentRevision') || !uuid.currentRevision) { + return res.status(404).json('no current revision') + } + + if (uuid.__typename === 'Article') { + const articleDocument = parseDocumentString( + uuid.currentRevision.content + ) as EditorArticleDocument + const articleContent = articleDocument.state.content + respondWithContent(articleContent) + return + } + return res.status(422).json('unknown entity type') + }) + .catch((e) => { + return res.status(500).json(`${String(e)} at ${path}`) + }) + } catch (e) { + return res.status(500).json(`${String(e)} at ${path}`) + } + + function respondWithContent(content: any) { + const twoDaysInSeconds = 172800 + res.setHeader('Cache-Control', `maxage=${twoDaysInSeconds}`) + if (!isProduction) res.setHeader('Access-Control-Allow-Origin', '*') + res.status(200).json(content) + } +} + +const query = gql` + query injectionOnlyContent($path: String!) { + uuid(alias: { path: $path, instance: de }) { + __typename + alias + title + + ... on AbstractEntity { + id + currentRevision { + content + } + licenseId + } + + ... on Video { + currentRevision { + url + } + } + ... on Applet { + currentRevision { + url + } + } + } + } +` diff --git a/packages/editor/src/plugins/text/hooks/use-editable-key-down-handler.tsx b/packages/editor/src/plugins/text/hooks/use-editable-key-down-handler.tsx index 923046b7a0..fe623f297f 100644 --- a/packages/editor/src/plugins/text/hooks/use-editable-key-down-handler.tsx +++ b/packages/editor/src/plugins/text/hooks/use-editable-key-down-handler.tsx @@ -19,7 +19,7 @@ import { Editor as SlateEditor, Range, Node, Transforms } from 'slate' import { useTextConfig } from './use-text-config' import type { TextEditorProps } from '../components/text-editor' import { emptyDocumentFactory, mergePlugins } from '../utils/document' -import { insertPlugin } from '../utils/insert-plugin' +import { insertPlugins } from '../utils/insert-plugins' import { instanceStateStore } from '../utils/instance-state-store' import { isSelectionAtEnd, isSelectionAtStart } from '../utils/selection' @@ -74,13 +74,14 @@ export const useEditableKeydownHandler = ( payload: { insertIndex, insertCallback: (plugin) => { - insertPlugin({ - pluginType: plugin.plugin, + insertPlugins({ + plugins: [ + { pluginType: plugin.plugin, state: plugin.state }, + ], editor, id, - dispatch, - state: plugin.state, getStoreState: () => store.getState(), + dispatch, }) }, }, diff --git a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx index fda7e3af89..2b4e9be6f6 100644 --- a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx +++ b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx @@ -15,7 +15,7 @@ import type { EditorRowsDocument } from '@editor/types/editor-plugins' import { useCallback } from 'react' import { Editor as SlateEditor } from 'slate' -import { insertPlugin } from '../utils/insert-plugin' +import { insertPlugins } from '../utils/insert-plugins' import { mathpixPasteHandler } from '../utils/mathpix-paste-handler' export interface UseEditablePasteHandlerArgs { @@ -60,44 +60,50 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { mathpixPasteHandler({ event, editor, text }) - let media + let pluginsToAdd: Array<{ pluginType: string; state?: unknown }> = [] // pasting editor document string and insert as plugins - if (!media && text.startsWith('{"plugin":"rows"')) { + if (!pluginsToAdd.length && text.startsWith('{"plugin":"rows"')) { const rowsDocument = JSON.parse(text) as EditorRowsDocument - if (rowsDocument.state.length !== 1) return - const pluginDocument = rowsDocument.state.at(0) - const typesOfAncestors = selectAncestorPluginTypes(store.getState(), id) - if (!pluginDocument || typesOfAncestors === null) return - - if ( - mayManipulateSiblings && - checkIsAllowedNesting(pluginDocument.plugin, typesOfAncestors) - ) { - event.preventDefault() // extra prevent for firefox to make it work 🤷 - media = { - pluginType: pluginDocument.plugin, - state: pluginDocument.state, + rowsDocument.state.forEach((_, index) => { + const pluginDocument = rowsDocument.state.at(index) + const typesOfAncestors = selectAncestorPluginTypes( + store.getState(), + id + ) + if (!pluginDocument || typesOfAncestors === null) return + + if ( + mayManipulateSiblings && + checkIsAllowedNesting(pluginDocument.plugin, typesOfAncestors) + ) { + event.preventDefault() // extra prevent for firefox to make it work 🤷 + pluginsToAdd.push({ + pluginType: pluginDocument.plugin, + state: pluginDocument.state, + }) + } else { + event.preventDefault() + showToastNotice(textStrings.pastingPluginNotAllowedHere, 'warning') } - } else { - event.preventDefault() - showToastNotice(textStrings.pastingPluginNotAllowedHere, 'warning') - } + }) } // Exit if not allowed to manipulate siblings if (!mayManipulateSiblings) return // Iterate through all plugins and try to process clipboard data - for (const { plugin, type } of editorPlugins.getAllWithData()) { - const state = plugin.onFiles?.(files) ?? (await plugin.onText?.(text)) - if (state?.state) { - media = { state: state.state as unknown, pluginType: type } - break + if (!pluginsToAdd.length) { + for (const { plugin, type } of editorPlugins.getAllWithData()) { + const state = plugin.onFiles?.(files) ?? (await plugin.onText?.(text)) + if (state?.state) { + pluginsToAdd = [{ state: state.state as unknown, pluginType: type }] + break + } } } // Exit if no media was processed from clipboard data - if (!media) return + if (!pluginsToAdd.length) return // Prevent URL being pasted as text in the text plugin event.preventDefault() @@ -108,13 +114,13 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { return } - // Insert the plugin with appropriate type and state - insertPlugin({ + // Insert the plugins with appropriate type and state + insertPlugins({ + plugins: pluginsToAdd, editor, id, - dispatch, getStoreState: () => store.getState(), - ...media, + dispatch, }) }, [dispatch, editor, id, textStrings, store] diff --git a/packages/editor/src/plugins/text/utils/insert-plugin.ts b/packages/editor/src/plugins/text/utils/insert-plugins.ts similarity index 54% rename from packages/editor/src/plugins/text/utils/insert-plugin.ts rename to packages/editor/src/plugins/text/utils/insert-plugins.ts index b48af43eda..79b8d9cbba 100644 --- a/packages/editor/src/plugins/text/utils/insert-plugin.ts +++ b/packages/editor/src/plugins/text/utils/insert-plugins.ts @@ -7,28 +7,28 @@ import { type RootState, } from '@editor/store' import { Action, ThunkDispatch } from '@reduxjs/toolkit' +import { reverse } from 'ramda' import { Editor as SlateEditor, Node } from 'slate' import { sliceNodesAfterSelection } from './document' -export interface insertPluginArgs { - pluginType: string +export interface insertPluginsArgs { + plugins: Array<{ pluginType: string; state?: unknown }> editor: SlateEditor id: string - dispatch: ThunkDispatch> - state?: unknown - getStoreState: () => RootState + dispatch: ThunkDispatch> } -export function insertPlugin({ - pluginType, +export function insertPlugins({ + plugins, editor, id, - dispatch, - state, getStoreState, -}: insertPluginArgs) { + dispatch, +}: insertPluginsArgs) { + if (!plugins.length) return + const storeState = getStoreState() const document = selectDocument(storeState, id) @@ -37,25 +37,41 @@ export function insertPlugin({ if (!document || !mayManipulateSiblings || !parent) return const parentPluginType = document.plugin + const reversedPlugins = reverse(plugins) const isEditorEmpty = Node.string(editor) === '' || Node.string(editor) === '/' - if (isEditorEmpty) { + if (isEditorEmpty) replaceCurrentTextPlugin() + else splitCurrentTextPlugin(parent.id) + + for (const { pluginType, state } of reversedPlugins) { + dispatch( + insertPluginChildAfter({ + parent: parent.id, + sibling: id, + document: { plugin: pluginType, state }, + }) + ) + } + + function replaceCurrentTextPlugin() { + const firstPlugin = reversedPlugins.pop() + const { pluginType, state } = firstPlugin! dispatch(runReplaceDocumentSaga({ id, pluginType, state })) - return } - const slicedNodes = sliceNodesAfterSelection(editor) + function splitCurrentTextPlugin(parentId: string) { + const slicedNodes = sliceNodesAfterSelection(editor) - if (slicedNodes) { - const cleanSlicedNodes = Node.string(slicedNodes[0]).length - ? slicedNodes - : slicedNodes.slice(1) - if (cleanSlicedNodes.length) { + if (slicedNodes) { + const cleanSlicedNodes = Node.string(slicedNodes[0]).length + ? slicedNodes + : slicedNodes.slice(1) + if (!cleanSlicedNodes.length) return dispatch( insertPluginChildAfter({ - parent: parent.id, + parent: parentId, sibling: id, document: { plugin: parentPluginType, @@ -65,12 +81,4 @@ export function insertPlugin({ ) } } - - dispatch( - insertPluginChildAfter({ - parent: parent.id, - sibling: id, - document: { plugin: pluginType, state }, - }) - ) } From 4a7235a965cfe6032fdd40a3f1450f6b20304e3e Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sun, 15 Dec 2024 20:11:52 +0100 Subject: [PATCH 08/54] New translations index.ts (Spanish) --- apps/web/src/data/es/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/data/es/index.ts b/apps/web/src/data/es/index.ts index 2136fd27fc..2c36cb3651 100644 --- a/apps/web/src/data/es/index.ts +++ b/apps/web/src/data/es/index.ts @@ -88,8 +88,8 @@ export const instanceData = { button: "Compartir", title: "¡Comparte!", copyLink: "Copiar enlace", - copySuccess: 'Link copied!', - copyFailed: 'Error copying link!', + copySuccess: "¡Enlace copiado!", + copyFailed: "¡Error al copiar enlace!", close: "Cerrar", pdf: "Descargar PDF", pdfNoSolutions: "PDF sin soluciones" @@ -366,7 +366,7 @@ export const instanceData = { code4000007: "Ya existe una cuenta con el mismo correo electrónico o nombre de usuario.", code4000008: "El código de autentificación proporcionado no es válido, por favor, inténtalo de nuevo.", code4000010: "¿Has verificado ya tu dirección de correo electrónico?%break%%verificationLinkText%", - code4000032: "You inserted less than 8 characters.", + code4000032: "Has introducido menos de 8 caracteres.", code4060004: "El enlace de recuperación no es válido o ya ha sido utilizado. Por favor, intenta solicitar un correo electrónico de nuevo", code4070001: "El enlace de verificación no es válido o ya ha sido utilizado. Por favor, intenta solicitar un correo electrónico de nuevo.", code4070005: "Lo sentimos, este enlace de verificación ya no es válido. Por favor, intenta solicitar un correo electrónico de nuevo." From 801eb2ca6591a14f4bccda6c628d53c4b5820105 Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:58:06 +0100 Subject: [PATCH 09/54] chore(e2e): increase wait --- e2e-tests/tests/000-general.mobile.ts | 2 +- e2e-tests/tests/000-general.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/tests/000-general.mobile.ts b/e2e-tests/tests/000-general.mobile.ts index b566715e23..92c8c1969d 100644 --- a/e2e-tests/tests/000-general.mobile.ts +++ b/e2e-tests/tests/000-general.mobile.ts @@ -33,7 +33,7 @@ Scenario('About Serlo @mobile', ({ I }) => { // Navigating around I.click('Pädagogisches Konzept') - I.waitForText('Anleitung für die Lernplattform serlo.org', 5) + I.waitForText('Anleitung für die Lernplattform serlo.org', 15) I.click('Anleitung für die Lernplattform serlo.org') I.scrollPageToBottom() I.click('Community') diff --git a/e2e-tests/tests/000-general.ts b/e2e-tests/tests/000-general.ts index 6ebf1a47d1..ac93164b46 100644 --- a/e2e-tests/tests/000-general.ts +++ b/e2e-tests/tests/000-general.ts @@ -22,7 +22,7 @@ Scenario('About Serlo', ({ I }) => { // Navigating around I.click('Pädagogisches Konzept') - I.waitForText('Anleitung für die Lernplattform serlo.org', 5) + I.waitForText('Anleitung für die Lernplattform serlo.org', 15) I.click('Anleitung für die Lernplattform serlo.org') I.scrollPageToBottom() // close newsletter modal in case it popped up From 641e437a10d7a9bf628cffdad07654dd56fc00c2 Mon Sep 17 00:00:00 2001 From: Vitomir Budimir Date: Mon, 16 Dec 2024 11:39:17 +0100 Subject: [PATCH 10/54] feat(bildungsraum-share): add rows plugin decoding to paste handler --- .../text/hooks/use-editable-paste-handler.tsx | 114 +++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx index 2b4e9be6f6..e5385508d2 100644 --- a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx +++ b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx @@ -11,7 +11,10 @@ import { useStore, selectAncestorPluginTypes, } from '@editor/store' -import type { EditorRowsDocument } from '@editor/types/editor-plugins' +import { EditorPluginType } from '@editor/types/editor-plugin-type' +import { AnyEditorDocument } from '@editor/types/editor-plugins' +import { either as E } from 'fp-ts' +import * as t from 'io-ts' import { useCallback } from 'react' import { Editor as SlateEditor } from 'slate' @@ -60,37 +63,18 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { mathpixPasteHandler({ event, editor, text }) + // Exit if not allowed to manipulate siblings + if (!mayManipulateSiblings) return + let pluginsToAdd: Array<{ pluginType: string; state?: unknown }> = [] - // pasting editor document string and insert as plugins + + // Pasting editor document string and insert as plugins if (!pluginsToAdd.length && text.startsWith('{"plugin":"rows"')) { - const rowsDocument = JSON.parse(text) as EditorRowsDocument - rowsDocument.state.forEach((_, index) => { - const pluginDocument = rowsDocument.state.at(index) - const typesOfAncestors = selectAncestorPluginTypes( - store.getState(), - id - ) - if (!pluginDocument || typesOfAncestors === null) return - - if ( - mayManipulateSiblings && - checkIsAllowedNesting(pluginDocument.plugin, typesOfAncestors) - ) { - event.preventDefault() // extra prevent for firefox to make it work 🤷 - pluginsToAdd.push({ - pluginType: pluginDocument.plugin, - state: pluginDocument.state, - }) - } else { - event.preventDefault() - showToastNotice(textStrings.pastingPluginNotAllowedHere, 'warning') - } - }) + const rowsDocument = decodeRowsPlugin(text) + if (!rowsDocument || !rowsDocument.state.length) return + rowsDocument.state.forEach(processPlugin) } - // Exit if not allowed to manipulate siblings - if (!mayManipulateSiblings) return - // Iterate through all plugins and try to process clipboard data if (!pluginsToAdd.length) { for (const { plugin, type } of editorPlugins.getAllWithData()) { @@ -102,7 +86,7 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { } } - // Exit if no media was processed from clipboard data + // Exit if no plugin was processed from clipboard data if (!pluginsToAdd.length) return // Prevent URL being pasted as text in the text plugin @@ -122,7 +106,79 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { getStoreState: () => store.getState(), dispatch, }) + + function processPlugin({ plugin, state }: AnyEditorDocument) { + const typesOfAncestors = selectAncestorPluginTypes(store.getState(), id) + if (typesOfAncestors === null) return + if (checkIsAllowedNesting(plugin, typesOfAncestors)) { + pluginsToAdd.push({ pluginType: plugin, state }) + } else { + showToastNotice(textStrings.pastingPluginNotAllowedHere, 'warning') + } + } }, [dispatch, editor, id, textStrings, store] ) } + +export const StateDecoder = t.strict({ + plugin: t.literal(EditorPluginType.Rows), + state: t.array( + t.strict({ + plugin: t.union([ + t.literal(EditorPluginType.Article), + t.literal(EditorPluginType.ArticleIntroduction), + t.literal(EditorPluginType.Course), + + t.literal(EditorPluginType.Anchor), + t.literal(EditorPluginType.Audio), + t.literal(EditorPluginType.Box), + t.literal(EditorPluginType.Equations), + t.literal(EditorPluginType.Geogebra), + t.literal(EditorPluginType.Highlight), + t.literal(EditorPluginType.Image), + t.literal(EditorPluginType.ImageGallery), + t.literal(EditorPluginType.Injection), + t.literal(EditorPluginType.InteractiveVideo), + t.literal(EditorPluginType.Multimedia), + t.literal(EditorPluginType.SerloInjection), + t.literal(EditorPluginType.SerloTable), + t.literal(EditorPluginType.Spoiler), + t.literal(EditorPluginType.Text), + t.literal(EditorPluginType.Video), + + t.literal(EditorPluginType.Exercise), + t.literal(EditorPluginType.ExerciseGroup), + t.literal(EditorPluginType.BlanksExercise), + t.literal(EditorPluginType.DropzoneImage), + t.literal(EditorPluginType.InputExercise), + t.literal(EditorPluginType.ScMcExercise), + t.literal(EditorPluginType.Solution), + t.literal(EditorPluginType.TextAreaExercise), + ]), + state: t.unknown, + }) + ), +}) + +function decodeRowsPlugin(text: string) { + try { + const decoded = StateDecoder.decode(JSON.parse(text)) + if (E.isLeft(decoded)) return throwError() + return decoded.right + } catch (error) { + throwError(error) + } +} + +function throwError(error?: unknown) { + showToastNotice( + '⚠️ Sorry, something is wrong with the data you pasted.', + 'warning' + ) + // eslint-disable-next-line no-console + console.error(error) + throw new Error( + 'Pasted JSON data is not a valid editor-state or contains unsupported plugins' + ) +} From cd4a5fd6e7cc1fda2ebb75bee5d8cbe5515f2d5a Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:05:40 +0100 Subject: [PATCH 11/54] feat(editor): add iframed demo --- packages/editor/demo/react-iframe/index.html | 27 ++++++++++++++++++++ packages/editor/index.html | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 packages/editor/demo/react-iframe/index.html diff --git a/packages/editor/demo/react-iframe/index.html b/packages/editor/demo/react-iframe/index.html new file mode 100644 index 0000000000..8d0736a21e --- /dev/null +++ b/packages/editor/demo/react-iframe/index.html @@ -0,0 +1,27 @@ + + + + + + editor react | in iframe + + + + + + diff --git a/packages/editor/index.html b/packages/editor/index.html index 93f1d7b4b7..59a761936f 100644 --- a/packages/editor/index.html +++ b/packages/editor/index.html @@ -47,7 +47,8 @@

🐦 ✏️ 🚀

hot-reloads the editor:

react (edit only)
- react (with preview) + react (with preview)
+ iframed react (edit only)

hot-reloads the component (but not the editor):

web component (edit only)
From c245870f02aabf636a51432c7d10230ec6fffd72 Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:13:49 +0100 Subject: [PATCH 12/54] add padding --- packages/editor/demo/react-iframe/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/editor/demo/react-iframe/index.html b/packages/editor/demo/react-iframe/index.html index 8d0736a21e..68e620f489 100644 --- a/packages/editor/demo/react-iframe/index.html +++ b/packages/editor/demo/react-iframe/index.html @@ -10,11 +10,12 @@ } iframe { border: none; - width: calc(100vw - 3rem); - height: calc(100vh - 3rem); + width: calc(100vw - 7rem); + height: calc(100vh - 7rem); margin-left: 1rem; margin-top: 1rem; background-color: white; + padding: 2rem; } From badf0ca9312142706814adeacf650804ebb16511 Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:25:53 +0100 Subject: [PATCH 13/54] max width for iframe --- packages/editor/demo/react-iframe/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/editor/demo/react-iframe/index.html b/packages/editor/demo/react-iframe/index.html index 68e620f489..5f1b985421 100644 --- a/packages/editor/demo/react-iframe/index.html +++ b/packages/editor/demo/react-iframe/index.html @@ -7,15 +7,18 @@ From 729f39a398515786b42eef93ac0a3163c2626655 Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:30:16 +0100 Subject: [PATCH 14/54] another style change --- packages/editor/demo/react-iframe/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor/demo/react-iframe/index.html b/packages/editor/demo/react-iframe/index.html index 5f1b985421..fcb64cb852 100644 --- a/packages/editor/demo/react-iframe/index.html +++ b/packages/editor/demo/react-iframe/index.html @@ -6,16 +6,16 @@ editor react | in iframe