diff --git a/packages/kg-default-nodes/src/generate-decorator-node.ts b/packages/kg-default-nodes/src/generate-decorator-node.ts index 4df86a07a2..2158f0d144 100644 --- a/packages/kg-default-nodes/src/generate-decorator-node.ts +++ b/packages/kg-default-nodes/src/generate-decorator-node.ts @@ -2,11 +2,13 @@ import {KoenigDecoratorNode} from './KoenigDecoratorNode.js'; import type {ExportDOMOptions, ExportDOMOutput} from './export-dom.js'; import readTextContent from './utils/read-text-content.js'; import {buildDefaultVisibility, isVisibilityRestricted, migrateOldVisibilityFormat} from './utils/visibility.js'; -import type {LexicalEditor} from 'lexical'; +import type {LexicalEditor, SerializedLexicalNode} from 'lexical'; import type {Visibility} from './utils/visibility.js'; -type RenderFn = (node: any, options: ExportDOMOptions) => TOutput; -type VersionedRenderFn = Record>; +type RenderFn = { + bivarianceHack(node: TNode, options: ExportDOMOptions): TOutput; +}['bivarianceHack']; +type VersionedRenderFn = Record>; type WidenLiteral = T extends string ? string : T extends number ? number : @@ -62,7 +64,7 @@ export interface DecoratorNodeProperty = { [Prop in Props[number] as Prop['name']]: WidenLiteral; -} & (HasVisibility extends true ? {visibility: Visibility} : {}); +} & (HasVisibility extends true ? {visibility: Visibility} : unknown); export type DecoratorNodeData = Partial>; @@ -70,6 +72,8 @@ type GeneratedDecoratorNodeInstance, TO exportDOM(editor: LexicalEditor, options?: ExportDOMOptions): TOutput; }; +export type SerializedGeneratedDecoratorNode = Record> = SerializedLexicalNode & TDataset; + export interface GeneratedDecoratorNodeClass, TOutput extends ExportDOMOutput = ExportDOMOutput> { new (data?: Partial, key?: string): GeneratedDecoratorNodeInstance; prototype: GeneratedDecoratorNodeInstance; @@ -135,19 +139,26 @@ export class GeneratedDecoratorNodeBase export function generateDecoratorNode< Props extends readonly DecoratorNodeProperty[] = readonly [], HasVisibility extends boolean = false, - TOutput extends ExportDOMOutput = ExportDOMOutput ->({nodeType, properties = [] as unknown as Props, defaultRenderFn, version = 1, hasVisibility = false as HasVisibility}: { + TOutput extends ExportDOMOutput = ExportDOMOutput, + TRenderNode = GeneratedDecoratorNodeInstance, TOutput> +>({nodeType, properties, defaultRenderFn, version = 1, hasVisibility}: { nodeType: string; properties?: Props; - defaultRenderFn?: RenderFn | VersionedRenderFn; + defaultRenderFn?: RenderFn | VersionedRenderFn; version?: number; hasVisibility?: HasVisibility; }): GeneratedDecoratorNodeClass, TOutput> { - validateArguments(nodeType, properties); + type GeneratedDataset = DecoratorNodeValueMap; + type GeneratedRenderFn = RenderFn; + type GeneratedVersionedRenderFn = VersionedRenderFn; + + const nodeProperties = properties ?? []; + + validateArguments(nodeType, nodeProperties); // Adds a `privateName` field to the properties for convenience (e.g. `__name`): // properties: [{name: 'name', privateName: '__name', type: 'string', default: 'hello'}, {...}] - const internalProps = properties.map((prop) => { + const internalProps = nodeProperties.map((prop) => { return Object.defineProperties({}, { ...Object.getOwnPropertyDescriptors(prop), privateName: { @@ -174,7 +185,7 @@ export function generateDecoratorNode< class GeneratedDecoratorNode extends KoenigDecoratorNode { [key: string]: unknown; - constructor(data: Partial> = {}, key?: string) { + constructor(data: Partial = {}, key?: string) { super(key); const dataset = data as Record; internalProps.forEach((prop) => { @@ -206,7 +217,7 @@ export function generateDecoratorNode< * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode */ static clone(node: GeneratedDecoratorNodeInstance, TOutput>) { - return new this(node.getDataset() as Partial>, node.__key); + return new this(node.getDataset() as Partial, node.__key); } /** @@ -217,7 +228,7 @@ export function generateDecoratorNode< return internalProps.reduce((obj: Record, prop) => { obj[prop.name] = prop.default; return obj; - }, {}) as DecoratorNodeValueMap; + }, {}) as GeneratedDataset; } /** @@ -272,7 +283,7 @@ export function generateDecoratorNode< data[prop.name] = serializedNode[prop.name]; }); - return new this(data as Partial>); + return new this(data as Partial); } /** @@ -280,54 +291,52 @@ export function generateDecoratorNode< * @extends DecoratorNode * @see https://lexical.dev/docs/concepts/serialization#lexicalnodeexportjson */ - // @ts-expect-error -- strict mode migration - exportJSON() { - const dataset: Record = { + exportJSON(): SerializedGeneratedDecoratorNode { + const dataset = { type: nodeType, version: version, ...internalProps.reduce((obj: Record, prop) => { obj[prop.name] = this[prop.name]; return obj; }, {}) - }; + } as SerializedGeneratedDecoratorNode; return dataset; } exportDOM(_editor: LexicalEditor, options: ExportDOMOptions = {}): TOutput { // this.__version is used when a node has a version property which // means it's set from the serialized version data at runtime - const nodeVersion = this.__version || version; + const nodeVersion = typeof this.__version === 'string' || typeof this.__version === 'number' ? this.__version : version; + const node = this as unknown as TRenderNode; - const nodeRenderers = options.nodeRenderers as Record | VersionedRenderFn> | undefined; + const nodeRenderers = options.nodeRenderers as Record | undefined; if (nodeRenderers?.[nodeType]) { const render = nodeRenderers[nodeType]; if (typeof render === 'object') { - const versionRenderer = (render as VersionedRenderFn)[nodeVersion as number]; + const versionRenderer = render[nodeVersion]; if (!versionRenderer) { throw new Error(`[generateDecoratorNode] ${nodeType}: options.nodeRenderers['${nodeType}'] for version ${nodeVersion} is required`); } - return versionRenderer(this, options); + return versionRenderer(node, options); } else { - return (render as RenderFn)(this, options); + return render(node, options); } } if (typeof defaultRenderFn === 'object') { - const render = (defaultRenderFn as VersionedRenderFn)[nodeVersion as number]; + const render = defaultRenderFn[nodeVersion]; if (!render) { throw new Error(`[generateDecoratorNode] ${nodeType}: "defaultRenderFn" for version ${nodeVersion} is required`); } - return render(this, options); + return render(node, options); } if (!defaultRenderFn) { throw new Error(`[generateDecoratorNode] ${nodeType}: "defaultRenderFn" is required`); } - const render = defaultRenderFn as RenderFn; - - return render(this, options); + return defaultRenderFn(node, options); } /* c8 ignore start */ @@ -380,7 +389,7 @@ export function generateDecoratorNode< */ getTextContent() { const self = this.getLatest(); - const propertiesWithText = properties.filter(prop => !!prop.wordCount); + const propertiesWithText = nodeProperties.filter(prop => !!prop.wordCount); const text = propertiesWithText.map( prop => readTextContent(self, prop.name) diff --git a/packages/kg-default-nodes/src/kg-default-nodes.ts b/packages/kg-default-nodes/src/kg-default-nodes.ts index 56e393566c..b32aea9ba6 100644 --- a/packages/kg-default-nodes/src/kg-default-nodes.ts +++ b/packages/kg-default-nodes/src/kg-default-nodes.ts @@ -64,6 +64,7 @@ export * from './nodes/ExtendedQuoteNode.js'; export * from './nodes/TKNode.js'; export * from './nodes/at-link/index.js'; export * from './nodes/zwnj/ZWNJNode.js'; +export * from './utils/card-widths.js'; // export utility functions that are useful in other packages or tests import * as visibilityUtils from './utils/visibility.js'; diff --git a/packages/kg-default-nodes/src/utils/card-widths.ts b/packages/kg-default-nodes/src/utils/card-widths.ts new file mode 100644 index 0000000000..c6270f9590 --- /dev/null +++ b/packages/kg-default-nodes/src/utils/card-widths.ts @@ -0,0 +1,11 @@ +export const CARD_WIDTHS = ['regular', 'wide', 'full'] as const; + +export type CardWidth = typeof CARD_WIDTHS[number]; + +export function isCardWidth(width: unknown): width is CardWidth { + return typeof width === 'string' && (CARD_WIDTHS as readonly string[]).includes(width); +} + +export function normalizeCardWidth(width: unknown): CardWidth | undefined { + return isCardWidth(width) ? width : undefined; +} diff --git a/packages/kg-default-nodes/src/utils/visibility.ts b/packages/kg-default-nodes/src/utils/visibility.ts index ce76e98a25..0e087f4d40 100644 --- a/packages/kg-default-nodes/src/utils/visibility.ts +++ b/packages/kg-default-nodes/src/utils/visibility.ts @@ -21,7 +21,7 @@ function isNullish(value: unknown) { } // ensure we always work with a deep copy to avoid accidental ref mutations -export function buildDefaultVisibility() { +export function buildDefaultVisibility(): typeof DEFAULT_VISIBILITY { return JSON.parse(JSON.stringify(DEFAULT_VISIBILITY)); } diff --git a/packages/koenig-lexical/.storybook/editorEmptyState.js b/packages/koenig-lexical/.storybook/editorEmptyState.ts similarity index 100% rename from packages/koenig-lexical/.storybook/editorEmptyState.js rename to packages/koenig-lexical/.storybook/editorEmptyState.ts diff --git a/packages/koenig-lexical/.storybook/main.ts b/packages/koenig-lexical/.storybook/main.ts index 00c4f1604c..d552cce75e 100644 --- a/packages/koenig-lexical/.storybook/main.ts +++ b/packages/koenig-lexical/.storybook/main.ts @@ -1,7 +1,10 @@ import { dirname, join } from "path"; +import { createRequire } from "module"; import { mergeConfig } from 'vite'; import type {StorybookConfig} from '@storybook/react-vite'; +const require = createRequire(import.meta.url); + const config: StorybookConfig = { framework: { name: getAbsolutePath("@storybook/react-vite"), diff --git a/packages/koenig-lexical/.storybook/preview.jsx b/packages/koenig-lexical/.storybook/preview.jsx deleted file mode 100644 index 7934104fa6..0000000000 --- a/packages/koenig-lexical/.storybook/preview.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import '../src/styles/index.css'; -import {LexicalComposer} from '@lexical/react/LexicalComposer'; - -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, - backgrounds: { - default: 'light', - values: [ - { - name: 'light', - value: '#fff', - }, - { - name: 'dark', - value: '#15171A', - }, - ], - }, - status: { - statuses: { - toDo: { - background: '#AEB7C1', - color: '#ffffff', - description: 'This component has not yet been created', - }, - inProgress: { - background: '#FFB41F', - color: '#ffffff', - description: 'The UI for this component is in progress', - }, - uiReady: { - background: '#30CF43', - color: '#ffffff', - description: 'This component is ready to be wired up', - }, - functional: { - background: '#14B8FF', - color: '#ffffff', - description: 'This component is live and functional', - }, - uiBlocked: { - background: '#F50B23', - color: '#ffffff', - description: 'The UI for this component is blocked', - }, - }, - }, -} - -export const decorators = [ - (Story) => { - return ( - -
-
- -
-
-
- ) - } -]; -export const tags = ['autodocs']; diff --git a/packages/koenig-lexical/.storybook/preview.tsx b/packages/koenig-lexical/.storybook/preview.tsx new file mode 100644 index 0000000000..da1e29d691 --- /dev/null +++ b/packages/koenig-lexical/.storybook/preview.tsx @@ -0,0 +1,70 @@ +import '../src/styles/index.css'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import type {FC} from 'react'; + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + backgrounds: { + default: 'light', + values: [ + { + name: 'light', + value: '#fff', + }, + { + name: 'dark', + value: '#15171A', + }, + ], + }, + status: { + statuses: { + toDo: { + background: '#AEB7C1', + color: '#ffffff', + description: 'This component has not yet been created', + }, + inProgress: { + background: '#FFB41F', + color: '#ffffff', + description: 'The UI for this component is in progress', + }, + uiReady: { + background: '#30CF43', + color: '#ffffff', + description: 'This component is ready to be wired up', + }, + functional: { + background: '#14B8FF', + color: '#ffffff', + description: 'This component is live and functional', + }, + uiBlocked: { + background: '#F50B23', + color: '#ffffff', + description: 'The UI for this component is blocked', + }, + }, + }, +} + +export const decorators = [ + (Story: FC) => { + return ( + +
+
+ +
+
+
+ ) + } +]; +export const tags = ['autodocs']; diff --git a/packages/koenig-lexical/demo/DemoApp.jsx b/packages/koenig-lexical/demo/DemoApp.jsx deleted file mode 100644 index eb86e4502b..0000000000 --- a/packages/koenig-lexical/demo/DemoApp.jsx +++ /dev/null @@ -1,445 +0,0 @@ -import DarkModeToggle from './components/DarkModeToggle'; -import DollarIcon from './assets/icons/kg-dollar.svg?react'; -import EmailEditorWrapper from './components/EmailEditorWrapper'; -import FloatingButton from './components/FloatingButton'; -import InitialContentToggle from './components/InitialContentToggle'; -import LockIcon from './assets/icons/kg-lock.svg?react'; -import React, {useState} from 'react'; -import Sidebar from './components/Sidebar'; -import TitleTextBox from './components/TitleTextBox'; -import Watermark from './components/Watermark'; -import WordCount from './components/WordCount'; -import basicContent from './content/basic-content.json'; -import content from './content/content.json'; -import emailContent from './content/email-content.json'; -import minimalContent from './content/minimal-content.json'; -import {$getRoot, $isDecoratorNode} from 'lexical'; -import { - BASIC_NODES, BASIC_TRANSFORMERS, EmailEditor, - KoenigComposableEditor, KoenigComposer, KoenigEditor, MINIMAL_NODES, - MINIMAL_TRANSFORMERS, RestrictContentPlugin, TKCountPlugin, WordCountPlugin -} from '../src'; -import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; -import {fetchEmbed} from './utils/fetchEmbed'; -import {fileTypes, useFileUpload} from './utils/useFileUpload'; -import {klipyConfig, tenorConfig} from './utils/gifConfig'; -import {useLocation, useSearchParams} from 'react-router-dom'; -import {useSnippets} from './utils/useSnippets'; - -const url = new URL(window.location.href); -const params = new URLSearchParams(url.search); -const WEBSOCKET_ENDPOINT = params.get('multiplayerEndpoint') || 'ws://localhost:1234'; -const WEBSOCKET_ID = params.get('multiplayerId') || '0'; - -// show deprecated cards by default so they can be tested, unless explicitly hidden -// so we can test they are removed from the menu when deprecated/behind a feature flag -function hideDeprecatedCardInMenu(searchParams) { - // allow tests to opt in to hiding deprecated cards - if (searchParams.get('hideDeprecatedCards') === 'true') { - return true; - } - - // otherwise show deprecated cards by default so tests don't need updating - return process.env.NODE_ENV === 'test' ? false : true; -} - -const defaultCardConfig = { - unsplash: defaultUnsplashHeaders, - fetchEmbed: fetchEmbed, - tenor: tenorConfig, - klipy: klipyConfig, - fetchAutocompleteLinks: () => Promise.resolve([ - {label: 'Homepage', value: window.location.origin + '/'}, - {label: 'Free signup', value: window.location.origin + '/#/portal/signup/free'} - ]), - renderLabels: true, - fetchLabels: () => Promise.resolve(['Label 1', 'Label 2']), - siteTitle: 'Koenig Lexical', - siteDescription: `There's a whole lot to discover in this editor. Let us help you settle in.`, - siteUrl: window.location.origin, - membersEnabled: true, - stripeEnabled: true, - feature: { - transistor: false - }, - // this enables the internal linking feature, can be disabled with `/#/?searchLinks=false` - searchLinks: async (term) => { - // default to showing latest posts when search is empty - // no delay to simulate posts being pre-loaded in editor - if (!term) { - return [ - {label: 'Latest posts', key: 'latest-posts', items: [ - {id: '1', groupName: 'Latest posts', title: 'Remote Work\'s Impact on Job Markets and Employment', url: 'https://source.ghost.io/remote-works-impact-on-job-markets/', metaText: '8 May 2024', MetaIcon: LockIcon, metaIconTitle: 'Members only'}, - {id: '2', groupName: 'Latest posts', title: 'Robotics Renaissance: How Automation is Transforming Industries', url: 'https://source-newsletter.ghost.io/mental-health-awareness-in-the-workplace/', metaText: '2 May 2024', MetaIcon: DollarIcon, metaIconTitle: 'Specific tiers only'}, - {id: '3', groupName: 'Latest posts', title: 'Biodiversity Conservation in Fragile Ecosystems', url: 'https://source.ghost.io/biodiversity-conservation-in-fragile-ecosystems/', metaText: '26 June 2024', MetaIcon: DollarIcon, metaIconTitle: 'Paid-members only'}, - {id: '4', groupName: 'Latest posts', title: 'Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment', url: 'https://source.ghost.io/plastic-pollution-crisis-deepens/', metaText: '16 Aug 2023'} - ]} - ]; - } - - // actual search, simulate a network request delay - return new Promise((resolve) => { - setTimeout(() => { - const posts = [ - {id: '1', groupName: 'Posts', title: 'TK Reminders', url: 'https://ghost.org/changelog/tk-reminders/'}, - {id: '2', groupName: 'Posts', title: '✨ Emoji autocomplete ✨', url: 'https://ghost.org/changelog/emoji-picker/'} - ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); - - const pages = [ - {id: '3', groupName: 'Pages', title: 'How to update Ghost', url: 'https://ghost.org/docs/update/'} - ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); - - const tags = [ - {id: '4', groupName: 'Tags', title: 'Improved', url: 'https://ghost.org/changelog/tag/improved/'} - ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); - - const groups = []; - - if (posts.length) { - groups.push({label: 'Posts', key: 'posts', items: posts}); - } - if (pages.length) { - groups.push({label: 'Pages', key: 'pages', items: pages}); - } - if (tags.length) { - groups.push({label: 'Tags', key: 'tags', items: tags}); - } - - resolve(groups); - }, process.env.NODE_ENV === 'test' ? 25 : 250); - }); - } -}; - -function getDefaultContent({editorType}) { - if (editorType === 'basic') { - return basicContent; - } else if (editorType === 'minimal') { - return minimalContent; - } else if (editorType === 'email') { - return emailContent; - } - return content; -} - -function getAllowedNodes({editorType}) { - if (editorType === 'basic') { - return BASIC_NODES; - } else if (editorType === 'minimal') { - return MINIMAL_NODES; - } - return undefined; -} - -function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setWordCount, setTKCount}) { - if (editorType === 'basic') { - return ( - - - - ); - } else if (editorType === 'minimal') { - return ( - - - - - ); - } - - return ( - - - - - ); -} - -function DemoComposer({editorType, isMultiplayer, setWordCount, setTKCount}) { - const [searchParams, setSearchParams] = useSearchParams(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [sidebarView, setSidebarView] = useState('json'); - const {snippets, createSnippet, deleteSnippet} = useSnippets(); - - const skipFocusEditor = React.useRef(false); - - const darkMode = searchParams.get('darkMode') === 'true'; - const contentParam = searchParams.get('content'); - - const defaultContent = React.useMemo(() => { - return JSON.stringify(getDefaultContent({editorType})); - }, [editorType]); - - const initialContent = React.useMemo(() => { - if (isMultiplayer) { - return null; - } - - if (contentParam === 'false') { - return undefined; - } - - return contentParam ? decodeURIComponent(contentParam) : defaultContent; - }, [isMultiplayer, contentParam, defaultContent]); - - const [title, setTitle] = useState(initialContent ? 'Meet the Koenig editor.' : ''); - const [editorAPI, setEditorAPI] = useState(null); - const titleRef = React.useRef(null); - const containerRef = React.useRef(null); - - function openSidebar(view = 'json') { - if (isSidebarOpen && sidebarView === view) { - return setIsSidebarOpen(false); - } - setSidebarView(view); - setIsSidebarOpen(true); - } - - function focusTitle() { - titleRef.current?.focus(); - } - - // mousedown can select a node which can deselect another node meaning the - // mouseup/click event can occur outside of the initially clicked node, in - // which case we don't want to then "re-focus" the editor and cause unexpected - // selection changes - function maybeSkipFocusEditor(event) { - const clickedOnDecorator = (event.target.closest('[data-lexical-decorator]') !== null) || event.target.hasAttribute('data-lexical-decorator'); - const clickedOnSlashMenu = (event.target.closest('[data-kg-slash-menu]') !== null) || event.target.hasAttribute('data-kg-slash-menu'); - const clickedOnPortal = (event.target.closest('[data-kg-portal]') !== null) || event.target.hasAttribute('data-kg-portal'); - - if (clickedOnDecorator || clickedOnSlashMenu || clickedOnPortal) { - skipFocusEditor.current = true; - } - } - - function focusEditor(event) { - const clickedOnDecorator = (event.target.closest('[data-lexical-decorator]') !== null) || event.target.hasAttribute('data-lexical-decorator'); - const clickedOnSlashMenu = (event.target.closest('[data-kg-slash-menu]') !== null) || event.target.hasAttribute('data-kg-slash-menu'); - const clickedOnPortal = (event.target.closest('[data-kg-portal]') !== null) || event.target.hasAttribute('data-kg-portal'); - - if (!skipFocusEditor.current && editorAPI && !clickedOnDecorator && !clickedOnSlashMenu && !clickedOnPortal) { - let editor = editorAPI.editorInstance; - - // if a mousedown and subsequent mouseup occurs below the editor - // canvas, focus the editor and put the cursor at the end of the document - let {bottom} = editor._rootElement.getBoundingClientRect(); - if (event.pageY > bottom && event.clientY > bottom) { - event.preventDefault(); - - // we should always have a visible cursor when focusing - // at the bottom so create an empty paragraph if last - // section is a card - let addLastParagraph = false; - - editor.getEditorState().read(() => { - const nodes = $getRoot().getChildren(); - const lastNode = nodes[nodes.length - 1]; - - if (lastNode && $isDecoratorNode(lastNode)) { - addLastParagraph = true; - } - }); - - if (addLastParagraph) { - editorAPI.insertParagraphAtBottom(); - } - - // Focus the editor - editorAPI.focusEditor({position: 'bottom'}); - - //scroll to the bottom of the container - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - } - - skipFocusEditor.current = false; - } - - function toggleDarkMode() { - if (darkMode) { - searchParams.delete('darkMode'); - } else { - searchParams.set('darkMode', 'true'); - } - setSearchParams(searchParams); - } - - function saveContent() { - const serializedState = editorAPI.serialize(); - const encodedContent = encodeURIComponent(serializedState); - searchParams.set('content', encodedContent); - setSearchParams(searchParams); - } - - React.useEffect(() => { - const handleFileDrag = (event) => { - event.preventDefault(); - }; - - const handleFileDrop = (event) => { - if (event.dataTransfer.files.length > 0) { - event.preventDefault(); - editorAPI?.insertFiles(Array.from(event.dataTransfer.files)); - } - }; - - window.addEventListener('dragover', handleFileDrag); - window.addEventListener('drop', handleFileDrop); - - return () => { - window.removeEventListener('dragover', handleFileDrag); - window.removeEventListener('drop', handleFileDrop); - }; - }, [editorAPI]); - - const showTitle = !isMultiplayer && !['basic', 'minimal', 'email'].includes(editorType); - const isEmailEditor = editorType === 'email'; - - const cardConfig = { - ...defaultCardConfig, - editorType, - snippets, - createSnippet, - deleteSnippet, - feature: { - ...defaultCardConfig.feature, - transistor: searchParams.get('labs')?.includes('transistor') || defaultCardConfig.feature.transistor - }, - searchLinks: searchParams.get('searchLinks') === 'false' ? undefined : defaultCardConfig.searchLinks, - stripeEnabled: searchParams.get('stripe') === 'false' ? false : defaultCardConfig.stripeEnabled, - deprecated: { - headerV1: hideDeprecatedCardInMenu(searchParams), - emailCta: hideDeprecatedCardInMenu(searchParams) - } - }; - - const fileUploader = {useFileUpload: useFileUpload({isMultiplayer}), fileTypes}; - - // Sidebar uses useLexicalComposerContext so it must be inside a KoenigComposer. - // The email editor manages its own composer, so the sidebar is only available - // for non-email editor types. - const demoChrome = ( - <> - - {!isEmailEditor && ( -
- - -
- )} - - ); - - const demoLayout = (children) => ( -
- { - !isMultiplayer && !isEmailEditor && contentParam !== 'false' - ? - : null - } - -
-
- {showTitle - ? - : null - } - {children} -
-
-
- ); - - // Email editor includes its own KoenigComposer, so it renders outside the shared one - if (isEmailEditor) { - return ( - <> - {demoLayout( - - - - - - )} - {demoChrome} - - ); - } - - return ( - - {demoLayout( - - )} - {demoChrome} - - ); -} - -const MemoizedDemoComposer = React.memo(DemoComposer); - -function DemoApp({editorType, isMultiplayer}) { - const [wordCount, setWordCount] = useState(0); - const [tkCount, setTKCount] = useState(0); - - // used to force a re-initialization of the editor when URL changes, otherwise - // content is memoized and causes issues when switching between editor types - const location = useLocation(); - - return ( -
- {/* outside of DemoComposer to avoid re-renders and flaky tests when word count changes */} - - - -
- ); -} - -export default DemoApp; diff --git a/packages/koenig-lexical/demo/DemoApp.tsx b/packages/koenig-lexical/demo/DemoApp.tsx new file mode 100644 index 0000000000..6eef311408 --- /dev/null +++ b/packages/koenig-lexical/demo/DemoApp.tsx @@ -0,0 +1,491 @@ +import DarkModeToggle from './components/DarkModeToggle'; +import DollarIcon from './assets/icons/kg-dollar.svg?react'; +import EmailEditorWrapper from './components/EmailEditorWrapper'; +import FloatingButton from './components/FloatingButton'; +import InitialContentToggle from './components/InitialContentToggle'; +import LockIcon from './assets/icons/kg-lock.svg?react'; +import React, {useState} from 'react'; +import Sidebar from './components/Sidebar'; +import TitleTextBox, {type TitleTextBoxHandle} from './components/TitleTextBox'; +import Watermark from './components/Watermark'; +import WordCount from './components/WordCount'; +import basicContent from './content/basic-content.json'; +import content from './content/content.json'; +import emailContent from './content/email-content.json'; +import minimalContent from './content/minimal-content.json'; +import {$getRoot, $isDecoratorNode, type Klass, type LexicalNode, type LexicalNodeReplacement} from 'lexical'; +import { + BASIC_NODES, BASIC_TRANSFORMERS, BookmarkPlugin, + ButtonPlugin, CallToActionPlugin, CalloutPlugin, CardMenuPlugin, EMAIL_EDITOR_NODES, + EMAIL_TRANSFORMERS, EmEnDashPlugin, EmailCtaPlugin, EmbedPlugin, EmojiPickerPlugin, + HorizontalRulePlugin, HtmlPlugin, ImagePlugin, + KoenigComposableEditor, KoenigComposer, KoenigEditor, KoenigSelectorPlugin, KoenigSnippetPlugin, ListPlugin, MINIMAL_NODES, + MINIMAL_TRANSFORMERS, ProductPlugin, ReplacementStringsPlugin, RestrictContentPlugin, TKCountPlugin, TransistorPlugin, WordCountPlugin +} from '../src'; +import {VISIBILITY_SETTINGS} from '../src/utils/visibility'; +import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; +import {fetchEmbed} from './utils/fetchEmbed'; +import {fileTypes, useFileUpload} from './utils/useFileUpload'; +import {klipyConfig, tenorConfig} from './utils/gifConfig'; +import {useLocation, useSearchParams} from 'react-router-dom'; +import {useSnippets} from './utils/useSnippets'; + +const url = new URL(window.location.href); +const params = new URLSearchParams(url.search); +const WEBSOCKET_ENDPOINT = params.get('multiplayerEndpoint') || 'ws://localhost:1234'; +const WEBSOCKET_ID = params.get('multiplayerId') || '0'; + +// show deprecated cards by default so they can be tested, unless explicitly hidden +// so we can test they are removed from the menu when deprecated/behind a feature flag +function hideDeprecatedCardInMenu(searchParams: URLSearchParams) { + // allow tests to opt in to hiding deprecated cards + if (searchParams.get('hideDeprecatedCards') === 'true') { + return true; + } + + // otherwise show deprecated cards by default so tests don't need updating + return process.env.NODE_ENV === 'test' ? false : true; +} + +const defaultCardConfig = { + unsplash: defaultUnsplashHeaders, + fetchEmbed: fetchEmbed, + tenor: tenorConfig, + klipy: klipyConfig, + fetchAutocompleteLinks: () => Promise.resolve([ + {label: 'Homepage', value: window.location.origin + '/'}, + {label: 'Free signup', value: window.location.origin + '/#/portal/signup/free'} + ]), + renderLabels: true, + fetchLabels: () => Promise.resolve(['Label 1', 'Label 2']), + siteTitle: 'Koenig Lexical', + siteDescription: `There's a whole lot to discover in this editor. Let us help you settle in.`, + siteUrl: window.location.origin, + membersEnabled: true, + stripeEnabled: true, + feature: { + transistor: false + }, + // this enables the internal linking feature, can be disabled with `/#/?searchLinks=false` + searchLinks: async (term = '') => { + // default to showing latest posts when search is empty + // no delay to simulate posts being pre-loaded in editor + if (!term) { + return [ + {label: 'Latest posts', key: 'latest-posts', items: [ + {id: '1', groupName: 'Latest posts', title: 'Remote Work\'s Impact on Job Markets and Employment', url: 'https://source.ghost.io/remote-works-impact-on-job-markets/', metaText: '8 May 2024', MetaIcon: LockIcon, metaIconTitle: 'Members only'}, + {id: '2', groupName: 'Latest posts', title: 'Robotics Renaissance: How Automation is Transforming Industries', url: 'https://source-newsletter.ghost.io/mental-health-awareness-in-the-workplace/', metaText: '2 May 2024', MetaIcon: DollarIcon, metaIconTitle: 'Specific tiers only'}, + {id: '3', groupName: 'Latest posts', title: 'Biodiversity Conservation in Fragile Ecosystems', url: 'https://source.ghost.io/biodiversity-conservation-in-fragile-ecosystems/', metaText: '26 June 2024', MetaIcon: DollarIcon, metaIconTitle: 'Paid-members only'}, + {id: '4', groupName: 'Latest posts', title: 'Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment', url: 'https://source.ghost.io/plastic-pollution-crisis-deepens/', metaText: '16 Aug 2023'} + ]} + ]; + } + + // actual search, simulate a network request delay + return new Promise((resolve) => { + setTimeout(() => { + const posts = [ + {id: '1', groupName: 'Posts', title: 'TK Reminders', url: 'https://ghost.org/changelog/tk-reminders/'}, + {id: '2', groupName: 'Posts', title: '✨ Emoji autocomplete ✨', url: 'https://ghost.org/changelog/emoji-picker/'} + ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); + + const pages = [ + {id: '3', groupName: 'Pages', title: 'How to update Ghost', url: 'https://ghost.org/docs/update/'} + ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); + + const tags = [ + {id: '4', groupName: 'Tags', title: 'Improved', url: 'https://ghost.org/changelog/tag/improved/'} + ].filter(item => item.title.toLowerCase().includes(term.toLowerCase())); + + const groups = []; + + if (posts.length) { + groups.push({label: 'Posts', key: 'posts', items: posts}); + } + if (pages.length) { + groups.push({label: 'Pages', key: 'pages', items: pages}); + } + if (tags.length) { + groups.push({label: 'Tags', key: 'tags', items: tags}); + } + + resolve(groups); + }, process.env.NODE_ENV === 'test' ? 25 : 250); + }); + } +}; + +function getDefaultContent({editorType}: {editorType?: string}) { + if (editorType === 'basic') { + return basicContent; + } else if (editorType === 'minimal') { + return minimalContent; + } else if (editorType === 'email') { + return emailContent; + } + return content; +} + +function getAllowedNodes({editorType}: {editorType?: string}): ReadonlyArray | LexicalNodeReplacement> | undefined { + if (editorType === 'basic') { + return BASIC_NODES as ReadonlyArray | LexicalNodeReplacement>; + } else if (editorType === 'minimal') { + return MINIMAL_NODES as ReadonlyArray | LexicalNodeReplacement>; + } else if (editorType === 'email') { + return EMAIL_EDITOR_NODES as ReadonlyArray | LexicalNodeReplacement>; + } + return undefined; +} + +interface DemoEditorAPI { + editorInstance: unknown; + editorIsEmpty: () => boolean; + insertParagraphAtTop: (options: {focus: boolean}) => void; + insertParagraphAtBottom: () => void; + focusEditor: (options: {position: string}) => void; + insertFiles: (files: File[]) => void; + serialize: () => string; + [key: string]: unknown; +} + +interface DemoEditorProps { + editorType?: string; + registerAPI: (api: unknown) => void; + cursorDidExitAtTop: () => void; + setWordCount: (count: number) => void; + setTKCount: (count: number) => void; +} + +function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, setWordCount, setTKCount}: DemoEditorProps) { + if (editorType === 'basic') { + return ( + + + + ); + } else if (editorType === 'minimal') { + return ( + + + + + ); + } else if (editorType === 'email') { + return ( + + + + + + + + + + + + + + + + + + + + + + ); + } + + return ( + + + + + ); +} + +interface DemoComposerProps { + editorType?: string; + isMultiplayer?: boolean; + setWordCount: (count: number) => void; + setTKCount: (count: number) => void; +} + +function DemoComposer({editorType, isMultiplayer, setWordCount, setTKCount}: DemoComposerProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [sidebarView, setSidebarView] = useState('json'); + const {snippets, createSnippet, deleteSnippet} = useSnippets(); + + const skipFocusEditor = React.useRef(false); + + const darkMode = searchParams.get('darkMode') === 'true'; + const contentParam = searchParams.get('content'); + + const defaultContent = React.useMemo(() => { + return JSON.stringify(getDefaultContent({editorType})); + }, [editorType]); + + const initialContent = React.useMemo(() => { + if (isMultiplayer) { + return null; + } + + if (contentParam === 'false') { + return undefined; + } + + return contentParam ? decodeURIComponent(contentParam) : defaultContent; + }, [isMultiplayer, contentParam, defaultContent]); + + const [title, setTitle] = useState(initialContent ? 'Meet the Koenig editor.' : ''); + const [editorAPI, setEditorAPI] = useState(null); + const titleRef = React.useRef(null); + const containerRef = React.useRef(null); + + function openSidebar(view = 'json') { + if (isSidebarOpen && sidebarView === view) { + return setIsSidebarOpen(false); + } + setSidebarView(view); + setIsSidebarOpen(true); + } + + function focusTitle() { + titleRef.current?.focus(); + } + + // mousedown can select a node which can deselect another node meaning the + // mouseup/click event can occur outside of the initially clicked node, in + // which case we don't want to then "re-focus" the editor and cause unexpected + // selection changes + function maybeSkipFocusEditor(event: React.MouseEvent) { + const target = event.target as HTMLElement; + const clickedOnDecorator = (target.closest('[data-lexical-decorator]') !== null) || target.hasAttribute('data-lexical-decorator'); + const clickedOnSlashMenu = (target.closest('[data-kg-slash-menu]') !== null) || target.hasAttribute('data-kg-slash-menu'); + const clickedOnPortal = (target.closest('[data-kg-portal]') !== null) || target.hasAttribute('data-kg-portal'); + + if (clickedOnDecorator || clickedOnSlashMenu || clickedOnPortal) { + skipFocusEditor.current = true; + } + } + + function focusEditor(event: React.MouseEvent) { + const target = event.target as HTMLElement; + const clickedOnDecorator = (target.closest('[data-lexical-decorator]') !== null) || target.hasAttribute('data-lexical-decorator'); + const clickedOnSlashMenu = (target.closest('[data-kg-slash-menu]') !== null) || target.hasAttribute('data-kg-slash-menu'); + const clickedOnPortal = (target.closest('[data-kg-portal]') !== null) || target.hasAttribute('data-kg-portal'); + + if (!skipFocusEditor.current && editorAPI && !clickedOnDecorator && !clickedOnSlashMenu && !clickedOnPortal) { + const editor = editorAPI.editorInstance as {_rootElement: HTMLElement; getEditorState: () => {read: (fn: () => void) => void}}; + + // if a mousedown and subsequent mouseup occurs below the editor + // canvas, focus the editor and put the cursor at the end of the document + const {bottom} = editor._rootElement.getBoundingClientRect(); + if (event.pageY > bottom && event.clientY > bottom) { + event.preventDefault(); + + // we should always have a visible cursor when focusing + // at the bottom so create an empty paragraph if last + // section is a card + let addLastParagraph = false; + + editor.getEditorState().read(() => { + const nodes = $getRoot().getChildren(); + const lastNode = nodes[nodes.length - 1]; + + if (lastNode && $isDecoratorNode(lastNode)) { + addLastParagraph = true; + } + }); + + if (addLastParagraph) { + editorAPI.insertParagraphAtBottom(); + } + + // Focus the editor + editorAPI.focusEditor({position: 'bottom'}); + + //scroll to the bottom of the container + containerRef.current!.scrollTop = containerRef.current!.scrollHeight; + } + } + + skipFocusEditor.current = false; + } + + function toggleDarkMode() { + if (darkMode) { + searchParams.delete('darkMode'); + } else { + searchParams.set('darkMode', 'true'); + } + setSearchParams(searchParams); + } + + function saveContent() { + const serializedState = editorAPI!.serialize(); + const encodedContent = encodeURIComponent(serializedState); + searchParams.set('content', encodedContent); + setSearchParams(searchParams); + } + + React.useEffect(() => { + const handleFileDrag = (event: DragEvent) => { + event.preventDefault(); + }; + + const handleFileDrop = (event: DragEvent) => { + if (event.dataTransfer && event.dataTransfer.files.length > 0) { + event.preventDefault(); + editorAPI?.insertFiles(Array.from(event.dataTransfer.files)); + } + }; + + window.addEventListener('dragover', handleFileDrag); + window.addEventListener('drop', handleFileDrop); + + return () => { + window.removeEventListener('dragover', handleFileDrag); + window.removeEventListener('drop', handleFileDrop); + }; + }, [editorAPI]); + + const showTitle = !isMultiplayer && !['basic', 'minimal', 'email'].includes(editorType || ''); + const isEmailEditor = editorType === 'email'; + + const cardConfig = { + ...defaultCardConfig, + editorType, + snippets, + createSnippet, + deleteSnippet, + feature: { + ...defaultCardConfig.feature, + transistor: searchParams.get('labs')?.includes('transistor') || defaultCardConfig.feature.transistor + }, + searchLinks: searchParams.get('searchLinks') === 'false' ? undefined : defaultCardConfig.searchLinks, + stripeEnabled: searchParams.get('stripe') === 'false' ? false : defaultCardConfig.stripeEnabled, + deprecated: { + headerV1: hideDeprecatedCardInMenu(searchParams), + emailCta: hideDeprecatedCardInMenu(searchParams) + }, + ...(isEmailEditor ? { + image: { + ...((defaultCardConfig as Record).image as Record || {}), + allowedWidths: ['regular'] + }, + visibilitySettings: VISIBILITY_SETTINGS.EMAIL_ONLY + } : {}) + }; + + return ( + +
+ { + !isMultiplayer + ? + : null + } + +
+
+ {showTitle + ? + : null + } + {editorType === 'email' ? ( + + void} + setTKCount={setTKCount} + setWordCount={setWordCount} + /> + + ) : ( + void} + setTKCount={setTKCount} + setWordCount={setWordCount} + /> + )} +
+
+
+ +
+ + +
+
+ ); +} + +const MemoizedDemoComposer = React.memo(DemoComposer); + +interface DemoAppProps { + editorType?: string; + isMultiplayer?: boolean; + introContent?: boolean; +} + +function DemoApp({editorType, isMultiplayer}: DemoAppProps) { + const [wordCount, setWordCount] = useState(0); + const [tkCount, setTKCount] = useState(0); + + // used to force a re-initialization of the editor when URL changes, otherwise + // content is memoized and causes issues when switching between editor types + const location = useLocation(); + + return ( +
+ {/* outside of DemoComposer to avoid re-renders and flaky tests when word count changes */} + + + +
+ ); +} + +export default DemoApp; diff --git a/packages/koenig-lexical/demo/HtmlOutputDemo.jsx b/packages/koenig-lexical/demo/HtmlOutputDemo.jsx deleted file mode 100644 index bb65f9e9da..0000000000 --- a/packages/koenig-lexical/demo/HtmlOutputDemo.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import FloatingButton from './components/FloatingButton'; -import React from 'react'; -import Sidebar from './components/Sidebar'; -import Watermark from './components/Watermark'; -import {$getRoot, $isDecoratorNode} from 'lexical'; -import {HtmlOutputPlugin, KoenigComposableEditor, KoenigComposer} from '../src'; -import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; -import {fileTypes, useFileUpload} from './utils/useFileUpload'; -import {klipyConfig, tenorConfig} from './utils/gifConfig'; -import {useSnippets} from './utils/useSnippets'; -import {useState} from 'react'; - -const cardConfig = { - unsplash: {defaultHeaders: defaultUnsplashHeaders}, - tenor: tenorConfig, - klipy: klipyConfig -}; - -function HtmlOutputDemo() { - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [html, setHtml] = useState('

check ghost.org/changelog/markdown/

'); - const [sidebarView, setSidebarView] = useState('json'); - const [defaultContent] = useState(undefined); - const [editorAPI, setEditorAPI] = useState(null); - const titleRef = React.useRef(null); - const containerRef = React.useRef(null); - const {snippets, createSnippet, deleteSnippet} = useSnippets(); - - function openSidebar(view = 'json') { - if (isSidebarOpen && sidebarView === view) { - return setIsSidebarOpen(false); - } - setSidebarView(view); - setIsSidebarOpen(true); - } - - function focusTitle() { - titleRef.current?.focus(); - } - - function focusEditor(event) { - const clickedOnDecorator = (event.target.closest('[data-lexical-decorator]') !== null) || event.target.hasAttribute('data-lexical-decorator'); - const clickedOnSlashMenu = (event.target.closest('[data-kg-slash-menu]') !== null) || event.target.hasAttribute('data-kg-slash-menu'); - - if (editorAPI && !clickedOnDecorator && !clickedOnSlashMenu) { - let editor = editorAPI.editorInstance; - let {bottom} = editor._rootElement.getBoundingClientRect(); - - // if a mousedown and subsequent mouseup occurs below the editor - // canvas, focus the editor and put the cursor at the end of the document - if (event.pageY > bottom && event.clientY > bottom) { - event.preventDefault(); - - // we should always have a visible cursor when focusing - // at the bottom so create an empty paragraph if last - // section is a card - let addLastParagraph = false; - - editor.getEditorState().read(() => { - const nodes = $getRoot().getChildren(); - const lastNode = nodes[nodes.length - 1]; - - if (lastNode && $isDecoratorNode(lastNode)) { - addLastParagraph = true; - } - }); - - if (addLastParagraph) { - editorAPI.insertParagraphAtBottom(); - } - - // Focus the editor - editorAPI.focusEditor({position: 'bottom'}); - - //scroll to the bottom of the container - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - } - } - - return ( - <> - -
- -
-
-
- - - -
-
-
- -
- - -
-
-
- - ); -} - -export default HtmlOutputDemo; diff --git a/packages/koenig-lexical/demo/HtmlOutputDemo.tsx b/packages/koenig-lexical/demo/HtmlOutputDemo.tsx new file mode 100644 index 0000000000..73faa7497e --- /dev/null +++ b/packages/koenig-lexical/demo/HtmlOutputDemo.tsx @@ -0,0 +1,121 @@ +import FloatingButton from './components/FloatingButton'; +import React from 'react'; +import Sidebar from './components/Sidebar'; +import Watermark from './components/Watermark'; +import {$getRoot, $isDecoratorNode} from 'lexical'; +import {HtmlOutputPlugin, KoenigComposableEditor, KoenigComposer} from '../src'; +import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; +import {fileTypes, useFileUpload} from './utils/useFileUpload'; +import {klipyConfig, tenorConfig} from './utils/gifConfig'; +import {useSnippets} from './utils/useSnippets'; +import {useState} from 'react'; + +const cardConfig = { + unsplash: {defaultHeaders: defaultUnsplashHeaders}, + tenor: tenorConfig, + klipy: klipyConfig +}; + +function HtmlOutputDemo() { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [html, setHtml] = useState('

check ghost.org/changelog/markdown/

'); + const [sidebarView, setSidebarView] = useState('json'); + const [defaultContent] = useState(undefined); + const [editorAPI, setEditorAPI] = useState<{ + editorInstance: unknown; + insertParagraphAtBottom: () => void; + focusEditor: (options: {position: string}) => void; + [key: string]: unknown; + } | null>(null); + const titleRef = React.useRef<{focus: () => void} | null>(null); + const containerRef = React.useRef(null); + const {snippets, createSnippet, deleteSnippet} = useSnippets(); + + function openSidebar(view = 'json') { + if (isSidebarOpen && sidebarView === view) { + return setIsSidebarOpen(false); + } + setSidebarView(view); + setIsSidebarOpen(true); + } + + function focusTitle() { + titleRef.current?.focus(); + } + + function focusEditor(event: React.MouseEvent) { + const target = event.target as HTMLElement; + const clickedOnDecorator = (target.closest('[data-lexical-decorator]') !== null) || target.hasAttribute('data-lexical-decorator'); + const clickedOnSlashMenu = (target.closest('[data-kg-slash-menu]') !== null) || target.hasAttribute('data-kg-slash-menu'); + + if (editorAPI && !clickedOnDecorator && !clickedOnSlashMenu) { + const editor = editorAPI.editorInstance as {_rootElement: HTMLElement; getEditorState: () => {read: (fn: () => void) => void}}; + const {bottom} = editor._rootElement.getBoundingClientRect(); + + // if a mousedown and subsequent mouseup occurs below the editor + // canvas, focus the editor and put the cursor at the end of the document + if (event.pageY > bottom && event.clientY > bottom) { + event.preventDefault(); + + // we should always have a visible cursor when focusing + // at the bottom so create an empty paragraph if last + // section is a card + let addLastParagraph = false; + + editor.getEditorState().read(() => { + const nodes = $getRoot().getChildren(); + const lastNode = nodes[nodes.length - 1]; + + if (lastNode && $isDecoratorNode(lastNode)) { + addLastParagraph = true; + } + }); + + if (addLastParagraph) { + editorAPI.insertParagraphAtBottom(); + } + + // Focus the editor + editorAPI.focusEditor({position: 'bottom'}); + + //scroll to the bottom of the container + containerRef.current!.scrollTop = containerRef.current!.scrollHeight; + } + } + } + + return ( + <> + +
+ +
+
+
+ void} + > + + +
+
+
+ +
+ + +
+
+
+ + ); +} + +export default HtmlOutputDemo; diff --git a/packages/koenig-lexical/demo/RestrictedContentDemo.jsx b/packages/koenig-lexical/demo/RestrictedContentDemo.jsx deleted file mode 100644 index 1c4b25cd9b..0000000000 --- a/packages/koenig-lexical/demo/RestrictedContentDemo.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import FloatingButton from './components/FloatingButton'; -import React from 'react'; -import Sidebar from './components/Sidebar'; -import Watermark from './components/Watermark'; -import {$getRoot, $isDecoratorNode} from 'lexical'; -import {KoenigComposableEditor, KoenigComposer, RestrictContentPlugin} from '../src'; -import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; -import {fileTypes, useFileUpload} from './utils/useFileUpload'; -import {klipyConfig, tenorConfig} from './utils/gifConfig'; -import {useLocation} from 'react-router-dom'; -import {useSnippets} from './utils/useSnippets'; -import {useState} from 'react'; - -const cardConfig = { - unsplash: {defaultHeaders: defaultUnsplashHeaders}, - tenor: tenorConfig, - klipy: klipyConfig -}; - -function useQuery() { - const {search} = useLocation(); - - return React.useMemo(() => new URLSearchParams(search), [search]); -} - -function RestrictedContentDemo() { - let query = useQuery(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [sidebarView, setSidebarView] = useState('json'); - const [defaultContent] = useState(undefined); - const [editorAPI, setEditorAPI] = useState(null); - const titleRef = React.useRef(null); - const containerRef = React.useRef(null); - const paragraphs = query.get('paragraphs') || 1; - const {snippets, createSnippet, deleteSnippet} = useSnippets(); - - function openSidebar(view = 'json') { - if (isSidebarOpen && sidebarView === view) { - return setIsSidebarOpen(false); - } - setSidebarView(view); - setIsSidebarOpen(true); - } - - function focusTitle() { - titleRef.current?.focus(); - } - - function focusEditor(event) { - const clickedOnDecorator = (event.target.closest('[data-lexical-decorator]') !== null) || event.target.hasAttribute('data-lexical-decorator'); - const clickedOnSlashMenu = (event.target.closest('[data-kg-slash-menu]') !== null) || event.target.hasAttribute('data-kg-slash-menu'); - - if (editorAPI && !clickedOnDecorator && !clickedOnSlashMenu) { - let editor = editorAPI.editorInstance; - let {bottom} = editor._rootElement.getBoundingClientRect(); - - // if a mousedown and subsequent mouseup occurs below the editor - // canvas, focus the editor and put the cursor at the end of the document - if (event.pageY > bottom && event.clientY > bottom) { - event.preventDefault(); - - // we should always have a visible cursor when focusing - // at the bottom so create an empty paragraph if last - // section is a card - let addLastParagraph = false; - - editor.getEditorState().read(() => { - const nodes = $getRoot().getChildren(); - const lastNode = nodes[nodes.length - 1]; - - if (lastNode && $isDecoratorNode(lastNode)) { - addLastParagraph = true; - } - }); - - if (addLastParagraph) { - editorAPI.insertParagraphAtBottom(); - } - - // Focus the editor - editorAPI.focusEditor({position: 'bottom'}); - - //scroll to the bottom of the container - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - } - } - - return ( -
- -
-
-
- - - -
-
-
- -
- - -
-
-
- ); -} - -export default RestrictedContentDemo; diff --git a/packages/koenig-lexical/demo/RestrictedContentDemo.tsx b/packages/koenig-lexical/demo/RestrictedContentDemo.tsx new file mode 100644 index 0000000000..41701123dd --- /dev/null +++ b/packages/koenig-lexical/demo/RestrictedContentDemo.tsx @@ -0,0 +1,128 @@ +import FloatingButton from './components/FloatingButton'; +import React from 'react'; +import Sidebar from './components/Sidebar'; +import Watermark from './components/Watermark'; +import {$getRoot, $isDecoratorNode} from 'lexical'; +import {KoenigComposableEditor, KoenigComposer, RestrictContentPlugin} from '../src'; +import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig'; +import {fileTypes, useFileUpload} from './utils/useFileUpload'; +import {klipyConfig, tenorConfig} from './utils/gifConfig'; +import {useLocation} from 'react-router-dom'; +import {useSnippets} from './utils/useSnippets'; +import {useState} from 'react'; + +const cardConfig = { + unsplash: {defaultHeaders: defaultUnsplashHeaders}, + tenor: tenorConfig, + klipy: klipyConfig +}; + +function useQuery() { + const {search} = useLocation(); + + return React.useMemo(() => new URLSearchParams(search), [search]); +} + +function RestrictedContentDemo(_props: {paragraphs?: number}) { + const query = useQuery(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [sidebarView, setSidebarView] = useState('json'); + const [defaultContent] = useState(undefined); + const [editorAPI, setEditorAPI] = useState<{ + editorInstance: unknown; + insertParagraphAtBottom: () => void; + focusEditor: (options: {position: string}) => void; + [key: string]: unknown; + } | null>(null); + const titleRef = React.useRef<{focus: () => void} | null>(null); + const containerRef = React.useRef(null); + const paragraphs = Number(query.get('paragraphs')) || 1; + const {snippets, createSnippet, deleteSnippet} = useSnippets(); + + function openSidebar(view = 'json') { + if (isSidebarOpen && sidebarView === view) { + return setIsSidebarOpen(false); + } + setSidebarView(view); + setIsSidebarOpen(true); + } + + function focusTitle() { + titleRef.current?.focus(); + } + + function focusEditor(event: React.MouseEvent) { + const target = event.target as HTMLElement; + const clickedOnDecorator = (target.closest('[data-lexical-decorator]') !== null) || target.hasAttribute('data-lexical-decorator'); + const clickedOnSlashMenu = (target.closest('[data-kg-slash-menu]') !== null) || target.hasAttribute('data-kg-slash-menu'); + + if (editorAPI && !clickedOnDecorator && !clickedOnSlashMenu) { + const editor = editorAPI.editorInstance as {_rootElement: HTMLElement; getEditorState: () => {read: (fn: () => void) => void}}; + const {bottom} = editor._rootElement.getBoundingClientRect(); + + // if a mousedown and subsequent mouseup occurs below the editor + // canvas, focus the editor and put the cursor at the end of the document + if (event.pageY > bottom && event.clientY > bottom) { + event.preventDefault(); + + // we should always have a visible cursor when focusing + // at the bottom so create an empty paragraph if last + // section is a card + let addLastParagraph = false; + + editor.getEditorState().read(() => { + const nodes = $getRoot().getChildren(); + const lastNode = nodes[nodes.length - 1]; + + if (lastNode && $isDecoratorNode(lastNode)) { + addLastParagraph = true; + } + }); + + if (addLastParagraph) { + editorAPI.insertParagraphAtBottom(); + } + + // Focus the editor + editorAPI.focusEditor({position: 'bottom'}); + + //scroll to the bottom of the container + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + } + } + } + + return ( +
+ +
+
+
+ void} + > + + +
+
+
+ +
+ + +
+
+
+ ); +} + +export default RestrictedContentDemo; diff --git a/packages/koenig-lexical/demo/components/DarkModeToggle.jsx b/packages/koenig-lexical/demo/components/DarkModeToggle.jsx deleted file mode 100644 index cb39d8eaba..0000000000 --- a/packages/koenig-lexical/demo/components/DarkModeToggle.jsx +++ /dev/null @@ -1,11 +0,0 @@ -const DarkModeToggle = ({darkMode, toggleDarkMode}) => { - return ( - <> - - - ); -}; - -export default DarkModeToggle; diff --git a/packages/koenig-lexical/demo/components/DarkModeToggle.tsx b/packages/koenig-lexical/demo/components/DarkModeToggle.tsx new file mode 100644 index 0000000000..48833e77c8 --- /dev/null +++ b/packages/koenig-lexical/demo/components/DarkModeToggle.tsx @@ -0,0 +1,16 @@ +interface DarkModeToggleProps { + darkMode: boolean; + toggleDarkMode: () => void; +} + +const DarkModeToggle = ({darkMode, toggleDarkMode}: DarkModeToggleProps) => { + return ( + <> + + + ); +}; + +export default DarkModeToggle; diff --git a/packages/koenig-lexical/demo/components/EmailEditorWrapper.jsx b/packages/koenig-lexical/demo/components/EmailEditorWrapper.jsx deleted file mode 100644 index 29889fdf6c..0000000000 --- a/packages/koenig-lexical/demo/components/EmailEditorWrapper.jsx +++ /dev/null @@ -1,21 +0,0 @@ -const EmailEditorWrapper = ({children}) => { - return ( -
-
-
- From: - Ghost <noreply@example.com> -
-
- Subject: - Welcome to Ghost -
-
-
- {children} -
-
- ); -}; - -export default EmailEditorWrapper; diff --git a/packages/koenig-lexical/demo/components/EmailEditorWrapper.tsx b/packages/koenig-lexical/demo/components/EmailEditorWrapper.tsx new file mode 100644 index 0000000000..ed9f94af97 --- /dev/null +++ b/packages/koenig-lexical/demo/components/EmailEditorWrapper.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +const EmailEditorWrapper = ({children}: {children: React.ReactNode}) => { + return ( +
+
+
+ From: + Ghost <noreply@example.com> +
+
+ Subject: + Welcome to Ghost +
+
+
+ {children} +
+
+ ); +}; + +export default EmailEditorWrapper; diff --git a/packages/koenig-lexical/demo/components/FloatingButton.jsx b/packages/koenig-lexical/demo/components/FloatingButton.jsx deleted file mode 100644 index 81b628ff19..0000000000 --- a/packages/koenig-lexical/demo/components/FloatingButton.jsx +++ /dev/null @@ -1,15 +0,0 @@ -const FloatingButton = ({isOpen, ...props}) => { - return ( -
- -  |  - -
- ); -}; - -export default FloatingButton; diff --git a/packages/koenig-lexical/demo/components/FloatingButton.tsx b/packages/koenig-lexical/demo/components/FloatingButton.tsx new file mode 100644 index 0000000000..6b9768fc82 --- /dev/null +++ b/packages/koenig-lexical/demo/components/FloatingButton.tsx @@ -0,0 +1,20 @@ +interface FloatingButtonProps { + isOpen: boolean; + onClick: (view: string) => void; +} + +const FloatingButton = ({isOpen, ...props}: FloatingButtonProps) => { + return ( +
+ +  |  + +
+ ); +}; + +export default FloatingButton; diff --git a/packages/koenig-lexical/demo/components/InitialContentToggle.jsx b/packages/koenig-lexical/demo/components/InitialContentToggle.jsx deleted file mode 100644 index a0d6b8d259..0000000000 --- a/packages/koenig-lexical/demo/components/InitialContentToggle.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import EyeClosedIcon from './icons/eye-closed.svg?react'; -import EyeOpenIcon from './icons/eye-open.svg?react'; -import React from 'react'; -import {$createParagraphNode, $getRoot} from 'lexical'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -const InitialContentToggle = ({defaultContent, setTitle, searchParams, setSearchParams}) => { - const [editor] = useLexicalComposerContext(); - const [isOn, setIsOn] = React.useState(searchParams.get('content') !== 'false'); - - const toggle = () => { - if (!isOn) { - const editorState = editor.parseEditorState(defaultContent); - editor.setEditorState(editorState); - setTitle('Meet the Koenig editor.'); - searchParams.delete('content'); - setSearchParams(searchParams); - } - if (isOn) { - editor.update(() => { - const root = $getRoot(); - const paragraph = $createParagraphNode(); - root.clear(); - root.append(paragraph); - paragraph.select(); - }); - setTitle(''); - searchParams.set('content', 'false'); - setSearchParams(searchParams); - } - setIsOn(!isOn); - }; - - return ( - <> - - - ); -}; - -export default InitialContentToggle; diff --git a/packages/koenig-lexical/demo/components/InitialContentToggle.tsx b/packages/koenig-lexical/demo/components/InitialContentToggle.tsx new file mode 100644 index 0000000000..45166ec462 --- /dev/null +++ b/packages/koenig-lexical/demo/components/InitialContentToggle.tsx @@ -0,0 +1,52 @@ +import EyeClosedIcon from './icons/eye-closed.svg?react'; +import EyeOpenIcon from './icons/eye-open.svg?react'; +import React from 'react'; +import {$createParagraphNode, $getRoot} from 'lexical'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +interface InitialContentToggleProps { + defaultContent: string; + setTitle: (title: string) => void; + searchParams: URLSearchParams; + setSearchParams: (params: URLSearchParams) => void; +} + +const InitialContentToggle = ({defaultContent, setTitle, searchParams, setSearchParams}: InitialContentToggleProps) => { + const [editor] = useLexicalComposerContext(); + const [isOn, setIsOn] = React.useState(searchParams.get('content') !== 'false'); + + const toggle = () => { + if (!isOn) { + const editorState = editor.parseEditorState(defaultContent); + editor.setEditorState(editorState); + setTitle('Meet the Koenig editor.'); + searchParams.delete('content'); + setSearchParams(searchParams); + } + if (isOn) { + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.clear(); + root.append(paragraph); + paragraph.select(); + }); + setTitle(''); + searchParams.set('content', 'false'); + setSearchParams(searchParams); + } + setIsOn(!isOn); + }; + + return ( + <> + + + ); +}; + +export default InitialContentToggle; diff --git a/packages/koenig-lexical/demo/components/Navigator.jsx b/packages/koenig-lexical/demo/components/Navigator.jsx deleted file mode 100644 index 982ddc6b0e..0000000000 --- a/packages/koenig-lexical/demo/components/Navigator.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import {useNavigate} from 'react-router-dom'; - -const Navigator = () => { - const navigate = useNavigate(); - - // Hack, used to allow Playwright to navigate without triggering a full page reload. - window.navigate = navigate; - - return null; -}; - -export default Navigator; diff --git a/packages/koenig-lexical/demo/components/Navigator.tsx b/packages/koenig-lexical/demo/components/Navigator.tsx new file mode 100644 index 0000000000..77c3929cd2 --- /dev/null +++ b/packages/koenig-lexical/demo/components/Navigator.tsx @@ -0,0 +1,18 @@ +import {useNavigate} from 'react-router-dom'; + +declare global { + interface Window { + navigate: (path: string) => void; + } +} + +const Navigator = () => { + const navigate = useNavigate(); + + // Hack, used to allow Playwright to navigate without triggering a full page reload. + window.navigate = navigate; + + return null; +}; + +export default Navigator; diff --git a/packages/koenig-lexical/demo/components/SerializedStateTextarea.jsx b/packages/koenig-lexical/demo/components/SerializedStateTextarea.jsx deleted file mode 100644 index 58dfba65c5..0000000000 --- a/packages/koenig-lexical/demo/components/SerializedStateTextarea.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import 'highlight.js/styles/atom-one-dark.css'; -import React from 'react'; -import ReactHighlight from 'react-highlight'; -import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -const Highlight = ReactHighlight.default || ReactHighlight; - -const SerializedStateTextarea = ({isOpen}) => { - const [editor] = useLexicalComposerContext(); - - const renderEditorState = () => JSON.stringify(editor.getEditorState().toJSON(), null, 2); - - const [serializedJson, setSerializedJson] = React.useState(renderEditorState()); - - const onChange = () => { - setSerializedJson(renderEditorState()); - }; - - return ( - <> -
- {isOpen && ( - - {serializedJson} - - )} -
- - - ); -}; - -export default SerializedStateTextarea; diff --git a/packages/koenig-lexical/demo/components/SerializedStateTextarea.tsx b/packages/koenig-lexical/demo/components/SerializedStateTextarea.tsx new file mode 100644 index 0000000000..109f68221b --- /dev/null +++ b/packages/koenig-lexical/demo/components/SerializedStateTextarea.tsx @@ -0,0 +1,34 @@ +import 'highlight.js/styles/atom-one-dark.css'; +import React from 'react'; +import ReactHighlight from 'react-highlight'; +import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +const Highlight = ReactHighlight.default || ReactHighlight; + +const SerializedStateTextarea = ({isOpen}: {isOpen: boolean}) => { + const [editor] = useLexicalComposerContext(); + + const renderEditorState = () => JSON.stringify(editor.getEditorState().toJSON(), null, 2); + + const [serializedJson, setSerializedJson] = React.useState(renderEditorState()); + + const onChange = () => { + setSerializedJson(renderEditorState()); + }; + + return ( + <> +
+ {isOpen && ( + + {serializedJson} + + )} +
+ + + ); +}; + +export default SerializedStateTextarea; diff --git a/packages/koenig-lexical/demo/components/Sidebar.jsx b/packages/koenig-lexical/demo/components/Sidebar.jsx deleted file mode 100644 index 919bf388e4..0000000000 --- a/packages/koenig-lexical/demo/components/Sidebar.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import SerializedStateTextarea from './SerializedStateTextarea'; -import TreeView from './TreeView'; - -const Sidebar = ({isOpen, view, saveContent}) => { - return ( -
- {view === 'json' && } - {view === 'tree' && } - - {view === 'json' && ( -
- -
- )} -
- ); -}; - -export default Sidebar; diff --git a/packages/koenig-lexical/demo/components/Sidebar.tsx b/packages/koenig-lexical/demo/components/Sidebar.tsx new file mode 100644 index 0000000000..f31c430ad5 --- /dev/null +++ b/packages/koenig-lexical/demo/components/Sidebar.tsx @@ -0,0 +1,25 @@ +import SerializedStateTextarea from './SerializedStateTextarea'; +import TreeView from './TreeView'; + +interface SidebarProps { + isOpen: boolean; + view: string; + saveContent?: () => void; +} + +const Sidebar = ({isOpen, view, saveContent}: SidebarProps) => { + return ( +
+ {view === 'json' && } + {view === 'tree' && } + + {view === 'json' && ( +
+ +
+ )} +
+ ); +}; + +export default Sidebar; diff --git a/packages/koenig-lexical/demo/components/TitleTextBox.jsx b/packages/koenig-lexical/demo/components/TitleTextBox.jsx deleted file mode 100644 index 0d9a48c2ce..0000000000 --- a/packages/koenig-lexical/demo/components/TitleTextBox.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; - -export const TitleTextBox = React.forwardRef(({title, setTitle, editorAPI}, ref) => { - const titleEl = React.useRef(null); - - React.useImperativeHandle(ref, () => ({ - focus: () => { - titleEl.current?.focus(); - } - })); - - React.useEffect(() => { - if (titleEl.current) { - titleEl.current.style.height = '58px'; - titleEl.current.style.height = titleEl.current.scrollHeight + 'px'; - } - }, [titleEl, title]); - - const handleTitleInput = (e) => { - setTitle(e.target.value); - }; - - // move cursor to the editor on - // - Tab - // - Arrow Down/Right when input is empty or caret at end of input - // - Enter, creating an empty paragraph when editor is not empty - const handleTitleKeyDown = (event) => { - if (!editorAPI) { - return; - } - - const {key} = event; - const {value, selectionStart} = event.target; - - const couldLeaveTitle = !value || selectionStart === value.length; - const arrowLeavingTitle = ['ArrowDown', 'ArrowRight'].includes(key) && couldLeaveTitle; - - if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) { - event.preventDefault(); - - if (key === 'Enter' && !editorAPI.editorIsEmpty()) { - editorAPI.insertParagraphAtTop({focus: true}); - } else { - editorAPI.focusEditor({position: 'top'}); - } - } - }; - - return ( - - - - - - - {isUnsplashDialogOpen && ( - - )} - - ); -} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx new file mode 100644 index 0000000000..6fbbb352c4 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx @@ -0,0 +1,265 @@ +import MarkdownHelpDialog from './MarkdownHelpDialog'; +import MarkdownImageUploader from './MarkdownImageUploader'; +import SimpleMDE from '@tryghost/kg-simplemde'; +import UnsplashModal from '../../file-selectors/UnsplashModal'; +import {useLayoutEffect, useRef, useState} from 'react'; + +import ctrlOrCmd from '../../../../utils/ctrlOrCmd'; +import useMarkdownImageUploader from './useMarkdownImageUploader'; + +interface CodeMirrorInstance { + on(event: string, handler: (...args: unknown[]) => void): void; + off(event: string, handler: (...args: unknown[]) => void): void; + focus(): void; + execCommand(command: string): void; + getOption(name: string): unknown; + setOption(name: string, value: unknown): void; +} + +interface SimpleMDEInstance { + value(val?: string): string; + codemirror: CodeMirrorInstance; + toTextArea(): void; + toolbarElements: Record; +} + +interface MarkdownEditorProps { + markdown?: string; + updateMarkdown?: (value: string) => void; + imageUploader: (type: string) => unknown; + unsplashConf?: unknown; + autofocus?: boolean; + placeholder?: string; +} + +export default function MarkdownEditor({ + markdown, + updateMarkdown, + imageUploader, + unsplashConf, + autofocus = true, + placeholder = '' +}: MarkdownEditorProps) { + const editorRef = useRef(null); + const markdownEditor = useRef(null); + const [isHelpDialogOpen, setHelpDialogOpen] = useState(false); + const [isUnsplashDialogOpen, setUnsplashDialogOpen] = useState(false); + const { + openImageUploadDialog, + uploadImages, + insertUnsplashImage, + imageInputRef, + progress, + errors: imageUploadErrors, + isLoading, + filesNumber + } = useMarkdownImageUploader(markdownEditor as Parameters[0], imageUploader as Parameters[1]); + + const shortcuts = { + openImageDialog: `${ctrlOrCmd}-Alt-I`, + toggleSpellcheck: `${ctrlOrCmd}-Alt-S`, + openUnsplashDialog: `${ctrlOrCmd}-Alt-O` + }; + + // init markdown editor on component mount + useLayoutEffect(() => { + markdownEditor.current = new SimpleMDE({ + element: editorRef.current, + autofocus, + indentWithTabs: false, + placeholder, + tabSize: 4, + // disable shortcuts for side-by-side and fullscreen because they + // trigger internal SimpleMDE methods that will result in broken + // layouts + shortcuts: { + toggleFullScreen: null, + togglePreview: null, + toggleSideBySide: null, + drawImage: null, + + // Enable strikethrough with CMD + Alt + U + toggleStrikethrough: `${ctrlOrCmd}-Alt-U` + }, + hideIcons: getListOfHiddenIcons(), + // hide status bar + status: [], + // Ghost-specific SimpleMDE toolbar config - allows us to create a + // bridge between SimpleMDE buttons and Ember actions + toolbar: [ + 'bold', 'italic', 'heading', '|', + 'quote', 'unordered-list', 'ordered-list', '|', + 'link', + { + name: 'image', + action: openImageUploadDialog, + className: 'fa fa-picture-o', + title: `Upload Image(s) (${shortcuts.openImageDialog})` + }, + { + name: 'unsplash', + action: openUnsplashDialog, + className: 'fa fa-camera', + title: `Add Image from Unsplash (${shortcuts.openUnsplashDialog})` + }, + '|', + { + name: 'spellcheck', + action: toggleSpellcheck, + className: 'fa fa-check', + title: `Spellcheck (${shortcuts.toggleSpellcheck})` + }, + { + name: 'guide', + action: openHelpDialog, + className: 'fa fa-question-circle', + title: 'Markdown Guide' + } + ] + }); + + const editorInstance = markdownEditor.current; + if (!editorInstance) { + return; + } + + editorInstance.value(markdown ?? ''); + + editorInstance.codemirror.on('change', (_instance: unknown, changeObj: unknown) => { + // avoid a "modified x twice in a single render" error that occurs + // when the underlying value is completely swapped out + if ((changeObj as {origin?: string}).origin !== 'setValue') { + updateMarkdown?.(editorInstance.value()); + } + }); + + // add non-breaking space as a special char + // control characters are intentional - this regex detects special chars for CodeMirror + const specialCharsPattern = `[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}-${String.fromCharCode(0x9f)}\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff\xa0]`; + editorInstance.codemirror.setOption('specialChars', new RegExp(specialCharsPattern, 'g')); + + if (autofocus) { + editorInstance.codemirror.execCommand('goDocEnd'); + } + + // Prevents the editor from losing focus when double clicking inside + editorInstance.codemirror.on('mousedown', (_instance: unknown, event: unknown) => { + (event as MouseEvent).stopPropagation(); + }); + + addShortcuts(); + + // spellchecker turned off by default + const codemirror = editorInstance.codemirror; + codemirror.setOption('mode', 'gfm'); + + // remove editor on unmount + return () => { + markdownEditor.current?.toTextArea(); + }; + + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function addShortcuts() { + const codemirror = markdownEditor.current!.codemirror; + + const keys = (codemirror.getOption('extraKeys') as Record | undefined) ?? {}; + + keys[shortcuts.toggleSpellcheck] = toggleSpellcheck; + keys[shortcuts.openImageDialog] = openImageUploadDialog; + + if (unsplashConf) { + keys[shortcuts.openUnsplashDialog] = openUnsplashDialog; + } + // update shortcuts + codemirror.setOption('extraKeys', keys); + } + + function toggleSpellcheck() { + const codemirror = markdownEditor.current!.codemirror; + + if (codemirror.getOption('mode') === 'spell-checker') { + codemirror.setOption('mode', 'gfm'); + } else { + codemirror.setOption('mode', 'spell-checker'); + } + + toggleButtonClass(); + codemirror.focus(); + } + + function toggleButtonClass() { + const spellcheckButton = markdownEditor.current!.toolbarElements.spellcheck; + + if (spellcheckButton) { + if (markdownEditor.current!.codemirror.getOption('mode') === 'spell-checker') { + spellcheckButton.classList.add('active'); + } else { + spellcheckButton.classList.remove('active'); + } + } + } + + function openHelpDialog() { + setHelpDialogOpen(true); + } + + function closeHelpDialog() { + setHelpDialogOpen(false); + markdownEditor.current!.codemirror.focus(); + } + + function getListOfHiddenIcons() { + const icons: string[] = []; + + if (!unsplashConf) { + icons.push('unsplash'); + } + + return icons; + } + + function openUnsplashDialog() { + setUnsplashDialogOpen(true); + } + + function closeUnsplashDialog() { + markdownEditor.current!.codemirror.focus(); + setUnsplashDialogOpen(false); + } + + function onUnsplashInsert(img: {src: string; alt?: string; caption: string}) { + insertUnsplashImage(img); + setUnsplashDialogOpen(false); + } + + return ( +
+ + + + + + + {isUnsplashDialogOpen && ( + void} + /> + )} +
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.jsx b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.jsx deleted file mode 100644 index f4d2fe686a..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.jsx +++ /dev/null @@ -1,144 +0,0 @@ -import {Modal} from '../../Modal'; - -function Th({value}) { - return ( - {value} - ); -} - -function superSub(value) { - switch (value) { - case 'Super': - return ( - - {value} - text - - ); - case 'Sub': - return ( - - {value} - text - - ); - default: - return value; - } -} - -export function Td({value}) { - return ( - - - {superSub(value)} - - - ); -} - -export default function MarkdownHelpDialog(props) { - return ( - -
-
-

- Markdown Help -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - For further Markdown syntax reference: Markdown Documentation - -
-
-
- ); -} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.tsx b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.tsx new file mode 100644 index 0000000000..24958c6980 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.tsx @@ -0,0 +1,144 @@ +import {Modal} from '../../Modal'; + +function Th({value}: {value: string}) { + return ( + {value} + ); +} + +function superSub(value: string) { + switch (value) { + case 'Super': + return ( + + {value} + text + + ); + case 'Sub': + return ( + + {value} + text + + ); + default: + return value; + } +} + +export function Td({value}: {value: string}) { + return ( + + + {superSub(value)} + + + ); +} + +export default function MarkdownHelpDialog(props: {isOpen?: boolean; onClose: () => void}) { + return ( + +
+
+

+ Markdown Help +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + For further Markdown syntax reference: Markdown Documentation + +
+
+
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.jsx b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.jsx deleted file mode 100644 index 213be82c09..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import pluralize from 'pluralize'; -import {ProgressBar} from '../../ProgressBar'; - -export default function MarkdownImageUploader({onChange, inputRef, progress, loading, filesNumber, errors = []}) { - const progressStyle = { - width: `${progress}%` - }; - return ( - <> - { - loading && !!progress && ( -
-
-

- Uploading {filesNumber} {pluralize('image', filesNumber)}... -

- -
-
- ) - } - - { - !!errors.length && ( - errors.map(error => ( -
-
-

{error.fileName} failed to upload.

-

{error.message}

-
-
- )) - ) - } -
- -
- - ); -} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.tsx b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.tsx new file mode 100644 index 0000000000..f585c3a8da --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.tsx @@ -0,0 +1,56 @@ +import pluralize from 'pluralize'; +import {ProgressBar} from '../../ProgressBar'; + +interface MarkdownImageUploaderProps { + onChange?: (e: React.ChangeEvent) => void; + inputRef?: React.Ref; + progress?: number; + loading?: boolean; + filesNumber?: number; + errors?: {fileName?: string; message: string}[]; +} + +export default function MarkdownImageUploader({onChange, inputRef, progress, loading, filesNumber, errors = []}: MarkdownImageUploaderProps) { + const progressStyle = { + width: `${progress}%` + }; + return ( + <> + { + loading && !!progress && ( +
+
+

+ Uploading {filesNumber} {pluralize('image', filesNumber)}... +

+ +
+
+ ) + } + + { + !!errors.length && ( + errors.map(error => ( +
+
+

{error.fileName} failed to upload.

+

{error.message}

+
+
+ )) + ) + } +
+ +
+ + ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.js b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.js deleted file mode 100644 index a956e0a58c..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.js +++ /dev/null @@ -1,74 +0,0 @@ -import {useRef} from 'react'; - -export default function useMarkdownImageUploader(editor, imageUploader) { - const imageInputRef = useRef(null); - const {progress, upload, errors, isLoading, filesNumber} = imageUploader('image'); - - const uploadImages = async (event) => { - const files = event.target.files; - const filesSrc = await upload(files); - insertImages(filesSrc); - }; - - function openImageUploadDialog() { - imageInputRef.current.click(); - } - - function insertUnsplashImage({src, alt, caption}) { - let image = { - alt, - url: src, - credit: `${caption}` - }; - - insertImages([image]); - } - - function insertImages(urls = []) { - const codemirror = editor.current.codemirror; - - // loop through urls and generate image markdown - let images = urls.map((url) => { - // plain url string, so extract filename from path - if (typeof url === 'string') { - let filename = url.split('/').pop(); - let alt = filename; - - // if we have a normal filename.ext, set alt to filename -ext - if (filename.lastIndexOf('.') > 0) { - alt = filename.slice(0, filename.lastIndexOf('.')); - } - - return `![${alt}](${url})`; - - // full url object, use attrs we're given - } else { - let image = `![${url.fileName}](${url.url})`; - - if (url.credit) { - image += `\n${url.credit}`; - } - - return image; - } - }); - let text = images.join('\n\n'); - - editor.current.codemirror.focus(); - - // insert at cursor or replace selection then position cursor at end - // of inserted text - codemirror.replaceSelection(text, 'end'); - } - - return { - openImageUploadDialog, - uploadImages, - insertUnsplashImage, - imageInputRef, - progress, - errors, - isLoading, - filesNumber - }; -} diff --git a/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts new file mode 100644 index 0000000000..c7dcfd2f0f --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts @@ -0,0 +1,100 @@ +import {useRef} from 'react'; + +interface UploadResult { + fileName?: string; + url: string; + credit?: string; +} + +interface ImageUploaderResult { + progress: number; + upload: (files: FileList) => Promise<(string | UploadResult)[]>; + errors: {message: string}[]; + isLoading: boolean; + filesNumber: number; +} + +interface SimpleMDEEditorRef { + current: { + codemirror: { + focus: () => void; + replaceSelection: (text: string, position: string) => void; + }; + } | null; +} + +export default function useMarkdownImageUploader(editor: SimpleMDEEditorRef, imageUploader: (type: string) => ImageUploaderResult) { + const imageInputRef = useRef(null); + const {progress, upload, errors, isLoading, filesNumber} = imageUploader('image'); + + const uploadImages = async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files) { + return; + } + const filesSrc = await upload(files); + insertImages(filesSrc); + }; + + function openImageUploadDialog() { + imageInputRef.current?.click(); + } + + function insertUnsplashImage({src, alt, caption}: {src: string; alt?: string; caption: string}) { + const image = { + alt, + url: src, + credit: `${caption}` + }; + + insertImages([image]); + } + + function insertImages(urls: (string | UploadResult)[] = []) { + const codemirror = editor.current!.codemirror; + + // loop through urls and generate image markdown + const images = urls.map((url) => { + // plain url string, so extract filename from path + if (typeof url === 'string') { + const filename = url.split('/').pop() || ''; + let alt = filename; + + // if we have a normal filename.ext, set alt to filename -ext + if (filename.lastIndexOf('.') > 0) { + alt = filename.slice(0, filename.lastIndexOf('.')); + } + + return `![${alt}](${url})`; + + // full url object, use attrs we're given + } else { + let image = `![${url.fileName}](${url.url})`; + + if (url.credit) { + image += `\n${url.credit}`; + } + + return image; + } + }); + const text = images.join('\n\n'); + + editor.current!.codemirror.focus(); + + // insert at cursor or replace selection then position cursor at end + // of inserted text + codemirror.replaceSelection(text, 'end'); + } + + return { + openImageUploadDialog, + uploadImages, + insertUnsplashImage, + imageInputRef, + progress, + errors, + isLoading, + filesNumber + }; +} diff --git a/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.jsx deleted file mode 100644 index 94f5e5e947..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import {CardWrapper} from './../CardWrapper'; -import {PaywallCard} from './PaywallCard'; - -const displayOptions = { - Default: {isSelected: false, isEditing: false}, - Selected: {isSelected: true, isEditing: false} -}; - -const story = { - title: 'Primary cards/Public preview card', - component: PaywallCard, - subcomponent: {CardWrapper}, - argTypes: { - display: { - options: Object.keys(displayOptions), - mapping: displayOptions, - control: { - type: 'radio', - labels: { - Default: 'Default', - Selected: 'Selected' - }, - defaultValue: displayOptions.Default - } - } - }, - parameters: { - status: { - type: 'uiReady' - } - } -}; -export default story; - -const Template = ({display, ...args}) => ( -
-
- - - -
-
- - - -
-
-); - -export const Default = Template.bind({}); -Default.args = { - display: 'Selected' -}; - diff --git a/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.tsx b/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.tsx new file mode 100644 index 0000000000..58127cc979 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.tsx @@ -0,0 +1,56 @@ +import {CardWrapper} from './../CardWrapper'; +import {PaywallCard} from './PaywallCard'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; + +const displayOptions = { + Default: {isSelected: false, isEditing: false}, + Selected: {isSelected: true, isEditing: false} +}; + +type StoryArgs = ComponentProps & {display: keyof typeof displayOptions}; + +const story: Meta = { + title: 'Primary cards/Public preview card', + component: PaywallCard, + subcomponents: {CardWrapper}, + argTypes: { + display: { + options: Object.keys(displayOptions), + control: { + type: 'radio', + labels: { + Default: 'Default', + Selected: 'Selected' + }, + defaultValue: displayOptions.Default + } + } + }, + parameters: { + status: { + type: 'uiReady' + } + } +}; +export default story; + +const Template: StoryFn = ({display, ...args}) => ( +
+
+ + + +
+
+ + + +
+
+); + +export const Default = Template.bind({}); +Default.args = { + display: 'Selected' +}; diff --git a/packages/koenig-lexical/src/components/ui/cards/PaywallCard.jsx b/packages/koenig-lexical/src/components/ui/cards/PaywallCard.tsx similarity index 100% rename from packages/koenig-lexical/src/components/ui/cards/PaywallCard.jsx rename to packages/koenig-lexical/src/components/ui/cards/PaywallCard.tsx diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard.jsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard.jsx deleted file mode 100644 index 26439243e7..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ProductCard.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import KoenigNestedEditor from '../../KoenigNestedEditor'; -import PropTypes from 'prop-types'; -import {Button} from '../Button'; -import {InputSetting, InputUrlSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; -import {ProductCardImage} from './ProductCard/ProductCardImage'; -import {RatingButton} from './ProductCard/RatingButton'; -import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; -import {isEditorEmpty} from '../../../utils/isEditorEmpty'; - -export function ProductCard({ - isEditing, - imgSrc, - isButtonEnabled, - buttonText, - buttonUrl, - rating, - isRatingEnabled, - onButtonToggle, - onButtonTextChange, - onButtonUrlChange, - onRatingToggle, - imgDragHandler, - onImgChange, - imgMimeTypes, - imgUploader, - isPinturaEnabled, - openImageEditor, - onRemoveImage, - titleEditor, - titleEditorInitialState, - descriptionEditor, - descriptionEditorInitialState, - onRatingChange -}) { - const showFilledButton = !!buttonUrl && !!buttonText && isButtonEnabled; - const showButtonInEditMode = isButtonEnabled && isEditing; - return ( - <> -
- - -
- { - (isEditing || !isEditorEmpty(titleEditor)) && ( -
- -
- ) - } - - {isRatingEnabled && ( - - )} -
- - { - (isEditing || !isEditorEmpty(descriptionEditor)) && ( -
- -
- ) - } - - {(showButtonInEditMode || showFilledButton) && ( -
-
- )} -
- - {isEditing && ( - - - - {isButtonEnabled && ( - <> - - - - )} - - )} - - {!isEditing && } - - ); -} - -ProductCard.propTypes = { - isEditing: PropTypes.bool, - imgSrc: PropTypes.string, - isButtonEnabled: PropTypes.bool, - buttonText: PropTypes.string, - buttonUrl: PropTypes.string, - isRatingEnabled: PropTypes.bool, - rating: PropTypes.number, - onButtonToggle: PropTypes.func, - onButtonTextChange: PropTypes.func, - onButtonUrlChange: PropTypes.func, - onRatingToggle: PropTypes.func, - onImgChange: PropTypes.func, - onRemoveImage: PropTypes.func, - imgDragHandler: PropTypes.object, - imgUploader: PropTypes.object, - imgMimeTypes: PropTypes.array, - isPinturaEnabled: PropTypes.bool, - openImageEditor: PropTypes.func, - title: PropTypes.string, - description: PropTypes.string, - titleEditor: PropTypes.object, - titleEditorInitialState: PropTypes.object, - descriptionEditor: PropTypes.object, - descriptionEditorInitialState: PropTypes.object, - onRatingChange: PropTypes.func -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.jsx deleted file mode 100644 index b732c44826..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.jsx +++ /dev/null @@ -1,147 +0,0 @@ -import populateEditor from '../../../utils/storybook/populate-storybook-editor.js'; -import {BASIC_NODES, MINIMAL_NODES} from '../../../index.js'; -import {CardWrapper} from './../CardWrapper'; -import {ProductCard} from './ProductCard'; -import {createEditor} from 'lexical'; - -const displayOptions = { - Default: {isSelected: false, isEditing: false}, - Selected: {isSelected: true, isEditing: false}, - Editing: {isSelected: true, isEditing: true} -}; - -const story = { - title: 'Primary cards/Product card', - component: ProductCard, - subcomponent: {CardWrapper}, - argTypes: { - display: { - options: Object.keys(displayOptions), - mapping: displayOptions, - control: { - type: 'radio', - labels: { - Default: 'Default', - Selected: 'Selected', - Editing: 'Editing' - }, - defaultValue: displayOptions.Default - } - } - }, - parameters: { - status: { - type: 'uiReady' - } - } -}; -export default story; - -const Template = ({display, title, description, ...args}) => { - const titleEditor = createEditor({nodes: MINIMAL_NODES}); - populateEditor({editor: titleEditor, initialHtml: `${title}`}); - - const descriptionEditor = createEditor({nodes: BASIC_NODES}); - populateEditor({editor: descriptionEditor, initialHtml: `${description}`}); - - return ( -
-
- -
- -
-
-
-
- ); -}; - -export const Empty = Template.bind({}); -Empty.args = { - display: 'Editing', - image: false, - title: '', - description: '', - isRatingEnabled: false, - isButtonEnabled: false, - buttonText: '', - buttonUrl: '', - imgMimeTypes: ['image/*'] -}; - -export const Uploading = Template.bind({}); -Uploading.args = { - display: 'Editing', - image: true, - title: 'Fujifilm X100V', - description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', - isRatingEnabled: false, - isButtonEnabled: false, - buttonText: 'Get it now', - buttonUrl: 'https://ghost.org/', - rating: 5, - imgMimeTypes: ['image/*'], - imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - imgUploader: { - isLoading: true - } -}; - -export const DraggedOver = Template.bind({}); -DraggedOver.args = { - display: 'Editing', - image: true, - title: 'Fujifilm X100V', - description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', - isRatingEnabled: false, - isButtonEnabled: false, - buttonText: 'Get it now', - buttonUrl: 'https://ghost.org/', - rating: 5, - imgMimeTypes: ['image/*'], - imgSrc: '', - imgDragHandler: { - isDraggedOver: true - } -}; - -export const Error = Template.bind({}); -Error.args = { - display: 'Editing', - image: true, - title: 'Fujifilm X100V', - description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', - isRatingEnabled: false, - isButtonEnabled: false, - buttonText: 'Get it now', - buttonUrl: 'https://ghost.org/', - rating: 5, - imgMimeTypes: ['image/*'], - imgSrc: '', - imgUploader: { - errors: [{message: 'This file type is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'}] - } -}; - -export const Populated = Template.bind({}); -Populated.args = { - display: 'Editing', - image: true, - title: 'Fujifilm X100V', - description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', - isRatingEnabled: true, - isButtonEnabled: true, - buttonText: 'Get it now', - buttonUrl: 'https://ghost.org/', - rating: 4, - imgMimeTypes: ['image/*'], - imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg' -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx new file mode 100644 index 0000000000..db0d654087 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx @@ -0,0 +1,143 @@ +import populateEditor from '../../../utils/storybook/populate-storybook-editor'; +import {BASIC_NODES, MINIMAL_NODES} from '../../../index'; +import {CardWrapper} from './../CardWrapper'; +import {ProductCard} from './ProductCard'; +import {createEditor} from 'lexical'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; + +const displayOptions = { + Default: {isSelected: false, isEditing: false}, + Selected: {isSelected: true, isEditing: false}, + Editing: {isSelected: true, isEditing: true} +}; + +type StoryArgs = ComponentProps & {display: keyof typeof displayOptions; title?: string; description?: string}; + +const story: Meta = { + title: 'Primary cards/Product card', + component: ProductCard, + subcomponents: {CardWrapper}, + argTypes: { + display: { + options: Object.keys(displayOptions), + control: { + type: 'radio', + labels: { + Default: 'Default', + Selected: 'Selected', + Editing: 'Editing' + }, + defaultValue: displayOptions.Default + } + } + }, + parameters: { + status: { + type: 'uiReady' + } + } +}; +export default story; + +const Template: StoryFn = ({display, title, description, ...args}) => { + const titleEditor = createEditor({nodes: MINIMAL_NODES}); + populateEditor({editor: titleEditor, initialHtml: `${title}`}); + + const descriptionEditor = createEditor({nodes: BASIC_NODES}); + populateEditor({editor: descriptionEditor, initialHtml: `${description}`}); + + return ( +
+
+ +
+ +
+
+
+
+ ); +}; + +export const Empty = Template.bind({}); +Empty.args = { + display: 'Editing', + title: '', + description: '', + isRatingEnabled: false, + isButtonEnabled: false, + buttonText: '', + buttonUrl: '', + imgMimeTypes: ['image/*'] +}; + +export const Uploading = Template.bind({}); +Uploading.args = { + display: 'Editing', + title: 'Fujifilm X100V', + description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', + isRatingEnabled: false, + isButtonEnabled: false, + buttonText: 'Get it now', + buttonUrl: 'https://ghost.org/', + rating: 5, + imgMimeTypes: ['image/*'], + imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + imgUploader: { + isLoading: true + } +}; + +export const DraggedOver = Template.bind({}); +DraggedOver.args = { + display: 'Editing', + title: 'Fujifilm X100V', + description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', + isRatingEnabled: false, + isButtonEnabled: false, + buttonText: 'Get it now', + buttonUrl: 'https://ghost.org/', + rating: 5, + imgMimeTypes: ['image/*'], + imgSrc: '', + imgDragHandler: { + isDraggedOver: true + } +}; + +export const Error = Template.bind({}); +Error.args = { + display: 'Editing', + title: 'Fujifilm X100V', + description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', + isRatingEnabled: false, + isButtonEnabled: false, + buttonText: 'Get it now', + buttonUrl: 'https://ghost.org/', + rating: 5, + imgMimeTypes: ['image/*'], + imgSrc: '', + imgUploader: { + errors: [{message: 'This file type is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'}] + } +}; + +export const Populated = Template.bind({}); +Populated.args = { + display: 'Editing', + title: 'Fujifilm X100V', + description: 'Simple actions that lead to making everyday moments remarkable. Rediscover photography in a new and exciting way with FUJIFILM X100V mirrorless digital camera.', + isRatingEnabled: true, + isButtonEnabled: true, + buttonText: 'Get it now', + buttonUrl: 'https://ghost.org/', + rating: 4, + imgMimeTypes: ['image/*'], + imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg' +}; diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard.tsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard.tsx new file mode 100644 index 0000000000..947c96f04d --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ProductCard.tsx @@ -0,0 +1,164 @@ +import KoenigNestedEditor from '../../KoenigNestedEditor'; +import React from 'react'; +import {Button} from '../Button'; +import {InputSetting, InputUrlSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; +import {ProductCardImage} from './ProductCard/ProductCardImage'; +import {RatingButton} from './ProductCard/RatingButton'; +import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; +import {isEditorEmpty} from '../../../utils/isEditorEmpty'; +import type {LexicalEditor} from 'lexical'; +import type {OpenImageEditor} from '../../../hooks/usePinturaEditor'; + +interface ProductCardProps { + isEditing?: boolean; + imgSrc?: string; + isButtonEnabled?: boolean; + buttonText?: string; + buttonUrl?: string; + rating: number; + isRatingEnabled?: boolean; + onButtonToggle?: (event: React.ChangeEvent) => void; + onButtonTextChange?: (e: React.ChangeEvent) => void; + onButtonUrlChange: (value: string) => void; + onRatingToggle?: (event: React.ChangeEvent) => void; + imgDragHandler?: {isDraggedOver?: boolean; setRef?: React.Ref}; + onImgChange?: (e: React.ChangeEvent) => void; + imgMimeTypes?: string[]; + imgUploader?: {isLoading?: boolean; progress?: number; errors?: {message: string}[]}; + isPinturaEnabled?: boolean; + openImageEditor?: OpenImageEditor; + onRemoveImage?: () => void; + titleEditor: LexicalEditor; + titleEditorInitialState?: string; + descriptionEditor: LexicalEditor; + descriptionEditorInitialState?: string; + onRatingChange: (rating: number) => void; +} + +export function ProductCard({ + isEditing, + imgSrc, + isButtonEnabled, + buttonText, + buttonUrl, + rating, + isRatingEnabled, + onButtonToggle, + onButtonTextChange, + onButtonUrlChange, + onRatingToggle, + imgDragHandler, + onImgChange, + imgMimeTypes, + imgUploader, + isPinturaEnabled, + openImageEditor, + onRemoveImage, + titleEditor, + titleEditorInitialState, + descriptionEditor, + descriptionEditorInitialState, + onRatingChange +}: ProductCardProps) { + const showFilledButton = !!buttonUrl && !!buttonText && isButtonEnabled; + const showButtonInEditMode = isButtonEnabled && isEditing; + return ( + <> +
+ + +
+ { + (isEditing || !isEditorEmpty(titleEditor)) && ( +
+ +
+ ) + } + + {isRatingEnabled && ( + + )} +
+ + { + (isEditing || !isEditorEmpty(descriptionEditor)) && ( +
+ +
+ ) + } + + {(showButtonInEditMode || showFilledButton) && ( +
+
+ )} +
+ + {isEditing && ( + + + + {isButtonEnabled && ( + <> + + + + )} + + )} + + {!isEditing && } + + ); +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.jsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.jsx deleted file mode 100644 index 94f18920d7..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import DeleteIcon from '../../../../assets/icons/kg-trash.svg?react'; -import React from 'react'; -import WandIcon from '../../../../assets/icons/kg-wand.svg?react'; -import {IconButton} from '../../IconButton.jsx'; -import {MediaPlaceholder} from '../../MediaPlaceholder.jsx'; -import {ProgressBar} from '../../ProgressBar.jsx'; -import {openFileSelection} from '../../../../utils/openFileSelection.js'; - -export function ProductCardImage({ - imgSrc, - imgUploader = {}, - imgDragHandler = {}, - onImgChange, - imgMimeTypes, - onRemoveImage, - isPinturaEnabled, - openImageEditor, - isEditing -}) { - const fileInputRef = React.useRef(null); - - const onRemove = (e) => { - e.stopPropagation(); // prevents card from losing selected state - onRemoveImage(); - }; - - const showPlaceholder = imgDragHandler.isDraggedOver || !imgSrc; - const progressStyle = { - width: `${imgUploader.progress?.toFixed(0)}%` - }; - - return ( -
- { - showPlaceholder - ? ( - <> - openFileSelection({fileInputRef})} - icon='product' - isDraggedOver={imgDragHandler.isDraggedOver} - placeholderRef={imgDragHandler.setRef} - size='small' - /> - -
- -
- - ) - : ( - <> - Product thumbnail - - { - isEditing && ( - <> -
- - ) - } - - { - isEditing && ( - <> -
- -
- - ) - } - - { - isEditing && isPinturaEnabled && ( - <> -
- openImageEditor({ - image: imgSrc, - handleSave: (editedImage) => { - onImgChange({ - target: { - files: [editedImage] - } - }); - } - })} /> -
- - ) - } - - { - imgUploader.isLoading && ( -
- -
- ) - } - - ) - } -
- ); -} diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx new file mode 100644 index 0000000000..14559e2ee1 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx @@ -0,0 +1,122 @@ +import DeleteIcon from '../../../../assets/icons/kg-trash.svg?react'; +import React from 'react'; +import WandIcon from '../../../../assets/icons/kg-wand.svg?react'; +import {IconButton} from '../../IconButton'; +import {MediaPlaceholder} from '../../MediaPlaceholder'; +import {ProgressBar} from '../../ProgressBar'; +import {createFileInputChangeEventFromBlob} from '../../../../utils/createFileInputChangeEvent'; +import {openFileSelection} from '../../../../utils/openFileSelection'; +import type {OpenImageEditor} from '../../../../hooks/usePinturaEditor'; + +interface ProductCardImageProps { + imgSrc?: string; + imgUploader?: {isLoading?: boolean; progress?: number; errors?: {message: string}[]}; + imgDragHandler?: {isDraggedOver?: boolean; setRef?: React.Ref}; + onImgChange?: (e: React.ChangeEvent) => void; + imgMimeTypes?: string[]; + onRemoveImage?: () => void; + isPinturaEnabled?: boolean; + openImageEditor?: OpenImageEditor; + isEditing?: boolean; +} + +export function ProductCardImage({ + imgSrc, + imgUploader = {}, + imgDragHandler = {}, + onImgChange, + imgMimeTypes, + onRemoveImage, + isPinturaEnabled, + openImageEditor, + isEditing +}: ProductCardImageProps) { + const fileInputRef = React.useRef(null); + + const onRemove = (e: React.MouseEvent) => { + e.stopPropagation(); // prevents card from losing selected state + onRemoveImage?.(); + }; + + const showPlaceholder = imgDragHandler.isDraggedOver || !imgSrc; + const progressStyle = { + width: `${(imgUploader.progress ?? 0).toFixed(0)}%` + }; + + return ( +
+ { + showPlaceholder + ? ( + <> + openFileSelection({fileInputRef})} + icon='product' + isDraggedOver={imgDragHandler.isDraggedOver} + placeholderRef={imgDragHandler.setRef} + size='small' + /> + +
+ +
+ + ) + : ( + <> + Product thumbnail + + { + isEditing && ( + <> +
+ + ) + } + + { + isEditing && ( + <> +
+ +
+ + ) + } + + { + isEditing && isPinturaEnabled && ( + <> +
+ openImageEditor?.({ + image: imgSrc || '', + handleSave: (editedImage: Blob) => { + onImgChange?.(createFileInputChangeEventFromBlob(editedImage)); + } + })} /> +
+ + ) + } + + { + imgUploader.isLoading && ( +
+ +
+ ) + } + + ) + } +
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.jsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.jsx deleted file mode 100644 index befb283953..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import StarIcon from '../../../../assets/icons/kg-star.svg?react'; - -export function RatingButton({rating, onRatingChange}) { - const [hoveredStarIndex, setHoveredStarIndex] = React.useState(-1); - - const resetHoveredStarIndex = () => { - setHoveredStarIndex(-1); - }; - - const getStyles = (index) => { - const styles = { - active: rating >= (index + 1) ? 'fill-grey-900 dark:fill-white' : 'fill-grey-200 dark:fill-grey-900', - hovered: hoveredStarIndex >= index ? 'opacity-70' : '' - }; - - return Object.values(styles).join(' '); - }; - - return ( -
- { - [...Array(5).keys()].map((star, i) => ( - - )) - } -
- ); -} diff --git a/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.tsx b/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.tsx new file mode 100644 index 0000000000..51744d28f7 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import StarIcon from '../../../../assets/icons/kg-star.svg?react'; + +interface RatingButtonProps { + rating: number; + onRatingChange: (rating: number) => void; +} + +export function RatingButton({rating, onRatingChange}: RatingButtonProps) { + const [hoveredStarIndex, setHoveredStarIndex] = React.useState(-1); + + const resetHoveredStarIndex = () => { + setHoveredStarIndex(-1); + }; + + const getStyles = (index: number) => { + const styles = { + active: rating >= (index + 1) ? 'fill-grey-900 dark:fill-white' : 'fill-grey-200 dark:fill-grey-900', + hovered: hoveredStarIndex >= index ? 'opacity-70' : '' + }; + + return Object.values(styles).join(' '); + }; + + return ( +
+ { + [...Array(5).keys()].map((star, i) => ( + + )) + } +
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/SignupCard.jsx b/packages/koenig-lexical/src/components/ui/cards/SignupCard.jsx deleted file mode 100644 index dfdbd71255..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/SignupCard.jsx +++ /dev/null @@ -1,576 +0,0 @@ -import CenterAlignIcon from '../../../assets/icons/kg-align-center.svg?react'; -import ExpandIcon from '../../../assets/icons/kg-expand.svg?react'; -import ImgBgIcon from '../../../assets/icons/kg-img-bg.svg?react'; -import ImgFullIcon from '../../../assets/icons/kg-img-full.svg?react'; -import ImgRegularIcon from '../../../assets/icons/kg-img-regular.svg?react'; -import ImgWideIcon from '../../../assets/icons/kg-img-wide.svg?react'; -import KoenigNestedEditor from '../../KoenigNestedEditor'; -import LayoutSplitIcon from '../../../assets/icons/kg-layout-split.svg?react'; -import LeftAlignIcon from '../../../assets/icons/kg-align-left.svg?react'; -import PropTypes from 'prop-types'; -import ShrinkIcon from '../../../assets/icons/kg-shrink.svg?react'; -import clsx from 'clsx'; -import trackEvent from '../../../utils/analytics'; -import {ButtonGroupSetting, ColorPickerSetting, InputSetting, MediaUploadSetting, MultiSelectDropdownSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; -import {Color, textColorForBackgroundColor} from '@tryghost/color-utils'; -import {FastAverageColor} from 'fast-average-color'; -import {IconButton} from '../IconButton'; -import {MediaUploader} from '../MediaUploader'; -import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; -import {SubscribeForm} from '../SubscribeForm'; -import {Tooltip} from '../Tooltip'; -import {getAccentColor} from '../../../utils/getAccentColor'; -import {isEditorEmpty} from '../../../utils/isEditorEmpty'; -import {useEffect, useState} from 'react'; - -export function SignupCard({alignment, - buttonText, - showBackgroundImage, - backgroundImageSrc, - backgroundSize, - backgroundColor, - buttonColor, - buttonTextColor, - textColor, - isEditing, - fileUploader, - handleAlignment, - handleButtonText, - handleShowBackgroundImage, - handleHideBackgroundImage, - handleClearBackgroundImage, - handleBackgroundColor, - handleButtonColor, - handleLayout, - handleTextColor, - isPinturaEnabled, - labels, - layout, - availableLabels, - handleLabels, - onFileChange, - openImageEditor, - imageDragHandler, - headerTextEditor, - headerTextEditorInitialState, - renderLabels, - subheaderTextEditor, - subheaderTextEditorInitialState, - disclaimerTextEditor, - disclaimerTextEditorInitialState, - isSwapped, - handleSwapLayout, - handleBackgroundSize, - handleButtonTextBlur, - setFileInputRef}) { - const [backgroundColorPickerExpanded, setBackgroundColorPickerExpanded] = useState(false); - const [buttonColorPickerExpanded, setButtonColorPickerExpanded] = useState(false); - - const matchingTextColor = (color) => { - return color === 'transparent' ? '' : textColorForBackgroundColor(hexColorValue(color)).hex(); - }; - - /** - * Convert a semi transparent color to a fully opaque color by merging it with a white background - */ - const mergeWhiteColor = ({r, g, b, a}) => { - const aPercentage = a / 255; - - return Color({ - r: r * aPercentage + 255 * (1 - aPercentage), - g: g * aPercentage + 255 * (1 - aPercentage), - b: b * aPercentage + 255 * (1 - aPercentage) - }).hex(); - }; - - useEffect(() => { - if (backgroundImageSrc && layout !== 'split') { - new FastAverageColor().getColorAsync(backgroundImageSrc, {defaultColor: [255, 255, 255, 255]}).then((color) => { - // If we uploaded a transparent image, the average color will be semi transparent, we need to merge it with white - // Merge white color to the color - const correctedHex = mergeWhiteColor({ - r: color.value[0], - g: color.value[1], - b: color.value[2], - a: color.value[3] - }); - handleTextColor(matchingTextColor(correctedHex)); - }); - } - // This is only needed when the background image or layout is changed - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [backgroundImageSrc, layout === 'split']); - - useEffect(() => { - if (backgroundColor && layout === 'split') { - // Make sure the text color matches the background color - // It might be different if an image was uploaded in a non-split layout - handleBackgroundColor(backgroundColor, matchingTextColor(backgroundColor)); - } - // This is only needed when the layout is changed - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout === 'split']); - - const layoutChildren = [ - { - label: 'Regular', - name: 'regular', - Icon: ImgRegularIcon, - dataTestId: 'signup-layout-regular' - }, - { - label: 'Wide', - name: 'wide', - Icon: ImgWideIcon, - dataTestId: 'signup-layout-wide' - }, - { - label: 'Full', - name: 'full', - Icon: ImgFullIcon, - dataTestId: 'signup-layout-full' - }, - { - label: 'Split', - name: 'split', - Icon: LayoutSplitIcon, - dataTestId: 'signup-layout-split' - } - ]; - - const alignmentChildren = [ - { - label: 'Left', - name: 'left', - Icon: LeftAlignIcon, - dataTestId: 'signup-alignment-left' - }, - { - label: 'Center', - name: 'center', - Icon: CenterAlignIcon, - dataTestId: 'signup-alignment-center' - } - ]; - - const {isLoading, progress} = fileUploader || {}; - - const headerPlaceholder = layout === 'split' ? 'Heading' : 'Enter heading text'; - const subheaderPlaceholder = layout === 'split' ? 'Subheading text' : 'Enter subheading text'; - const disclaimerPlaceholder = layout === 'split' ? 'Disclaimer text' : 'Enter disclaimer text'; - - const hexColorValue = (color) => { - if (color === 'accent') { - const accentColor = getAccentColor().trim(); - return accentColor; - } - return color.trim(); - }; - - const wrapperStyle = () => { - if (backgroundImageSrc && layout !== 'split' && textColor) { - return { - backgroundImage: `url(${backgroundImageSrc})`, - backgroundSize: 'cover', - backgroundPosition: 'center center', - backgroundColor: 'white', - color: hexColorValue(textColor) - }; - } else if (backgroundColor && textColor) { - return { - backgroundColor: hexColorValue(backgroundColor), - color: hexColorValue(textColor) - }; - } - - return { - backgroundImage: `url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ctitle%3ERectangle%3C/title%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='%23F2F6F8' d='M0 0h24v24H0z'/%3E%3Cpath fill='%23E5ECF0' d='M0 0h12v12H0zM12 12h12v12H12z'/%3E%3C/g%3E%3C/svg%3E")`, - backgroundColor: 'transparent', - color: hexColorValue(textColor) - }; - }; - - const toggleBackgroundSize = (event) => { - event.stopPropagation(); - if (backgroundSize === 'cover') { - handleBackgroundSize('contain'); - trackEvent('Signup Card Toggle Size', {size: 'contain'}); - } else { - handleBackgroundSize('cover'); - trackEvent('Signup Card Toggle Size', {size: 'cover'}); - } - }; - - const toggleSwapped = () => { - trackEvent('Signup Card Toggle Swapped', {swapped: !isSwapped}); - handleSwapLayout(); - }; - - const correctedBackgroundSize = backgroundSize === 'contain' && backgroundImageSrc ? 'contain' : 'cover'; - - return ( - <> -
-
- {layout === 'split' && ( - - - } - alt='Background image' - backgroundSize={backgroundSize} - borderStyle='squared' - className={clsx( - 'sm:w-1/2', - (correctedBackgroundSize === 'contain') && 'sm:my-10 md:my-14', - (!isSwapped && (correctedBackgroundSize === 'contain')) && 'mt-10 px-[calc(32px-(4rem/2))] xs:px-[calc(92px-(8rem/2))] sm:pl-[calc(92px-(12rem/2))] sm:pr-0 md:pl-[calc(92px-(12rem/2))] lg:pl-0', - (isSwapped && (correctedBackgroundSize === 'contain')) && 'mb-10 px-[calc(32px-(4rem/2))] xs:px-[calc(92px-(8rem/2))] sm:pl-0 sm:pr-[calc(92px-(12rem/2))] md:pr-[calc(92px-(12rem/2))] lg:pr-0', - )} - desc='Click to select an image' - dragHandler={imageDragHandler} - errors={fileUploader?.errors} - icon='image' - imgClassName={`${(correctedBackgroundSize === 'cover') && 'aspect-[3/2]'}`} - isEditing={isEditing} - isLoading={isLoading} - isPinturaEnabled={isPinturaEnabled} - mimeTypes={['image/*']} - openImageEditor={openImageEditor} - progress={progress} - size='large' - src={backgroundImageSrc} - onFileChange={onFileChange} - onRemoveMedia={handleClearBackgroundImage} - /> - )} - -
- {/* Heading */} - {} - - {/* Subheading */} - {} - - {/* Subscribe form */} -
- -
- - {/* Disclaimer */} - -
-
- - {/* Read-only overlay */} - {!isEditing && } -
- - {isEditing && -
- Only visible to logged out visitors, this card is not shown in emails or to existing members. -
- } - - {isEditing && ( - - - - { - layout === 'split' && ( - - - ) - } - - - - { - handleShowBackgroundImage(); - setBackgroundColorPickerExpanded(false); - setButtonColorPickerExpanded(false); - }} - > - - - - ) - }), - {title: 'Grey', hex: '#F0F0F0'}, - {title: 'Black', hex: '#000000'}, - {title: 'Brand color', accent: true} - ].filter(Boolean)} - value={(showBackgroundImage && layout !== 'split') ? 'image' : backgroundColor} - onPickerChange={color => handleBackgroundColor(color, matchingTextColor(color))} - onSwatchChange={(color) => { - handleBackgroundColor(color, matchingTextColor(color)); - setBackgroundColorPickerExpanded(false); - }} - onTogglePicker={ (isExpanded) => { - if (isExpanded) { - if (layout !== 'split') { - handleHideBackgroundImage(); - } - - if (backgroundColor) { - handleBackgroundColor(backgroundColor, matchingTextColor(backgroundColor)); - } - } - - setBackgroundColorPickerExpanded(isExpanded); - if (isExpanded) { - setButtonColorPickerExpanded(!isExpanded); - } - }} - > - { - handleClearBackgroundImage(); - handleTextColor(matchingTextColor(backgroundColor)); - }} - /> - - - handleButtonColor(color, matchingTextColor(color))} - onSwatchChange={(color) => { - handleButtonColor(color, matchingTextColor(color)); - setButtonColorPickerExpanded(false); - }} - onTogglePicker={(isExpanded) => { - setButtonColorPickerExpanded(isExpanded); - if (isExpanded) { - setBackgroundColorPickerExpanded(!isExpanded); - } - }} - /> - - { renderLabels && ( - - )} - - )} - - ); -} - -SignupCard.propTypes = { - alignment: PropTypes.oneOf(['left', 'center']), - buttonColor: PropTypes.string, - buttonText: PropTypes.string, - buttonTextColor: PropTypes.string, - buttonPlaceholder: PropTypes.string, - backgroundImageSrc: PropTypes.string, - backgroundSize: PropTypes.oneOf(['cover', 'contain']), - backgroundColor: PropTypes.string, - textColor: PropTypes.string, - showBackgroundImage: PropTypes.bool, - isEditing: PropTypes.bool, - isPinturaEnabled: PropTypes.bool, - fileUploader: PropTypes.object, - fileInputRef: PropTypes.object, - handleLayout: PropTypes.func, - handleAlignment: PropTypes.func, - handleButtonText: PropTypes.func, - handleClearBackgroundImage: PropTypes.func, - handleBackgroundColor: PropTypes.func, - handleShowBackgroundImage: PropTypes.func, - handleHideBackgroundImage: PropTypes.func, - handleButtonColor: PropTypes.func, - handleLabels: PropTypes.func, - handleTextColor: PropTypes.func, - labels: PropTypes.arrayOf(PropTypes.string), - layout: PropTypes.oneOf(['regular', 'wide', 'full', 'split']), - availableLabels: PropTypes.arrayOf(PropTypes.string), - openFilePicker: PropTypes.func, - onFileChange: PropTypes.func, - openImageEditor: PropTypes.func, - imageDragHandler: PropTypes.object, - headerTextEditor: PropTypes.object, - headerTextEditorInitialState: PropTypes.object, - renderLabels: PropTypes.bool, - subheaderTextEditor: PropTypes.object, - subheaderTextEditorInitialState: PropTypes.object, - disclaimerTextEditor: PropTypes.object, - disclaimerTextEditorInitialState: PropTypes.object, - isSwapped: PropTypes.bool, - handleSwapLayout: PropTypes.func, - handleBackgroundSize: PropTypes.func, - setFileInputRef: PropTypes.func, - handleButtonTextBlur: PropTypes.func -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.jsx deleted file mode 100644 index a3c6e69fbd..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import populateEditor from '../../../utils/storybook/populate-storybook-editor'; -import {CardWrapper} from './../CardWrapper'; -import {MINIMAL_NODES} from '../../../index.js'; -import {SignupCard} from './SignupCard'; -import {createEditor} from 'lexical'; -import {editorEmptyState} from '../../../../.storybook/editorEmptyState'; - -const displayOptions = { - Default: {isSelected: false, isEditing: false}, - Selected: {isSelected: true, isEditing: false}, - Editing: {isSelected: true, isEditing: true} -}; - -const story = { - title: 'Primary cards/Signup card', - component: SignupCard, - subcomponent: {CardWrapper}, - argTypes: { - display: { - options: Object.keys(displayOptions), - mapping: displayOptions, - control: { - type: 'radio', - labels: { - Default: 'Default', - Selected: 'Selected', - Editing: 'Editing' - }, - defaultValue: displayOptions.Default - } - }, - layout: { - options: ['regular', 'wide', 'full', 'split'], - control: {type: 'radio'} - } - }, - parameters: { - status: { - type: 'Functional' - } - } -}; -export default story; - -const Template = ({display, heading, subheader, disclaimer, ...args}) => { - const headerTextEditor = createEditor({nodes: MINIMAL_NODES}); - const subheaderTextEditor = createEditor({nodes: MINIMAL_NODES}); - const disclaimerTextEditor = createEditor({nodes: MINIMAL_NODES}); - const cardWidth = args.layout === 'split' ? 'full' : args.layout; - - populateEditor({editor: headerTextEditor, initialHtml: `${heading}`}); - populateEditor({editor: subheaderTextEditor, initialHtml: `${subheader}`}); - populateEditor({editor: disclaimerTextEditor, initialHtml: `${disclaimer}`}); - - return (
-
- - - -
-
); -}; - -export const Default = Template.bind({}); -Default.args = { - display: 'Editing', - layout: 'wide', - alignment: 'left', - showBackgroundImage: false, - heading: 'Sign up for Koenig Lexical', - subheader: `There's a whole lot to discover in this editor. Let us help you settle in.`, - disclaimer: 'No spam. Unsubscribe anytime.', - buttonText: '', - buttonColor: 'accent', - buttonTextColor: '#FFFFFF', - backgroundColor: '#F0F0F0', - textColor: '#000000', - availableLabels: ['First label', 'Second label'] -}; - -export const Empty = Template.bind({}); -Empty.args = { - display: 'Editing', - layout: 'wide', - alignment: 'left', - showBackgroundImage: false, - heading: '', - subheader: '', - disclaimer: '', - buttonText: '', - buttonColor: '#ffffff', - buttonTextColor: '#000000', - backgroundColor: 'transparent', - textColor: '', - availableLabels: ['First label', 'Second label'], - headerTextEditorInitialState: editorEmptyState, - subheaderTextEditorInitialState: editorEmptyState, - disclaimerTextEditorInitialState: editorEmptyState -}; - -export const Populated = Template.bind({}); -Populated.args = { - display: 'Editing', - layout: 'split', - alignment: 'left', - showBackgroundImage: true, - backgroundImageSrc: 'https://static.ghost.org/v4.0.0/images/andreas-selter-e4yK8QQlZa0-unsplash.jpg', - heading: 'This is a heading', - subheader: 'And here is some subheading text.', - disclaimer: 'And here is some disclaimer text.', - buttonText: 'Subscribe', - buttonColor: '#000000', - buttonTextColor: '#ffffff', - backgroundColor: '#F3B389', - textColor: '#000000', - availableLabels: ['First label', 'Second label'], - handleBackgroundColor: () => {} -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.tsx b/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.tsx new file mode 100644 index 0000000000..697a12b154 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.tsx @@ -0,0 +1,127 @@ +import populateEditor from '../../../utils/storybook/populate-storybook-editor'; +import {CardWrapper} from './../CardWrapper'; +import {MINIMAL_NODES} from '../../../index'; +import {SignupCard} from './SignupCard'; +import {createEditor} from 'lexical'; +import {editorEmptyState} from '../../../../.storybook/editorEmptyState'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; + +const displayOptions = { + Default: {isSelected: false, isEditing: false}, + Selected: {isSelected: true, isEditing: false}, + Editing: {isSelected: true, isEditing: true} +}; + +type StoryArgs = ComponentProps & {display: keyof typeof displayOptions; heading?: string; subheader?: string; disclaimer?: string}; + +const story: Meta = { + title: 'Primary cards/Signup card', + component: SignupCard, + subcomponents: {CardWrapper}, + argTypes: { + display: { + options: Object.keys(displayOptions), + control: { + type: 'radio', + labels: { + Default: 'Default', + Selected: 'Selected', + Editing: 'Editing' + }, + defaultValue: displayOptions.Default + } + }, + layout: { + options: ['regular', 'wide', 'full', 'split'], + control: {type: 'radio'} + } + }, + parameters: { + status: { + type: 'Functional' + } + } +}; +export default story; + +const Template: StoryFn = ({display, heading, subheader, disclaimer, ...args}) => { + const headerTextEditor = createEditor({nodes: MINIMAL_NODES}); + const subheaderTextEditor = createEditor({nodes: MINIMAL_NODES}); + const disclaimerTextEditor = createEditor({nodes: MINIMAL_NODES}); + const cardWidth = args.layout === 'split' ? 'full' : args.layout; + + populateEditor({editor: headerTextEditor, initialHtml: `${heading}`}); + populateEditor({editor: subheaderTextEditor, initialHtml: `${subheader}`}); + populateEditor({editor: disclaimerTextEditor, initialHtml: `${disclaimer}`}); + + return (
+
+ + + +
+
); +}; + +export const Default = Template.bind({}); +Default.args = { + display: 'Editing', + layout: 'wide', + alignment: 'left', + showBackgroundImage: false, + heading: 'Sign up for Koenig Lexical', + subheader: `There's a whole lot to discover in this editor. Let us help you settle in.`, + disclaimer: 'No spam. Unsubscribe anytime.', + buttonText: '', + buttonColor: 'accent', + buttonTextColor: '#FFFFFF', + backgroundColor: '#F0F0F0', + textColor: '#000000', + availableLabels: ['First label', 'Second label'] +}; + +export const Empty = Template.bind({}); +Empty.args = { + display: 'Editing', + layout: 'wide', + alignment: 'left', + showBackgroundImage: false, + heading: '', + subheader: '', + disclaimer: '', + buttonText: '', + buttonColor: '#ffffff', + buttonTextColor: '#000000', + backgroundColor: 'transparent', + textColor: '', + availableLabels: ['First label', 'Second label'], + headerTextEditorInitialState: editorEmptyState, + subheaderTextEditorInitialState: editorEmptyState, + disclaimerTextEditorInitialState: editorEmptyState +}; + +export const Populated = Template.bind({}); +Populated.args = { + display: 'Editing', + layout: 'split', + alignment: 'left', + showBackgroundImage: true, + backgroundImageSrc: 'https://static.ghost.org/v4.0.0/images/andreas-selter-e4yK8QQlZa0-unsplash.jpg', + heading: 'This is a heading', + subheader: 'And here is some subheading text.', + disclaimer: 'And here is some disclaimer text.', + buttonText: 'Subscribe', + buttonColor: '#000000', + buttonTextColor: '#ffffff', + backgroundColor: '#F3B389', + textColor: '#000000', + availableLabels: ['First label', 'Second label'], + handleBackgroundColor: () => {} +}; diff --git a/packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx b/packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx new file mode 100644 index 0000000000..5ef166258d --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx @@ -0,0 +1,575 @@ +import CenterAlignIcon from '../../../assets/icons/kg-align-center.svg?react'; +import ExpandIcon from '../../../assets/icons/kg-expand.svg?react'; +import ImgBgIcon from '../../../assets/icons/kg-img-bg.svg?react'; +import ImgFullIcon from '../../../assets/icons/kg-img-full.svg?react'; +import ImgRegularIcon from '../../../assets/icons/kg-img-regular.svg?react'; +import ImgWideIcon from '../../../assets/icons/kg-img-wide.svg?react'; +import KoenigNestedEditor from '../../KoenigNestedEditor'; +import LayoutSplitIcon from '../../../assets/icons/kg-layout-split.svg?react'; +import LeftAlignIcon from '../../../assets/icons/kg-align-left.svg?react'; +import React, {useEffect, useState} from 'react'; +import ShrinkIcon from '../../../assets/icons/kg-shrink.svg?react'; +import clsx from 'clsx'; +import trackEvent from '../../../utils/analytics'; +import {ButtonGroupSetting, ColorPickerSetting, InputSetting, MediaUploadSetting, MultiSelectDropdownSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; +import {Color, textColorForBackgroundColor} from '@tryghost/color-utils'; +import {FastAverageColor} from 'fast-average-color'; +import {IconButton} from '../IconButton'; +import {MediaUploader} from '../MediaUploader'; +import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; +import {SubscribeForm} from '../SubscribeForm'; +import {Tooltip} from '../Tooltip'; +import {getAccentColor} from '../../../utils/getAccentColor'; +import {isEditorEmpty} from '../../../utils/isEditorEmpty'; +import type {LexicalEditor} from 'lexical'; +import type {OpenImageEditor} from '../../../hooks/usePinturaEditor'; + +type Layout = 'regular' | 'wide' | 'full' | 'split'; +type Alignment = 'left' | 'center'; +type BackgroundSize = 'cover' | 'contain'; + +interface SignupCardProps { + alignment: Alignment; + buttonText?: string; + showBackgroundImage?: boolean; + backgroundImageSrc?: string; + backgroundSize?: BackgroundSize; + backgroundColor: string; + buttonColor?: string; + buttonTextColor?: string; + textColor?: string; + isEditing?: boolean; + fileUploader?: {isLoading?: boolean; progress?: number; errors?: {message: string}[]}; + handleAlignment: (name: string) => void; + handleButtonText?: (e: React.ChangeEvent) => void; + handleShowBackgroundImage?: () => void; + handleHideBackgroundImage?: () => void; + handleClearBackgroundImage?: () => void; + handleBackgroundColor: (color: string, textColor: string) => void; + handleButtonColor: (color: string, textColor: string) => void; + handleLayout: (name: string) => void; + handleTextColor: (color: string) => void; + isPinturaEnabled?: boolean; + labels?: string[]; + layout: Layout; + availableLabels?: string[]; + handleLabels?: (labels: string[]) => void; + onFileChange?: (e: React.ChangeEvent) => void; + openImageEditor?: OpenImageEditor; + imageDragHandler?: {isDraggedOver?: boolean; setRef?: React.Ref}; + headerTextEditor: LexicalEditor; + headerTextEditorInitialState?: string; + renderLabels?: boolean; + subheaderTextEditor: LexicalEditor; + subheaderTextEditorInitialState?: string; + disclaimerTextEditor: LexicalEditor; + disclaimerTextEditorInitialState?: string; + isSwapped?: boolean; + handleSwapLayout?: () => void; + handleBackgroundSize?: (size: string) => void; + handleButtonTextBlur?: (event: React.FocusEvent) => void; + setFileInputRef?: (el: HTMLInputElement | null) => void; +} + +export function SignupCard({alignment, + buttonText, + showBackgroundImage, + backgroundImageSrc, + backgroundSize, + backgroundColor, + buttonColor, + buttonTextColor, + textColor, + isEditing, + fileUploader, + handleAlignment, + handleButtonText, + handleShowBackgroundImage, + handleHideBackgroundImage, + handleClearBackgroundImage, + handleBackgroundColor, + handleButtonColor, + handleLayout, + handleTextColor, + isPinturaEnabled, + labels, + layout, + availableLabels, + handleLabels, + onFileChange, + openImageEditor, + imageDragHandler, + headerTextEditor, + headerTextEditorInitialState, + renderLabels, + subheaderTextEditor, + subheaderTextEditorInitialState, + disclaimerTextEditor, + disclaimerTextEditorInitialState, + isSwapped, + handleSwapLayout, + handleBackgroundSize, + handleButtonTextBlur, + setFileInputRef}: SignupCardProps) { + const [backgroundColorPickerExpanded, setBackgroundColorPickerExpanded] = useState(false); + const [buttonColorPickerExpanded, setButtonColorPickerExpanded] = useState(false); + + const matchingTextColor = (color: string) => { + return color === 'transparent' ? '' : textColorForBackgroundColor(hexColorValue(color)).hex(); + }; + + /** + * Convert a semi transparent color to a fully opaque color by merging it with a white background + */ + const mergeWhiteColor = ({r, g, b, a}: {r: number; g: number; b: number; a: number}) => { + const aPercentage = a / 255; + + return Color({ + r: r * aPercentage + 255 * (1 - aPercentage), + g: g * aPercentage + 255 * (1 - aPercentage), + b: b * aPercentage + 255 * (1 - aPercentage) + }).hex(); + }; + + useEffect(() => { + if (backgroundImageSrc && layout !== 'split') { + new FastAverageColor().getColorAsync(backgroundImageSrc, {defaultColor: [255, 255, 255, 255]}).then((color) => { + // If we uploaded a transparent image, the average color will be semi transparent, we need to merge it with white + // Merge white color to the color + const correctedHex = mergeWhiteColor({ + r: color.value[0], + g: color.value[1], + b: color.value[2], + a: color.value[3] + }); + handleTextColor(matchingTextColor(correctedHex)); + }); + } + // This is only needed when the background image or layout is changed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [backgroundImageSrc, layout === 'split']); + + useEffect(() => { + if (backgroundColor && layout === 'split') { + // Make sure the text color matches the background color + // It might be different if an image was uploaded in a non-split layout + handleBackgroundColor(backgroundColor, matchingTextColor(backgroundColor)); + } + // This is only needed when the layout is changed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout === 'split']); + + const layoutChildren = [ + { + label: 'Regular', + name: 'regular', + Icon: ImgRegularIcon, + dataTestId: 'signup-layout-regular' + }, + { + label: 'Wide', + name: 'wide', + Icon: ImgWideIcon, + dataTestId: 'signup-layout-wide' + }, + { + label: 'Full', + name: 'full', + Icon: ImgFullIcon, + dataTestId: 'signup-layout-full' + }, + { + label: 'Split', + name: 'split', + Icon: LayoutSplitIcon, + dataTestId: 'signup-layout-split' + } + ]; + + const alignmentChildren = [ + { + label: 'Left', + name: 'left', + Icon: LeftAlignIcon, + dataTestId: 'signup-alignment-left' + }, + { + label: 'Center', + name: 'center', + Icon: CenterAlignIcon, + dataTestId: 'signup-alignment-center' + } + ]; + + const {isLoading, progress} = fileUploader || {}; + + const headerPlaceholder = layout === 'split' ? 'Heading' : 'Enter heading text'; + const subheaderPlaceholder = layout === 'split' ? 'Subheading text' : 'Enter subheading text'; + const disclaimerPlaceholder = layout === 'split' ? 'Disclaimer text' : 'Enter disclaimer text'; + + const hexColorValue = (color: string) => { + if (color === 'accent') { + const accentColor = getAccentColor().trim(); + return accentColor; + } + return color.trim(); + }; + + const wrapperStyle = (): React.CSSProperties => { + if (backgroundImageSrc && layout !== 'split' && textColor) { + return { + backgroundImage: `url(${backgroundImageSrc})`, + backgroundSize: 'cover', + backgroundPosition: 'center center', + backgroundColor: 'white', + color: hexColorValue(textColor) + }; + } else if (backgroundColor && textColor) { + return { + backgroundColor: hexColorValue(backgroundColor), + color: hexColorValue(textColor) + }; + } + + return { + backgroundImage: `url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ctitle%3ERectangle%3C/title%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='%23F2F6F8' d='M0 0h24v24H0z'/%3E%3Cpath fill='%23E5ECF0' d='M0 0h12v12H0zM12 12h12v12H12z'/%3E%3C/g%3E%3C/svg%3E")`, + backgroundColor: 'transparent', + color: textColor ? hexColorValue(textColor) : '' + }; + }; + + const toggleBackgroundSize = (event: React.MouseEvent) => { + event.stopPropagation(); + if (backgroundSize === 'cover') { + handleBackgroundSize?.('contain'); + trackEvent('Signup Card Toggle Size', {size: 'contain'}); + } else { + handleBackgroundSize?.('cover'); + trackEvent('Signup Card Toggle Size', {size: 'cover'}); + } + }; + + const toggleSwapped = () => { + trackEvent('Signup Card Toggle Swapped', {swapped: !isSwapped}); + handleSwapLayout?.(); + }; + + const correctedBackgroundSize = backgroundSize === 'contain' && backgroundImageSrc ? 'contain' : 'cover'; + + return ( + <> +
+
+ {layout === 'split' && ( + + + } + alt='Background image' + backgroundSize={backgroundSize} + borderStyle='squared' + className={clsx( + 'sm:w-1/2', + (correctedBackgroundSize === 'contain') && 'sm:my-10 md:my-14', + (!isSwapped && (correctedBackgroundSize === 'contain')) && 'mt-10 px-[calc(32px-(4rem/2))] xs:px-[calc(92px-(8rem/2))] sm:pl-[calc(92px-(12rem/2))] sm:pr-0 md:pl-[calc(92px-(12rem/2))] lg:pl-0', + (isSwapped && (correctedBackgroundSize === 'contain')) && 'mb-10 px-[calc(32px-(4rem/2))] xs:px-[calc(92px-(8rem/2))] sm:pl-0 sm:pr-[calc(92px-(12rem/2))] md:pr-[calc(92px-(12rem/2))] lg:pr-0', + )} + desc='Click to select an image' + dragHandler={imageDragHandler} + errors={fileUploader?.errors} + icon='image' + imgClassName={`${(correctedBackgroundSize === 'cover') && 'aspect-[3/2]'}`} + isEditing={isEditing} + isLoading={isLoading} + isPinturaEnabled={isPinturaEnabled} + mimeTypes={['image/*']} + openImageEditor={openImageEditor} + progress={progress} + size='large' + src={backgroundImageSrc} + onFileChange={onFileChange!} + onRemoveMedia={handleClearBackgroundImage} + /> + )} + +
+ {/* Heading */} + {} + + {/* Subheading */} + {} + + {/* Subscribe form */} +
+ +
+ + {/* Disclaimer */} + +
+
+ + {/* Read-only overlay */} + {!isEditing && } +
+ + {isEditing && +
+ Only visible to logged out visitors, this card is not shown in emails or to existing members. +
+ } + + {isEditing && ( + + + + { + layout === 'split' && ( + + + ) + } + + + + { + handleShowBackgroundImage?.(); + setBackgroundColorPickerExpanded(false); + setButtonColorPickerExpanded(false); + }} + > + + + + ) + }), + {title: 'Grey', hex: '#F0F0F0'}, + {title: 'Black', hex: '#000000'}, + {title: 'Brand color', accent: true} + ].filter(Boolean) as {hex?: string; accent?: boolean; transparent?: boolean; image?: boolean; title: string; customContent?: React.ReactNode}[]} + value={(showBackgroundImage && layout !== 'split') ? 'image' : backgroundColor} + onPickerChange={(color: string) => handleBackgroundColor(color, matchingTextColor(color))} + onSwatchChange={(color: string) => { + handleBackgroundColor(color, matchingTextColor(color)); + setBackgroundColorPickerExpanded(false); + }} + onTogglePicker={ (isExpanded: boolean) => { + if (isExpanded) { + if (layout !== 'split') { + handleHideBackgroundImage?.(); + } + + if (backgroundColor) { + handleBackgroundColor(backgroundColor, matchingTextColor(backgroundColor)); + } + } + + setBackgroundColorPickerExpanded(isExpanded); + if (isExpanded) { + setButtonColorPickerExpanded(!isExpanded); + } + }} + > + { + handleClearBackgroundImage?.(); + handleTextColor(matchingTextColor(backgroundColor)); + }} + /> + + + handleButtonColor(color, matchingTextColor(color))} + onSwatchChange={(color: string) => { + handleButtonColor(color, matchingTextColor(color)); + setButtonColorPickerExpanded(false); + }} + onTogglePicker={(isExpanded: boolean) => { + setButtonColorPickerExpanded(isExpanded); + if (isExpanded) { + setBackgroundColorPickerExpanded(!isExpanded); + } + }} + /> + + { renderLabels && ( + + )} + + )} + + ); +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.jsx b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.jsx deleted file mode 100644 index 339b362974..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import ArrowDownIcon from '../../../assets/icons/kg-toggle-arrow.svg?react'; -import KoenigNestedEditor from '../../KoenigNestedEditor'; -import PropTypes from 'prop-types'; -import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; - -export function ToggleCard({ - contentEditor, - contentEditorInitialState, - contentPlaceholder = 'Collapsible content', - headingEditor, - headingEditorInitialState, - headingPlaceholder = 'Toggle header', - isEditing = false -}) { - return ( - <> -
-
-
- -
-
- -
-
-
- -
-
- {!isEditing && } - - ); -} - -ToggleCard.propTypes = { - contentEditor: PropTypes.object, - contentPlaceholder: PropTypes.string, - headingEditor: PropTypes.object, - headingPlaceholder: PropTypes.string, - isEditing: PropTypes.bool, - contentEditorInitialState: PropTypes.object, - headingEditorInitialState: PropTypes.object -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.jsx deleted file mode 100644 index e725a036ec..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import populateEditor from '../../../utils/storybook/populate-storybook-editor'; -import {BASIC_NODES, MINIMAL_NODES} from '../../../index.js'; -import {CardWrapper} from './../CardWrapper'; -import {ToggleCard} from './ToggleCard'; -import {createEditor} from 'lexical'; - -const displayOptions = { - Default: {isSelected: false, isEditing: false}, - Selected: {isSelected: true, isEditing: false}, - Editing: {isSelected: true, isEditing: true} -}; - -const story = { - title: 'Primary cards/Toggle card', - component: ToggleCard, - subcomponent: {CardWrapper}, - argTypes: { - display: { - options: Object.keys(displayOptions), - mapping: displayOptions, - control: { - type: 'radio', - labels: { - Default: 'Default', - Selected: 'Selected', - Editing: 'Editing' - }, - defaultValue: displayOptions.Default - } - } - }, - parameters: { - status: { - type: 'uiReady' - } - } -}; -export default story; - -const Template = ({display, heading, content, ...args}) => { - const headingEditor = createEditor({nodes: MINIMAL_NODES}); - populateEditor({editor: headingEditor, initialHtml: `${heading}`}); - - const contentEditor = createEditor({nodes: BASIC_NODES}); - populateEditor({editor: contentEditor, initialHtml: `${content}`}); - - return ( -
-
- - - -
-
-
- - - -
-
-
- ); -}; - -export const Empty = Template.bind({}); -Empty.args = { - content: '', - contentPlaceholder: 'Collapsible content', - display: 'Editing', - heading: '', - headingPlaceholder: 'Toggle header' -}; - -export const Populated = Template.bind({}); -Populated.args = { - content: 'Toggles allow you to create collapsible sections of content which is a great way to make your content less overwhelming and easy to navigate. A common example is an FAQ section, like this one.', - contentPlaceholder: 'Collapsible content', - display: 'Editing', - heading: 'When should I use Toggles?', - headingPlaceholder: 'Toggle header' -}; - diff --git a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.tsx b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.tsx new file mode 100644 index 0000000000..ce94a0f2f2 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.tsx @@ -0,0 +1,84 @@ +import populateEditor from '../../../utils/storybook/populate-storybook-editor'; +import {BASIC_NODES, MINIMAL_NODES} from '../../../index'; +import {CardWrapper} from './../CardWrapper'; +import {ToggleCard} from './ToggleCard'; +import {createEditor} from 'lexical'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; + +const displayOptions = { + Default: {isSelected: false, isEditing: false}, + Selected: {isSelected: true, isEditing: false}, + Editing: {isSelected: true, isEditing: true} +}; + +type StoryArgs = ComponentProps & {display: keyof typeof displayOptions; heading?: string; content?: string}; + +const story: Meta = { + title: 'Primary cards/Toggle card', + component: ToggleCard, + subcomponents: {CardWrapper}, + argTypes: { + display: { + options: Object.keys(displayOptions), + control: { + type: 'radio', + labels: { + Default: 'Default', + Selected: 'Selected', + Editing: 'Editing' + }, + defaultValue: displayOptions.Default + } + } + }, + parameters: { + status: { + type: 'uiReady' + } + } +}; +export default story; + +const Template: StoryFn = ({display, heading, content, ...args}) => { + const headingEditor = createEditor({nodes: MINIMAL_NODES}); + populateEditor({editor: headingEditor, initialHtml: `${heading}`}); + + const contentEditor = createEditor({nodes: BASIC_NODES}); + populateEditor({editor: contentEditor, initialHtml: `${content}`}); + + return ( +
+
+ + + +
+
+
+ + + +
+
+
+ ); +}; + +export const Empty = Template.bind({}); +Empty.args = { + content: '', + contentPlaceholder: 'Collapsible content', + display: 'Editing', + heading: '', + headingPlaceholder: 'Toggle header' +}; + +export const Populated = Template.bind({}); +Populated.args = { + content: 'Toggles allow you to create collapsible sections of content which is a great way to make your content less overwhelming and easy to navigate. A common example is an FAQ section, like this one.', + contentPlaceholder: 'Collapsible content', + display: 'Editing', + heading: 'When should I use Toggles?', + headingPlaceholder: 'Toggle header' +}; diff --git a/packages/koenig-lexical/src/components/ui/cards/ToggleCard.tsx b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.tsx new file mode 100644 index 0000000000..21421f7248 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/ToggleCard.tsx @@ -0,0 +1,59 @@ +import ArrowDownIcon from '../../../assets/icons/kg-toggle-arrow.svg?react'; +import KoenigNestedEditor from '../../KoenigNestedEditor'; +import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; +import type {LexicalEditor} from 'lexical'; + +interface ToggleCardProps { + contentEditor: LexicalEditor; + contentEditorInitialState?: string; + contentPlaceholder?: string; + headingEditor: LexicalEditor; + headingEditorInitialState?: string; + headingPlaceholder?: string; + isEditing?: boolean; +} + +export function ToggleCard({ + contentEditor, + contentEditorInitialState, + contentPlaceholder = 'Collapsible content', + headingEditor, + headingEditorInitialState, + headingPlaceholder = 'Toggle header', + isEditing = false +}: ToggleCardProps) { + return ( + <> +
+
+
+ +
+
+ +
+
+
+ +
+
+ {!isEditing && } + + ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/TransistorCard.jsx b/packages/koenig-lexical/src/components/ui/cards/TransistorCard.jsx deleted file mode 100644 index 1eafe687d3..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/TransistorCard.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import PropTypes from 'prop-types'; -import TransistorIcon from '../../../assets/icons/kg-card-type-transistor.svg?react'; -// TODO: Re-enable when design tab is implemented -// import {useState} from 'react'; -// import {ColorPickerSetting, SettingsPanel} from '../SettingsPanel.jsx'; -// import {VisibilitySettings} from '../VisibilitySettings.jsx'; - -export function TransistorCard({ - accentColor = '', - backgroundColor = '' - // TODO: Re-enable when design tab is implemented - // isEditing = false, - // visibilityOptions = {}, - // handleAccentColorChange = () => {}, - // handleBackgroundColorChange = () => {}, - // toggleVisibility = () => {}, - // showVisibilitySettings = false -}) { - // TODO: Re-enable design tab when color customization is fully implemented - // const [accentColorPickerExpanded, setAccentColorPickerExpanded] = useState(false); - // const [backgroundColorPickerExpanded, setBackgroundColorPickerExpanded] = useState(false); - - // const tabs = [ - // {id: 'design', label: 'Design'}, - // {id: 'visibility', label: 'Visibility'} - // ]; - - // const designSettings = ( - // <> - // handleAccentColorChange(color)} - // onSwatchChange={(color) => { - // handleAccentColorChange(color); - // setAccentColorPickerExpanded(false); - // }} - // onTogglePicker={(isExpanded) => { - // setAccentColorPickerExpanded(isExpanded); - // if (isExpanded) { - // setBackgroundColorPickerExpanded(false); - // } - // }} - // /> - // handleBackgroundColorChange(color)} - // onSwatchChange={(color) => { - // handleBackgroundColorChange(color); - // setBackgroundColorPickerExpanded(false); - // }} - // onTogglePicker={(isExpanded) => { - // setBackgroundColorPickerExpanded(isExpanded); - // if (isExpanded) { - // setAccentColorPickerExpanded(false); - // } - // }} - // /> - // - // ); - - return ( -
- -
- ); -} - -function TransistorPlaceholder() { - return ( -
-
- -
-
-
- Members-only podcasts -
-
- Your Transistor podcasts will appear here. Members will see subscribe links based on their access level. -
-
- {/* - TODO: decide on whether to show Transistor Logo in the Editor -
- - Transistor -
*/} -
- ); -} - -TransistorCard.propTypes = { - accentColor: PropTypes.string, - backgroundColor: PropTypes.string - // TODO: Re-enable when design tab is implemented - // isEditing: PropTypes.bool, - // visibilityOptions: PropTypes.array, - // handleAccentColorChange: PropTypes.func, - // handleBackgroundColorChange: PropTypes.func, - // toggleVisibility: PropTypes.func, - // showVisibilitySettings: PropTypes.bool -}; - -TransistorPlaceholder.propTypes = {}; diff --git a/packages/koenig-lexical/src/components/ui/cards/TransistorCard.tsx b/packages/koenig-lexical/src/components/ui/cards/TransistorCard.tsx new file mode 100644 index 0000000000..0c83759e12 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/TransistorCard.tsx @@ -0,0 +1,38 @@ +import TransistorIcon from '../../../assets/icons/kg-card-type-transistor.svg?react'; + +interface TransistorCardProps { + accentColor?: string; + backgroundColor?: string; +} + +export function TransistorCard({ + accentColor: _accentColor = '', + backgroundColor: _backgroundColor = '' +}: TransistorCardProps) { + return ( +
+ +
+ ); +} + +function TransistorPlaceholder() { + return ( +
+
+ +
+
+
+ Members-only podcasts +
+
+ Your Transistor podcasts will appear here. Members will see subscribe links based on their access level. +
+
+
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/cards/VideoCard.jsx b/packages/koenig-lexical/src/components/ui/cards/VideoCard.jsx deleted file mode 100644 index ab7b80bad9..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/VideoCard.jsx +++ /dev/null @@ -1,233 +0,0 @@ -import ImgFullIcon from '../../../assets/icons/kg-img-full.svg?react'; -import ImgRegularIcon from '../../../assets/icons/kg-img-regular.svg?react'; -import ImgWideIcon from '../../../assets/icons/kg-img-wide.svg?react'; -import PlayIcon from '../../../assets/icons/kg-play.svg?react'; -import PropTypes from 'prop-types'; -import {ButtonGroupSetting, MediaUploadSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; -import {CardCaptionEditor} from '../CardCaptionEditor'; -import {MediaPlaceholder} from '../MediaPlaceholder'; -import {MediaPlayer} from '../MediaPlayer'; -import {ProgressBar} from '../ProgressBar'; -import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; -import {openFileSelection} from '../../../utils/openFileSelection'; - -function PopulatedVideoCard({ - thumbnail, - customThumbnail, - onCustomThumbnailChange, - videoUploader = {}, - customThumbnailUploader = {}, - onRemoveCustomThumbnail, - totalDuration, - cardWidth, - isLoopChecked, - onLoopChange, - onCardWidthChange, - isEditing, - thumbnailMimeTypes, - thumbnailDragHandler = {} -}) { - const progressStyle = { - width: `${videoUploader.progress?.toFixed(0)}%` - }; - - const buttonGroupChildren = [ - { - label: 'Regular', - name: 'regular', - Icon: ImgRegularIcon - }, - { - label: 'Wide', - name: 'wide', - Icon: ImgWideIcon - }, - { - label: 'Full', - name: 'full', - Icon: ImgFullIcon - } - ]; - - return ( - <> -
-
- Video thumbnail - {customThumbnail && Video custom thumbnail} -
-
- {videoUploader.isLoading || ( - - )} -
-
- -
- {/* This prevents interacting with the buttons that don't do anything, causing focus loss */} - -
- { - videoUploader.isLoading && ( -
- -
- ) - } - - { - !!thumbnail && !videoUploader.isLoading && isEditing && ( - - - - - - ) - } - - ); -} - -function EmptyVideoCard({onFileChange, fileInputRef, errors, videoMimeTypes = [], videoDragHandler = {}}) { - return ( - <> - openFileSelection({fileInputRef})} - icon='video' - isDraggedOver={videoDragHandler.isDraggedOver} - placeholderRef={videoDragHandler.setRef} - /> -
- -
- - ); -} - -const VideoHolder = ({ - fileInputRef, - onVideoFileChange, - videoDragHandler, - videoUploader = {}, - videoUploadErrors, - videoMimeTypes, - ...props -}) => { - const showPopulatedCard = props.customThumbnail || props.thumbnail || videoUploader.isLoading; - if (showPopulatedCard) { - return ( - - ); - } else { - return ( - - ); - } -}; - -export function VideoCard({ - captionEditor, - captionEditorInitialState, - isSelected, - isEditing, - ...props -}) { - return ( -
- - -
- ); -} - -VideoCard.propTypes = { - captionEditor: PropTypes.object, - captionEditorInitialState: PropTypes.object, - isSelected: PropTypes.bool, - isEditing: PropTypes.bool -}; - -PopulatedVideoCard.propTypes = { - thumbnail: PropTypes.string, - customThumbnail: PropTypes.string, - onCustomThumbnailChange: PropTypes.func, - videoUploader: PropTypes.object, - customThumbnailUploader: PropTypes.object, - onRemoveCustomThumbnail: PropTypes.func, - totalDuration: PropTypes.string, - cardWidth: PropTypes.string, - isLoopChecked: PropTypes.bool, - onLoopChange: PropTypes.func, - onCardWidthChange: PropTypes.func, - isEditing: PropTypes.bool, - thumbnailMimeTypes: PropTypes.array, - thumbnailDragHandler: PropTypes.object -}; - -EmptyVideoCard.propTypes = { - onFileChange: PropTypes.func, - fileInputRef: PropTypes.object, - errors: PropTypes.array, - videoMimeTypes: PropTypes.array, - videoDragHandler: PropTypes.object -}; - -VideoHolder.propTypes = { - fileInputRef: PropTypes.object, - onVideoFileChange: PropTypes.func, - videoDragHandler: PropTypes.object, - videoUploader: PropTypes.object, - videoUploadErrors: PropTypes.array, - videoMimeTypes: PropTypes.array, - customThumbnail: PropTypes.string, - thumbnail: PropTypes.string -}; diff --git a/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.jsx deleted file mode 100644 index 240b090abb..0000000000 --- a/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import populateEditor from '../../../utils/storybook/populate-storybook-editor'; -import {CardWrapper} from './../CardWrapper'; -import {MINIMAL_NODES} from '../../../index.js'; -import {VideoCard} from './VideoCard'; -import {createEditor} from 'lexical'; - -const displayOptions = { - Default: {isSelected: false, isEditing: false}, - Selected: {isSelected: true, isEditing: false}, - Editing: {isSelected: true, isEditing: true} -}; - -const story = { - title: 'Primary cards/Video card', - component: VideoCard, - subcomponent: {CardWrapper}, - argTypes: { - display: { - options: Object.keys(displayOptions), - mapping: displayOptions, - control: { - type: 'radio', - labels: { - Default: 'Default', - Selected: 'Selected', - Editing: 'Editing' - }, - defaultValue: displayOptions.Default - } - }, - cardWidth: { - options: ['regular', 'wide', 'full'], - control: {type: 'radio'} - } - }, - parameters: { - status: { - type: 'functional' - } - } -}; -export default story; - -const Template = ({display, caption, ...args}) => { - const captionEditor = createEditor({nodes: MINIMAL_NODES}); - populateEditor({editor: captionEditor, initialHtml: `${caption}`}); - - return ( -
-
- - - -
-
- ); -}; - -export const Empty = Template.bind({}); -Empty.args = { - display: 'Editing', - caption: '' -}; - -export const Uploading = Template.bind({}); -Uploading.args = { - display: 'Editing', - cardWidth: 'regular', - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: '', - totalDuration: '32:27', - caption: '', - videoUploader: { - isLoading: true, - progress: 60 - } -}; - -export const DraggedOver = Template.bind({}); -DraggedOver.args = { - display: 'Editing', - cardWidth: 'regular', - thumbnail: '', - customThumbnail: '', - caption: '', - videoDragHandler: { - isDraggedOver: true - } -}; - -export const Populated = Template.bind({}); -Populated.args = { - display: 'Editing', - cardWidth: 'regular', - isLoopChecked: false, - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: '', - totalDuration: '32:27', - caption: 'Watch the full documentary here.' -}; - -export const Error = Template.bind({}); -Error.args = { - display: 'Editing', - cardWidth: 'regular', - thumbnail: '', - customThumbnail: '', - totalDuration: '32:27', - caption: '', - videoUploadErrors: [{message: 'The file type you uploaded is not supported. Please use .MP4, .WEBM, .OGV'}] -}; - -export const ThumbnailUploading = Template.bind({}); -ThumbnailUploading.args = { - display: 'Editing', - cardWidth: 'regular', - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: '', - totalDuration: '32:27', - caption: 'Watch the full documentary here.', - customThumbnailUploader: { - isLoading: true, - progress: 60 - } -}; - -export const ThumbnailDraggedOver = Template.bind({}); -ThumbnailDraggedOver.args = { - display: 'Editing', - cardWidth: 'regular', - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: '', - totalDuration: '32:27', - caption: 'Watch the full documentary here.', - thumbnailDragHandler: { - isDraggedOver: true - } -}; - -export const ThumbnailPopulated = Template.bind({}); -ThumbnailPopulated.args = { - display: 'Editing', - cardWidth: 'regular', - isLoopChecked: false, - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: 'https://images.unsplash.com/photo-1543242594-c8bae8b9e708?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2970&q=80', - totalDuration: '32:27', - caption: 'Watch the full documentary here.' -}; - -export const ThumbnailError = Template.bind({}); -ThumbnailError.args = { - display: 'Editing', - cardWidth: 'regular', - isLoopChecked: false, - thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', - customThumbnail: '', - totalDuration: '32:27', - caption: 'Watch the full documentary here.', - customThumbnailUploader: { - errors: [{message: 'This file type is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'}] - } -}; - diff --git a/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.tsx b/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.tsx new file mode 100644 index 0000000000..a038e94ed1 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.tsx @@ -0,0 +1,166 @@ +import populateEditor from '../../../utils/storybook/populate-storybook-editor'; +import {CardWrapper} from './../CardWrapper'; +import {MINIMAL_NODES} from '../../../index'; +import {VideoCard} from './VideoCard'; +import {createEditor} from 'lexical'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; + +const displayOptions = { + Default: {isSelected: false, isEditing: false}, + Selected: {isSelected: true, isEditing: false}, + Editing: {isSelected: true, isEditing: true} +}; + +type StoryArgs = ComponentProps & {display: keyof typeof displayOptions; caption?: string}; + +const story: Meta = { + title: 'Primary cards/Video card', + component: VideoCard, + subcomponents: {CardWrapper}, + argTypes: { + display: { + options: Object.keys(displayOptions), + control: { + type: 'radio', + labels: { + Default: 'Default', + Selected: 'Selected', + Editing: 'Editing' + }, + defaultValue: displayOptions.Default + } + }, + cardWidth: { + options: ['regular', 'wide', 'full'], + control: {type: 'radio'} + } + }, + parameters: { + status: { + type: 'functional' + } + } +}; +export default story; + +const Template: StoryFn = ({display, caption, ...args}) => { + const captionEditor = createEditor({nodes: MINIMAL_NODES}); + populateEditor({editor: captionEditor, initialHtml: `${caption}`}); + + return ( +
+
+ + + +
+
+ ); +}; + +export const Empty = Template.bind({}); +Empty.args = { + display: 'Editing', + caption: '' +}; + +export const Uploading = Template.bind({}); +Uploading.args = { + display: 'Editing', + cardWidth: 'regular', + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: '', + totalDuration: '32:27', + caption: '', + videoUploader: { + isLoading: true, + progress: 60 + } +}; + +export const DraggedOver = Template.bind({}); +DraggedOver.args = { + display: 'Editing', + cardWidth: 'regular', + thumbnail: '', + customThumbnail: '', + caption: '', + videoDragHandler: { + isDraggedOver: true + } +}; + +export const Populated = Template.bind({}); +Populated.args = { + display: 'Editing', + cardWidth: 'regular', + isLoopChecked: false, + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: '', + totalDuration: '32:27', + caption: 'Watch the full documentary here.' +}; + +export const Error = Template.bind({}); +Error.args = { + display: 'Editing', + cardWidth: 'regular', + thumbnail: '', + customThumbnail: '', + totalDuration: '32:27', + caption: '', + videoUploadErrors: [{message: 'The file type you uploaded is not supported. Please use .MP4, .WEBM, .OGV'}] +}; + +export const ThumbnailUploading = Template.bind({}); +ThumbnailUploading.args = { + display: 'Editing', + cardWidth: 'regular', + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: '', + totalDuration: '32:27', + caption: 'Watch the full documentary here.', + customThumbnailUploader: { + isLoading: true, + progress: 60 + } +}; + +export const ThumbnailDraggedOver = Template.bind({}); +ThumbnailDraggedOver.args = { + display: 'Editing', + cardWidth: 'regular', + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: '', + totalDuration: '32:27', + caption: 'Watch the full documentary here.', + thumbnailDragHandler: { + isDraggedOver: true + } +}; + +export const ThumbnailPopulated = Template.bind({}); +ThumbnailPopulated.args = { + display: 'Editing', + cardWidth: 'regular', + isLoopChecked: false, + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: 'https://images.unsplash.com/photo-1543242594-c8bae8b9e708?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2970&q=80', + totalDuration: '32:27', + caption: 'Watch the full documentary here.' +}; + +export const ThumbnailError = Template.bind({}); +ThumbnailError.args = { + display: 'Editing', + cardWidth: 'regular', + isLoopChecked: false, + thumbnail: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + customThumbnail: '', + totalDuration: '32:27', + caption: 'Watch the full documentary here.', + customThumbnailUploader: { + errors: [{message: 'This file type is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'}] + } +}; diff --git a/packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx b/packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx new file mode 100644 index 0000000000..7b8bbc2554 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx @@ -0,0 +1,244 @@ +import ImgFullIcon from '../../../assets/icons/kg-img-full.svg?react'; +import ImgRegularIcon from '../../../assets/icons/kg-img-regular.svg?react'; +import ImgWideIcon from '../../../assets/icons/kg-img-wide.svg?react'; +import PlayIcon from '../../../assets/icons/kg-play.svg?react'; +import React from 'react'; +import {ButtonGroupSetting, MediaUploadSetting, SettingsPanel, ToggleSetting} from '../SettingsPanel'; +import {CardCaptionEditor} from '../CardCaptionEditor'; +import {MediaPlaceholder} from '../MediaPlaceholder'; +import {MediaPlayer} from '../MediaPlayer'; +import {ProgressBar} from '../ProgressBar'; +import {ReadOnlyOverlay} from '../ReadOnlyOverlay'; +import {openFileSelection} from '../../../utils/openFileSelection'; +import type {CardWidth} from '@tryghost/kg-default-nodes'; +import type {LexicalEditor} from 'lexical'; + +interface FileUploader { + isLoading?: boolean; + progress?: number; + errors?: {message: string}[]; +} + +interface DragHandler { + isDraggedOver?: boolean; + setRef?: React.Ref; +} + +interface PopulatedVideoCardProps { + thumbnail?: string; + customThumbnail?: string; + onCustomThumbnailChange?: (e: React.ChangeEvent) => void; + videoUploader?: FileUploader; + customThumbnailUploader?: FileUploader; + onRemoveCustomThumbnail?: () => void; + totalDuration?: string; + cardWidth?: CardWidth; + isLoopChecked?: boolean; + onLoopChange?: (event: React.ChangeEvent) => void; + onCardWidthChange?: (name: string) => void; + isEditing?: boolean; + thumbnailMimeTypes?: string[]; + thumbnailDragHandler?: DragHandler; +} + +function PopulatedVideoCard({ + thumbnail, + customThumbnail, + onCustomThumbnailChange, + videoUploader = {}, + customThumbnailUploader = {}, + onRemoveCustomThumbnail, + totalDuration, + cardWidth, + isLoopChecked, + onLoopChange, + onCardWidthChange, + isEditing, + thumbnailMimeTypes, + thumbnailDragHandler = {} +}: PopulatedVideoCardProps) { + const progressStyle = { + width: `${(videoUploader.progress ?? 0).toFixed(0)}%` + }; + + const buttonGroupChildren = [ + { + label: 'Regular', + name: 'regular', + Icon: ImgRegularIcon + }, + { + label: 'Wide', + name: 'wide', + Icon: ImgWideIcon + }, + { + label: 'Full', + name: 'full', + Icon: ImgFullIcon + } + ]; + + return ( + <> +
+
+ Video thumbnail + {customThumbnail && Video custom thumbnail} +
+
+ {videoUploader.isLoading || ( + + )} +
+
+ +
+ {/* This prevents interacting with the buttons that don't do anything, causing focus loss */} + +
+ { + videoUploader.isLoading && ( +
+ +
+ ) + } + + { + !!thumbnail && !videoUploader.isLoading && isEditing && ( + + + + + + ) + } + + ); +} + +interface EmptyVideoCardProps { + onFileChange?: (e: React.ChangeEvent) => void; + fileInputRef: React.RefObject; + errors?: {message: string}[]; + videoMimeTypes?: string[]; + videoDragHandler?: DragHandler; +} + +function EmptyVideoCard({onFileChange, fileInputRef, errors, videoMimeTypes = [], videoDragHandler = {}}: EmptyVideoCardProps) { + return ( + <> + openFileSelection({fileInputRef})} + icon='video' + isDraggedOver={videoDragHandler.isDraggedOver} + placeholderRef={videoDragHandler.setRef} + /> +
}> + } + accept={videoMimeTypes.join(',')} + hidden={true} + name="image-input" + type='file' + /> +
+ + ); +} + +interface VideoHolderProps extends PopulatedVideoCardProps { + fileInputRef: React.RefObject; + onVideoFileChange?: (e: React.ChangeEvent) => void; + videoDragHandler?: DragHandler; + videoUploadErrors?: {message: string}[]; + videoMimeTypes?: string[]; +} + +const VideoHolder = ({ + fileInputRef, + onVideoFileChange, + videoDragHandler, + videoUploader = {}, + videoUploadErrors, + videoMimeTypes, + ...props +}: VideoHolderProps) => { + const showPopulatedCard = props.customThumbnail || props.thumbnail || videoUploader.isLoading; + if (showPopulatedCard) { + return ( + + ); + } else { + return ( + + ); + } +}; + +interface VideoCardProps extends VideoHolderProps { + captionEditor?: LexicalEditor; + captionEditorInitialState?: string; + isSelected?: boolean; +} + +export function VideoCard({ + captionEditor, + captionEditorInitialState, + isSelected, + isEditing, + ...props +}: VideoCardProps) { + return ( +
+ + {captionEditor && ( + + )} +
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx deleted file mode 100644 index b77144b271..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import {ERROR_TYPE} from '../../../../utils/services/gif.js'; - -export function Error({error}) { - if (error === ERROR_TYPE.COMMON) { - return ( -

- Uh-oh! Trouble reaching the GIF service, please check your connection -

- ); - } - - if (error === ERROR_TYPE.INVALID_API_KEY) { - return ( -

- The GIF API key is not valid. Please check your configuration by following our - documentation here. -

- ); - } - return ( -

{error}

- ); -} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.tsx new file mode 100644 index 0000000000..8ae81099f2 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.tsx @@ -0,0 +1,27 @@ +import {ERROR_TYPE} from '../../../../utils/services/gif'; + +interface ErrorProps { + error: string; +} + +export function Error({error}: ErrorProps) { + if (error === ERROR_TYPE.COMMON) { + return ( +

+ Uh-oh! Trouble reaching the GIF service, please check your connection +

+ ); + } + + if (error === ERROR_TYPE.INVALID_API_KEY) { + return ( +

+ The GIF API key is not valid. Please check your configuration by following our + documentation here. +

+ ); + } + return ( +

{error}

+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx deleted file mode 100644 index 19a3c8a814..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import {useEffect, useRef} from 'react'; -export function Gif({gif, onClick, highlightedGif = {}}) { - const gifRef = useRef(null); - const media = gif.media_formats.tinygif; - - useEffect(() => { - const isFocused = highlightedGif.id === gif.id; - if (isFocused) { - gifRef.current?.focus(); - } else { - gifRef.current?.blur(); - } - }, [gif.id, highlightedGif.id]); - - const handleClick = () => { - onClick(gif); - }; - - return ( - - ); -} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.tsx new file mode 100644 index 0000000000..2c76fc9bf7 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.tsx @@ -0,0 +1,52 @@ +import {useEffect, useRef} from 'react'; + +export interface GifMediaFormat { + url: string; + dims: [number, number]; + content_description?: string; +} + +export interface GifData { + id: string; + index: number; + media_formats: { + tinygif: GifMediaFormat; + [key: string]: GifMediaFormat; + }; +} + +interface GifProps { + gif: T; + onClick: (gif: T) => void; + highlightedGif?: {id?: string}; +} + +export function Gif({gif, onClick, highlightedGif = {}}: GifProps) { + const gifRef = useRef(null); + const media = gif.media_formats.tinygif; + + useEffect(() => { + const isFocused = highlightedGif.id === gif.id; + if (isFocused) { + gifRef.current?.focus(); + } else { + gifRef.current?.blur(); + } + }, [gif.id, highlightedGif.id]); + + const handleClick = () => { + onClick(gif); + }; + + return ( + + ); +} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.jsx deleted file mode 100644 index 89ca345632..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.jsx +++ /dev/null @@ -1,14 +0,0 @@ -export function Loader({isLazyLoading}) { - if (isLazyLoading) { - return ( -
-
-
- ); - } - return ( -
-
-
- ); -} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.tsx new file mode 100644 index 0000000000..1a9dd6dab3 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.tsx @@ -0,0 +1,18 @@ +interface LoaderProps { + isLazyLoading?: boolean; +} + +export function Loader({isLazyLoading}: LoaderProps) { + if (isLazyLoading) { + return ( +
+
+
+ ); + } + return ( +
+
+
+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.jsx deleted file mode 100644 index 82de55df55..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import DownloadIcon from '../../../../assets/icons/kg-download.svg?react'; -import UnsplashHeartIcon from '../../../../assets/icons/kg-unsplash-heart.svg?react'; - -const BUTTON_ICONS = { - heart: UnsplashHeartIcon, - download: DownloadIcon -}; - -function UnsplashButton({icon, label, ...props}) { - const Icon = BUTTON_ICONS[icon]; - - return ( - e.stopPropagation()} - {...props} - > - {icon && } - {label && {label}} - - ); -} - -export default UnsplashButton; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.tsx new file mode 100644 index 0000000000..f0b4d61bbe --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.tsx @@ -0,0 +1,29 @@ +import DownloadIcon from '../../../../assets/icons/kg-download.svg?react'; +import React from 'react'; +import UnsplashHeartIcon from '../../../../assets/icons/kg-unsplash-heart.svg?react'; + +const BUTTON_ICONS: Record> = { + heart: UnsplashHeartIcon, + download: DownloadIcon +}; + +interface UnsplashButtonProps extends React.AnchorHTMLAttributes { + icon?: string; + label?: string | number; +} + +function UnsplashButton({icon, label, ...props}: UnsplashButtonProps) { + const Icon = icon ? BUTTON_ICONS[icon] : null; + + return ( + e.stopPropagation()} + {...props} + > + {Icon && } + {label && {label}} + + ); +} + +export default UnsplashButton; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.jsx deleted file mode 100644 index 64380934d5..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import UnsplashImage from './UnsplashImage'; -import UnsplashZoomed from './UnsplashZoomed'; - -function UnsplashGalleryLoading() { - return ( -
-
-
- ); -} - -export function MasonryColumn(props) { - return ( -
- {props.children} -
- ); -} - -export function UnsplashGalleryColumns(props) { - if (!props?.columns) { - return null; - } - - return ( - props?.columns.map((array, index) => ( - // eslint-disable-next-line react/no-array-index-key - - { - array.map(payload => ( - - )) - } - - )) - ); -} - -export function GalleryLayout(props) { - return ( -
-
- {props.children} - {props?.isLoading && } -
-
- ); -} - -function UnsplashGallery({zoomed, - error, - galleryRef, - isLoading, - dataset, - selectImg, - insertImage}) { - if (zoomed) { - return ( - - - - ); - } - - if (error) { - return ( - -
-

Error

-

{error}

-
-
- ); - } - - return ( - - - - ); -} - -export default UnsplashGallery; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.tsx new file mode 100644 index 0000000000..687a650cf0 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.tsx @@ -0,0 +1,140 @@ +import UnsplashImage from './UnsplashImage'; +import UnsplashZoomed from './UnsplashZoomed'; +import type {UnsplashPhotoPayload} from './UnsplashImage'; + +function UnsplashGalleryLoading() { + return ( +
+
+
+ ); +} + +export function MasonryColumn({children}: {children: React.ReactNode}) { + return ( +
+ {children} +
+ ); +} + +interface UnsplashGalleryColumnsProps { + columns?: UnsplashPhotoPayload[][]; + insertImage?: (image: {src: string; caption: string; height: number; width: number; alt?: string; links: unknown}) => void; + selectImg?: (payload: UnsplashPhotoPayload | null) => void; + zoomed?: UnsplashPhotoPayload | null | false; +} + +export function UnsplashGalleryColumns(props: UnsplashGalleryColumnsProps) { + if (!props?.columns) { + return null; + } + + return ( + props?.columns.map(array => ( + + { + array.map(payload => ( + {})} + likes={payload.likes} + links={payload.links} + payload={payload} + selectImg={props?.selectImg ?? (() => {})} + srcUrl={payload.urls.regular} + urls={payload.urls} + user={payload.user} + width={payload.width} + zoomed={props?.zoomed} + /> + )) + } + + )) + ); +} + +interface GalleryLayoutProps { + galleryRef?: React.Ref; + zoomed?: UnsplashPhotoPayload | null | false; + isLoading?: boolean; + children?: React.ReactNode; + dataset?: UnsplashPhotoPayload[][]; +} + +export function GalleryLayout(props: GalleryLayoutProps) { + return ( +
+
+ {props.children} + {props?.isLoading && } +
+
+ ); +} + +interface UnsplashGalleryProps { + zoomed?: UnsplashPhotoPayload | null | false; + error?: string; + galleryRef?: React.Ref; + isLoading?: boolean; + dataset?: UnsplashPhotoPayload[][]; + selectImg: (payload: UnsplashPhotoPayload | null) => void; + insertImage: (image: {src: string; caption: string; height: number; width: number; alt?: string; links: unknown}) => void; +} + +function UnsplashGallery({zoomed, + error, + galleryRef, + isLoading, + dataset, + selectImg, + insertImage}: UnsplashGalleryProps) { + if (zoomed) { + return ( + + + + ); + } + + if (error) { + return ( + +
+

Error

+

{error}

+
+
+ ); + } + + return ( + + + + ); +} + +export default UnsplashGallery; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.jsx deleted file mode 100644 index 8aa66649cc..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import UnsplashButton from './UnsplashButton'; - -function UnsplashImage({payload, - srcUrl, - links, - likes, - user, - alt, - urls, - height, - width, - zoomed, - insertImage, - selectImg}) { - return ( -
{ - e.stopPropagation(); - selectImg(zoomed ? null : payload); - }}> - {alt} -
-
- {/* TODO: we may want to pass in the Ghost referral data from consuming app and parse to the urls */} - - -
-
-
- author -
{user.name}
-
- { - e.stopPropagation(); - insertImage({ - src: urls.regular.replace(/&w=1080/, '&w=2000'), - caption: `Photo by ${user.name} / Unsplash`, - height: height, - width: width, - alt: alt, - links: links - }); - }} /> -
-
-
- ); -} - -export default UnsplashImage; \ No newline at end of file diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.tsx new file mode 100644 index 0000000000..9b3533dc4f --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.tsx @@ -0,0 +1,101 @@ +import UnsplashButton from './UnsplashButton'; + +export interface UnsplashPhotoPayload { + id: string; + alt_description?: string; + height: number; + width: number; + likes: number; + links: {html: string; download: string; [key: string]: string}; + urls: {regular: string; [key: string]: string}; + user: { + name: string; + links: {html: string; [key: string]: string}; + profile_image: {small: string; [key: string]: string}; + }; + [key: string]: unknown; +} + +export interface UnsplashImageProps { + payload: UnsplashPhotoPayload; + srcUrl: string; + links: {html: string; download: string; [key: string]: string}; + likes: number; + user: UnsplashPhotoPayload['user']; + alt?: string; + urls: {regular: string; [key: string]: string}; + height: number; + width: number; + zoomed?: UnsplashPhotoPayload | null | false; + insertImage: (image: {src: string; caption: string; height: number; width: number; alt?: string; links: unknown}) => void; + selectImg: (payload: UnsplashPhotoPayload | null) => void; +} + +function UnsplashImage({payload, + srcUrl, + links, + likes, + user, + alt, + urls, + height, + width, + zoomed, + insertImage, + selectImg}: UnsplashImageProps) { + return ( +
{ + e.stopPropagation(); + selectImg(zoomed ? null : payload); + }}> + {alt} +
+
+ + +
+
+
+ author +
{user.name}
+
+ { + e.stopPropagation(); + insertImage({ + src: urls.regular.replace(/&w=1080/, '&w=2000'), + caption: `Photo by ${user.name} / Unsplash`, + height: height, + width: width, + alt: alt, + links: links + }); + }} /> +
+
+
+ ); +} + +export default UnsplashImage; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.jsx deleted file mode 100644 index e519c0927a..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import CloseIcon from '../../../../assets/icons/kg-close.svg?react'; -import PropTypes from 'prop-types'; -import SearchIcon from '../../../../assets/icons/kg-search.svg?react'; -import UnsplashIcon from '../../../../assets/icons/kg-card-type-unsplash.svg?react'; - -function UnsplashSelector({closeModal, handleSearch, children, galleryRef}) { - return ( - <> -
-
- -
-
-

- - Unsplash -

-
- - -
-
- {children} -
-
- - ); -} - -UnsplashSelector.propTypes = { - closeModal: PropTypes.func, - handleSearch: PropTypes.func -}; - -export default UnsplashSelector; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.jsx deleted file mode 100644 index cf670a7b81..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import UnsplashImage from './UnsplashImage'; -import UnsplashSelector from './UnsplashSelector'; -import UnsplashZoomed from './UnsplashZoomed'; -import {GalleryLayout, MasonryColumn} from '../Unsplash/UnsplashGallery'; - -const story = { - title: 'File Selectors/Unsplash', - component: UnsplashSelector, - parameters: { - status: { - type: 'functional' - } - } -}; -export default story; - -const GalleryTemplate = (args) => { - return ( -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- ); -}; - -export const Gallery = GalleryTemplate.bind({}); - -Gallery.args = { - zoomed: false, - isLoading: false, - selectImg: () => {}, - insertImage: () => {}, - closeModal: () => {}, - srcUrl: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080', - alt: 'alt text here', - links: { - download: 'https://unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4', - html: 'https://unsplash.com/photos/OudVFouGJmM', - download_location: 'https://api.unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4' - }, - likes: 69, - user: { - name: 'John Doe', - profile_image: { - small: 'https://images.unsplash.com/profile-1600184424687-de96bd61fa67image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=32&h=32' - } - }, - urls: { - regular: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080' - }, - height: 500, - width: 500 -}; - -const ZoomedTemplate = (args) => { - return ( -
- - - - - -
- ); -}; - -export const Zoomed = ZoomedTemplate.bind({}); - -Zoomed.args = { - zoomed: true, - isLoading: false, - payload: { - srcUrl: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080', - alt: 'alt text here', - links: { - download: 'https://unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4', - html: 'https://unsplash.com/photos/OudVFouGJmM', - download_location: 'https://api.unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4' - }, - likes: 69, - user: { - name: 'John Doe', - profile_image: { - small: 'https://images.unsplash.com/profile-1600184424687-de96bd61fa67image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=32&h=32' - } - }, - urls: { - regular: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080' - }, - height: 500, - width: 500 - } -}; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.tsx new file mode 100644 index 0000000000..84bfbebcf2 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.tsx @@ -0,0 +1,126 @@ +import UnsplashImage from './UnsplashImage'; +import UnsplashSelector from './UnsplashSelector'; +import UnsplashZoomed from './UnsplashZoomed'; +import {GalleryLayout, MasonryColumn} from '../Unsplash/UnsplashGallery'; +import type {ComponentProps} from 'react'; +import type {Meta, StoryFn} from '@storybook/react-vite'; +import type {UnsplashPhotoPayload} from './UnsplashImage'; + +const story: Meta = { + title: 'File Selectors/Unsplash', + component: UnsplashSelector, + parameters: { + status: { + type: 'functional' + } + } +}; +export default story; + +const GalleryTemplate: StoryFn> = (args) => { + return ( +
+
+ {}} handleSearch={() => {}}> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export const Gallery = GalleryTemplate.bind({}); + +Gallery.args = { + zoomed: false, + selectImg: () => {}, + insertImage: () => {}, + srcUrl: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080', + alt: 'alt text here', + links: { + download: 'https://unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4', + html: 'https://unsplash.com/photos/OudVFouGJmM', + download_location: 'https://api.unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4' + }, + likes: 69, + user: { + name: 'John Doe', + links: {html: 'https://unsplash.com/@johndoe'}, + profile_image: { + small: 'https://images.unsplash.com/profile-1600184424687-de96bd61fa67image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=32&h=32' + } + }, + urls: { + regular: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080' + }, + height: 500, + width: 500 +}; + +const ZoomedTemplate: StoryFn> = (args) => { + return ( +
+ {}} handleSearch={() => {}}> + + + + +
+ ); +}; + +export const Zoomed = ZoomedTemplate.bind({}); + +const zoomedPayload: UnsplashPhotoPayload = { + id: 'OudVFouGJmM', + srcUrl: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080', + alt: 'alt text here', + links: { + download: 'https://unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4', + html: 'https://unsplash.com/photos/OudVFouGJmM', + download_location: 'https://api.unsplash.com/photos/OudVFouGJmM/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4' + }, + likes: 69, + user: { + name: 'John Doe', + links: {html: 'https://unsplash.com/@johndoe'}, + profile_image: { + small: 'https://images.unsplash.com/profile-1600184424687-de96bd61fa67image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=32&h=32' + } + }, + urls: { + regular: 'https://images.unsplash.com/photo-1670171336566-6f08f1fbf648?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjcwMjI0MDg4&ixlib=rb-4.0.3&q=80&w=1080' + }, + height: 500, + width: 500 +}; + +Zoomed.args = { + zoomed: zoomedPayload, + payload: zoomedPayload +}; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.tsx new file mode 100644 index 0000000000..89df853795 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.tsx @@ -0,0 +1,43 @@ +import CloseIcon from '../../../../assets/icons/kg-close.svg?react'; +import React from 'react'; +import SearchIcon from '../../../../assets/icons/kg-search.svg?react'; +import UnsplashIcon from '../../../../assets/icons/kg-card-type-unsplash.svg?react'; + +interface UnsplashSelectorProps { + closeModal: () => void; + handleSearch: (e: React.ChangeEvent) => void; + children?: React.ReactNode; + galleryRef?: React.Ref; +} + +function UnsplashSelector({closeModal, handleSearch, children, galleryRef}: UnsplashSelectorProps) { + return ( + <> +
+
+ +
+
+

+ + Unsplash +

+
+ + +
+
+ {children} +
+
+ + ); +} + +export default UnsplashSelector; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.jsx deleted file mode 100644 index 6ab2bfcd2e..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from 'prop-types'; - -import UnsplashImage from './UnsplashImage'; - -function UnsplashZoomed({payload, insertImage, selectImg, zoomed}) { - return ( -
selectImg(null)}> - -
- ); -} - -export default UnsplashZoomed; - -UnsplashZoomed.propTypes = { - payload: PropTypes.object, - insertImage: PropTypes.func, - selectImg: PropTypes.func, - zoomed: PropTypes.object || PropTypes.bool, - srcUrl: PropTypes.string, - alt: PropTypes.string, - links: PropTypes.object, - likes: PropTypes.number, - user: PropTypes.object, - urls: PropTypes.object, - height: PropTypes.number, - width: PropTypes.number -}; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.tsx new file mode 100644 index 0000000000..666255b939 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.tsx @@ -0,0 +1,33 @@ +import UnsplashImage from './UnsplashImage'; +import type {UnsplashPhotoPayload} from './UnsplashImage'; + +export interface UnsplashZoomedProps { + payload: UnsplashPhotoPayload; + insertImage: (image: {src: string; caption: string; height: number; width: number; alt?: string; links: unknown}) => void; + selectImg: (payload: UnsplashPhotoPayload | null) => void; + zoomed?: UnsplashPhotoPayload | null | false; +} + +function UnsplashZoomed({payload, insertImage, selectImg, zoomed}: UnsplashZoomedProps) { + return ( +
selectImg(null)}> + +
+ ); +} + +export default UnsplashZoomed; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.jsx deleted file mode 100644 index 2260b1e29f..0000000000 --- a/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import Portal from '../Portal'; -import {UnsplashSearchModal} from '@tryghost/kg-unsplash-selector'; - -const UnsplashModal = ({unsplashConf, onImageInsert, onClose}) => { - return ( - - - - ); -}; - -export default UnsplashModal; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.tsx b/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.tsx new file mode 100644 index 0000000000..3b11de9a76 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.tsx @@ -0,0 +1,22 @@ +import Portal from '../Portal'; +import {UnsplashSearchModal} from '@tryghost/kg-unsplash-selector'; + +interface UnsplashModalProps { + unsplashConf: unknown; + onImageInsert: (image: unknown) => void; + onClose: () => void; +} + +const UnsplashModal = ({unsplashConf, onImageInsert, onClose}: UnsplashModalProps) => { + return ( + + [0]['unsplashProviderConfig']} + onClose={onClose} + onImageInsert={onImageInsert} + /> + + ); +}; + +export default UnsplashModal; diff --git a/packages/koenig-lexical/src/context/CardContext.jsx b/packages/koenig-lexical/src/context/CardContext.jsx deleted file mode 100644 index de29d22d80..0000000000 --- a/packages/koenig-lexical/src/context/CardContext.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const CardContext = React.createContext({}); - -export default CardContext; diff --git a/packages/koenig-lexical/src/context/CardContext.tsx b/packages/koenig-lexical/src/context/CardContext.tsx new file mode 100644 index 0000000000..0eb7ca4de1 --- /dev/null +++ b/packages/koenig-lexical/src/context/CardContext.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type {CardWidth} from '@tryghost/kg-default-nodes'; + +export interface CardContextType { + isSelected: boolean; + captionHasFocus: boolean | null; + isEditing: boolean; + cardWidth: CardWidth; + setCardWidth: (width: CardWidth) => void; + setCaptionHasFocus: (hasFocus: boolean | null) => void; + setEditing: (editing: boolean) => void; + nodeKey: string; + cardContainerRef: React.RefObject; +} + +const noop = () => {}; + +const CardContext = React.createContext({ + isSelected: false, + captionHasFocus: null, + isEditing: false, + cardWidth: 'regular', + setCardWidth: noop, + setCaptionHasFocus: noop, + setEditing: noop, + nodeKey: '', + cardContainerRef: React.createRef() +}); + +export default CardContext; diff --git a/packages/koenig-lexical/src/context/KoenigComposerContext.jsx b/packages/koenig-lexical/src/context/KoenigComposerContext.jsx deleted file mode 100644 index cbc4cd9b8b..0000000000 --- a/packages/koenig-lexical/src/context/KoenigComposerContext.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const KoenigComposerContext = React.createContext({}); - -export default KoenigComposerContext; diff --git a/packages/koenig-lexical/src/context/KoenigComposerContext.tsx b/packages/koenig-lexical/src/context/KoenigComposerContext.tsx new file mode 100644 index 0000000000..e9722c5c4e --- /dev/null +++ b/packages/koenig-lexical/src/context/KoenigComposerContext.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import type {Doc} from 'yjs'; + +export interface FileTypeConfig { + mimeTypes: string[]; + extensions?: string[]; + [key: string]: unknown; +} + +export interface FileUploadResultItem { + url: string; + fileName?: string; + [key: string]: unknown; +} + +export interface FileUploadResult { + isLoading: boolean; + upload: (files: File[] | FileList, options?: Record) => Promise; + progress: number; + errors: {message: string}[]; + filesNumber: number; + [key: string]: unknown; +} + +export interface FileUploader { + useFileUpload: (type: string) => FileUploadResult; + fileTypes: Record; + [key: string]: unknown; +} + +export interface PinturaConfig { + jsUrl?: string; + cssUrl?: string; +} + +export interface EmbedResponse { + url?: string; + title?: string; + description?: string; + icon?: string; + thumbnail?: string; + author?: string; + publisher?: string; + type?: string; + html?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface Snippet { + name: string; + value: string; +} + +export interface FetchEmbedOptions { + type?: string; + [key: string]: unknown; +} + +export interface CardConfig { + createSnippet?: (snippet: Snippet) => void; + deleteSnippet?: (snippet: {name: string}) => void; + fetchEmbed?: (url: string, options: FetchEmbedOptions) => Promise; + fetchLabels?: () => Promise; + fetchAutocompleteLinks?: () => Promise<{value: string; label: string}[]>; + searchLinks?: (term?: string) => Promise; + siteUrl?: string; + tenor?: {googleApiKey: string; contentFilter?: string} | null; + unsplash?: unknown; + pinturaConfig?: PinturaConfig; + renderLabels?: boolean; + image?: {allowedWidths?: string[]}; + feature?: Record; + snippets?: Snippet[]; + [key: string]: unknown; +} + +export interface KoenigComposerContextType { + fileUploader: FileUploader; + editorContainerRef: React.RefObject; + cardConfig: CardConfig; + darkMode: boolean; + enableMultiplayer: boolean; + isTKEnabled?: boolean; + multiplayerEndpoint?: string; + multiplayerDocId?: string; + multiplayerUsername?: string; + createWebsocketProvider: (id: string, yjsDocMap: Map) => unknown; + onWordCountChangeRef: React.RefObject<((counts: unknown) => void) | null>; + onError?: (error: Error) => void; + dragDropHandler?: unknown; + [key: string]: unknown; +} + +export const defaultFileUploader: FileUploader = { + fileTypes: {}, + useFileUpload() { + console.error(' requires a `fileUploader` prop object to be passed containing a `useFileUpload` custom hook'); + return { + isLoading: false, + async upload() { + return null; + }, + progress: 0, + errors: [], + filesNumber: 0 + }; + } +}; + +const KoenigComposerContext = React.createContext({ + fileUploader: defaultFileUploader, + editorContainerRef: React.createRef(), + cardConfig: {}, + darkMode: false, + enableMultiplayer: false, + createWebsocketProvider() { + throw new Error('KoenigComposerContext createWebsocketProvider was called outside KoenigComposer'); + }, + onWordCountChangeRef: React.createRef<((counts: unknown) => void) | null>() +}); + +export default KoenigComposerContext; diff --git a/packages/koenig-lexical/src/context/KoenigSelectedCardContext.jsx b/packages/koenig-lexical/src/context/KoenigSelectedCardContext.jsx deleted file mode 100644 index c92a4f5caf..0000000000 --- a/packages/koenig-lexical/src/context/KoenigSelectedCardContext.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -const Context = React.createContext({}); - -export const KoenigSelectedCardContext = ({children}) => { - const [selectedCardKey, setSelectedCardKey] = React.useState(null); - const [isEditingCard, setIsEditingCard] = React.useState(false); - const [isDragging, setIsDragging] = React.useState(false); - const [showVisibilitySettings, setShowVisibilitySettings] = React.useState(false); - const contextValue = React.useMemo(() => { - return { - selectedCardKey, - setSelectedCardKey, - isEditingCard, - setIsEditingCard, - isDragging, - setIsDragging, - showVisibilitySettings, - setShowVisibilitySettings - }; - }, [ - selectedCardKey, - setSelectedCardKey, - isEditingCard, - setIsEditingCard, - isDragging, - setIsDragging, - showVisibilitySettings, - setShowVisibilitySettings - ]); - - return {children}; -}; - -export const useKoenigSelectedCardContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/KoenigSelectedCardContext.tsx b/packages/koenig-lexical/src/context/KoenigSelectedCardContext.tsx new file mode 100644 index 0000000000..1e470bb7fa --- /dev/null +++ b/packages/koenig-lexical/src/context/KoenigSelectedCardContext.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +interface KoenigSelectedCardContextType { + selectedCardKey: string | null; + setSelectedCardKey: (value: string | null) => void; + isEditingCard: boolean; + setIsEditingCard: (value: boolean) => void; + isDragging: boolean; + setIsDragging: (value: boolean) => void; + showVisibilitySettings: boolean; + setShowVisibilitySettings: (value: boolean) => void; +} + +const noop = () => {}; + +const Context = React.createContext({ + selectedCardKey: null, + setSelectedCardKey: noop, + isEditingCard: false, + setIsEditingCard: noop, + isDragging: false, + setIsDragging: noop, + showVisibilitySettings: false, + setShowVisibilitySettings: noop +}); + +export const KoenigSelectedCardContext = ({children}: {children: React.ReactNode}) => { + const [selectedCardKey, setSelectedCardKey] = React.useState(null); + const [isEditingCard, setIsEditingCard] = React.useState(false); + const [isDragging, setIsDragging] = React.useState(false); + const [showVisibilitySettings, setShowVisibilitySettings] = React.useState(false); + const contextValue = React.useMemo(() => { + return { + selectedCardKey, + setSelectedCardKey, + isEditingCard, + setIsEditingCard, + isDragging, + setIsDragging, + showVisibilitySettings, + setShowVisibilitySettings + }; + }, [ + selectedCardKey, + setSelectedCardKey, + isEditingCard, + setIsEditingCard, + isDragging, + setIsDragging, + showVisibilitySettings, + setShowVisibilitySettings + ]); + + return {children}; +}; + +export const useKoenigSelectedCardContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/SharedHistoryContext.jsx b/packages/koenig-lexical/src/context/SharedHistoryContext.jsx deleted file mode 100644 index d23088f729..0000000000 --- a/packages/koenig-lexical/src/context/SharedHistoryContext.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import {createEmptyHistoryState} from '@lexical/react/LexicalHistoryPlugin'; - -const Context = React.createContext({}); - -export const SharedHistoryContext = ({children}) => { - const historyContext = React.useMemo( - () => ({historyState: createEmptyHistoryState()}), - [] - ); - - return {children}; -}; - -export const useSharedHistoryContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/SharedHistoryContext.tsx b/packages/koenig-lexical/src/context/SharedHistoryContext.tsx new file mode 100644 index 0000000000..b00de8e22a --- /dev/null +++ b/packages/koenig-lexical/src/context/SharedHistoryContext.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import {createEmptyHistoryState} from '@lexical/react/LexicalHistoryPlugin'; +import type {HistoryState} from '@lexical/react/LexicalHistoryPlugin'; + +interface SharedHistoryContextType { + historyState: HistoryState; +} + +const Context = React.createContext({ + historyState: createEmptyHistoryState() +}); + +export const SharedHistoryContext = ({children}: {children: React.ReactNode}) => { + const historyContext = React.useMemo( + () => ({historyState: createEmptyHistoryState()}), + [] + ); + + return {children}; +}; + +export const useSharedHistoryContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/SharedOnChangeContext.jsx b/packages/koenig-lexical/src/context/SharedOnChangeContext.jsx deleted file mode 100644 index e783225fd1..0000000000 --- a/packages/koenig-lexical/src/context/SharedOnChangeContext.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -const Context = React.createContext({}); - -export const SharedOnChangeContext = ({onChange, children}) => { - const onChangeContext = React.useMemo( - () => ({onChange}), - [onChange] - ); - - return {children}; -}; - -export const useSharedOnChangeContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/SharedOnChangeContext.tsx b/packages/koenig-lexical/src/context/SharedOnChangeContext.tsx new file mode 100644 index 0000000000..9424762cdd --- /dev/null +++ b/packages/koenig-lexical/src/context/SharedOnChangeContext.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import type {SerializedEditorState} from 'lexical'; + +interface SharedOnChangeContextType { + onChange?: (serializedState: SerializedEditorState) => void; +} + +const Context = React.createContext({}); + +export const SharedOnChangeContext = ({onChange, children}: {onChange?: SharedOnChangeContextType['onChange']; children: React.ReactNode}) => { + const onChangeContext = React.useMemo( + () => ({onChange}), + [onChange] + ); + + return {children}; +}; + +export const useSharedOnChangeContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/TKContext.jsx b/packages/koenig-lexical/src/context/TKContext.jsx deleted file mode 100644 index 7e63e8c5b9..0000000000 --- a/packages/koenig-lexical/src/context/TKContext.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import throttle from 'lodash/throttle'; - -const Context = React.createContext({}); - -export const TKContext = ({children}) => { - // Map( - // editorKey: Map( - // tkNodeKey: {topLevelNodeKey} - // ) - // ) - // - // We store under the editor key because a top level node (i.e. a decorator) - // may contain multiple nested editors and it's easier for the plugin in each - // editor to only know about it's own nodes - const editorTkNodeMapRef = React.useRef(new Map()); - - // node map used in top-level editor to display indicators - const [tkNodeMap, setTkNodeMap] = React.useState({}); - const [tkCount, setTkCount] = React.useState(0); - - // throttled update function to update the top-level node map - // - // this is throttled because the add/remove functions are called many times - // in succession when the editor is opened or large blocks of TK-containing - // content is added/removed (e.g. delete and undo) - const updateTkNodeMap = React.useMemo(() => { - const updateFn = () => { - // derive a top-level tk node map to use for rendering indicators - const editorTkNodeMap = editorTkNodeMapRef.current; - - const newTkNodeMap = {}; - let newTkCount = 0; - - editorTkNodeMap.forEach((nodeMap) => { - nodeMap.forEach(({topLevelNodeKey}, tkNodeKey) => { - newTkCount = newTkCount + 1; - - if (newTkNodeMap[topLevelNodeKey] === undefined) { - newTkNodeMap[topLevelNodeKey] = [tkNodeKey]; - } else { - newTkNodeMap[topLevelNodeKey].push(tkNodeKey); - } - }); - }); - - setTkNodeMap(newTkNodeMap); - setTkCount(newTkCount); - }; - - return throttle(updateFn, 5, {trailing: true}); - }, []); - - const addEditorTkNode = React.useCallback((editorKey, topLevelNodeKey, tkNodeKey) => { - const editorTkNodeMap = editorTkNodeMapRef.current; - - if (!editorTkNodeMap.has(editorKey)) { - editorTkNodeMap.set(editorKey, new Map()); - } - - editorTkNodeMap.get(editorKey).set(tkNodeKey, {topLevelNodeKey}); - - updateTkNodeMap(); - }, [updateTkNodeMap]); - - const removeEditorTkNode = React.useCallback((editorKey, tkNodeKey) => { - const editorTkNodeMap = editorTkNodeMapRef.current; - - editorTkNodeMap.get(editorKey)?.delete(tkNodeKey); - - if (editorTkNodeMap.get(editorKey)?.size === 0) { - editorTkNodeMap.delete(editorKey); - } - - updateTkNodeMap(); - }, [updateTkNodeMap]); - - const removeEditor = React.useCallback((editorKey) => { - editorTkNodeMapRef.current.delete(editorKey); - updateTkNodeMap(); - }, [updateTkNodeMap]); - - const contextValue = React.useMemo(() => { - return { - tkNodeMap, - tkCount, - addEditorTkNode, - removeEditorTkNode, - removeEditor - }; - }, [ - tkNodeMap, - tkCount, - addEditorTkNode, - removeEditorTkNode, - removeEditor - ]); - - return {children}; -}; - -export const useTKContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/context/TKContext.tsx b/packages/koenig-lexical/src/context/TKContext.tsx new file mode 100644 index 0000000000..8add2c7315 --- /dev/null +++ b/packages/koenig-lexical/src/context/TKContext.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import throttle from 'lodash/throttle'; + +interface TKContextType { + tkNodeMap: Record; + tkCount: number; + addEditorTkNode: (editorKey: string, topLevelNodeKey: string, tkNodeKey: string) => void; + removeEditorTkNode: (editorKey: string, tkNodeKey: string) => void; + removeEditor: (editorKey: string) => void; +} + +const noop = () => {}; + +const Context = React.createContext({ + tkNodeMap: {}, + tkCount: 0, + addEditorTkNode: noop, + removeEditorTkNode: noop, + removeEditor: noop +}); + +export const TKContext = ({children}: {children: React.ReactNode}) => { + const editorTkNodeMapRef = React.useRef(new Map()); + + const [tkNodeMap, setTkNodeMap] = React.useState>({}); + const [tkCount, setTkCount] = React.useState(0); + + const updateTkNodeMap = React.useMemo(() => { + const updateFn = () => { + const editorTkNodeMap = editorTkNodeMapRef.current; + + const newTkNodeMap: Record = {}; + let newTkCount = 0; + + editorTkNodeMap.forEach((nodeMap: Map) => { + nodeMap.forEach(({topLevelNodeKey}: {topLevelNodeKey: string}, tkNodeKey: string) => { + newTkCount = newTkCount + 1; + + if (newTkNodeMap[topLevelNodeKey] === undefined) { + newTkNodeMap[topLevelNodeKey] = [tkNodeKey]; + } else { + newTkNodeMap[topLevelNodeKey].push(tkNodeKey); + } + }); + }); + + setTkNodeMap(newTkNodeMap); + setTkCount(newTkCount); + }; + + return throttle(updateFn, 5, {trailing: true}); + }, []); + + const addEditorTkNode = React.useCallback((editorKey: string, topLevelNodeKey: string, tkNodeKey: string) => { + const editorTkNodeMap = editorTkNodeMapRef.current; + + if (!editorTkNodeMap.has(editorKey)) { + editorTkNodeMap.set(editorKey, new Map()); + } + + editorTkNodeMap.get(editorKey).set(tkNodeKey, {topLevelNodeKey}); + + updateTkNodeMap(); + }, [updateTkNodeMap]); + + const removeEditorTkNode = React.useCallback((editorKey: string, tkNodeKey: string) => { + const editorTkNodeMap = editorTkNodeMapRef.current; + + editorTkNodeMap.get(editorKey)?.delete(tkNodeKey); + + if (editorTkNodeMap.get(editorKey)?.size === 0) { + editorTkNodeMap.delete(editorKey); + } + + updateTkNodeMap(); + }, [updateTkNodeMap]); + + const removeEditor = React.useCallback((editorKey: string) => { + editorTkNodeMapRef.current.delete(editorKey); + updateTkNodeMap(); + }, [updateTkNodeMap]); + + const contextValue = React.useMemo(() => { + return { + tkNodeMap, + tkCount, + addEditorTkNode, + removeEditorTkNode, + removeEditor + }; + }, [ + tkNodeMap, + tkCount, + addEditorTkNode, + removeEditorTkNode, + removeEditor + ]); + + return {children}; +}; + +export const useTKContext = () => React.useContext(Context); diff --git a/packages/koenig-lexical/src/hooks/useCardDragAndDrop.js b/packages/koenig-lexical/src/hooks/useCardDragAndDrop.js deleted file mode 100644 index 2c9ed80854..0000000000 --- a/packages/koenig-lexical/src/hooks/useCardDragAndDrop.js +++ /dev/null @@ -1,109 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; - -export default function useCardDragAndDrop({ - enabled = true, - canDrop, - onDrop, - onDropEnd, - getDraggableInfo, - getIndicatorPosition, - draggableSelector, - droppableSelector -}) { - const koenig = React.useContext(KoenigComposerContext); - - const [containerRef, setContainerRef] = React.useState(null); - const [isDraggedOver, setIsDraggedOver] = React.useState(false); - const dragDropContainer = React.useRef(null); - - const onDragStart = React.useCallback((draggableInfo) => { - if (canDrop(draggableInfo)) { - dragDropContainer.current.enableDrag(); - } else { - dragDropContainer.current.disableDrag(); - } - }, [canDrop]); - - const onDragEnd = React.useCallback(() => { - setIsDraggedOver(false); - }, [setIsDraggedOver]); - - const onDragEnterContainer = React.useCallback((draggableInfo) => { - setIsDraggedOver(canDrop(draggableInfo)); - }, [setIsDraggedOver, canDrop]); - - const onDragLeaveContainer = React.useCallback(() => { - setIsDraggedOver(false); - }, [setIsDraggedOver]); - - const _onDrop = React.useCallback((draggableInfo) => { - return onDrop?.(draggableInfo) || false; - }, [onDrop]); - - const _onDropEnd = React.useCallback((draggableInfo, success) => { - onDropEnd?.(draggableInfo, success); - }, [onDropEnd]); - - // returns { - // direction: 'horizontal' TODO: use a constant? - // position: 'left'/'right' TODO: use constants? - // beforeElems: array of elems to left of indicator - // afterElems: array of elems to right of indicator - // droppableIndex: - // } - const _getIndicatorPosition = React.useCallback((draggableInfo) => { - return getIndicatorPosition?.(draggableInfo) || false; - }, [getIndicatorPosition]); - - const _getDraggableInfo = React.useCallback((draggableElement) => { - return getDraggableInfo?.(draggableElement) || {}; - }, [getDraggableInfo]); - - React.useEffect(() => { - if (enabled) { - dragDropContainer.current?.enableDrag(); - } else { - dragDropContainer.current?.disableDrag(); - } - }, [enabled, containerRef]); - - React.useEffect(() => { - if (!containerRef || !koenig.dragDropHandler) { - return; - } - - dragDropContainer.current = koenig.dragDropHandler.registerContainer( - containerRef, - { - draggableSelector, - droppableSelector, - isDragEnabled: enabled, - onDragStart, - onDragEnd, - onDragEnterContainer, - onDragLeaveContainer, - getDraggableInfo: _getDraggableInfo, - getIndicatorPosition: _getIndicatorPosition, - onDrop: _onDrop, - onDropEnd: _onDropEnd - } - ); - }, [ - _getDraggableInfo, - _getIndicatorPosition, - _onDrop, - _onDropEnd, - containerRef, - draggableSelector, - droppableSelector, - enabled, - koenig.dragDropHandler, - onDragEnd, - onDragEnterContainer, - onDragLeaveContainer, - onDragStart - ]); - - return {setRef: setContainerRef, isDraggedOver}; -} diff --git a/packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts b/packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts new file mode 100644 index 0000000000..634ff08423 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts @@ -0,0 +1,123 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import type {DragDropHandler} from '../utils/draggable/DragDropHandler'; +import type {DraggableInfo, DraggableInfoSeed} from '../utils/draggable/ScrollHandler'; +import type {IndicatorPosition} from '../utils/draggable/DragDropContainer'; + +interface UseCardDragAndDropOptions { + enabled?: boolean; + canDrop: (draggableInfo: DraggableInfo) => boolean; + onDrop?: (draggableInfo: DraggableInfo) => boolean | void; + onDropEnd?: (draggableInfo: DraggableInfo, success: boolean) => void; + getDraggableInfo?: (draggableElement: HTMLElement) => DraggableInfoSeed | Record; + getIndicatorPosition?: (draggableInfo: DraggableInfo) => IndicatorPosition | false; + draggableSelector: string; + droppableSelector: string; +} + +interface DragDropContainerRef { + enableDrag: () => void; + disableDrag: () => void; + refresh: () => void; + destroy: () => void; +} + +export default function useCardDragAndDrop({ + enabled = true, + canDrop, + onDrop, + onDropEnd, + getDraggableInfo, + getIndicatorPosition, + draggableSelector, + droppableSelector +}: UseCardDragAndDropOptions) { + const koenig = React.useContext(KoenigComposerContext); + + const [containerRef, setContainerRef] = React.useState(null); + const [isDraggedOver, setIsDraggedOver] = React.useState(false); + const dragDropContainer = React.useRef(null); + + const onDragStart = React.useCallback((draggableInfo: DraggableInfo) => { + if (canDrop(draggableInfo)) { + dragDropContainer.current?.enableDrag(); + } else { + dragDropContainer.current?.disableDrag(); + } + }, [canDrop]); + + const onDragEnd = React.useCallback(() => { + setIsDraggedOver(false); + }, [setIsDraggedOver]); + + const onDragEnterContainer = React.useCallback((draggableInfo: DraggableInfo) => { + setIsDraggedOver(canDrop(draggableInfo)); + }, [setIsDraggedOver, canDrop]); + + const onDragLeaveContainer = React.useCallback(() => { + setIsDraggedOver(false); + }, [setIsDraggedOver]); + + const _onDrop = React.useCallback((draggableInfo: DraggableInfo) => { + return onDrop?.(draggableInfo) || false; + }, [onDrop]); + + const _onDropEnd = React.useCallback((draggableInfo: DraggableInfo, success: boolean) => { + onDropEnd?.(draggableInfo, success); + }, [onDropEnd]); + + const _getIndicatorPosition = React.useCallback((draggableInfo: DraggableInfo) => { + return getIndicatorPosition?.(draggableInfo) || false; + }, [getIndicatorPosition]); + + const _getDraggableInfo = React.useCallback((draggableElement: HTMLElement) => { + return getDraggableInfo?.(draggableElement) || {}; + }, [getDraggableInfo]); + + React.useEffect(() => { + if (enabled) { + dragDropContainer.current?.enableDrag(); + } else { + dragDropContainer.current?.disableDrag(); + } + }, [enabled, containerRef]); + + React.useEffect(() => { + if (!containerRef || !koenig.dragDropHandler) { + return; + } + + dragDropContainer.current = (koenig.dragDropHandler as DragDropHandler).registerContainer( + containerRef, + { + draggableSelector, + droppableSelector, + isDragEnabled: enabled, + onDragStart, + onDragEnd, + onDragEnterContainer, + onDragLeaveContainer, + getDraggableInfo: _getDraggableInfo, + getIndicatorPosition: _getIndicatorPosition, + onDrop: _onDrop, + onDropEnd: _onDropEnd + } + ); + }, [ + _getDraggableInfo, + _getIndicatorPosition, + _onDrop, + _onDropEnd, + containerRef, + draggableSelector, + droppableSelector, + enabled, + koenig.dragDropHandler, + onDragEnd, + onDragEnterContainer, + onDragLeaveContainer, + onDragStart + ]); + + return {setRef: setContainerRef, isDraggedOver}; +} diff --git a/packages/koenig-lexical/src/hooks/useClickOutside.js b/packages/koenig-lexical/src/hooks/useClickOutside.js deleted file mode 100644 index 39d752c8c2..0000000000 --- a/packages/koenig-lexical/src/hooks/useClickOutside.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -export function useClickOutside(enabled, ref, handler) { - React.useEffect(() => { - if (!enabled) { - return; - } - - const handleClickOutside = (event) => { - if (ref.current && !ref.current.contains(event.target)) { - handler(); - } - }; - - window.addEventListener('mousedown', handleClickOutside, {capture: true}); - return () => window.removeEventListener('mousedown', handleClickOutside, {capture: true}); - }, [enabled, handler, ref]); -} diff --git a/packages/koenig-lexical/src/hooks/useClickOutside.ts b/packages/koenig-lexical/src/hooks/useClickOutside.ts new file mode 100644 index 0000000000..f739b16910 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useClickOutside.ts @@ -0,0 +1,18 @@ +import React from 'react'; + +export function useClickOutside(enabled: boolean, ref: React.RefObject, handler: () => void): void { + React.useEffect(() => { + if (!enabled) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handler(); + } + }; + + window.addEventListener('mousedown', handleClickOutside, {capture: true}); + return () => window.removeEventListener('mousedown', handleClickOutside, {capture: true}); + }, [enabled, handler, ref]); +} diff --git a/packages/koenig-lexical/src/hooks/useFileDragAndDrop.js b/packages/koenig-lexical/src/hooks/useFileDragAndDrop.js deleted file mode 100644 index d05574a249..0000000000 --- a/packages/koenig-lexical/src/hooks/useFileDragAndDrop.js +++ /dev/null @@ -1,58 +0,0 @@ -import {useEffect, useState} from 'react'; - -export default function useFileDragAndDrop({handleDrop, disabled = false}) { - const [ref, setRef] = useState(null); - const [isDraggedOver, setDraggedOver] = useState(false); - - useEffect(() => { - const node = ref; - if (!node || disabled) { - return; - } - - node.addEventListener('dragenter', onDragEnter); - node.addEventListener('dragover', onDragOver); - node.addEventListener('dragleave', onDragLeave); - node.addEventListener('drop', onDrop); - - function onDragEnter(event) { - cancelEvents(event); - setDraggedOver(true); - } - - function onDragOver(event) { - cancelEvents(event); - setDraggedOver(true); - } - - function onDragLeave(event) { - cancelEvents(event); - setDraggedOver(false); - } - - function onDrop(event) { - cancelEvents(event); - const {dataTransfer} = event; - - if (dataTransfer.files && dataTransfer.files.length > 0) { - handleDrop(Array.from(dataTransfer.files)); - } - - setDraggedOver(false); - } - - function cancelEvents(event) { - event.preventDefault(); - event.stopPropagation(); - } - - return () => { - node.removeEventListener('dragenter', onDragEnter); - node.removeEventListener('dragover', onDragOver); - node.removeEventListener('dragleave', onDragLeave); - node.removeEventListener('drop', onDrop); - }; - }, [handleDrop, ref, disabled]); - - return {setRef, isDraggedOver}; -} diff --git a/packages/koenig-lexical/src/hooks/useFileDragAndDrop.ts b/packages/koenig-lexical/src/hooks/useFileDragAndDrop.ts new file mode 100644 index 0000000000..acaf3222a8 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useFileDragAndDrop.ts @@ -0,0 +1,58 @@ +import {useEffect, useState} from 'react'; + +export default function useFileDragAndDrop({handleDrop, disabled = false}: {handleDrop: (files: File[]) => void; disabled?: boolean}) { + const [ref, setRef] = useState(null); + const [isDraggedOver, setDraggedOver] = useState(false); + + useEffect(() => { + const node = ref; + if (!node || disabled) { + return; + } + + node.addEventListener('dragenter', onDragEnter); + node.addEventListener('dragover', onDragOver); + node.addEventListener('dragleave', onDragLeave); + node.addEventListener('drop', onDrop); + + function onDragEnter(event: DragEvent) { + cancelEvents(event); + setDraggedOver(true); + } + + function onDragOver(event: DragEvent) { + cancelEvents(event); + setDraggedOver(true); + } + + function onDragLeave(event: DragEvent) { + cancelEvents(event); + setDraggedOver(false); + } + + function onDrop(event: DragEvent) { + cancelEvents(event); + const {dataTransfer} = event; + + if (dataTransfer?.files && dataTransfer.files.length > 0) { + handleDrop(Array.from(dataTransfer.files)); + } + + setDraggedOver(false); + } + + function cancelEvents(event: Event) { + event.preventDefault(); + event.stopPropagation(); + } + + return () => { + node.removeEventListener('dragenter', onDragEnter); + node.removeEventListener('dragover', onDragOver); + node.removeEventListener('dragleave', onDragLeave); + node.removeEventListener('drop', onDrop); + }; + }, [handleDrop, ref, disabled]); + + return {setRef, isDraggedOver}; +} diff --git a/packages/koenig-lexical/src/hooks/useGalleryReorder.js b/packages/koenig-lexical/src/hooks/useGalleryReorder.js deleted file mode 100644 index a6bfe47f6b..0000000000 --- a/packages/koenig-lexical/src/hooks/useGalleryReorder.js +++ /dev/null @@ -1,238 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import pick from 'lodash/pick'; -import {getImageFilenameFromSrc} from '../utils/getImageFilenameFromSrc'; - -export default function useGalleryReorder({images, updateImages, isSelected = false, maxImages = 9, disabled = false}) { - const koenig = React.useContext(KoenigComposerContext); - - const [containerRef, setContainerRef] = React.useState(null); - const [isDraggedOver, setIsDraggedOver] = React.useState(false); - const dragDropContainer = React.useRef(null); - const skipOnDragEndRef = React.useRef(false); - - const onDragStart = (draggableInfo) => { - // enable dropping when an image is dragged in from outside of this card - const isImageDrag = draggableInfo.type === 'image' || draggableInfo.cardName === 'image'; - if (isImageDrag && draggableInfo.dataset.src && images.length !== maxImages) { - dragDropContainer.current.enableDrag(); - } - }; - - const onDragEnd = () => { - setIsDraggedOver(false); - }; - - const onDragEnterContainer = () => { - setIsDraggedOver(true); - }; - - const onDragLeaveContainer = () => { - setIsDraggedOver(false); - }; - - const onDrop = (draggableInfo) => { - // do not allow dropping of non-images - if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { - return false; - } - - let updatedImages = [...images]; - let {insertIndex} = draggableInfo; - const droppables = Array.from(containerRef.querySelectorAll('[data-image]')); - const draggableIndex = droppables.indexOf(draggableInfo.element); - - if (!updatedImages.length) { - insertIndex = 0; - } - - if (isDropAllowed(draggableIndex, insertIndex)) { - if (draggableIndex === -1) { - // external image being added - const {dataset} = draggableInfo; - const img = draggableInfo.element.querySelector(`img[src="${dataset.src}"]`); - - // image card datasets may not have all of the details we need but we can fill them in - dataset.width = dataset.width || img.naturalWidth; - dataset.height = dataset.height || img.naturalHeight; - dataset.fileName = dataset?.fileName || getImageFilenameFromSrc(dataset.src); - - updatedImages.splice(insertIndex, 0, dataset); - } else { - // internal image being re-ordered - const draggedImage = updatedImages.find(i => i.src === draggableInfo.dataset.src); - const accountForRemoval = draggableIndex < insertIndex && insertIndex ? -1 : 0; - updatedImages = updatedImages.filter(i => i !== draggedImage); - updatedImages.splice(insertIndex + accountForRemoval, 0, draggedImage); - } - - updateImages(updatedImages); - dragDropContainer.current.refresh(); - - skipOnDragEndRef.current = true; - return true; - } - - return false; - }; - - // if an image is dragged out of a gallery we need to remove it - const onDropEnd = (draggableInfo, success) => { - if (skipOnDragEndRef.current || !success) { - skipOnDragEndRef.current = false; - return; - } - - const image = images.find(i => i.src === draggableInfo.dataset.src); - if (image) { - const updatedImages = images.filter(i => i !== image); - updateImages(updatedImages); - dragDropContainer.current.refresh(); - } - }; - - const getDraggableInfo = (draggableElement) => { - let src = draggableElement.querySelector('img').getAttribute('src'); - let image = images.find(i => i.src === src) || images.find(i => i.previewSrc === src); - let dataset = image && pick(image, ['fileName', 'src', 'row', 'width', 'height', 'caption']); - - if (image) { - return { - type: 'image', - dataset - }; - } - - return {}; - }; - - // returns { - // direction: 'horizontal' TODO: use a constant? - // position: 'left'/'right' TODO: use constants? - // beforeElems: array of elems to left of indicator - // afterElems: array of elems to right of indicator - // droppableIndex: - // } - const getIndicatorPosition = (draggableInfo, droppableElem, position) => { - // do not allow dropping of non-images - if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { - return false; - } - - const row = droppableElem.closest('[data-row]'); - const droppables = Array.from(containerRef.querySelectorAll('[data-image]')); - const draggableIndex = droppables.indexOf(draggableInfo.element); - const droppableIndex = droppables.indexOf(droppableElem); - - if (row && isDropAllowed(draggableIndex, droppableIndex, position)) { - const rowImages = Array.from(row.querySelectorAll('[data-image]')); - const rowDroppableIndex = rowImages.indexOf(droppableElem); - let insertIndex = droppableIndex; - const beforeElems = []; - const afterElems = []; - - rowImages.forEach((image, index) => { - if (index < rowDroppableIndex) { - beforeElems.push(image); - } - - if (index === rowDroppableIndex) { - if (position.match(/left/)) { - afterElems.push(image); - } else { - beforeElems.push(image); - } - } - - if (index > rowDroppableIndex) { - afterElems.push(image); - } - }); - - if (position.match(/right/)) { - insertIndex += 1; - } - - return { - direction: 'horizontal', - position: position.match(/left/) ? 'left' : 'right', - beforeElems, - afterElems, - insertIndex - }; - } else { - return false; - } - }; - - // we don't allow an image to be dropped where it would end up in the - // same position within the gallery - const isDropAllowed = (draggableIndex, droppableIndex, position = '') => { - // external images can always be dropped - if (draggableIndex === -1) { - return true; - } - - // can't drop on itself or when droppableIndex doesn't exist - if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { - return false; - } - - // account for dropping at beginning or end of a row - if (position.match(/left/)) { - droppableIndex -= 1; - } - - if (position.match(/right/)) { - droppableIndex += 1; - } - - return droppableIndex !== draggableIndex; - }; - - React.useEffect(() => { - if (isSelected) { - dragDropContainer.current?.enableDrag(); - } else { - dragDropContainer.current?.disableDrag(); - } - }, [isSelected, containerRef]); - - React.useEffect(() => { - const galleryElem = containerRef; - - if (!galleryElem || !koenig?.dragDropHandler) { - return; - } - - dragDropContainer.current = koenig.dragDropHandler.registerContainer( - galleryElem, - { - draggableSelector: '[data-image]', - droppableSelector: '[data-image]', - isDragEnabled: !disabled && images.length > 0, - onDragStart, - onDragEnd, - onDragEnterContainer, - onDragLeaveContainer, - getDraggableInfo, - getIndicatorPosition, - onDrop, - onDropEnd - } - ); - - return () => { - if (dragDropContainer.current) { - dragDropContainer.current.destroy(); - dragDropContainer.current = null; - } - }; - - // we want to be specific about when we want the drag/drop handler to - // be set up or refreshed so we disable the exhaustive-deps rule here - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [containerRef, images, koenig.dragDropHandler]); - - return {setContainerRef, isDraggedOver}; -} diff --git a/packages/koenig-lexical/src/hooks/useGalleryReorder.ts b/packages/koenig-lexical/src/hooks/useGalleryReorder.ts new file mode 100644 index 0000000000..37ae43487d --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useGalleryReorder.ts @@ -0,0 +1,239 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import pick from 'lodash/pick'; +import {getImageFilenameFromSrc} from '../utils/getImageFilenameFromSrc'; +import type {DragDropHandler} from '../utils/draggable/DragDropHandler'; +import type {DraggableInfo} from '../utils/draggable/ScrollHandler'; +import type {GalleryImage} from '../types/GalleryImage'; +import type {IndicatorPosition} from '../utils/draggable/DragDropContainer'; + +interface DragDropContainerRef { + enableDrag: () => void; + disableDrag: () => void; + refresh: () => void; + destroy: () => void; +} + +export default function useGalleryReorder({images, updateImages, isSelected = false, maxImages = 9, disabled = false}: {images: GalleryImage[]; updateImages: (images: GalleryImage[]) => void; isSelected?: boolean; maxImages?: number; disabled?: boolean}) { + const koenig = React.useContext(KoenigComposerContext); + + const [containerRef, setContainerRef] = React.useState(null); + const [isDraggedOver, setIsDraggedOver] = React.useState(false); + const dragDropContainer = React.useRef(null); + const skipOnDragEndRef = React.useRef(false); + + const onDragStart = (draggableInfo: DraggableInfo) => { + // enable dropping when an image is dragged in from outside of this card + const isImageDrag = draggableInfo.type === 'image' || draggableInfo.cardName === 'image'; + if (isImageDrag && draggableInfo.dataset?.src && images.length !== maxImages) { + dragDropContainer.current?.enableDrag(); + } + }; + + const onDragEnd = () => { + setIsDraggedOver(false); + }; + + const onDragEnterContainer = () => { + setIsDraggedOver(true); + }; + + const onDragLeaveContainer = () => { + setIsDraggedOver(false); + }; + + const onDrop = (draggableInfo: DraggableInfo) => { + // do not allow dropping of non-images + if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { + return false; + } + + let updatedImages = [...images]; + let {insertIndex} = draggableInfo; + const droppables = Array.from(containerRef!.querySelectorAll('[data-image]')); + const draggableIndex = droppables.indexOf(draggableInfo.element); + + if (!updatedImages.length) { + insertIndex = 0; + } + + if (isDropAllowed(draggableIndex, insertIndex!)) { + if (draggableIndex === -1) { + // external image being added + const dataset = draggableInfo.dataset as GalleryImage; + const img = draggableInfo.element.querySelector(`img[src="${dataset.src}"]`) as HTMLImageElement | null; + + // image card datasets may not have all of the details we need but we can fill them in + dataset.width = dataset.width || img?.naturalWidth; + dataset.height = dataset.height || img?.naturalHeight; + dataset.fileName = dataset?.fileName || (dataset.src ? getImageFilenameFromSrc(dataset.src) : undefined); + + updatedImages.splice(insertIndex!, 0, dataset); + } else { + // internal image being re-ordered + const draggableDataset = draggableInfo.dataset as {src: string}; + const draggedImage = updatedImages.find(i => i.src === draggableDataset.src); + const accountForRemoval = draggableIndex < insertIndex! && insertIndex! ? -1 : 0; + updatedImages = updatedImages.filter(i => i !== draggedImage); + updatedImages.splice(insertIndex! + accountForRemoval, 0, draggedImage!); + } + + updateImages(updatedImages); + dragDropContainer.current?.refresh(); + + skipOnDragEndRef.current = true; + return true; + } + + return false; + }; + + // if an image is dragged out of a gallery we need to remove it + const onDropEnd = (draggableInfo: DraggableInfo, success: boolean) => { + if (skipOnDragEndRef.current || !success) { + skipOnDragEndRef.current = false; + return; + } + + const draggableDataset = draggableInfo.dataset as {src: string}; + const image = images.find(i => i.src === draggableDataset.src); + if (image) { + const updatedImages = images.filter(i => i !== image); + updateImages(updatedImages); + dragDropContainer.current?.refresh(); + } + }; + + const getDraggableInfo = (draggableElement: HTMLElement) => { + const src = draggableElement.querySelector('img')!.getAttribute('src')!; + const image = images.find(i => i.src === src) || images.find(i => i.previewSrc === src); + const dataset = image && pick(image, ['fileName', 'src', 'row', 'width', 'height', 'caption']); + + if (image) { + return { + type: 'image', + dataset + }; + } + + return {}; + }; + + const getIndicatorPosition = (draggableInfo: DraggableInfo, droppableElem: Element, position: string): IndicatorPosition | false => { + // do not allow dropping of non-images + if (draggableInfo.type !== 'image' && draggableInfo.cardName !== 'image') { + return false; + } + + const row = droppableElem.closest('[data-row]'); + const droppables = Array.from(containerRef!.querySelectorAll('[data-image]')); + const draggableIndex = droppables.indexOf(draggableInfo.element); + const droppableIndex = droppables.indexOf(droppableElem); + + if (row && isDropAllowed(draggableIndex, droppableIndex, position)) { + const rowImages = Array.from(row.querySelectorAll('[data-image]')); + const rowDroppableIndex = rowImages.indexOf(droppableElem); + let insertIndex = droppableIndex; + const beforeElems: HTMLElement[] = []; + const afterElems: HTMLElement[] = []; + + rowImages.forEach((image, index) => { + if (index < rowDroppableIndex) { + beforeElems.push(image as HTMLElement); + } + + if (index === rowDroppableIndex) { + if (position.match(/left/)) { + afterElems.push(image as HTMLElement); + } else { + beforeElems.push(image as HTMLElement); + } + } + + if (index > rowDroppableIndex) { + afterElems.push(image as HTMLElement); + } + }); + + if (position.match(/right/)) { + insertIndex += 1; + } + + return { + direction: 'horizontal', + position: position.match(/left/) ? 'left' : 'right', + beforeElems, + afterElems, + insertIndex + }; + } else { + return false; + } + }; + + const isDropAllowed = (draggableIndex: number, droppableIndex: number, position = ''): boolean => { + if (draggableIndex === -1) { + return true; + } + + if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { + return false; + } + + if (position.match(/left/)) { + droppableIndex -= 1; + } + + if (position.match(/right/)) { + droppableIndex += 1; + } + + return droppableIndex !== draggableIndex; + }; + + React.useEffect(() => { + if (isSelected) { + dragDropContainer.current?.enableDrag(); + } else { + dragDropContainer.current?.disableDrag(); + } + }, [isSelected, containerRef]); + + React.useEffect(() => { + const galleryElem = containerRef; + + if (!galleryElem || !koenig?.dragDropHandler) { + return; + } + + dragDropContainer.current = (koenig.dragDropHandler as DragDropHandler).registerContainer( + galleryElem, + { + draggableSelector: '[data-image]', + droppableSelector: '[data-image]', + isDragEnabled: !disabled && images.length > 0, + onDragStart, + onDragEnd, + onDragEnterContainer, + onDragLeaveContainer, + getDraggableInfo, + getIndicatorPosition, + onDrop, + onDropEnd + } + ); + + return () => { + if (dragDropContainer.current) { + dragDropContainer.current.destroy(); + dragDropContainer.current = null; + } + }; + + // we want to be specific about when we want the drag/drop handler to + // be set up or refreshed so we disable the exhaustive-deps rule here + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerRef, images, koenig.dragDropHandler]); + + return {setContainerRef, isDraggedOver}; +} diff --git a/packages/koenig-lexical/src/hooks/useInputSelection.js b/packages/koenig-lexical/src/hooks/useInputSelection.js deleted file mode 100644 index b3c326df7c..0000000000 --- a/packages/koenig-lexical/src/hooks/useInputSelection.js +++ /dev/null @@ -1,39 +0,0 @@ -import {useEffect, useState} from 'react'; - -/** - * useInputSelection - * @description - * Hook helps to keep cursor position when using controlled input. - * When to use: It mainly needs if there is an input controlled directly by editor (input value is gotten - * from editor state). In such cases cursor jumps to the end in response to Delete or other keyboard input. - * - * Some related issues: - * https://github.com/facebook/react/issues/14904 - * https://github.com/AnyVisionltd/anv-ui-components/pull/201/files - * https://github.com/facebook/lexical/issues/3488 - * https://github.com/facebook/lexical/pull/3491 - */ -export default function useInputSelection({value}) { - const [ref, setRef] = useState(null); - const [selectionStart, setSelectionStart] = useState(0); - const [selectionEnd, setSelectionEnd] = useState(0); - - function saveSelectionRange(e) { - setSelectionStart(e.target.selectionStart); - setSelectionEnd(e.target.selectionEnd); - } - - useEffect(() => { - if (!ref) { - return; - } - - ref.selectionStart = selectionStart; - ref.selectionEnd = selectionEnd; - }, [ref, selectionEnd, selectionStart, value]); - - return { - saveSelectionRange, - setRef - }; -} diff --git a/packages/koenig-lexical/src/hooks/useInputSelection.ts b/packages/koenig-lexical/src/hooks/useInputSelection.ts new file mode 100644 index 0000000000..c2c61542e0 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useInputSelection.ts @@ -0,0 +1,39 @@ +import {useEffect, useState} from 'react'; + +/** + * useInputSelection + * @description + * Hook helps to keep cursor position when using controlled input. + * When to use: It mainly needs if there is an input controlled directly by editor (input value is gotten + * from editor state). In such cases cursor jumps to the end in response to Delete or other keyboard input. + * + * Some related issues: + * https://github.com/facebook/react/issues/14904 + * https://github.com/AnyVisionltd/anv-ui-components/pull/201/files + * https://github.com/facebook/lexical/issues/3488 + * https://github.com/facebook/lexical/pull/3491 + */ +export default function useInputSelection({value}: {value: string}) { + const [ref, setRef] = useState(null); + const [selectionStart, setSelectionStart] = useState(0); + const [selectionEnd, setSelectionEnd] = useState(0); + + function saveSelectionRange(e: React.SyntheticEvent) { + setSelectionStart((e.target as HTMLInputElement).selectionStart ?? 0); + setSelectionEnd((e.target as HTMLInputElement).selectionEnd ?? 0); + } + + useEffect(() => { + if (!ref) { + return; + } + + ref.selectionStart = selectionStart; + ref.selectionEnd = selectionEnd; + }, [ref, selectionEnd, selectionStart, value]); + + return { + saveSelectionRange, + setRef + }; +} diff --git a/packages/koenig-lexical/src/hooks/useKoenigTextEntity.js b/packages/koenig-lexical/src/hooks/useKoenigTextEntity.js deleted file mode 100644 index fd4aa751b4..0000000000 --- a/packages/koenig-lexical/src/hooks/useKoenigTextEntity.js +++ /dev/null @@ -1,190 +0,0 @@ -// see lexical useLexicalTextEntity hook -// duplicated here because the upstream version is dependent on TextNode but we use ExtendedTextNode - -import {$createTextNode, $isTextNode, TextNode} from 'lexical'; -import {mergeRegister} from '@lexical/utils'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function useKoenigTextEntity(getMatch, targetNode, createNode, nodeType = TextNode) { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return mergeRegister( - ...registerExtendedTextEntity(editor, getMatch, targetNode, createNode, nodeType), - ); - }, [createNode, editor, getMatch, targetNode, nodeType]); -} - -function registerExtendedTextEntity(editor, getMatch, targetNode, createNode, nodeType) { - const isTargetNode = (node) => { - return node instanceof targetNode; - }; - - const replaceWithSimpleText = (node) => { - const textNode = $createTextNode(node.getTextContent()); - textNode.setFormat(node.getFormat()); - node.replace(textNode); - }; - - const getMode = (node) => { - return node.getLatest().__mode; - }; - - const textNodeTransform = (node) => { - if (!node.isSimpleText()) { - return; - } - - const prevSibling = node.getPreviousSibling(); - let text = node.getTextContent(); - let currentNode = node; - let match; - - if ($isTextNode(prevSibling)) { - const previousText = prevSibling.getTextContent(); - const combinedText = previousText + text; - const prevMatch = getMatch(combinedText); - - if (isTargetNode(prevSibling)) { - if (prevMatch === null || getMode(prevSibling) !== 0) { - replaceWithSimpleText(prevSibling); - - return; - } else { - const diff = prevMatch.end - previousText.length; - - if (diff > 0) { - const concatText = text.slice(0, diff); - const newTextContent = previousText + concatText; - prevSibling.select(); - prevSibling.setTextContent(newTextContent); - - if (diff === text.length) { - node.remove(); - } else { - const remainingText = text.slice(diff); - node.setTextContent(remainingText); - } - - return; - } - } - } else if (prevMatch === null || prevMatch.start < previousText.length) { - return; - } - } - - - while (true) { - match = getMatch(text); - let nextText = match === null ? '' : text.slice(match.end); - text = nextText; - - if (nextText === '') { - const nextSibling = currentNode?.getNextSibling(); - - if ($isTextNode(nextSibling)) { - nextText = currentNode.getTextContent() + nextSibling.getTextContent(); - const nextMatch = getMatch(nextText); - - if (nextMatch === null) { - if (isTargetNode(nextSibling)) { - replaceWithSimpleText(nextSibling); - } else { - nextSibling.markDirty(); - } - - return; - } else if (nextMatch.start !== 0) { - return; - } - } - } else { - const nextMatch = getMatch(nextText); - - if (nextMatch !== null && nextMatch.start === 0) { - return; - } - } - - if (match === null) { - return; - } - - if ( - match.start === 0 && - $isTextNode(prevSibling) && - prevSibling.isTextEntity() - ) { - continue; - } - - let nodeToReplace; - - if (match.start === 0) { - [nodeToReplace, currentNode] = currentNode.splitText(match.end); - } else { - [, nodeToReplace, currentNode] = currentNode.splitText( - match.start, - match.end, - ); - } - - const replacementNode = createNode(nodeToReplace); - replacementNode.setFormat(nodeToReplace.getFormat()); - nodeToReplace.replace(replacementNode); - - if (currentNode === null) { - return; - } - } - }; - - const reverseNodeTransform = (node) => { - const text = node.getTextContent(); - const match = getMatch(text); - - if (match === null || match.start !== 0) { - replaceWithSimpleText(node); - - return; - } - - if (text.length > match.end) { - // This will split out the rest of the text as simple text - node.splitText(match.end); - - return; - } - - const prevSibling = node.getPreviousSibling(); - - if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) { - replaceWithSimpleText(prevSibling); - replaceWithSimpleText(node); - } - - const nextSibling = node.getNextSibling(); - - if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) { - replaceWithSimpleText(nextSibling); - - // This may have already been converted in the previous block - if (isTargetNode(node)) { - replaceWithSimpleText(node); - } - } - }; - - const removePlainTextTransform = editor.registerNodeTransform( - nodeType, - textNodeTransform, - ); - const removeReverseNodeTransform = editor.registerNodeTransform( - targetNode, - reverseNodeTransform, - ); - - return [removePlainTextTransform, removeReverseNodeTransform]; -} diff --git a/packages/koenig-lexical/src/hooks/useKoenigTextEntity.ts b/packages/koenig-lexical/src/hooks/useKoenigTextEntity.ts new file mode 100644 index 0000000000..8e2562b604 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useKoenigTextEntity.ts @@ -0,0 +1,209 @@ +// see lexical useLexicalTextEntity hook +// duplicated here because the upstream version is dependent on TextNode but we use ExtendedTextNode + +import {$createTextNode, $isTextNode, TextNode} from 'lexical'; +import {mergeRegister} from '@lexical/utils'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {Klass, LexicalEditor, LexicalNode, TextModeType, TextNode as TextNodeType} from 'lexical'; + +interface TextMatch { + start: number; + end: number; +} + +export function useKoenigTextEntity( + getMatch: (text: string) => TextMatch | null, + targetNode: Klass, + createNode: (node: TextNodeType) => LexicalNode, + nodeType: Klass = TextNode +): void { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return mergeRegister( + ...registerExtendedTextEntity(editor, getMatch, targetNode, createNode, nodeType), + ); + }, [createNode, editor, getMatch, targetNode, nodeType]); +} + +function registerExtendedTextEntity( + editor: LexicalEditor, + getMatch: (text: string) => TextMatch | null, + targetNode: Klass, + createNode: (node: TextNodeType) => LexicalNode, + nodeType: Klass +): Array<() => void> { + const isTargetNode = (node: LexicalNode): boolean => { + return node instanceof targetNode; + }; + + const replaceWithSimpleText = (node: TextNodeType): void => { + const textNode = $createTextNode(node.getTextContent()); + textNode.setFormat(node.getFormat()); + node.replace(textNode); + }; + + // 'normal' is the default mode; the original code compared the numeric __mode + // against 0 (normal), which is equivalent to comparing getMode() against 'normal' + const getMode = (node: TextNodeType): TextModeType => { + return node.getLatest().getMode(); + }; + + const textNodeTransform = (node: TextNodeType): void => { + if (!node.isSimpleText()) { + return; + } + + const prevSibling = node.getPreviousSibling(); + let text = node.getTextContent(); + let currentNode: TextNodeType | null = node; + let match: TextMatch | null; + + if ($isTextNode(prevSibling)) { + const previousText = prevSibling.getTextContent(); + const combinedText = previousText + text; + const prevMatch = getMatch(combinedText); + + if (isTargetNode(prevSibling)) { + if (prevMatch === null || getMode(prevSibling) !== 'normal') { + replaceWithSimpleText(prevSibling); + + return; + } else { + const diff = prevMatch.end - previousText.length; + + if (diff > 0) { + const concatText = text.slice(0, diff); + const newTextContent = previousText + concatText; + prevSibling.select(); + prevSibling.setTextContent(newTextContent); + + if (diff === text.length) { + node.remove(); + } else { + const remainingText = text.slice(diff); + node.setTextContent(remainingText); + } + + return; + } + } + } else if (prevMatch === null || prevMatch.start < previousText.length) { + return; + } + } + + + while (true) { + match = getMatch(text); + let nextText = match === null ? '' : text.slice(match.end); + text = nextText; + + if (nextText === '') { + const nextSibling = currentNode?.getNextSibling(); + + if ($isTextNode(nextSibling)) { + nextText = currentNode!.getTextContent() + nextSibling.getTextContent(); + const nextMatch = getMatch(nextText); + + if (nextMatch === null) { + if (isTargetNode(nextSibling)) { + replaceWithSimpleText(nextSibling); + } else { + nextSibling.markDirty(); + } + + return; + } else if (nextMatch.start !== 0) { + return; + } + } + } else { + const nextMatch = getMatch(nextText); + + if (nextMatch !== null && nextMatch.start === 0) { + return; + } + } + + if (match === null) { + return; + } + + if ( + match.start === 0 && + $isTextNode(prevSibling) && + prevSibling.isTextEntity() + ) { + continue; + } + + let nodeToReplace: TextNodeType; + + if (match.start === 0) { + [nodeToReplace, currentNode] = currentNode!.splitText(match.end) as [TextNodeType, TextNodeType]; + } else { + [, nodeToReplace, currentNode] = currentNode!.splitText( + match.start, + match.end, + ) as [TextNodeType, TextNodeType, TextNodeType]; + } + + const replacementNode = createNode(nodeToReplace); + (replacementNode as TextNodeType).setFormat(nodeToReplace.getFormat()); + nodeToReplace.replace(replacementNode); + + if (currentNode === null) { + return; + } + } + }; + + const reverseNodeTransform = (node: LexicalNode): void => { + const text = node.getTextContent(); + const match = getMatch(text); + + if (match === null || match.start !== 0) { + replaceWithSimpleText(node as TextNodeType); + + return; + } + + if (text.length > match.end) { + // This will split out the rest of the text as simple text + (node as TextNodeType).splitText(match.end); + + return; + } + + const prevSibling = node.getPreviousSibling(); + + if ($isTextNode(prevSibling) && prevSibling.isTextEntity()) { + replaceWithSimpleText(prevSibling); + replaceWithSimpleText(node as TextNodeType); + } + + const nextSibling = node.getNextSibling(); + + if ($isTextNode(nextSibling) && nextSibling.isTextEntity()) { + replaceWithSimpleText(nextSibling); + + // This may have already been converted in the previous block + if (isTargetNode(node)) { + replaceWithSimpleText(node as TextNodeType); + } + } + }; + + const removePlainTextTransform = editor.registerNodeTransform( + nodeType as Klass, + textNodeTransform, + ); + const removeReverseNodeTransform = editor.registerNodeTransform( + targetNode as Klass, + reverseNodeTransform as (node: TextNodeType) => void, + ); + + return [removePlainTextTransform, removeReverseNodeTransform]; +} diff --git a/packages/koenig-lexical/src/hooks/useMovable.js b/packages/koenig-lexical/src/hooks/useMovable.js deleted file mode 100644 index b13149b592..0000000000 --- a/packages/koenig-lexical/src/hooks/useMovable.js +++ /dev/null @@ -1,297 +0,0 @@ -import {useCallback, useEffect, useRef} from 'react'; - -// TODO: this is a temporary fix, replacement for ember's id, need better solution -function guidFor() { - // create unique id - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - let r = Math.random() * 16 | 0; - let v = c === 'x' ? r : ((r & 0x3) | 0x8); - return v.toString(16); - }); -} - -/** - * useMovable - * @param {Object} options - * @param {Function} options.adjustOnResize - function called when panel size was changed - * @returns {Object} ref - a ref that should be attached to the element that should be movable - * - * @description - * useMovable is a hook that allows an element to be moved around the screen by dragging it. - * - * @example - * const {ref} = useMovable(); - */ -export default function useMovable({adjustOnResize, adjustOnDrag} = {}) { - const ref = useRef(null); - - const moveThreshold = 3; - - // Use refs to avoid re-renders, see https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables - const active = useRef(false); - const currentX = useRef(); - const currentY = useRef(); - - /** - * Cursor offset from the left top side of the panel on touchstart/mousedown - */ - const offsetX = useRef(); - const offsetY = useRef(); - - // Keep track of spacing, so we can allow negative spacing when resizing if the user placed the window outside the canvas - // Contains an object with top, left, right and bottom spacing between the panel and the viewport - const lastSpacing = useRef(); - - const originalOverflow = useRef(); - const guid = guidFor(); - - // React event handlers get added to the root element, so if we add listeners to the ref directly - // and call stopPropagation they stop any React events on child nodes from firing. - // Instead we add the listeners to the body and check if the event target is the ref. - const addRefEventListener = (event, handler) => { - const listener = (e) => { - if (ref.current?.contains(e.target)) { - handler(e); - } - }; - - document.body.addEventListener(event, listener, false); - - return listener; - }; - - const cancelClick = useCallback((e) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const setTranslate = useCallback((xPos, yPos) => { - ref.current.style.transform = `translate(${xPos}px, ${yPos}px)`; - }, [ref]); - - const setPosition = useCallback(({x, y}) => { - currentX.current = x; - currentY.current = y; - - const width = ref.current.offsetWidth; - const height = ref.current.offsetHeight; - - // Update spacing - const spacing = { - top: y, - left: x, - right: window.innerWidth - x - width, - bottom: window.innerHeight - y - height - }; - lastSpacing.current = spacing; - - setTranslate(x, y); - }, [setTranslate]); - - const getPosition = useCallback(() => { - return { - x: currentX.current, - y: currentY.current, - lastSpacing: lastSpacing.current - }; - }, []); - - const disableScroll = useCallback(() => { - originalOverflow.current = ref.current?.style.overflow; - ref.current.style.overflow = 'hidden'; - }, [ref]); - - const enableScroll = useCallback(() => { - ref.current.style.overflow = originalOverflow.current; - }, [ref]); - - const disableSelection = useCallback(() => { - window.getSelection().removeAllRanges(); - - const stylesheet = document.createElement('style'); - stylesheet.id = `stylesheet-${guid}`; - - document.head.appendChild(stylesheet); - - stylesheet.sheet.insertRule('* { user-select: none !important; }', 0); - }, [guid]); - - const enableSelection = useCallback(() => { - const stylesheet = document.getElementById(`stylesheet-${guid}`); - stylesheet?.remove(); - }, [guid]); - - // disabling pointer events prevents inputs being activated when drag finishes, - // preventing clicks stops any event handlers that may otherwise result in the - // movable element being closed when the drag finishes - const disablePointerEvents = useCallback(() => { - if (ref.current) { - ref.current.style.pointerEvents = 'none'; - } - window.addEventListener('click', cancelClick, {capture: true, passive: false}); - }, [ref, cancelClick]); - - const enablePointerEvents = useCallback(() => { - if (ref.current) { - ref.current.style.pointerEvents = ''; - } - window.removeEventListener('click', cancelClick, {capture: true, passive: false}); - }, [ref, cancelClick]); - - const drag = useCallback((e) => { - let eventX, eventY; - - if (e.type === 'touchmove') { - eventX = e.touches[0].clientX; - eventY = e.touches[0].clientY; - } else { - eventX = e.clientX; - eventY = e.clientY; - } - - if (!active.current) { - if ( - Math.abs(eventX - offsetX.current - currentX.current) > moveThreshold || - Math.abs(eventY - offsetY.current - currentY.current) > moveThreshold - ) { - disableScroll(); - disableSelection(); - disablePointerEvents(); - active.current = true; - } - } - - if (active.current) { - let position = { - x: eventX - offsetX.current, - y: eventY - offsetY.current - }; - - if (adjustOnDrag) { - position = adjustOnDrag(ref.current, {...position, lastSpacing: lastSpacing.current}); - } - - setPosition(position); - } - }, [moveThreshold, setPosition, disableScroll, disableSelection, disablePointerEvents, adjustOnDrag]); - - const dragEnd = useCallback((e) => { - active.current = false; - - window.removeEventListener('touchend', dragEnd, {capture: true, passive: true}); - window.removeEventListener('touchmove', drag, {capture: true, passive: true}); - window.removeEventListener('mouseup', dragEnd, {capture: true, passive: true}); - window.removeEventListener('mousemove', drag, {capture: true, passive: true}); - - // Removing this immediately results in the click event behind re-enabled in the same - // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. - // Putting in the next tick stops the immediate click event firing when finishing drag - setTimeout(() => { - window.removeEventListener('click', cancelClick.bind(this), {capture: true, passive: false}); - }, 1); - - enableScroll(); - enableSelection(); - - // timeout required so immediate events blocked until the dragEnd has fully realised - setTimeout(() => { - enablePointerEvents(); - }, 5); - }, [enableScroll, enableSelection, enablePointerEvents, drag, cancelClick]); - - const addActiveEventListeners = useCallback(() => { - window.addEventListener('touchend', dragEnd, {capture: true, passive: true}); - window.addEventListener('touchmove', drag, {capture: true, passive: true}); - window.addEventListener('mouseup', dragEnd, {capture: true, passive: true}); - window.addEventListener('mousemove', drag, {capture: true, passive: true}); - }, [dragEnd, drag]); - - const dragStart = useCallback((e) => { - e.stopPropagation(); - active.current = false; - - if (e.type === 'touchstart' || e.button === 0) { - if (e.type === 'touchstart') { - offsetX.current = e.touches[0].clientX - (currentX.current || 0); - offsetY.current = e.touches[0].clientY - (currentY.current || 0); - } else { - offsetX.current = e.clientX - (currentX.current || 0); - offsetY.current = e.clientY - (currentY.current || 0); - } - - for (const element of (e.path || e.composedPath())) { - if (element?.matches?.('input, .ember-basic-dropdown-trigger')) { - break; - } - - if (element === ref.current) { - addActiveEventListeners(); - break; - } - } - } - }, [ref, addActiveEventListeners]); - - const addStartEventListeners = useCallback(() => { - const touchStartListener = addRefEventListener('touchstart', dragStart); - const mouseDownListener = addRefEventListener('mousedown', dragStart); - - return () => { - ref.current?.removeEventListener('touchstart', touchStartListener); - ref.current?.removeEventListener('mousedown', mouseDownListener); - }; - }, [dragStart]); - - const removeActiveEventListeners = useCallback(() => { - window.removeEventListener('touchend', dragEnd, {capture: true, passive: true}); - window.removeEventListener('touchmove', drag, {capture: true, passive: true}); - window.removeEventListener('mouseup', dragEnd, {capture: true, passive: true}); - window.removeEventListener('mousemove', drag, {capture: true, passive: true}); - - // Removing this immediately results in the click event behind re-enabled in the same - // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. - // Putting in the next tick stops the immediate click event firing when finishing drag - setTimeout(() => { - window.removeEventListener('click', cancelClick.bind(this), {capture: true, passive: false}); - }, 1); - }, [dragEnd, drag, cancelClick]); - - useEffect(() => { - const elem = ref.current; - elem.setAttribute('draggable', true); - ref.current?.classList.add('kg-card-movable'); - let _resizeObserver; - const removeStartEventListeners = addStartEventListeners(); - - if (adjustOnResize) { - _resizeObserver = new ResizeObserver(() => { - if (currentX.current === undefined || currentY.current === undefined) { - return; - } - - const position = adjustOnResize(elem, {x: currentX.current, y: currentY.current, lastSpacing: lastSpacing.current}); - - if (position.x !== currentX.current || position.y !== currentY.current) { - // Adjust offsetX and offsetY to account for the difference in position moved - // This is to make sure we don't jump drag position if the element is resized just after touch start - // Say you start dragging on a button that opens a collapsible section, if the section is resized -> this fixes glitches - offsetX.current = offsetX.current - (position.x - currentX.current); - offsetY.current = offsetY.current - (position.y - currentY.current); - setPosition(position); - } - }); - _resizeObserver.observe(elem); - } - - // Cleanup event listeners on unmount - return () => { - removeStartEventListeners(); - removeActiveEventListeners(); - _resizeObserver?.disconnect(); - enableSelection(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return {ref, setPosition, getPosition}; -} diff --git a/packages/koenig-lexical/src/hooks/useMovable.ts b/packages/koenig-lexical/src/hooks/useMovable.ts new file mode 100644 index 0000000000..7b7748717d --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useMovable.ts @@ -0,0 +1,306 @@ +import {useCallback, useEffect, useRef} from 'react'; + +// TODO: this is a temporary fix, replacement for ember's id, need better solution +function guidFor(): string { + // create unique id + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : ((r & 0x3) | 0x8); + return v.toString(16); + }); +} + +export interface Position { + x: number; + y: number; + lastSpacing?: Spacing; +} + +export interface Spacing { + top: number; + left: number; + right: number; + bottom: number; +} + +export interface UseMovableOptions { + adjustOnResize?: (elem: HTMLElement, position: Position) => Position; + adjustOnDrag?: (elem: HTMLElement, position: Position) => Position; +} + +/** + * useMovable + * useMovable is a hook that allows an element to be moved around the screen by dragging it. + */ +export default function useMovable({adjustOnResize, adjustOnDrag}: UseMovableOptions = {}) { + const ref = useRef(null); + + const moveThreshold = 3; + + // Use refs to avoid re-renders, see https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + const active = useRef(false); + const currentX = useRef(); + const currentY = useRef(); + + const offsetX = useRef(0); + const offsetY = useRef(0); + + // Keep track of spacing, so we can allow negative spacing when resizing if the user placed the window outside the canvas + // Contains an object with top, left, right and bottom spacing between the panel and the viewport + const lastSpacing = useRef(); + const originalOverflow = useRef(); + const guid = guidFor(); + + // React event handlers get added to the root element, so if we add listeners to the ref directly + // and call stopPropagation they stop any React events on child nodes from firing. + // Instead we add the listeners to the body and check if the event target is the ref. + const addRefEventListener = (event: string, handler: (e: Event) => void) => { + const listener = (e: Event) => { + if (ref.current?.contains(e.target as Node)) { + handler(e); + } + }; + + document.body.addEventListener(event, listener, false); + + return listener; + }; + + const cancelClick = useCallback((e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const setTranslate = useCallback((xPos: number, yPos: number) => { + ref.current!.style.transform = `translate(${xPos}px, ${yPos}px)`; + }, [ref]); + + const setPosition = useCallback(({x, y}: Position) => { + currentX.current = x; + currentY.current = y; + + const width = ref.current!.offsetWidth; + const height = ref.current!.offsetHeight; + + // Update spacing + const spacing: Spacing = { + top: y, + left: x, + right: window.innerWidth - x - width, + bottom: window.innerHeight - y - height + }; + lastSpacing.current = spacing; + + setTranslate(x, y); + }, [setTranslate]); + + const getPosition = useCallback(() => { + return { + x: currentX.current!, + y: currentY.current!, + lastSpacing: lastSpacing.current + }; + }, []); + + const disableScroll = useCallback(() => { + originalOverflow.current = ref.current?.style.overflow; + ref.current!.style.overflow = 'hidden'; + }, [ref]); + + const enableScroll = useCallback(() => { + ref.current!.style.overflow = originalOverflow.current || ''; + }, [ref]); + + const disableSelection = useCallback(() => { + window.getSelection()?.removeAllRanges(); + + const stylesheet = document.createElement('style'); + stylesheet.id = `stylesheet-${guid}`; + + document.head.appendChild(stylesheet); + + stylesheet.sheet!.insertRule('* { user-select: none !important; }', 0); + }, [guid]); + + const enableSelection = useCallback(() => { + const stylesheet = document.getElementById(`stylesheet-${guid}`); + stylesheet?.remove(); + }, [guid]); + + // disabling pointer events prevents inputs being activated when drag finishes, + // preventing clicks stops any event handlers that may otherwise result in the + // movable element being closed when the drag finishes + const disablePointerEvents = useCallback(() => { + if (ref.current) { + ref.current.style.pointerEvents = 'none'; + } + window.addEventListener('click', cancelClick, {capture: true, passive: false}); + }, [ref, cancelClick]); + + const enablePointerEvents = useCallback(() => { + if (ref.current) { + ref.current.style.pointerEvents = ''; + } + window.removeEventListener('click', cancelClick, {capture: true}); + }, [ref, cancelClick]); + + const drag = useCallback((e: Event) => { + let eventX: number, eventY: number; + + if ((e as TouchEvent).type === 'touchmove') { + eventX = (e as TouchEvent).touches[0].clientX; + eventY = (e as TouchEvent).touches[0].clientY; + } else { + eventX = (e as MouseEvent).clientX; + eventY = (e as MouseEvent).clientY; + } + + if (!active.current) { + if ( + Math.abs(eventX - offsetX.current - (currentX.current || 0)) > moveThreshold || + Math.abs(eventY - offsetY.current - (currentY.current || 0)) > moveThreshold + ) { + disableScroll(); + disableSelection(); + disablePointerEvents(); + active.current = true; + } + } + + if (active.current) { + let position: Position = { + x: eventX - offsetX.current, + y: eventY - offsetY.current + }; + + if (adjustOnDrag) { + position = adjustOnDrag(ref.current!, {...position, lastSpacing: lastSpacing.current}); + } + + setPosition(position); + } + }, [moveThreshold, setPosition, disableScroll, disableSelection, disablePointerEvents, adjustOnDrag]); + + const dragEnd = useCallback(() => { + active.current = false; + + window.removeEventListener('touchend', dragEnd, {capture: true}); + window.removeEventListener('touchmove', drag, {capture: true}); + window.removeEventListener('mouseup', dragEnd, {capture: true}); + window.removeEventListener('mousemove', drag, {capture: true}); + + // Removing this immediately results in the click event being re-enabled in the same + // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. + // Putting in the next tick stops the immediate click event firing when finishing drag + setTimeout(() => { + window.removeEventListener('click', cancelClick, {capture: true}); + }, 1); + + enableScroll(); + enableSelection(); + + // timeout required so immediate events blocked until the dragEnd has fully realised + setTimeout(() => { + enablePointerEvents(); + }, 5); + }, [enableScroll, enableSelection, enablePointerEvents, drag, cancelClick]); + + const addActiveEventListeners = useCallback(() => { + window.addEventListener('touchend', dragEnd, {capture: true, passive: true}); + window.addEventListener('touchmove', drag, {capture: true, passive: true}); + window.addEventListener('mouseup', dragEnd, {capture: true, passive: true}); + window.addEventListener('mousemove', drag, {capture: true, passive: true}); + }, [dragEnd, drag]); + + const dragStart = useCallback((e: Event) => { + e.stopPropagation(); + active.current = false; + + const mouseEvent = e as MouseEvent; + const touchEvent = e as TouchEvent; + + if (e.type === 'touchstart' || mouseEvent.button === 0) { + if (e.type === 'touchstart') { + offsetX.current = touchEvent.touches[0].clientX - (currentX.current || 0); + offsetY.current = touchEvent.touches[0].clientY - (currentY.current || 0); + } else { + offsetX.current = mouseEvent.clientX - (currentX.current || 0); + offsetY.current = mouseEvent.clientY - (currentY.current || 0); + } + + for (const element of ((e as unknown as {path?: EventTarget[]}).path || e.composedPath())) { + if ((element as HTMLElement)?.matches?.('input, .ember-basic-dropdown-trigger')) { + break; + } + + if (element === ref.current) { + addActiveEventListeners(); + break; + } + } + } + }, [ref, addActiveEventListeners]); + + const addStartEventListeners = useCallback(() => { + const touchStartListener = addRefEventListener('touchstart', dragStart); + const mouseDownListener = addRefEventListener('mousedown', dragStart); + + return () => { + document.body.removeEventListener('touchstart', touchStartListener); + document.body.removeEventListener('mousedown', mouseDownListener); + }; + }, [dragStart]); + + const removeActiveEventListeners = useCallback(() => { + window.removeEventListener('touchend', dragEnd, {capture: true}); + window.removeEventListener('touchmove', drag, {capture: true}); + window.removeEventListener('mouseup', dragEnd, {capture: true}); + window.removeEventListener('mousemove', drag, {capture: true}); + + // Removing this immediately results in the click event being re-enabled in the same + // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. + // Putting in the next tick stops the immediate click event firing when finishing drag + setTimeout(() => { + window.removeEventListener('click', cancelClick, {capture: true}); + }, 1); + }, [dragEnd, drag, cancelClick]); + + useEffect(() => { + const elem = ref.current!; + elem.setAttribute('draggable', 'true'); + ref.current?.classList.add('kg-card-movable'); + let _resizeObserver: ResizeObserver | undefined; + const removeStartEventListeners = addStartEventListeners(); + + if (adjustOnResize) { + _resizeObserver = new ResizeObserver(() => { + if (currentX.current === undefined || currentY.current === undefined) { + return; + } + + const position = adjustOnResize(elem, {x: currentX.current, y: currentY.current, lastSpacing: lastSpacing.current}); + + if (position.x !== currentX.current || position.y !== currentY.current) { + // Adjust offsetX and offsetY to account for the difference in position moved + // This is to make sure we don't jump drag position if the element is resized just after touch start + // Say you start dragging on a button that opens a collapsible section, if the section is resized -> this fixes glitches + offsetX.current = offsetX.current - (position.x - currentX.current); + offsetY.current = offsetY.current - (position.y - currentY.current); + setPosition(position); + } + }); + _resizeObserver.observe(elem); + } + + // Cleanup event listeners on unmount + return () => { + removeStartEventListeners(); + removeActiveEventListeners(); + _resizeObserver?.disconnect(); + enableSelection(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return {ref, setPosition, getPosition}; +} diff --git a/packages/koenig-lexical/src/hooks/usePinturaEditor.js b/packages/koenig-lexical/src/hooks/usePinturaEditor.js deleted file mode 100644 index bf8ded5d25..0000000000 --- a/packages/koenig-lexical/src/hooks/usePinturaEditor.js +++ /dev/null @@ -1,166 +0,0 @@ -import trackEvent from '../utils/analytics'; -import {useCallback, useEffect, useRef, useState} from 'react'; - -export default function usePinturaEditor({ - config, disabled = false -}) { - const [scriptLoaded, setScriptLoaded] = useState(false); - const [cssLoaded, setCssLoaded] = useState(false); - const allowClose = useRef(false); - - const isEnabled = !disabled && scriptLoaded && cssLoaded; - - useEffect(() => { - const jsUrl = config?.jsUrl; - - if (!jsUrl) { - return; - } - - if (window.pintura) { - setScriptLoaded(true); - return; - } - - try { - const url = new URL(jsUrl); - const importUrl = `${url.protocol}//${url.host}${url.pathname}`; - const importScriptPromise = import(/* @vite-ignore */ importUrl); - - importScriptPromise.then(() => { - setScriptLoaded(true); - }).catch(() => { - // log script loading failure - }); - } catch (e) { - // Log script loading error - } - }, [config?.jsUrl]); - - useEffect(() => { - let cssUrl = config?.cssUrl; - if (!cssUrl) { - return; - } - - try { - // Check if the CSS file is already present in the document's head - let cssLink = document.querySelector(`link[href="${cssUrl}"]`); - if (cssLink) { - setCssLoaded(true); - } else { - let link = document.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = cssUrl; - link.onload = () => { - setCssLoaded(true); - }; - document.head.appendChild(link); - } - } catch (e) { - // Log css loading error - } - }, [config?.cssUrl]); - - const openEditor = useCallback(({image, handleSave}) => { - allowClose.current = false; - - trackEvent('Image Edit Button Clicked', {location: 'editor'}); - if (image && isEnabled) { - // add a timestamp to the image src to bypass cache - // avoids cors issues with cached images - const imageUrl = new URL(image); - if (!imageUrl.searchParams.has('v')) { - imageUrl.searchParams.set('v', Date.now()); - } - - const imageSrc = imageUrl.href; - const editor = window.pintura.openDefaultEditor({ - src: imageSrc, - enableTransparencyGrid: true, - util: 'crop', - utils: [ - 'crop', - 'filter', - 'finetune', - 'redact', - 'annotate', - 'trim', - 'frame', - 'resize' - ], - frameOptions: [ - // No frame - [undefined, locale => locale.labelNone], - - // Sharp edge frame - ['solidSharp', locale => locale.frameLabelMatSharp], - - // Rounded edge frame - ['solidRound', locale => locale.frameLabelMatRound], - - // A single line frame - ['lineSingle', locale => locale.frameLabelLineSingle], - - // A frame with cornenr hooks - ['hook', locale => locale.frameLabelCornerHooks], - - // A polaroid frame - ['polaroid', locale => locale.frameLabelPolaroid] - ], - cropSelectPresetFilter: 'landscape', - cropSelectPresetOptions: [ - [undefined, 'Custom'], - [1, 'Square'], - // shown when cropSelectPresetFilter is set to 'landscape' - [2 / 1, '2:1'], - [3 / 2, '3:2'], - [4 / 3, '4:3'], - [16 / 10, '16:10'], - [16 / 9, '16:9'], - // shown when cropSelectPresetFilter is set to 'portrait' - [1 / 2, '1:2'], - [2 / 3, '2:3'], - [3 / 4, '3:4'], - [10 / 16, '10:16'], - [9 / 16, '9:16'] - ], - locale: { - labelButtonExport: 'Save and close' - }, - previewPad: true, - willClose: () => (allowClose.current) // prevent closing on escape, only allow on close button clicks - }); - - editor.on('loaderror', () => { - // TODO: log error message - }); - - editor.on('process', (result) => { - // save edited image - handleSave(result.dest); - trackEvent('Image Edit Saved', {location: 'editor'}); - }); - } - }, [isEnabled]); - - useEffect(() => { - const handleCloseClick = (event) => { - if (event.target.closest('.PinturaModal button[title="Close"]')) { - allowClose.current = true; - } - }; - - window.addEventListener('click', handleCloseClick, {capture: true}); - - return () => { - window.removeEventListener('click', handleCloseClick); - }; - }, []); - - return { - isEnabled, - openEditor - }; -} diff --git a/packages/koenig-lexical/src/hooks/usePinturaEditor.ts b/packages/koenig-lexical/src/hooks/usePinturaEditor.ts new file mode 100644 index 0000000000..1d6f34e0ac --- /dev/null +++ b/packages/koenig-lexical/src/hooks/usePinturaEditor.ts @@ -0,0 +1,179 @@ +import trackEvent from '../utils/analytics'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {PinturaConfig} from '../context/KoenigComposerContext'; + +export type OpenImageEditor = (options: {image: string; handleSave: (blob: Blob) => void}) => void; + +declare global { + interface Window { + pintura?: { + openDefaultEditor: (options: Record) => { + on: (event: string, callback: (result: {dest: Blob}) => void) => void; + }; + }; + } +} + +export default function usePinturaEditor({ + config, disabled = false +}: {config?: PinturaConfig; disabled?: boolean}) { + const [scriptLoaded, setScriptLoaded] = useState(false); + const [cssLoaded, setCssLoaded] = useState(false); + const allowClose = useRef(false); + + const isEnabled = !disabled && scriptLoaded && cssLoaded; + + useEffect(() => { + const jsUrl = config?.jsUrl; + + if (!jsUrl) { + return; + } + + if (window.pintura) { + setScriptLoaded(true); + return; + } + + try { + const url = new URL(jsUrl); + const importUrl = `${url.protocol}//${url.host}${url.pathname}`; + const importScriptPromise = import(/* @vite-ignore */ importUrl); + + importScriptPromise.then(() => { + setScriptLoaded(true); + }).catch(() => { + // log script loading failure + }); + } catch { + // Log script loading error + } + }, [config?.jsUrl]); + + useEffect(() => { + const cssUrl = config?.cssUrl; + if (!cssUrl) { + return; + } + + try { + // Check if the CSS file is already present in the document's head + const cssLink = document.querySelector(`link[href="${cssUrl}"]`); + if (cssLink) { + setCssLoaded(true); + } else { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = cssUrl; + link.onload = () => { + setCssLoaded(true); + }; + document.head.appendChild(link); + } + } catch { + // Log css loading error + } + }, [config?.cssUrl]); + + const openEditor = useCallback(({image, handleSave}) => { + allowClose.current = false; + + trackEvent('Image Edit Button Clicked', {location: 'editor'}); + if (image && isEnabled) { + // add a timestamp to the image src to bypass cache + // avoids cors issues with cached images + const imageUrl = new URL(image); + if (!imageUrl.searchParams.has('v')) { + imageUrl.searchParams.set('v', String(Date.now())); + } + + const imageSrc = imageUrl.href; + const editor = window.pintura!.openDefaultEditor({ + src: imageSrc, + enableTransparencyGrid: true, + util: 'crop', + utils: [ + 'crop', + 'filter', + 'finetune', + 'redact', + 'annotate', + 'trim', + 'frame', + 'resize' + ], + frameOptions: [ + // No frame + [undefined, (locale: Record) => locale.labelNone], + + // Sharp edge frame + ['solidSharp', (locale: Record) => locale.frameLabelMatSharp], + + // Rounded edge frame + ['solidRound', (locale: Record) => locale.frameLabelMatRound], + + // A single line frame + ['lineSingle', (locale: Record) => locale.frameLabelLineSingle], + + // A frame with cornenr hooks + ['hook', (locale: Record) => locale.frameLabelCornerHooks], + + // A polaroid frame + ['polaroid', (locale: Record) => locale.frameLabelPolaroid] + ], + cropSelectPresetFilter: 'landscape', + cropSelectPresetOptions: [ + [undefined, 'Custom'], + [1, 'Square'], + // shown when cropSelectPresetFilter is set to 'landscape' + [2 / 1, '2:1'], + [3 / 2, '3:2'], + [4 / 3, '4:3'], + [16 / 10, '16:10'], + [16 / 9, '16:9'], + // shown when cropSelectPresetFilter is set to 'portrait' + [1 / 2, '1:2'], + [2 / 3, '2:3'], + [3 / 4, '3:4'], + [10 / 16, '10:16'], + [9 / 16, '9:16'] + ], + locale: { + labelButtonExport: 'Save and close' + }, + previewPad: true, + willClose: () => (allowClose.current) // prevent closing on escape, only allow on close button clicks + }); + + editor.on('loaderror', () => { + // TODO: log error message + }); + + editor.on('process', (result) => { + // save edited image + handleSave(result.dest); + trackEvent('Image Edit Saved', {location: 'editor'}); + }); + } + }, [isEnabled]); + + useEffect(() => { + const handleCloseClick = (event: MouseEvent) => { + if ((event.target as HTMLElement).closest('.PinturaModal button[title="Close"]')) { + allowClose.current = true; + } + }; + + window.addEventListener('click', handleCloseClick, {capture: true}); + + return () => { + window.removeEventListener('click', handleCloseClick); + }; + }, []); + + return { + isEnabled, + openEditor + }; +} diff --git a/packages/koenig-lexical/src/hooks/usePreviousFocus.js b/packages/koenig-lexical/src/hooks/usePreviousFocus.js deleted file mode 100644 index 62b6119a95..0000000000 --- a/packages/koenig-lexical/src/hooks/usePreviousFocus.js +++ /dev/null @@ -1,24 +0,0 @@ -import {useRef} from 'react'; - -export const usePreviousFocus = (onClick, name) => { - const previousRangeRef = useRef(null); - - const handleMousedown = () => { - const selection = document.getSelection(); - previousRangeRef.current = (selection.rangeCount === 0 ? null : selection.getRangeAt(0)); - }; - - const handleClick = (e) => { - e.preventDefault(); - onClick(name); - - if (previousRangeRef.current) { - const selection = document.getSelection(); - selection.removeAllRanges(); - selection.addRange(previousRangeRef.current); - previousRangeRef.current = null; - } - }; - - return {handleMousedown, handleClick}; -}; diff --git a/packages/koenig-lexical/src/hooks/usePreviousFocus.ts b/packages/koenig-lexical/src/hooks/usePreviousFocus.ts new file mode 100644 index 0000000000..ccd60d7d88 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/usePreviousFocus.ts @@ -0,0 +1,24 @@ +import {useRef} from 'react'; + +export const usePreviousFocus = (onClick: (name: string) => void, name: string) => { + const previousRangeRef = useRef(null); + + const handleMousedown = () => { + const selection = document.getSelection(); + previousRangeRef.current = (selection?.rangeCount === 0 ? null : selection?.getRangeAt(0) ?? null); + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + onClick(name); + + if (previousRangeRef.current) { + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(previousRangeRef.current); + previousRangeRef.current = null; + } + }; + + return {handleMousedown, handleClick}; +}; diff --git a/packages/koenig-lexical/src/hooks/useSearchLinks.js b/packages/koenig-lexical/src/hooks/useSearchLinks.js deleted file mode 100644 index 77986d3c9f..0000000000 --- a/packages/koenig-lexical/src/hooks/useSearchLinks.js +++ /dev/null @@ -1,121 +0,0 @@ -import EarthIcon from '../assets/icons/kg-earth.svg?react'; -import React from 'react'; -import debounce from 'lodash/debounce'; - -const DEBOUNCE_MS = 100; -const URL_QUERY_REGEX = /^http|^#|^\/|^mailto:|^tel:/; - -function urlQueryOptions(query) { - return [{ - label: 'Link to web page', - items: [{ - label: query, - value: query, - Icon: EarthIcon, - highlight: false, - type: 'url' - }] - }]; -} - -function defaultNoResultOptions(query) { - return [{ - label: 'Link to web page', - items: [{ - label: `Enter URL to create link`, - value: null, - Icon: EarthIcon, - highlight: false, - type: 'no-results' - }] - }]; -} - -function convertSearchResultsToListOptions(results, {noResultOptions, type} = {}) { - if (!results || !results.length) { - return (noResultOptions || defaultNoResultOptions)(); - } - - return results.map((result) => { - const items = result.items.map((item) => { - return { - label: item.title, - value: item.url, - Icon: item.Icon, - metaText: item.metaText, - MetaIcon: item.MetaIcon, - metaIconTitle: item.metaIconTitle, - type: type || 'internal' - }; - }); - - return {...result, items}; - }); -} - -export const useSearchLinks = (query, searchLinks, {noResultOptions} = {}) => { - const [defaultListOptions, setDefaultListOptions] = React.useState([]); - const [listOptions, setListOptions] = React.useState([]); - const [isSearching, setIsSearching] = React.useState(false); - - const search = React.useMemo(() => { - return async function _search(term) { - if (URL_QUERY_REGEX.test(term)) { - setListOptions(urlQueryOptions(term)); - return; - } - - setIsSearching(true); - const results = await searchLinks(term); - - // can return undefined if the search was cancelled, avoid updating - // in that scenario because we can end up in a race condition where - // we overwrite the results with an empty array whilst still waiting - // for a later search to complete. Avoids flashing of "no results". - if (results === undefined) { - return; - } - - setListOptions(convertSearchResultsToListOptions(results, {noResultOptions})); - setIsSearching(false); - }; - }, [searchLinks, noResultOptions]); - - const debouncedSearch = React.useMemo(() => { - return debounce(search, DEBOUNCE_MS); - }, [search]); - - // Fetch default search results when first rendering - React.useEffect(() => { - const fetchDefaultOptions = async () => { - // if we have a query we don't want to show the searching state but - // we still want to load the default options in the background so - // they're available when the query is cleared - !query && setIsSearching(true); - const results = await searchLinks(); - setDefaultListOptions(convertSearchResultsToListOptions(results, {type: 'default'})); - !query && setIsSearching(false); - }; - - fetchDefaultOptions().catch(console.error); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - React.useEffect(() => { - // perform a non-debounced search if the query is a URL so the - // "Link to web page" option updates more responsively - if (URL_QUERY_REGEX.test(query)) { - debouncedSearch.cancel(); - search(query); - } else { - debouncedSearch(query); - } - }, [query, search, debouncedSearch]); - - const displayedListOptions = query ? listOptions : defaultListOptions; - - return { - isSearching, - listOptions: displayedListOptions - }; -}; diff --git a/packages/koenig-lexical/src/hooks/useSearchLinks.ts b/packages/koenig-lexical/src/hooks/useSearchLinks.ts new file mode 100644 index 0000000000..86eaf717e9 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useSearchLinks.ts @@ -0,0 +1,156 @@ +import EarthIcon from '../assets/icons/kg-earth.svg?react'; +import React from 'react'; +import debounce from 'lodash/debounce'; + +const DEBOUNCE_MS = 100; +const URL_QUERY_REGEX = /^http|^#|^\/|^mailto:|^tel:/; + +interface ListItem { + label: string; + value: string | null; + Icon?: React.ComponentType; + metaText?: string; + MetaIcon?: React.ComponentType; + metaIconTitle?: string; + highlight?: boolean; + type: string; + [key: string]: unknown; +} + +interface ListOption { + label: string; + items: ListItem[]; +} + +interface SearchResultItem { + title: string; + url: string; + Icon?: React.ComponentType; + metaText?: string; + MetaIcon?: React.ComponentType; + metaIconTitle?: string; +} + +interface SearchResult { + label: string; + items: SearchResultItem[]; +} + +function urlQueryOptions(query: string): ListOption[] { + return [{ + label: 'Link to web page', + items: [{ + label: query, + value: query, + Icon: EarthIcon, + highlight: false, + type: 'url' + }] + }]; +} + +function defaultNoResultOptions(): ListOption[] { + return [{ + label: 'Link to web page', + items: [{ + label: `Enter URL to create link`, + value: null, + Icon: EarthIcon, + highlight: false, + type: 'no-results' + }] + }]; +} + +function convertSearchResultsToListOptions(results: SearchResult[] | undefined, {noResultOptions, type}: {noResultOptions?: () => ListOption[]; type?: string} = {}): ListOption[] { + if (!results || !results.length) { + return (noResultOptions || defaultNoResultOptions)(); + } + + return results.map((result) => { + const items = result.items.map((item) => { + return { + label: item.title, + value: item.url, + Icon: item.Icon, + metaText: item.metaText, + MetaIcon: item.MetaIcon, + metaIconTitle: item.metaIconTitle, + type: type || 'internal' + }; + }); + + return {...result, items}; + }); +} + +export const useSearchLinks = (query: string, searchLinks: (term?: string) => Promise, {noResultOptions}: {noResultOptions?: () => ListOption[]} = {}) => { + const [defaultListOptions, setDefaultListOptions] = React.useState([]); + const [listOptions, setListOptions] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + + const search = React.useMemo(() => { + return async function _search(term: string) { + if (URL_QUERY_REGEX.test(term)) { + setListOptions(urlQueryOptions(term)); + return; + } + + setIsSearching(true); + const results = await searchLinks(term) as SearchResult[] | undefined; + + // can return undefined if the search was cancelled, avoid updating + // in that scenario because we can end up in a race condition where + // we overwrite the results with an empty array whilst still waiting + // for a later search to complete. Avoids flashing of "no results". + if (results === undefined) { + return; + } + + setListOptions(convertSearchResultsToListOptions(results, {noResultOptions})); + setIsSearching(false); + }; + }, [searchLinks, noResultOptions]); + + const debouncedSearch = React.useMemo(() => { + return debounce(search, DEBOUNCE_MS); + }, [search]); + + // Fetch default search results when first rendering + React.useEffect(() => { + const fetchDefaultOptions = async () => { + // if we have a query we don't want to show the searching state but + // we still want to load the default options in the background so + // they're available when the query is cleared + if (!query) { + setIsSearching(true); + } + const results = await searchLinks() as SearchResult[] | undefined; + setDefaultListOptions(convertSearchResultsToListOptions(results, {type: 'default'})); + if (!query) { + setIsSearching(false); + } + }; + + fetchDefaultOptions().catch(console.error); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + // perform a non-debounced search if the query is a URL so the + // "Link to web page" option updates more responsively + if (URL_QUERY_REGEX.test(query)) { + debouncedSearch.cancel(); + search(query); + } else { + debouncedSearch(query); + } + }, [query, search, debouncedSearch]); + + const displayedListOptions = query ? listOptions : defaultListOptions; + + return { + isSearching, + listOptions: displayedListOptions + }; +}; diff --git a/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.js b/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.js deleted file mode 100644 index 46ef6f6d3d..0000000000 --- a/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.js +++ /dev/null @@ -1,273 +0,0 @@ -import debounce from 'lodash/debounce'; -import useMovable from './useMovable.js'; -import {getScrollParent} from '../utils/getScrollParent.js'; -import {useCallback, useLayoutEffect, useRef} from 'react'; - -const CARD_SPACING = 20; // default distance between card and settings panel -const MIN_RIGHT_SPACING = 20; -const MIN_TOP_SPACING = 66; // 66 is publish menu and word count size -const MIN_BOTTOM_SPACING = 20; -const MIN_LEFT_SPACING = 20; - -function isMobile() { - return window.innerWidth < 768 && window.innerHeight > window.innerWidth; -} - -const getSelectedCardOrigin = () => { - const cardElement = document.querySelector('[data-kg-card-editing="true"]'); - if (!cardElement) { - return {x: 0, y: 0}; - } - const containerRect = cardElement.getBoundingClientRect(); - - // if the card element has a transform applied (e.g. wide cards) our panel elem becomes positioned - // relative to the card element rather than the window - const cardStyles = window.getComputedStyle(cardElement); - const origin = {x: 0, y: 0}; - if (cardStyles.transform !== 'none') { - origin.x = containerRect.left; - origin.y = containerRect.top; - } - return origin; -}; - -function getWindowWidthAdjustment(panelElem) { - if (!panelElem) { - return 0; - } - - return parseInt(window.getComputedStyle(panelElem).getPropertyValue('--kg-breakout-adjustment') || 0, 10); -} - -function getViewportDimensions(panelElem) { - const windowWidthAdjustment = getWindowWidthAdjustment(panelElem); - - return { - width: window.innerWidth - windowWidthAdjustment, - height: window.innerHeight - }; -} - -function keepWithinSpacing(panelElem, {x, y, origin = {x: 0, y: 0}, topSpacing, bottomSpacing, rightSpacing, leftSpacing, lastSpacing}) { - origin = getSelectedCardOrigin(); - - if (!panelElem) { - return {x: x + origin.x, y: y + origin.y}; - } - - const windowWidthAdjustment = getWindowWidthAdjustment(panelElem); - - // Take previous position into account, and adjust the spacing to allow negative spacing if the previous position was offscreen - if (lastSpacing && lastSpacing.top < topSpacing) { - topSpacing = lastSpacing.top; - } - if (lastSpacing && lastSpacing.bottom < bottomSpacing) { - bottomSpacing = lastSpacing.bottom; - } - if (lastSpacing && lastSpacing.right < rightSpacing) { - rightSpacing = lastSpacing.right; - } - if (lastSpacing && lastSpacing.left < leftSpacing) { - leftSpacing = lastSpacing.left; - } - - const width = panelElem.offsetWidth; - const height = panelElem.offsetHeight; - - const right = x + width + origin.x; - const bottom = y + height + origin.y; - - const topIsOffscreen = (y + origin.y) < topSpacing; - const bottomIsOffscreen = window.innerHeight - bottom < bottomSpacing; - const rightIsOffscreen = window.innerWidth - right - windowWidthAdjustment < rightSpacing; - const leftIsOffscreen = x < leftSpacing; - let yAdjustment = 0; - let xAdjustment = 0; - - if (topIsOffscreen && !bottomIsOffscreen) { - yAdjustment = topSpacing - y - origin.y; - } - - if (bottomIsOffscreen && !topIsOffscreen) { - yAdjustment = -(bottomSpacing - (window.innerHeight - bottom)); - } - - if (rightIsOffscreen) { - xAdjustment = -(rightSpacing - (window.innerWidth - right - windowWidthAdjustment)); - } - - if (leftIsOffscreen) { - xAdjustment = leftSpacing - x - origin.x; - } - - return {x: x + xAdjustment, y: y + yAdjustment}; -} - -function keepWithinSpacingOnDrag(panelElem, {x, y, origin}) { - const DISTANCE_FROM_BOUNDARY = 10; - - const topSpacing = DISTANCE_FROM_BOUNDARY; - const bottomSpacing = DISTANCE_FROM_BOUNDARY; - const rightSpacing = DISTANCE_FROM_BOUNDARY; - const leftSpacing = DISTANCE_FROM_BOUNDARY; - - // Last spacing is ignored - return keepWithinSpacing(panelElem, {x, y, origin, topSpacing, bottomSpacing, rightSpacing, leftSpacing, lastSpacing: undefined}); -} - -function keepWithinSpacingOnResize(panelElem, {x, y, origin, lastSpacing}) { - return keepWithinSpacingOnDrag(panelElem, keepWithinSpacing(panelElem, {x, y, origin, topSpacing: MIN_TOP_SPACING, bottomSpacing: MIN_BOTTOM_SPACING, rightSpacing: MIN_RIGHT_SPACING, leftSpacing: MIN_LEFT_SPACING, lastSpacing})); -} - -export default function useSettingsPanelReposition({positionToRef} = {}, cardWidth) { - const {ref, getPosition, setPosition} = useMovable({adjustOnResize: keepWithinSpacingOnResize, adjustOnDrag: keepWithinSpacingOnDrag}); - const previousViewport = useRef(getViewportDimensions(ref.current)); - const previousCardWidth = useRef(cardWidth); - const previousCardOrigin = useRef({x: 0, y: 0}); - - const getInitialPosition = useCallback((panelElem) => { - const panelHeight = panelElem.offsetHeight; - const cardElement = positionToRef || - document.querySelector('[data-kg-card-editing="true"]') || - document.querySelector('[data-kg-card-selected="true"]'); - if (!cardElement) { - return; - } - const containerRect = cardElement.getBoundingClientRect(); - - if (isMobile()) { - // Mobile behaviour: position below card - const x = window.innerWidth / 2 - panelElem.offsetWidth / 2; - const y = containerRect.bottom + CARD_SPACING; - return keepWithinSpacingOnDrag(panelElem, {x, y}); - } - - // We correct the height of the container to the height of the container that is on screen, then the positioning is better - const visibleHeight = Math.min(window.innerHeight, containerRect.bottom) - containerRect.top; - - // position vertically centered - // if we already have top set, leave it so that toggling additional settings doesn't cause the panel to jump (unless it would be offscreen) - const containerMiddle = containerRect.top + (visibleHeight / 2); - - let y = containerMiddle - (panelHeight) / 2; - - // position to right of panel - let x = containerRect.right + CARD_SPACING; - - // if the card element has a transform applied (e.g. wide cards) our panel elem becomes positioned - // relative to the card element rather than the window - const cardStyles = window.getComputedStyle(cardElement); - const origin = {x: 0, y: 0}; - if (cardStyles.transform !== 'none') { - origin.x = containerRect.left; - origin.y = containerRect.top; - } - - return keepWithinSpacingOnResize(panelElem, {x, y, origin}); - }, [positionToRef]); - - const onResize = useCallback((panelElem) => { - let {x, y, lastSpacing} = getPosition(); - - const viewport = getViewportDimensions(panelElem); - - // If the viewport size has increased, move the panel towards the initial position instead of keeping it in the same place - // This increases the UX when the viewport is too small and the user resizes or rotates the screen -> it will move towards the preferred position so that it is fully visible - if (viewport.height > previousViewport.current.height) { - const heightIncrease = viewport.height - previousViewport.current.height; - const initialPosition = getInitialPosition(panelElem); - if (initialPosition) { - if (initialPosition.y > y) { - y += Math.min(initialPosition.y - y, heightIncrease); - } - } - } - - if (viewport.width > previousViewport.current.width) { - const widthIncrease = viewport.width - previousViewport.current.width; - const initialPosition = getInitialPosition(panelElem); - if (initialPosition) { - if (initialPosition.x > x) { - x += Math.min(initialPosition.x - x, widthIncrease); - } - } - } - - setPosition(keepWithinSpacingOnResize(panelElem, {x, y, lastSpacing})); - - previousViewport.current = viewport; - }, [getInitialPosition, setPosition, getPosition]); - - // reposition on scroll container resize, covers two cases: - // 1. window is resized - // 2. sidebar is opened/closed - useLayoutEffect(() => { - if (!ref.current) { - return; - } - - const container = getScrollParent(ref.current) || document.body; - let prevWidth = 0; - - const panelRepositionDebounced = debounce((newWidth) => { - prevWidth = newWidth; - onResize(ref.current); - }, 100, {leading: true, trailing: true}); - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.contentBoxSize?.[0]) { - const width = entry.contentBoxSize[0].inlineSize; - if (typeof width === 'number' && width !== prevWidth) { - panelRepositionDebounced(width); - } - } - } - }); - - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - }; - }, [onResize, ref]); - - // position on first render - useLayoutEffect(() => { - if (!ref || !ref.current) { - return; - } - try { - setPosition(getInitialPosition(ref.current)); - } catch (e) { - console.error(e); - } - }, [getInitialPosition, setPosition, ref]); - - // account for wide cards using a transform so we need to adjust the origin position - // NOTE: we want to make sure this doesn't happen on the first render so previousCardWidth must start as undefined - useLayoutEffect(() => { - if (cardWidth === 'wide' && previousCardWidth.current !== 'wide') { - // offset origin to account for wide card (origin = card origin) - const cardElement = document.querySelector('[data-kg-card-editing="true"]'); - if (!cardElement) { - return; - } - const containerRect = cardElement.getBoundingClientRect(); - const origin = {x: containerRect.left + 2, y: containerRect.top + 1}; // not sure why 2,1 offsets mild bounce in positioning - previousCardOrigin.current = origin; - - const x = getPosition().x - origin.x; - const y = getPosition().y - origin.y; - setPosition(keepWithinSpacingOnResize(ref.current, {x, y, origin})); - } else if (previousCardWidth.current === 'wide' && cardWidth !== 'wide') { - // reset origin to window origin - const x = getPosition().x + previousCardOrigin.current.x; - const y = getPosition().y + previousCardOrigin.current.y; - setPosition(keepWithinSpacingOnResize(ref.current, {x, y, origin: {x: 0, y: 0}})); - } - previousCardWidth.current = cardWidth; - }, [cardWidth, getPosition, getInitialPosition, setPosition, ref]); - - return {ref}; -} diff --git a/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.ts b/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.ts new file mode 100644 index 0000000000..a5674ec703 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useSettingsPanelReposition.ts @@ -0,0 +1,311 @@ +import debounce from 'lodash/debounce'; +import useMovable from './useMovable'; +import {getScrollParent} from '../utils/getScrollParent'; +import {useCallback, useLayoutEffect, useRef} from 'react'; + +const CARD_SPACING = 20; // default distance between card and settings panel +const MIN_RIGHT_SPACING = 20; +const MIN_TOP_SPACING = 66; // 66 is publish menu and word count size +const MIN_BOTTOM_SPACING = 20; +const MIN_LEFT_SPACING = 20; + +interface Position { + x: number; + y: number; + lastSpacing?: Spacing; +} + +interface Spacing { + top: number; + left: number; + right: number; + bottom: number; +} + +interface Origin { + x: number; + y: number; +} + +function isMobile(): boolean { + return window.innerWidth < 768 && window.innerHeight > window.innerWidth; +} + +const getSelectedCardOrigin = (): Origin => { + const cardElement = document.querySelector('[data-kg-card-editing="true"]'); + if (!cardElement) { + return {x: 0, y: 0}; + } + const containerRect = cardElement.getBoundingClientRect(); + + // if the card element has a transform applied (e.g. wide cards) our panel elem becomes positioned + // relative to the card element rather than the window + const cardStyles = window.getComputedStyle(cardElement); + const origin: Origin = {x: 0, y: 0}; + if (cardStyles.transform !== 'none') { + origin.x = containerRect.left; + origin.y = containerRect.top; + } + return origin; +}; + +function getWindowWidthAdjustment(panelElem: HTMLElement | null): number { + if (!panelElem) { + return 0; + } + + return parseInt(window.getComputedStyle(panelElem).getPropertyValue('--kg-breakout-adjustment') || '0', 10); +} + +function getViewportDimensions(panelElem: HTMLElement | null): {width: number; height: number} { + const windowWidthAdjustment = getWindowWidthAdjustment(panelElem); + + return { + width: window.innerWidth - windowWidthAdjustment, + height: window.innerHeight + }; +} + +interface KeepWithinSpacingOptions { + x: number; + y: number; + origin?: Origin; + topSpacing?: number; + bottomSpacing?: number; + rightSpacing?: number; + leftSpacing?: number; + lastSpacing?: Spacing; +} + +function keepWithinSpacing(panelElem: HTMLElement | null, {x, y, origin = {x: 0, y: 0}, topSpacing = 0, bottomSpacing = 0, rightSpacing = 0, leftSpacing = 0, lastSpacing}: KeepWithinSpacingOptions): Position { + origin = getSelectedCardOrigin(); + + if (!panelElem) { + return {x: x + origin.x, y: y + origin.y}; + } + + const windowWidthAdjustment = getWindowWidthAdjustment(panelElem); + + // Take previous position into account, and adjust the spacing to allow negative spacing if the previous position was offscreen + if (lastSpacing && lastSpacing.top < topSpacing) { + topSpacing = lastSpacing.top; + } + if (lastSpacing && lastSpacing.bottom < bottomSpacing) { + bottomSpacing = lastSpacing.bottom; + } + if (lastSpacing && lastSpacing.right < rightSpacing) { + rightSpacing = lastSpacing.right; + } + if (lastSpacing && lastSpacing.left < leftSpacing) { + leftSpacing = lastSpacing.left; + } + + const width = panelElem.offsetWidth; + const height = panelElem.offsetHeight; + + const right = x + width + origin.x; + const bottom = y + height + origin.y; + + const topIsOffscreen = (y + origin.y) < topSpacing; + const bottomIsOffscreen = window.innerHeight - bottom < bottomSpacing; + const rightIsOffscreen = window.innerWidth - right - windowWidthAdjustment < rightSpacing; + const leftIsOffscreen = x < leftSpacing; + let yAdjustment = 0; + let xAdjustment = 0; + + if (topIsOffscreen && !bottomIsOffscreen) { + yAdjustment = topSpacing - y - origin.y; + } + + if (bottomIsOffscreen && !topIsOffscreen) { + yAdjustment = -(bottomSpacing - (window.innerHeight - bottom)); + } + + if (rightIsOffscreen) { + xAdjustment = -(rightSpacing - (window.innerWidth - right - windowWidthAdjustment)); + } + + if (leftIsOffscreen) { + xAdjustment = leftSpacing - x - origin.x; + } + + return {x: x + xAdjustment, y: y + yAdjustment}; +} + +function keepWithinSpacingOnDrag(panelElem: HTMLElement, {x, y}: Position): Position { + const DISTANCE_FROM_BOUNDARY = 10; + + const topSpacing = DISTANCE_FROM_BOUNDARY; + const bottomSpacing = DISTANCE_FROM_BOUNDARY; + const rightSpacing = DISTANCE_FROM_BOUNDARY; + const leftSpacing = DISTANCE_FROM_BOUNDARY; + + return keepWithinSpacing(panelElem, {x, y, topSpacing, bottomSpacing, rightSpacing, leftSpacing, lastSpacing: undefined}); +} + +function keepWithinSpacingOnResize(panelElem: HTMLElement, {x, y, lastSpacing}: Position): Position { + return keepWithinSpacingOnDrag(panelElem, keepWithinSpacing(panelElem, {x, y, topSpacing: MIN_TOP_SPACING, bottomSpacing: MIN_BOTTOM_SPACING, rightSpacing: MIN_RIGHT_SPACING, leftSpacing: MIN_LEFT_SPACING, lastSpacing})); +} + +export default function useSettingsPanelReposition({positionToRef}: {positionToRef?: Element} = {}, cardWidth?: string) { + const {ref, getPosition, setPosition} = useMovable({adjustOnResize: keepWithinSpacingOnResize, adjustOnDrag: keepWithinSpacingOnDrag}); + const previousViewport = useRef(getViewportDimensions(ref.current)); + const previousCardWidth = useRef(cardWidth); + const previousCardOrigin = useRef({x: 0, y: 0}); + + const getInitialPosition = useCallback((panelElem: HTMLElement): Position | undefined => { + const panelHeight = panelElem.offsetHeight; + const cardElement = positionToRef || + document.querySelector('[data-kg-card-editing="true"]') || + document.querySelector('[data-kg-card-selected="true"]'); + if (!cardElement) { + return; + } + const containerRect = cardElement.getBoundingClientRect(); + + if (isMobile()) { + // Mobile behaviour: position below card + const x = window.innerWidth / 2 - panelElem.offsetWidth / 2; + const y = containerRect.bottom + CARD_SPACING; + return keepWithinSpacingOnDrag(panelElem, {x, y}); + } + + // We correct the height of the container to the height of the container that is on screen, then the positioning is better + const visibleHeight = Math.min(window.innerHeight, containerRect.bottom) - containerRect.top; + + // position vertically centered + // if we already have top set, leave it so that toggling additional settings doesn't cause the panel to jump (unless it would be offscreen) + const containerMiddle = containerRect.top + (visibleHeight / 2); + + const y = containerMiddle - (panelHeight) / 2; + + // position to right of panel + const x = containerRect.right + CARD_SPACING; + + // if the card element has a transform applied (e.g. wide cards) our panel elem becomes positioned + // relative to the card element rather than the window + const cardStyles = window.getComputedStyle(cardElement); + const origin: Origin = {x: 0, y: 0}; + if (cardStyles.transform !== 'none') { + origin.x = containerRect.left; + origin.y = containerRect.top; + } + + return keepWithinSpacingOnResize(panelElem, {x, y}); + }, [positionToRef]); + + const onResize = useCallback((panelElem: HTMLElement) => { + const position = getPosition(); + const {lastSpacing} = position; + let {x, y} = position; + + const viewport = getViewportDimensions(panelElem); + + // If the viewport size has increased, move the panel towards the initial position instead of keeping it in the same place + // This increases the UX when the viewport is too small and the user resizes or rotates the screen + if (viewport.height > previousViewport.current.height) { + const heightIncrease = viewport.height - previousViewport.current.height; + const initialPosition = getInitialPosition(panelElem); + if (initialPosition) { + if (initialPosition.y > y) { + y += Math.min(initialPosition.y - y, heightIncrease); + } + } + } + + if (viewport.width > previousViewport.current.width) { + const widthIncrease = viewport.width - previousViewport.current.width; + const initialPosition = getInitialPosition(panelElem); + if (initialPosition) { + if (initialPosition.x > x) { + x += Math.min(initialPosition.x - x, widthIncrease); + } + } + } + + setPosition(keepWithinSpacingOnResize(panelElem, {x, y, lastSpacing})); + + previousViewport.current = viewport; + }, [getInitialPosition, setPosition, getPosition]); + + // reposition on scroll container resize, covers two cases: + // 1. window is resized + // 2. sidebar is opened/closed + useLayoutEffect(() => { + if (!ref.current) { + return; + } + + const container = getScrollParent(ref.current) || document.body; + let prevWidth = 0; + + const panelRepositionDebounced = debounce((newWidth: number) => { + if (!ref.current) { + return; + } + + prevWidth = newWidth; + onResize(ref.current); + }, 100, {leading: true, trailing: true}); + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentBoxSize?.[0]) { + const width = entry.contentBoxSize[0].inlineSize; + if (typeof width === 'number' && width !== prevWidth) { + panelRepositionDebounced(width); + } + } + } + }); + + resizeObserver.observe(container); + + return () => { + panelRepositionDebounced.cancel(); + resizeObserver.disconnect(); + }; + }, [onResize, ref]); + + // position on first render + useLayoutEffect(() => { + if (!ref || !ref.current) { + return; + } + try { + const initialPos = getInitialPosition(ref.current); + if (initialPos) { + setPosition(initialPos); + } + } catch (e) { + console.error(e); + } + }, [getInitialPosition, setPosition, ref]); + + // account for wide cards using a transform so we need to adjust the origin position + // NOTE: we want to make sure this doesn't happen on the first render so previousCardWidth must start as undefined + useLayoutEffect(() => { + if (cardWidth === 'wide' && previousCardWidth.current !== 'wide') { + const cardElement = document.querySelector('[data-kg-card-editing="true"]'); + if (!cardElement) { + return; + } + // offset origin to account for wide card (origin = card origin) + const containerRect = cardElement.getBoundingClientRect(); + const origin: Origin = {x: containerRect.left + 2, y: containerRect.top + 1}; // not sure why 2,1 offsets mild bounce in positioning + previousCardOrigin.current = origin; + + const x = getPosition().x - origin.x; + const y = getPosition().y - origin.y; + setPosition(keepWithinSpacingOnResize(ref.current!, {x, y})); + } else if (previousCardWidth.current === 'wide' && cardWidth !== 'wide') { + // reset origin to window origin + const x = getPosition().x + previousCardOrigin.current.x; + const y = getPosition().y + previousCardOrigin.current.y; + setPosition(keepWithinSpacingOnResize(ref.current!, {x, y})); + } + previousCardWidth.current = cardWidth; + }, [cardWidth, getPosition, getInitialPosition, setPosition, ref]); + + return {ref}; +} diff --git a/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.js b/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.js deleted file mode 100644 index ca8fe17594..0000000000 --- a/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.js +++ /dev/null @@ -1,32 +0,0 @@ -import {useCallback} from 'react'; - -// adapted from lexical-react/src/LexicalTypeaheadMenuPlugin -// we need the ability to match on punctuation, as well as a leading space, which was not possible using lexical's version - -export default function useBasicTypeaheadTriggerMatch(trigger,{minLength = 1, maxLength = 75}) { - return useCallback( - (text) => { - const invalidChars = '[^' + trigger + '\\s]'; // escaped set - these cannot be present in the matched string - const TypeaheadTriggerRegex = new RegExp( - '[' + trigger + ']' + - '(' + - '(?:' + invalidChars + ')' + - '{0,' + maxLength + '}' + - ')$', - ); - const match = TypeaheadTriggerRegex.exec(text); - if (match !== null) { - const matchingString = match[1]; - if (matchingString.length >= minLength) { - return { - leadOffset: match.index, - matchingString, - replaceableString: match[0] - }; - } - } - return null; - }, - [maxLength, minLength, trigger], - ); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts b/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts new file mode 100644 index 0000000000..19a8d1bbc4 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts @@ -0,0 +1,38 @@ +import {useCallback} from 'react'; + +// adapted from lexical-react/src/LexicalTypeaheadMenuPlugin +// we need the ability to match on punctuation, as well as a leading space, which was not possible using lexical's version + +interface TriggerMatch { + leadOffset: number; + matchingString: string; + replaceableString: string; +} + +export default function useBasicTypeaheadTriggerMatch(trigger: string, {minLength = 1, maxLength = 75}: {minLength?: number; maxLength?: number}) { + return useCallback( + (text: string): TriggerMatch | null => { + const invalidChars = '[^' + trigger + '\\s]'; // escaped set - these cannot be present in the matched string + const TypeaheadTriggerRegex = new RegExp( + '[' + trigger + ']' + + '(' + + '(?:' + invalidChars + ')' + + '{0,' + maxLength + '}' + + ')$', + ); + const match = TypeaheadTriggerRegex.exec(text); + if (match !== null) { + const matchingString = match[1]; + if (matchingString.length >= minLength) { + return { + leadOffset: match.index, + matchingString, + replaceableString: match[0] + }; + } + } + return null; + }, + [maxLength, minLength, trigger], + ); +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/hooks/useVisibilityToggle.js b/packages/koenig-lexical/src/hooks/useVisibilityToggle.js deleted file mode 100644 index 9c02e6ca2a..0000000000 --- a/packages/koenig-lexical/src/hooks/useVisibilityToggle.js +++ /dev/null @@ -1,45 +0,0 @@ -import {$getNodeByKey} from 'lexical'; -import {VISIBILITY_SETTINGS, getVisibilityOptions, parseVisibilityToToggles, serializeOptionsToVisibility} from '../utils/visibility'; - -export const useVisibilityToggle = (editor, nodeKey, cardConfig) => { - const isStripeEnabled = cardConfig?.stripeEnabled; - const visibilitySetting = cardConfig?.visibilitySettings ?? VISIBILITY_SETTINGS.WEB_AND_EMAIL; - const isVisibilityEnabled = visibilitySetting !== VISIBILITY_SETTINGS.NONE; - const showWeb = visibilitySetting === VISIBILITY_SETTINGS.WEB_AND_EMAIL || visibilitySetting === VISIBILITY_SETTINGS.WEB_ONLY; - const showEmail = visibilitySetting === VISIBILITY_SETTINGS.WEB_AND_EMAIL || visibilitySetting === VISIBILITY_SETTINGS.EMAIL_ONLY; - - let currentVisibility; - - editor.getEditorState().read(() => { - const htmlNode = $getNodeByKey(nodeKey); - if (!htmlNode) { - return; - } - currentVisibility = htmlNode.visibility; - }); - - const visibilityData = parseVisibilityToToggles(currentVisibility); - const visibilityOptions = getVisibilityOptions(currentVisibility, {isStripeEnabled, showWeb, showEmail}); - - return { - isVisibilityEnabled, - visibilityData, - visibilityOptions, - toggleVisibility: (type, key, value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - if (!node) { - return; - } - const newVisibilityOptions = structuredClone(getVisibilityOptions(node.visibility, {isStripeEnabled, showWeb, showEmail})); - const toggle = newVisibilityOptions.find(g => g.key === type)?.toggles?.find(t => t.key === key); - if (!toggle) { - return; - } - - toggle.checked = value; - node.visibility = serializeOptionsToVisibility(newVisibilityOptions, node.visibility); - }); - } - }; -}; diff --git a/packages/koenig-lexical/src/hooks/useVisibilityToggle.ts b/packages/koenig-lexical/src/hooks/useVisibilityToggle.ts new file mode 100644 index 0000000000..b8bf66a0f6 --- /dev/null +++ b/packages/koenig-lexical/src/hooks/useVisibilityToggle.ts @@ -0,0 +1,55 @@ +import {$getNodeByKey} from 'lexical'; +import {GeneratedDecoratorNodeBase} from '@tryghost/kg-default-nodes'; +import {VISIBILITY_SETTINGS, getVisibilityOptions, parseVisibilityToToggles, serializeOptionsToVisibility} from '../utils/visibility'; +import type {LexicalEditor, NodeKey} from 'lexical'; +import type {Visibility} from '../utils/visibility'; + +interface CardConfig { + stripeEnabled?: boolean; + visibilitySettings?: string; + [key: string]: unknown; +} + +export const useVisibilityToggle = (editor: LexicalEditor, nodeKey: NodeKey, cardConfig: CardConfig | undefined) => { + const isStripeEnabled = cardConfig?.stripeEnabled; + const visibilitySetting = cardConfig?.visibilitySettings ?? VISIBILITY_SETTINGS.WEB_AND_EMAIL; + const isVisibilityEnabled = visibilitySetting !== VISIBILITY_SETTINGS.NONE; + const showWeb = visibilitySetting === VISIBILITY_SETTINGS.WEB_AND_EMAIL || visibilitySetting === VISIBILITY_SETTINGS.WEB_ONLY; + const showEmail = visibilitySetting === VISIBILITY_SETTINGS.WEB_AND_EMAIL || visibilitySetting === VISIBILITY_SETTINGS.EMAIL_ONLY; + + let currentVisibility: Visibility | undefined; + + editor.getEditorState().read(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (!node) { + return; + } + currentVisibility = node.visibility as Visibility | undefined; + }); + + const visibilityData = currentVisibility ? parseVisibilityToToggles(currentVisibility) : undefined; + const visibilityOptions = getVisibilityOptions(currentVisibility, {isStripeEnabled, showWeb, showEmail}); + + return { + isVisibilityEnabled, + visibilityData, + visibilityOptions, + toggleVisibility: (type: string, key: string, value: boolean) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (!node) { + return; + } + const nodeVisibility = node.visibility as Visibility | undefined; + const newVisibilityOptions = structuredClone(getVisibilityOptions(nodeVisibility, {isStripeEnabled, showWeb, showEmail})); + const toggle = newVisibilityOptions.find(g => g.key === type)?.toggles?.find(t => t.key === key); + if (!toggle) { + return; + } + + toggle.checked = value; + node.visibility = serializeOptionsToVisibility(newVisibilityOptions, nodeVisibility); + }); + } + }; +}; diff --git a/packages/koenig-lexical/src/index.js b/packages/koenig-lexical/src/index.js deleted file mode 100644 index 798dbd61ff..0000000000 --- a/packages/koenig-lexical/src/index.js +++ /dev/null @@ -1,144 +0,0 @@ -/* Components */ -import DesignSandbox from './components/DesignSandbox'; -import EmailEditor, {EMAIL_EDITOR_CARD_CONFIG, getEmailEditorCardConfig} from './components/EmailEditor'; -import KoenigCardWrapper from './components/KoenigCardWrapper'; -import KoenigComposableEditor from './components/KoenigComposableEditor'; -import KoenigComposer from './components/KoenigComposer'; -import KoenigEditor from './components/KoenigEditor'; -import KoenigNestedComposer from './components/KoenigNestedComposer'; - -/* Plugins */ -import AllDefaultPlugins from './plugins/AllDefaultPlugins'; -import AudioPlugin from './plugins/AudioPlugin'; -import BookmarkPlugin from './plugins/BookmarkPlugin'; -import ButtonPlugin from './plugins/ButtonPlugin'; -import CallToActionPlugin from './plugins/CallToActionPlugin'; -import CalloutPlugin from './plugins/CalloutPlugin'; -import CardMenuPlugin from './plugins/CardMenuPlugin'; -import DragDropPastePlugin from './plugins/DragDropPastePlugin'; -import DragDropReorderPlugin from './plugins/DragDropReorderPlugin'; -import EmEnDashPlugin from './plugins/EmEnDashPlugin'; -import EmailCtaPlugin from './plugins/EmailCtaPlugin'; -import EmbedPlugin from './plugins/EmbedPlugin'; -import EmojiPickerPlugin from './plugins/EmojiPickerPlugin'; -import ExternalControlPlugin from './plugins/ExternalControlPlugin'; -import FilePlugin from './plugins/FilePlugin'; -import FloatingToolbarPlugin from './plugins/FloatingToolbarPlugin'; -import GalleryPlugin from './plugins/GalleryPlugin'; -import HeaderPlugin from './plugins/HeaderPlugin'; -import HorizontalRulePlugin from './plugins/HorizontalRulePlugin'; -import HtmlOutputPlugin from './plugins/HtmlOutputPlugin'; -import HtmlPlugin from './plugins/HtmlPlugin'; -import ImagePlugin from './plugins/ImagePlugin'; -import KoenigBehaviourPlugin from './plugins/KoenigBehaviourPlugin'; -import KoenigSelectorPlugin from './plugins/KoenigSelectorPlugin'; -import KoenigSnippetPlugin from './plugins/KoenigSnippetPlugin'; -import MarkdownPlugin from './plugins/MarkdownPlugin'; -import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin'; -import PlusCardMenuPlugin from './plugins/PlusCardMenuPlugin'; -import ProductPlugin from './plugins/ProductPlugin'; -import ReplacementStringsPlugin from './plugins/ReplacementStringsPlugin'; -import RestrictContentPlugin from './plugins/RestrictContentPlugin'; -import SignupPlugin from './plugins/SignupPlugin'; -import SlashCardMenuPlugin from './plugins/SlashCardMenuPlugin'; -import TKCountPlugin from './plugins/TKCountPlugin'; -import TogglePlugin from './plugins/TogglePlugin'; -import TransistorPlugin from './plugins/TransistorPlugin'; -import VideoPlugin from './plugins/VideoPlugin'; -import WordCountPlugin from './plugins/WordCountPlugin'; -import {ListPlugin} from '@lexical/react/LexicalListPlugin'; - -/* Nodes */ -import BASIC_NODES from './nodes/BasicNodes'; -import DEFAULT_NODES from './nodes/DefaultNodes'; -import EMAIL_EDITOR_NODES from './nodes/EmailEditorNodes'; -import EMAIL_NODES from './nodes/EmailNodes'; -import MINIMAL_NODES from './nodes/MinimalNodes'; - -/* Transformers */ -import { - BASIC_TRANSFORMERS, - CODE_BLOCK as CODE_BLOCK_TRANSFORMER, - DEFAULT_TRANSFORMERS, - ELEMENT_TRANSFORMERS, - EMAIL_TRANSFORMERS, - HR as HR_TRANSFORMER, - MINIMAL_TRANSFORMERS -} from './plugins/MarkdownShortcutPlugin'; - -/* Exports ------------------------------------------------------------------ */ - -export * from './utils'; - -export { - DesignSandbox, - EmailEditor, - KoenigComposableEditor, - KoenigComposer, - KoenigEditor, - KoenigNestedComposer, - KoenigCardWrapper, - - AllDefaultPlugins, - - AudioPlugin, - BookmarkPlugin, - ButtonPlugin, - CalloutPlugin, - CallToActionPlugin, - CardMenuPlugin, - DragDropPastePlugin, - DragDropReorderPlugin, - EmailCtaPlugin, - EmbedPlugin, - EmEnDashPlugin, - EmojiPickerPlugin, - ExternalControlPlugin, - FilePlugin, - FloatingToolbarPlugin, - GalleryPlugin, - HeaderPlugin, - HorizontalRulePlugin, - HtmlOutputPlugin, - HtmlPlugin, - ImagePlugin, - KoenigBehaviourPlugin, - KoenigSelectorPlugin, - KoenigSnippetPlugin, - ListPlugin, - MarkdownPlugin, - MarkdownShortcutPlugin, - PlusCardMenuPlugin, - ProductPlugin, - ReplacementStringsPlugin, - RestrictContentPlugin, - SignupPlugin, - SlashCardMenuPlugin, - TKCountPlugin, - TogglePlugin, - TransistorPlugin, - VideoPlugin, - WordCountPlugin, - - DEFAULT_NODES, - BASIC_NODES, - EMAIL_EDITOR_NODES, - EMAIL_NODES, - MINIMAL_NODES, - - EMAIL_EDITOR_CARD_CONFIG, - - ELEMENT_TRANSFORMERS, - HR_TRANSFORMER, - CODE_BLOCK_TRANSFORMER, - - DEFAULT_TRANSFORMERS, - BASIC_TRANSFORMERS, - EMAIL_TRANSFORMERS, - MINIMAL_TRANSFORMERS, - - getEmailEditorCardConfig -}; - -// eslint-disable-next-line no-undef -export const version = __APP_VERSION__ ? __APP_VERSION__ : 'development'; diff --git a/packages/koenig-lexical/src/index.ts b/packages/koenig-lexical/src/index.ts new file mode 100644 index 0000000000..b78285cd5a --- /dev/null +++ b/packages/koenig-lexical/src/index.ts @@ -0,0 +1,146 @@ +declare const __APP_VERSION__: string | undefined; + +/* Components */ +import DesignSandbox from './components/DesignSandbox'; +import EmailEditor, {EMAIL_EDITOR_CARD_CONFIG, getEmailEditorCardConfig} from './components/EmailEditor'; +import KoenigCardWrapper from './components/KoenigCardWrapper'; +import KoenigComposableEditor from './components/KoenigComposableEditor'; +import KoenigComposer from './components/KoenigComposer'; +import KoenigEditor from './components/KoenigEditor'; +import KoenigNestedComposer from './components/KoenigNestedComposer'; + +/* Plugins */ +import AllDefaultPlugins from './plugins/AllDefaultPlugins'; +import AudioPlugin from './plugins/AudioPlugin'; +import BookmarkPlugin from './plugins/BookmarkPlugin'; +import ButtonPlugin from './plugins/ButtonPlugin'; +import CallToActionPlugin from './plugins/CallToActionPlugin'; +import CalloutPlugin from './plugins/CalloutPlugin'; +import CardMenuPlugin from './plugins/CardMenuPlugin'; +import DragDropPastePlugin from './plugins/DragDropPastePlugin'; +import DragDropReorderPlugin from './plugins/DragDropReorderPlugin'; +import EmEnDashPlugin from './plugins/EmEnDashPlugin'; +import EmailCtaPlugin from './plugins/EmailCtaPlugin'; +import EmbedPlugin from './plugins/EmbedPlugin'; +import EmojiPickerPlugin from './plugins/EmojiPickerPlugin'; +import ExternalControlPlugin from './plugins/ExternalControlPlugin'; +import FilePlugin from './plugins/FilePlugin'; +import FloatingToolbarPlugin from './plugins/FloatingToolbarPlugin'; +import GalleryPlugin from './plugins/GalleryPlugin'; +import HeaderPlugin from './plugins/HeaderPlugin'; +import HorizontalRulePlugin from './plugins/HorizontalRulePlugin'; +import HtmlOutputPlugin from './plugins/HtmlOutputPlugin'; +import HtmlPlugin from './plugins/HtmlPlugin'; +import ImagePlugin from './plugins/ImagePlugin'; +import KoenigBehaviourPlugin from './plugins/KoenigBehaviourPlugin'; +import KoenigSelectorPlugin from './plugins/KoenigSelectorPlugin'; +import KoenigSnippetPlugin from './plugins/KoenigSnippetPlugin'; +import MarkdownPlugin from './plugins/MarkdownPlugin'; +import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin'; +import PlusCardMenuPlugin from './plugins/PlusCardMenuPlugin'; +import ProductPlugin from './plugins/ProductPlugin'; +import ReplacementStringsPlugin from './plugins/ReplacementStringsPlugin'; +import RestrictContentPlugin from './plugins/RestrictContentPlugin'; +import SignupPlugin from './plugins/SignupPlugin'; +import SlashCardMenuPlugin from './plugins/SlashCardMenuPlugin'; +import TKCountPlugin from './plugins/TKCountPlugin'; +import TogglePlugin from './plugins/TogglePlugin'; +import TransistorPlugin from './plugins/TransistorPlugin'; +import VideoPlugin from './plugins/VideoPlugin'; +import WordCountPlugin from './plugins/WordCountPlugin'; +import {ListPlugin} from '@lexical/react/LexicalListPlugin'; + +/* Nodes */ +import BASIC_NODES from './nodes/BasicNodes'; +import DEFAULT_NODES from './nodes/DefaultNodes'; +import EMAIL_EDITOR_NODES from './nodes/EmailEditorNodes'; +import EMAIL_NODES from './nodes/EmailNodes'; +import MINIMAL_NODES from './nodes/MinimalNodes'; + +/* Transformers */ +import { + BASIC_TRANSFORMERS, + CODE_BLOCK as CODE_BLOCK_TRANSFORMER, + DEFAULT_TRANSFORMERS, + ELEMENT_TRANSFORMERS, + EMAIL_TRANSFORMERS, + HR as HR_TRANSFORMER, + MINIMAL_TRANSFORMERS +} from './plugins/MarkdownShortcutPlugin'; + +/* Exports ------------------------------------------------------------------ */ + +export * from './utils'; + +export { + DesignSandbox, + EmailEditor, + KoenigComposableEditor, + KoenigComposer, + KoenigEditor, + KoenigNestedComposer, + KoenigCardWrapper, + + AllDefaultPlugins, + + AudioPlugin, + BookmarkPlugin, + ButtonPlugin, + CalloutPlugin, + CallToActionPlugin, + CardMenuPlugin, + DragDropPastePlugin, + DragDropReorderPlugin, + EmailCtaPlugin, + EmbedPlugin, + EmEnDashPlugin, + EmojiPickerPlugin, + ExternalControlPlugin, + FilePlugin, + FloatingToolbarPlugin, + GalleryPlugin, + HeaderPlugin, + HorizontalRulePlugin, + HtmlOutputPlugin, + HtmlPlugin, + ImagePlugin, + KoenigBehaviourPlugin, + KoenigSelectorPlugin, + KoenigSnippetPlugin, + ListPlugin, + MarkdownPlugin, + MarkdownShortcutPlugin, + PlusCardMenuPlugin, + ProductPlugin, + ReplacementStringsPlugin, + RestrictContentPlugin, + SignupPlugin, + SlashCardMenuPlugin, + TKCountPlugin, + TogglePlugin, + TransistorPlugin, + VideoPlugin, + WordCountPlugin, + + DEFAULT_NODES, + BASIC_NODES, + EMAIL_EDITOR_NODES, + EMAIL_NODES, + MINIMAL_NODES, + + EMAIL_EDITOR_CARD_CONFIG, + + ELEMENT_TRANSFORMERS, + HR_TRANSFORMER, + CODE_BLOCK_TRANSFORMER, + + DEFAULT_TRANSFORMERS, + BASIC_TRANSFORMERS, + EMAIL_TRANSFORMERS, + MINIMAL_TRANSFORMERS, + + getEmailEditorCardConfig +}; + + +export const version = __APP_VERSION__ ? __APP_VERSION__ : 'development'; diff --git a/packages/koenig-lexical/src/nodes/AsideNode.js b/packages/koenig-lexical/src/nodes/AsideNode.js deleted file mode 100644 index e6dca8bc87..0000000000 --- a/packages/koenig-lexical/src/nodes/AsideNode.js +++ /dev/null @@ -1,41 +0,0 @@ -import { - $createParagraphNode -} from 'lexical'; -import {AsideNode as BaseAsideNode} from '@tryghost/kg-default-nodes'; -import { - addClassNamesToElement -} from '@lexical/utils'; - -export class AsideNode extends BaseAsideNode { - createDOM(config) { - const element = document.createElement('aside'); - addClassNamesToElement(element, config.theme.aside); - return element; - } - - // Mutation - - insertNewAfter() { - const newBlock = $createParagraphNode(); - const direction = this.getDirection(); - newBlock.setDirection(direction); - this.insertAfter(newBlock); - return newBlock; - } - - collapseAtStart() { - const paragraph = $createParagraphNode(); - const children = this.getChildren(); - children.forEach(child => paragraph.append(child)); - this.replace(paragraph); - return true; - } -} - -export function $createAsideNode() { - return new AsideNode(); -} - -export function $isAsideNode(node) { - return node instanceof AsideNode; -} diff --git a/packages/koenig-lexical/src/nodes/AsideNode.ts b/packages/koenig-lexical/src/nodes/AsideNode.ts new file mode 100644 index 0000000000..af8d5d84ea --- /dev/null +++ b/packages/koenig-lexical/src/nodes/AsideNode.ts @@ -0,0 +1,42 @@ +import { + $createParagraphNode +} from 'lexical'; +import {AsideNode as BaseAsideNode} from '@tryghost/kg-default-nodes'; +import { + addClassNamesToElement +} from '@lexical/utils'; +import type {EditorConfig, LexicalNode, RangeSelection} from 'lexical'; + +export class AsideNode extends BaseAsideNode { + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('aside'); + addClassNamesToElement(element, config.theme.aside); + return element; + } + + // Mutation + + insertNewAfter(_selection: RangeSelection, _restoreSelection?: boolean): LexicalNode { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.insertAfter(newBlock); + return newBlock; + } + + collapseAtStart(): boolean { + const paragraph = $createParagraphNode(); + const children = this.getChildren(); + children.forEach(child => paragraph.append(child)); + this.replace(paragraph); + return true; + } +} + +export function $createAsideNode(): AsideNode { + return new AsideNode(); +} + +export function $isAsideNode(node: unknown): node is AsideNode { + return node instanceof AsideNode; +} diff --git a/packages/koenig-lexical/src/nodes/AudioNode.jsx b/packages/koenig-lexical/src/nodes/AudioNode.jsx deleted file mode 100644 index 8d1ca43be2..0000000000 --- a/packages/koenig-lexical/src/nodes/AudioNode.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import KoenigCardWrapper from '../components/KoenigCardWrapper'; - -import AudioCardIcon from '../assets/icons/kg-card-type-audio.svg?react'; -import {AudioNodeComponent} from './AudioNodeComponent'; -import {AudioNode as BaseAudioNode} from '@tryghost/kg-default-nodes'; -import {createCommand} from 'lexical'; - -export const INSERT_AUDIO_COMMAND = createCommand(); - -export class AudioNode extends BaseAudioNode { - __triggerFileDialog = false; - __initialFile = null; - - static kgMenu = [{ - label: 'Audio', - desc: 'Upload and play an audio file', - Icon: AudioCardIcon, - insertCommand: INSERT_AUDIO_COMMAND, - insertParams: { - triggerFileDialog: true - }, - matches: ['audio'], - priority: 14, - shortcut: '/audio' - }]; - - static uploadType = 'audio'; - - constructor(dataset = {}, key) { - super(dataset, key); - - const {triggerFileDialog, initialFile} = dataset; - - // don't trigger the file dialog when rendering if we've already been given a url - this.__triggerFileDialog = (!dataset.src && triggerFileDialog) || false; - this.__initialFile = initialFile || null; - } - - getIcon() { - return AudioCardIcon; - } - - set triggerFileDialog(shouldTrigger) { - const writable = this.getWritable(); - writable.__triggerFileDialog = shouldTrigger; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createAudioNode = (dataset) => { - return new AudioNode(dataset); -}; - -export function $isAudioNode(node) { - return node instanceof AudioNode; -} diff --git a/packages/koenig-lexical/src/nodes/AudioNode.tsx b/packages/koenig-lexical/src/nodes/AudioNode.tsx new file mode 100644 index 0000000000..f33da7e7c8 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/AudioNode.tsx @@ -0,0 +1,71 @@ +import KoenigCardWrapper from '../components/KoenigCardWrapper'; + +import AudioCardIcon from '../assets/icons/kg-card-type-audio.svg?react'; +import {AudioNodeComponent} from './AudioNodeComponent'; +import {AudioNode as BaseAudioNode} from '@tryghost/kg-default-nodes'; +import {createCommand} from 'lexical'; + +export const INSERT_AUDIO_COMMAND = createCommand(); + +export class AudioNode extends BaseAudioNode { + __triggerFileDialog = false; + __initialFile: File | null = null; + + static kgMenu = [{ + label: 'Audio', + desc: 'Upload and play an audio file', + Icon: AudioCardIcon, + insertCommand: INSERT_AUDIO_COMMAND, + insertParams: { + triggerFileDialog: true + }, + matches: ['audio'], + priority: 14, + shortcut: '/audio' + }]; + + static uploadType = 'audio'; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + const {triggerFileDialog, initialFile} = dataset; + + // don't trigger the file dialog when rendering if we've already been given a url + this.__triggerFileDialog = (!dataset.src && triggerFileDialog) as boolean || false; + this.__initialFile = (initialFile as File) || null; + } + + getIcon() { + return AudioCardIcon; + } + + set triggerFileDialog(shouldTrigger: boolean) { + const writable = this.getWritable(); + writable.__triggerFileDialog = shouldTrigger; + } + + decorate() { + return ( + + + + ); + } +} + +export const $createAudioNode = (dataset: Record) => { + return new AudioNode(dataset); +}; + +export function $isAudioNode(node: unknown): node is AudioNode { + return node instanceof AudioNode; +} diff --git a/packages/koenig-lexical/src/nodes/AudioNodeComponent.jsx b/packages/koenig-lexical/src/nodes/AudioNodeComponent.jsx deleted file mode 100644 index ff80e19778..0000000000 --- a/packages/koenig-lexical/src/nodes/AudioNodeComponent.jsx +++ /dev/null @@ -1,151 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {AudioCard} from '../components/ui/cards/AudioCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {audioUploadHandler} from '../utils/audioUploadHandler'; -import {openFileSelection} from '../utils/openFileSelection'; -import {thumbnailUploadHandler} from '../utils/thumbnailUploadHandler'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function AudioNodeComponent({duration, initialFile, nodeKey, src, thumbnailSrc, title, triggerFileDialog}) { - const [editor] = useLexicalComposerContext(); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const {isSelected, isEditing, setEditing} = React.useContext(CardContext); - const audioFileInputRef = React.useRef(); - const thumbnailFileInputRef = React.useRef(); - const cardContext = React.useContext(CardContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const audioUploader = fileUploader.useFileUpload('audio'); - const thumbnailUploader = fileUploader.useFileUpload('mediaThumbnail'); - const audioDragHandler = useFileDragAndDrop({handleDrop: handleAudioDrop}); - const thumbnailDragHandler = useFileDragAndDrop({handleDrop: handleThumbnailDrop, disabled: !isEditing}); - - React.useEffect(() => { - const uploadInitialFile = async (file) => { - if (file && !src && !audioUploader.isLoading) { - await audioUploadHandler([file], nodeKey, editor, audioUploader.upload); - } - }; - - uploadInitialFile(initialFile); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onAudioFileChange = async (e) => { - const fls = e.target.files; - return await audioUploadHandler(fls, nodeKey, editor, audioUploader.upload); - }; - - const onThumbnailFileChange = async (e) => { - const fls = e.target.files; - return await thumbnailUploadHandler(fls, nodeKey, editor, thumbnailUploader.upload); - }; - - const setTitle = (newTitle) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.title = newTitle; - }); - }; - - const removeThumbnail = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.thumbnailSrc = ''; - }); - }; - - async function handleAudioDrop(files) { - await audioUploadHandler(files, nodeKey, editor, audioUploader.upload); - } - - async function handleThumbnailDrop(files) { - await thumbnailUploadHandler(files, nodeKey, editor, thumbnailUploader.upload); - } - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - // when card is inserted from the card menu or slash command we want to show the file picker immediately - // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 - React.useEffect(() => { - if (!triggerFileDialog) { - return; - } - - const renderTimeout = setTimeout(() => { - // trigger dialog - openFileSelection({fileInputRef: audioFileInputRef}); - - // clear the property on the node so we don't accidentally trigger anything with a re-render - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.triggerFileDialog = false; - }); - }); - - return (() => { - clearTimeout(renderTimeout); - }); - }); - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/nodes/AudioNodeComponent.tsx b/packages/koenig-lexical/src/nodes/AudioNodeComponent.tsx new file mode 100644 index 0000000000..8e836b330e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/AudioNodeComponent.tsx @@ -0,0 +1,167 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {AudioCard} from '../components/ui/cards/AudioCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {audioUploadHandler} from '../utils/audioUploadHandler'; +import {openFileSelection} from '../utils/openFileSelection'; +import {thumbnailUploadHandler} from '../utils/thumbnailUploadHandler'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {AudioNode} from './AudioNode'; + +interface AudioNodeComponentProps { + duration: number; + initialFile: File | null; + nodeKey: string; + src: string; + thumbnailSrc: unknown; + title: string; + triggerFileDialog: boolean; +} + +export function AudioNodeComponent({duration, initialFile, nodeKey, src, thumbnailSrc, title, triggerFileDialog}: AudioNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const {isSelected, isEditing, setEditing} = React.useContext(CardContext); + const audioFileInputRef = React.useRef(null); + const thumbnailFileInputRef = React.useRef(null); + const cardContext = React.useContext(CardContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const audioUploader = fileUploader.useFileUpload('audio'); + const thumbnailUploader = fileUploader.useFileUpload('mediaThumbnail'); + const audioDragHandler = useFileDragAndDrop({handleDrop: handleAudioDrop}); + const thumbnailDragHandler = useFileDragAndDrop({handleDrop: handleThumbnailDrop, disabled: !isEditing}); + + React.useEffect(() => { + const uploadInitialFile = async (file: File) => { + if (file && !src && !audioUploader.isLoading) { + await audioUploadHandler([file], nodeKey, editor, audioUploader.upload); + } + }; + + uploadInitialFile(initialFile as File); + + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onAudioFileChange = async (e: React.ChangeEvent) => { + const fls = e.target.files; + return await audioUploadHandler(Array.from(fls!), nodeKey, editor, audioUploader.upload); + }; + + const onThumbnailFileChange = async (e: React.ChangeEvent) => { + const fls = e.target.files; + return await thumbnailUploadHandler(Array.from(fls!), nodeKey, editor, thumbnailUploader.upload as (files: File[], options?: {formData: {url: string}}) => Promise<{url: string}[]>); + }; + + const setTitle = (newTitle: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as AudioNode | null; + if (node) { + node.title = newTitle; + } + }); + }; + + const removeThumbnail = () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as AudioNode | null; + if (node) { + node.thumbnailSrc = ''; + } + }); + }; + + async function handleAudioDrop(files: FileList | File[]) { + await audioUploadHandler(Array.from(files), nodeKey, editor, audioUploader.upload); + } + + async function handleThumbnailDrop(files: FileList | File[]) { + await thumbnailUploadHandler(Array.from(files), nodeKey, editor, thumbnailUploader.upload as (files: File[], options?: {formData: {url: string}}) => Promise<{url: string}[]>); + } + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + // when card is inserted from the card menu or slash command we want to show the file picker immediately + // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 + React.useEffect(() => { + if (!triggerFileDialog) { + return; + } + + const renderTimeout = setTimeout(() => { + // trigger dialog + openFileSelection({fileInputRef: audioFileInputRef}); + + // clear the property on the node so we don't accidentally trigger anything with a re-render + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (node) { + (node as AudioNode).triggerFileDialog = false; + } + }); + }); + + return (() => { + clearTimeout(renderTimeout); + }); + }); + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/BasicNodes.js b/packages/koenig-lexical/src/nodes/BasicNodes.ts similarity index 100% rename from packages/koenig-lexical/src/nodes/BasicNodes.js rename to packages/koenig-lexical/src/nodes/BasicNodes.ts diff --git a/packages/koenig-lexical/src/nodes/BookmarkNode.jsx b/packages/koenig-lexical/src/nodes/BookmarkNode.jsx deleted file mode 100644 index 7cf8f5de28..0000000000 --- a/packages/koenig-lexical/src/nodes/BookmarkNode.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import BookmarkCardIcon from '../assets/icons/kg-card-type-bookmark.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BookmarkNode as BaseBookmarkNode} from '@tryghost/kg-default-nodes'; -import {BookmarkNodeComponent} from './BookmarkNodeComponent'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_BOOKMARK_COMMAND = createCommand(); - -export class BookmarkNode extends BaseBookmarkNode { - __captionEditor; - __captionEditorInitialState; - __createdWithUrl; - - static kgMenu = [{ - label: 'Bookmark', - desc: 'Embed a link as a visual bookmark', - Icon: BookmarkCardIcon, - insertCommand: INSERT_BOOKMARK_COMMAND, - matches: ['bookmark'], - queryParams: ['url'], - priority: 4, - shortcut: '/bookmark [url]' - }]; - - getIcon() { - return BookmarkCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - this.__createdWithUrl = !!dataset.url && !dataset.metadata; - - // set up nested editor instances - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.captionEditor && dataset.caption) { - populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createBookmarkNode = (dataset) => { - return new BookmarkNode(dataset); -}; - -export function $isBookmarkNode(node) { - return node instanceof BookmarkNode; -} diff --git a/packages/koenig-lexical/src/nodes/BookmarkNode.tsx b/packages/koenig-lexical/src/nodes/BookmarkNode.tsx new file mode 100644 index 0000000000..f3fa5d47ff --- /dev/null +++ b/packages/koenig-lexical/src/nodes/BookmarkNode.tsx @@ -0,0 +1,101 @@ +import BookmarkCardIcon from '../assets/icons/kg-card-type-bookmark.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BookmarkNode as BaseBookmarkNode} from '@tryghost/kg-default-nodes'; +import {BookmarkNodeComponent} from './BookmarkNodeComponent'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_BOOKMARK_COMMAND = createCommand(); + +export class BookmarkNode extends BaseBookmarkNode { + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + __createdWithUrl: boolean; + + static kgMenu = [{ + label: 'Bookmark', + desc: 'Embed a link as a visual bookmark', + Icon: BookmarkCardIcon, + insertCommand: INSERT_BOOKMARK_COMMAND, + matches: ['bookmark'], + queryParams: ['url'], + priority: 4, + shortcut: '/bookmark [url]' + }]; + + getIcon() { + return BookmarkCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + this.__createdWithUrl = !!dataset.url && !dataset.metadata; + + // set up nested editor instances + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.captionEditor && dataset.caption) { + populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html); + json.caption = cleanedHtml ?? ""; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } +} + +export const $createBookmarkNode = (dataset: Record) => { + return new BookmarkNode(dataset); +}; + +export function $isBookmarkNode(node: unknown): node is BookmarkNode { + return node instanceof BookmarkNode; +} diff --git a/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.jsx b/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.jsx deleted file mode 100644 index 3d06c86c2a..0000000000 --- a/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.jsx +++ /dev/null @@ -1,210 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React, {useCallback} from 'react'; -import trackEvent from '../utils/analytics.js'; -import {$createLinkNode} from '@lexical/link'; -import {$createParagraphNode, $createTextNode, $getNodeByKey, $isParagraphNode} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {BookmarkCard} from '../components/ui/cards/BookmarkCard.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem} from '../components/ui/ToolbarMenu.jsx'; -import {isInternalUrl} from '../utils/isInternalUrl.js'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function BookmarkNodeComponent({author, nodeKey, url, icon, title, description, publisher, thumbnail, captionEditor, captionEditorInitialState, createdWithUrl}) { - const [editor] = useLexicalComposerContext(); - - const {cardConfig} = React.useContext(KoenigComposerContext); - const {isSelected} = React.useContext(CardContext); - const [urlInputValue, setUrlInputValue] = React.useState(url); - const [loading, setLoading] = React.useState(false); - const [urlError, setUrlError] = React.useState(false); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleUrlChange = (eventOrUrl) => { - // TODO: change this so we only get given URL strings - child components should handle their own events - if (typeof eventOrUrl === 'string') { - setUrlInputValue(eventOrUrl); - return; - } - setUrlInputValue(eventOrUrl.target.value); - }; - - const handleUrlSubmit = async (eventOrUrl, type) => { - if (!eventOrUrl) { - return; - } - - // TODO: change this so we only get given URL strings - child components should handle their own events - if (typeof eventOrUrl === 'string') { - if (type === 'internal' || type === 'default') { - trackEvent('Link dropdown: Internal link chosen', {context: 'bookmark', fromLatest: type === 'default'}); - } - if (type === 'url') { - const target = isInternalUrl(eventOrUrl, cardConfig?.siteUrl) ? 'internal' : 'external'; - trackEvent('Link dropdown: URL entered', {context: 'bookmark', target}); - } - - fetchMetadata(eventOrUrl); - } - - if (eventOrUrl?.key === 'Enter') { - fetchMetadata(eventOrUrl.target.value); - } - }; - - const handleRetry = async () => { - setUrlError(false); - }; - - const handlePasteAsLink = useCallback(() => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - const paragraph = $createParagraphNode() - .append($createLinkNode(urlInputValue) - .append($createTextNode(urlInputValue))); - node.replace(paragraph); - paragraph.selectEnd(); - }); - }, [editor, nodeKey, urlInputValue]); - - const handleClose = useCallback(() => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - const nextSibling = node.getNextSibling(); - if (nextSibling && $isParagraphNode(nextSibling) && nextSibling.getTextContentSize() === 0) { - node.remove(); - nextSibling.selectEnd(); - } else { - const paragraph = $createParagraphNode(); - node.replace(paragraph); - paragraph.selectEnd(); - } - }); - }, [editor, nodeKey]); - - const fetchMetadata = async (href) => { - editor.getRootElement().focus({preventScroll: true}); // focus editor before causing the input element to dismount - setLoading(true); - let response; - try { - // set the test data return values in fetchEmbed.js - response = await cardConfig.fetchEmbed(href, {type: 'bookmark'}); - } catch (e) { - setLoading(false); - setUrlError(true); - return; - } - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.url = href; - node.author = response.metadata.author; - node.icon = response.metadata.icon; - node.title = response.metadata.title; - node.description = response.metadata.description; - node.publisher = response.metadata.publisher; - node.thumbnail = response.metadata.thumbnail; - }); - setLoading(false); - }; - - const fetchMetadataEffect = useCallback(async () => { - setLoading(true); - let response; - try { - // set the test data return values in fetchEmbed.js - response = await cardConfig.fetchEmbed(url, {type: 'bookmark'}); - } catch (e) { - setLoading(false); - setUrlError(true); - return; - } - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.url = response.url; - node.author = response.metadata.author; - node.icon = response.metadata.icon; - node.title = response.metadata.title; - node.description = response.metadata.description; - node.publisher = response.metadata.publisher; - node.thumbnail = response.metadata.thumbnail; - - if (createdWithUrl) { - node.selectNext(); - } - }); - setLoading(false); - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // TODO: this needs to be a custom hook - // if we create the node with a url - // fetch the metadata - // if it fails, paste as a link - React.useEffect(() => { - // only run this once - if (createdWithUrl) { - setUrlInputValue(url); - try { - fetchMetadataEffect(url); - } catch { - handlePasteAsLink(url); - } - } - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const searchEnabled = typeof cardConfig?.searchLinks === 'function'; - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx b/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx new file mode 100644 index 0000000000..b29cf56bc5 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx @@ -0,0 +1,237 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React, {useCallback} from 'react'; +import trackEvent from '../utils/analytics'; +import {$createLinkNode} from '@lexical/link'; +import {$createParagraphNode, $createTextNode, $getNodeByKey, $isParagraphNode} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {BookmarkCard} from '../components/ui/cards/BookmarkCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem} from '../components/ui/ToolbarMenu'; +import {isInternalUrl} from '../utils/isInternalUrl'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {BookmarkNode} from './BookmarkNode'; +import type {LexicalEditor} from 'lexical'; + +function $getBookmarkNodeByKey(nodeKey: string): BookmarkNode | null { + return $getNodeByKey(nodeKey) as BookmarkNode | null; +} + +interface BookmarkNodeComponentProps { + author: string; + nodeKey: string; + url: string; + icon: string; + title: string; + description: string; + publisher: string; + thumbnail: string; + captionEditor: LexicalEditor; + captionEditorInitialState: string | undefined; + createdWithUrl: boolean; +} + +export function BookmarkNodeComponent({author, nodeKey, url, icon, title, description, publisher, thumbnail, captionEditor, captionEditorInitialState, createdWithUrl}: BookmarkNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + + const {cardConfig} = React.useContext(KoenigComposerContext); + const {isSelected} = React.useContext(CardContext); + const [urlInputValue, setUrlInputValue] = React.useState(url); + const [loading, setLoading] = React.useState(false); + const [urlError, setUrlError] = React.useState(false); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleUrlChange = (eventOrUrl: string | React.ChangeEvent) => { + // TODO: change this so we only get given URL strings - child components should handle their own events + if (typeof eventOrUrl === 'string') { + setUrlInputValue(eventOrUrl); + return; + } + setUrlInputValue(eventOrUrl.target.value); + }; + + const handleUrlSubmit = async (eventOrUrl: string | React.KeyboardEvent, type?: string) => { + if (!eventOrUrl) { + return; + } + + // TODO: change this so we only get given URL strings - child components should handle their own events + if (typeof eventOrUrl === 'string') { + if (type === 'internal' || type === 'default') { + trackEvent('Link dropdown: Internal link chosen', {context: 'bookmark', fromLatest: type === 'default'}); + } + if (type === 'url') { + const target = isInternalUrl(eventOrUrl, cardConfig?.siteUrl ?? '') ? 'internal' : 'external'; + trackEvent('Link dropdown: URL entered', {context: 'bookmark', target}); + } + + fetchMetadata(eventOrUrl); + } + + if (typeof eventOrUrl !== 'string' && eventOrUrl?.key === 'Enter') { + fetchMetadata((eventOrUrl.target as HTMLInputElement).value); + } + }; + + const handleRetry = async () => { + setUrlError(false); + }; + + const handlePasteAsLink = useCallback(() => { + editor.update(() => { + const node = $getBookmarkNodeByKey(nodeKey); + if (!node) {return;} + const paragraph = $createParagraphNode() + .append($createLinkNode(urlInputValue) + .append($createTextNode(urlInputValue))); + node.replace(paragraph); + paragraph.selectEnd(); + }); + }, [editor, nodeKey, urlInputValue]); + + const handleClose = useCallback(() => { + editor.update(() => { + const node = $getBookmarkNodeByKey(nodeKey); + if (!node) {return;} + const nextSibling = node.getNextSibling(); + if (nextSibling && $isParagraphNode(nextSibling) && nextSibling.getTextContentSize() === 0) { + node.remove(); + nextSibling.selectEnd(); + } else { + const paragraph = $createParagraphNode(); + node.replace(paragraph); + paragraph.selectEnd(); + } + }); + }, [editor, nodeKey]); + + const fetchMetadata = async (href: string) => { + editor.getRootElement()?.focus({preventScroll: true}); // focus editor before causing the input element to dismount + setLoading(true); + let response: {url: string; metadata: {author: string; icon: string; title: string; description: string; publisher: string; thumbnail: string}}; + try { + // set the test data return values in fetchEmbed.js + response = await cardConfig.fetchEmbed!(href, {type: 'bookmark'}) as typeof response; + } catch { + setLoading(false); + setUrlError(true); + return false; + } + editor.update(() => { + const node = $getBookmarkNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.url = href; + node.author = response.metadata.author; + node.icon = response.metadata.icon; + node.title = response.metadata.title; + node.description = response.metadata.description; + node.publisher = response.metadata.publisher; + node.thumbnail = response.metadata.thumbnail; + }); + setLoading(false); + }; + + const fetchMetadataEffect = useCallback(async () => { + setLoading(true); + let response: {url: string; metadata: {author: string; icon: string; title: string; description: string; publisher: string; thumbnail: string}}; + try { + // set the test data return values in fetchEmbed.js + response = await cardConfig.fetchEmbed!(url, {type: 'bookmark'}) as typeof response; + } catch { + setLoading(false); + setUrlError(true); + return; + } + editor.update(() => { + const node = $getBookmarkNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.url = response.url; + node.author = response.metadata.author; + node.icon = response.metadata.icon; + node.title = response.metadata.title; + node.description = response.metadata.description; + node.publisher = response.metadata.publisher; + node.thumbnail = response.metadata.thumbnail; + + if (createdWithUrl) { + node.selectNext(); + } + }); + setLoading(false); + return true; + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // TODO: this needs to be a custom hook + // if we create the node with a url + // fetch the metadata + // if it fails, paste as a link + React.useEffect(() => { + // only run this once + if (createdWithUrl) { + setUrlInputValue(url); + fetchMetadataEffect().then((success) => { + if (!success) { + handlePasteAsLink(); + } + }); + } + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const searchEnabled = typeof cardConfig?.searchLinks === 'function'; + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/ButtonNode.jsx b/packages/koenig-lexical/src/nodes/ButtonNode.jsx deleted file mode 100644 index 71b2d5b7f3..0000000000 --- a/packages/koenig-lexical/src/nodes/ButtonNode.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import ButtonCardIcon from '../assets/icons/kg-card-type-button.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {ButtonNode as BaseButtonNode} from '@tryghost/kg-default-nodes'; -import {ButtonNodeComponent} from './ButtonNodeComponent'; -import {createCommand} from 'lexical'; - -export const INSERT_BUTTON_COMMAND = createCommand(); - -export class ButtonNode extends BaseButtonNode { - static kgMenu = { - label: 'Button', - desc: 'Add a button to your post', - Icon: ButtonCardIcon, - insertCommand: INSERT_BUTTON_COMMAND, - matches: ['button'], - priority: 3, - shortcut: '/button' - }; - - static getType() { - return 'button'; - } - - getIcon() { - return ButtonCardIcon; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createButtonNode(dataset) { - return new ButtonNode(dataset); -} - -export function $isButtonNode(node) { - return node instanceof ButtonNode; -} diff --git a/packages/koenig-lexical/src/nodes/ButtonNode.tsx b/packages/koenig-lexical/src/nodes/ButtonNode.tsx new file mode 100644 index 0000000000..692fed58eb --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ButtonNode.tsx @@ -0,0 +1,51 @@ +import ButtonCardIcon from '../assets/icons/kg-card-type-button.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {ButtonNode as BaseButtonNode} from '@tryghost/kg-default-nodes'; +import {ButtonNodeComponent} from './ButtonNodeComponent'; +import {createCommand} from 'lexical'; + +export const INSERT_BUTTON_COMMAND = createCommand(); + +export class ButtonNode extends BaseButtonNode { + static kgMenu = { + label: 'Button', + desc: 'Add a button to your post', + Icon: ButtonCardIcon, + insertCommand: INSERT_BUTTON_COMMAND, + matches: ['button'], + priority: 3, + shortcut: '/button' + }; + + static getType() { + return 'button'; + } + + getIcon() { + return ButtonCardIcon; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createButtonNode(dataset: Record) { + return new ButtonNode(dataset); +} + +export function $isButtonNode(node: unknown): node is ButtonNode { + return node instanceof ButtonNode; +} diff --git a/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx b/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx deleted file mode 100644 index 4c0dafbb87..0000000000 --- a/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {ButtonCard} from '../components/ui/cards/ButtonCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function ButtonNodeComponent({alignment, buttonText, buttonUrl, nodeKey}) { - const [editor] = useLexicalComposerContext(); - const {isEditing, isSelected, setEditing} = React.useContext(CardContext); - const {cardConfig} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - const handleButtonTextChange = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const handleButtonUrlChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = val; - }); - }; - - const handleAlignmentChange = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alignment = value; - }); - }; - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/nodes/ButtonNodeComponent.tsx b/packages/koenig-lexical/src/nodes/ButtonNodeComponent.tsx new file mode 100644 index 0000000000..009c55c74e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ButtonNodeComponent.tsx @@ -0,0 +1,93 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {$isButtonNode} from './ButtonNode'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {ButtonCard} from '../components/ui/cards/ButtonCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +interface ButtonNodeComponentProps { + alignment: string; + buttonText: string; + buttonUrl: string; + nodeKey: string; +} + +export function ButtonNodeComponent({alignment, buttonText, buttonUrl, nodeKey}: ButtonNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {isEditing, isSelected, setEditing} = React.useContext(CardContext); + const {cardConfig} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + const handleButtonTextChange = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isButtonNode(node)) {return;} + node.buttonText = event.target.value; + }); + }; + + const handleButtonUrlChange = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isButtonNode(node)) {return;} + node.buttonUrl = val; + }); + }; + + const handleAlignmentChange = (value: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isButtonNode(node)) {return;} + node.alignment = value; + }); + }; + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/nodes/CallToActionNode.jsx b/packages/koenig-lexical/src/nodes/CallToActionNode.jsx deleted file mode 100644 index b288439fc2..0000000000 --- a/packages/koenig-lexical/src/nodes/CallToActionNode.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import EmailCtaCardIcon from '../assets/icons/kg-card-type-email-cta.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BASIC_NODES} from '../index.js'; -import {CallToActionNode as BaseCallToActionNode} from '@tryghost/kg-default-nodes'; -import {CallToActionNodeComponent} from './CallToActionNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_CALL_TO_ACTION_COMMAND = createCommand(); - -export class CallToActionNode extends BaseCallToActionNode { - __callToActionHtmlEditor; - __callToActionHtmlEditorInitialState; - __sponsorLabelHtmlEditor; - __sponsorLabelHtmlEditorInitialState; - - static kgMenu = { - label: 'Call to action', - desc: 'Add a call to action to your post', - Icon: EmailCtaCardIcon, - insertCommand: INSERT_CALL_TO_ACTION_COMMAND, - matches: ['cta', 'call-to-action', 'email', 'email-cta', 'ad', 'sponsored', 'hidden'], - priority: 7, - shortcut: '/cta', - isHidden: () => false - }; - - static getType() { - return 'call-to-action'; - } - - getIcon() { - return EmailCtaCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - // set up nested editor instances - setupNestedEditor(this, '__callToActionHtmlEditor', {editor: dataset.callToActionHtmlEditor, nodes: BASIC_NODES}); - setupNestedEditor(this, '__sponsorLabelHtmlEditor', {editor: dataset.sponsorLabelHtmlEditor, nodes: BASIC_NODES}); - - // populate nested editors on initial construction - if (!dataset.callToActionHtmlEditor && dataset.textValue) { - populateNestedEditor(this, '__callToActionHtmlEditor', `${dataset.textValue}`); // we serialize with no wrapper - } - if (!dataset.sponsorLabelHtmlEditor) { - populateNestedEditor(this, '__sponsorLabelHtmlEditor', `${dataset.sponsorLabel || '

SPONSORED

'}`); - } - } - - getDataset() { - const dataset = super.getDataset(); - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.callToActionHtmlEditor = self.__callToActionHtmlEditor; - dataset.callToActionHtmlEditorInitialState = self.__callToActionHtmlEditorInitialState; - dataset.sponsorLabelHtmlEditor = self.__sponsorLabelHtmlEditor; - dataset.sponsorLabelHtmlEditorInitialState = self.__sponsorLabelHtmlEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instance back into HTML because `text` may not - // be automatically updated when the nested editor changes - if (this.__callToActionHtmlEditor) { - this.__callToActionHtmlEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__callToActionHtmlEditor, null); - const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); - json.textValue = cleanedHtml; - }); - } - - if (this.__sponsorLabelHtmlEditor) { - this.__sponsorLabelHtmlEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__sponsorLabelHtmlEditor, null); - const cleanedHtml = cleanBasicHtml(html, {allowBr: false}); - json.sponsorLabel = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createCallToActionNode(dataset) { - return new CallToActionNode(dataset); -} - -export function $isCallToActionNode(node) { - return node instanceof CallToActionNode; -} diff --git a/packages/koenig-lexical/src/nodes/CallToActionNode.tsx b/packages/koenig-lexical/src/nodes/CallToActionNode.tsx new file mode 100644 index 0000000000..275a9ce2d0 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CallToActionNode.tsx @@ -0,0 +1,135 @@ +import EmailCtaCardIcon from '../assets/icons/kg-card-type-email-cta.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BASIC_NODES} from '../index'; +import {CallToActionNode as BaseCallToActionNode, type CallToActionData} from '@tryghost/kg-default-nodes'; +import {CallToActionNodeComponent} from './CallToActionNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export type CallToActionNodeData = CallToActionData & { + callToActionHtmlEditor?: LexicalEditor; + callToActionHtmlEditorInitialState?: unknown; + sponsorLabelHtmlEditor?: LexicalEditor; + sponsorLabelHtmlEditorInitialState?: unknown; +}; + +export const INSERT_CALL_TO_ACTION_COMMAND = createCommand(); + +export class CallToActionNode extends BaseCallToActionNode { + __callToActionHtmlEditor!: LexicalEditor; + __callToActionHtmlEditorInitialState: unknown; + __sponsorLabelHtmlEditor!: LexicalEditor; + __sponsorLabelHtmlEditorInitialState: unknown; + + static kgMenu = { + label: 'Call to action', + desc: 'Add a call to action to your post', + Icon: EmailCtaCardIcon, + insertCommand: INSERT_CALL_TO_ACTION_COMMAND, + matches: ['cta', 'call-to-action', 'email', 'email-cta', 'ad', 'sponsored', 'hidden'], + priority: 7, + shortcut: '/cta', + isHidden: () => false + }; + + static getType() { + return 'call-to-action'; + } + + getIcon() { + return EmailCtaCardIcon; + } + + constructor(dataset: CallToActionNodeData = {}, key?: string) { + super(dataset, key); + + // set up nested editor instances + setupNestedEditor(this, '__callToActionHtmlEditor', {editor: dataset.callToActionHtmlEditor, nodes: BASIC_NODES}); + setupNestedEditor(this, '__sponsorLabelHtmlEditor', {editor: dataset.sponsorLabelHtmlEditor, nodes: BASIC_NODES}); + + // populate nested editors on initial construction + if (!dataset.callToActionHtmlEditor && dataset.textValue) { + populateNestedEditor(this, '__callToActionHtmlEditor', `${dataset.textValue}`); // we serialize with no wrapper + } + if (!dataset.sponsorLabelHtmlEditor) { + populateNestedEditor(this, '__sponsorLabelHtmlEditor', `${dataset.sponsorLabel || '

SPONSORED

'}`); + } + } + + getDataset() { + const dataset = super.getDataset(); + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.callToActionHtmlEditor = self.__callToActionHtmlEditor; + dataset.callToActionHtmlEditorInitialState = self.__callToActionHtmlEditorInitialState; + dataset.sponsorLabelHtmlEditor = self.__sponsorLabelHtmlEditor; + dataset.sponsorLabelHtmlEditorInitialState = self.__sponsorLabelHtmlEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instance back into HTML because `text` may not + // be automatically updated when the nested editor changes + if (this.__callToActionHtmlEditor) { + this.__callToActionHtmlEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__callToActionHtmlEditor, null); + const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); + json.textValue = cleanedHtml; + }); + } + + if (this.__sponsorLabelHtmlEditor) { + this.__sponsorLabelHtmlEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__sponsorLabelHtmlEditor, null); + const cleanedHtml = cleanBasicHtml(html, {allowBr: false}); + json.sponsorLabel = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createCallToActionNode(dataset: CallToActionNodeData = {}) { + return new CallToActionNode(dataset); +} + +export function $isCallToActionNode(node: unknown): node is CallToActionNode { + return node instanceof CallToActionNode; +} diff --git a/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.jsx b/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.jsx deleted file mode 100644 index 5d702d66aa..0000000000 --- a/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.jsx +++ /dev/null @@ -1,231 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React, {useRef} from 'react'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {CallToActionCard} from '../components/ui/cards/CallToActionCard.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {getImageDimensions} from '../utils/getImageDimensions'; -import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext.jsx'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useVisibilityToggle} from '../hooks/useVisibilityToggle.js'; - -export const CallToActionNodeComponent = ({ - nodeKey, - alignment, - backgroundColor, - buttonText, - buttonUrl, - hasSponsorLabel, - imageUrl, - layout, - linkColor, - showButton, - showDividers, - textValue, - buttonColor, - htmlEditor, - htmlEditorInitialState, - buttonTextColor, - sponsorLabelHtmlEditor, - sponsorLabelHtmlEditorInitialState -}) => { - const [editor] = useLexicalComposerContext(); - const {isEditing, isSelected, setEditing} = React.useContext(CardContext); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageDrop}); - - const {isVisibilityEnabled, visibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); - - const {showVisibilitySettings} = useKoenigSelectedCardContext(); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - const fileInputRef = useRef(null); - - const imageUploader = fileUploader.useFileUpload('image'); - - const toggleShowButton = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.showButton = !node.showButton; - }); - }; - - const toggleShowDividers = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.showDividers = !node.showDividers; - }); - }; - - const handleButtonTextChange = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const handleButtonUrlChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = val; - }); - }; - - const handleButtonColorChange = (val, matchingTextColor) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonColor = val; - node.buttonTextColor = matchingTextColor; - }); - }; - const handleHasSponsorLabelChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - // get the current value and toggle it - node.hasSponsorLabel = !node.hasSponsorLabel; - }); - }; - - const handleBackgroundColorChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundColor = val; - }); - }; - - const handleLinkColorChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.linkColor = val; - }); - }; - - const handleImageChange = async (files) => { - const imgPreviewUrl = URL.createObjectURL(files[0]); - try { - const {width, height} = await getImageDimensions(imgPreviewUrl); - const result = await imageUploader.upload(files); - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.imageUrl = result?.[0].url; - node.imageWidth = width; - node.imageHeight = height; - }); - } finally { - URL.revokeObjectURL(imgPreviewUrl); - } - }; - - const onFileChange = async (e) => { - handleImageChange(e.target.files); - }; - - const onRemoveMedia = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.imageUrl = null; - node.imageWidth = null; - node.imageHeight = null; - }); - }; - const handleUpdatingLayout = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.layout = val; - }); - }; - - const handleUpdatingAlignment = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alignment = val; - }); - }; - - async function handleImageDrop(files) { - await handleImageChange(files); - } - - React.useEffect(() => { - htmlEditor.setEditable(isEditing); - }, [isEditing, htmlEditor]); - - return ( - <> - fileInputRef.current = ref} - showButton={showButton} - showDividers={showDividers} - showVisibilitySettings={isVisibilityEnabled && showVisibilitySettings} - sponsorLabelHtmlEditor={sponsorLabelHtmlEditor} - sponsorLabelHtmlEditorInitialState={sponsorLabelHtmlEditorInitialState} - text={textValue} - toggleVisibility={toggleVisibility} - updateAlignment={handleUpdatingAlignment} - updateButtonText={handleButtonTextChange} - updateButtonUrl={handleButtonUrlChange} - updateHasSponsorLabel={handleHasSponsorLabelChange} - updateLayout={handleUpdatingLayout} - updateShowButton={toggleShowButton} - updateShowDividers={toggleShowDividers} - visibilityOptions={visibilityOptions} - onFileChange={onFileChange} - onRemoveMedia={onRemoveMedia} - /> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -}; diff --git a/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.tsx b/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.tsx new file mode 100644 index 0000000000..5377948195 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CallToActionNodeComponent.tsx @@ -0,0 +1,273 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React, {useRef} from 'react'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {CallToActionCard} from '../components/ui/cards/CallToActionCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {getImageDimensions} from '../utils/getImageDimensions'; +import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useVisibilityToggle} from '../hooks/useVisibilityToggle'; +import type {CallToActionNode} from '@tryghost/kg-default-nodes'; +import type {LexicalEditor} from 'lexical'; + +interface CallToActionNodeComponentProps { + nodeKey: string; + alignment: string; + backgroundColor: string; + buttonText: string; + buttonUrl: string; + hasSponsorLabel: boolean; + imageUrl: string | null; + layout: string; + linkColor: string; + showButton: boolean; + showDividers: boolean; + textValue: string; + buttonColor: string; + htmlEditor: LexicalEditor; + htmlEditorInitialState: unknown; + buttonTextColor: string; + sponsorLabelHtmlEditor: LexicalEditor; + sponsorLabelHtmlEditorInitialState: unknown; +} + +export const CallToActionNodeComponent: React.FC = ({ + nodeKey, + alignment, + backgroundColor, + buttonText, + buttonUrl, + hasSponsorLabel, + imageUrl, + layout, + linkColor, + showButton, + showDividers, + textValue: _textValue, + buttonColor, + htmlEditor, + htmlEditorInitialState, + buttonTextColor, + sponsorLabelHtmlEditor, + sponsorLabelHtmlEditorInitialState +}) => { + const [editor] = useLexicalComposerContext(); + const {isEditing, isSelected, setEditing} = React.useContext(CardContext); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageDrop}); + + const {isVisibilityEnabled, visibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); + + const {showVisibilitySettings} = useKoenigSelectedCardContext(); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + const fileInputRef = useRef(null); + + const imageUploader = fileUploader.useFileUpload('image'); + + const toggleShowButton = (_event: unknown) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.showButton = !node.showButton; + }); + }; + + const toggleShowDividers = (_event: unknown) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.showDividers = !node.showDividers; + }); + }; + + const handleButtonTextChange = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.buttonText = event.target.value; + }); + }; + + const handleButtonUrlChange = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.buttonUrl = val; + }); + }; + + const handleButtonColorChange = (val: string, matchingTextColor: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.buttonColor = val; + node.buttonTextColor = matchingTextColor; + }); + }; + const handleHasSponsorLabelChange = (_val: unknown) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + // get the current value and toggle it + node.hasSponsorLabel = !node.hasSponsorLabel; + }); + }; + + const handleBackgroundColorChange = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.backgroundColor = val; + }); + }; + + const handleLinkColorChange = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.linkColor = val; + }); + }; + + const handleImageChange = async (files: FileList | File[] | null) => { + if (!files || files.length === 0) { + return; + } + + const imgPreviewUrl = URL.createObjectURL(files[0]); + try { + const {width, height} = await getImageDimensions(imgPreviewUrl); + const result = await imageUploader.upload(Array.from(files)); + // reset original src so it can be replaced with preview and upload progress + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.imageUrl = result?.[0]?.url ?? null; + node.imageWidth = width; + node.imageHeight = height; + }); + } finally { + URL.revokeObjectURL(imgPreviewUrl); + } + }; + + const onFileChange = async (e: React.ChangeEvent) => { + const {files} = e.target; + if (!files || files.length === 0) { + return; + } + + handleImageChange(files); + }; + + const onRemoveMedia = () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.imageUrl = null; + node.imageWidth = null; + node.imageHeight = null; + }); + }; + const handleUpdatingLayout = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.layout = val; + }); + }; + + const handleUpdatingAlignment = (val: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CallToActionNode | null; + if (!node) {return;} + node.alignment = val; + }); + }; + + async function handleImageDrop(files: FileList | File[]) { + await handleImageChange(files); + } + + React.useEffect(() => { + (htmlEditor as {setEditable: (editable: boolean) => void}).setEditable(isEditing); + }, [isEditing, htmlEditor]); + + return ( + <> + fileInputRef.current = ref} + showButton={showButton} + showDividers={showDividers} + showVisibilitySettings={isVisibilityEnabled && showVisibilitySettings} + sponsorLabelHtmlEditor={sponsorLabelHtmlEditor} + sponsorLabelHtmlEditorInitialState={sponsorLabelHtmlEditorInitialState as string | undefined} + toggleVisibility={toggleVisibility} + updateAlignment={handleUpdatingAlignment} + updateButtonText={handleButtonTextChange} + updateButtonUrl={handleButtonUrlChange} + updateHasSponsorLabel={handleHasSponsorLabelChange} + updateLayout={handleUpdatingLayout} + updateShowButton={toggleShowButton} + updateShowDividers={toggleShowDividers} + visibilityOptions={visibilityOptions} + onFileChange={onFileChange} + onRemoveMedia={onRemoveMedia} + /> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +}; diff --git a/packages/koenig-lexical/src/nodes/CalloutNode.jsx b/packages/koenig-lexical/src/nodes/CalloutNode.jsx deleted file mode 100644 index a140c8b30f..0000000000 --- a/packages/koenig-lexical/src/nodes/CalloutNode.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import CalloutCardIcon from '../assets/icons/kg-card-type-callout.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import MINIMAL_NODES from './MinimalNodes'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {CalloutNode as BaseCalloutNode} from '@tryghost/kg-default-nodes'; -import {CalloutNodeComponent} from './CalloutNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_CALLOUT_COMMAND = createCommand(); - -export class CalloutNode extends BaseCalloutNode { - __calloutTextEditor; - __calloutTextEditorInitialState; - - static kgMenu = [{ - label: 'Callout', - desc: 'Info boxes that stand out', - Icon: CalloutCardIcon, - insertCommand: INSERT_CALLOUT_COMMAND, - matches: ['callout'], - priority: 9, - shortcut: '/callout' - }]; - - getIcon() { - return CalloutCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - // set up nested editor instances - setupNestedEditor(this, '__calloutTextEditor', {editor: dataset.calloutTextEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.calloutTextEditor && dataset.calloutText) { - populateNestedEditor(this, '__calloutTextEditor', `${dataset.calloutText}`); // we serialize with no wrapper - } - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instance back into HTML because `text` may not - // be automatically updated when the nested editor changes - if (this.__calloutTextEditor) { - this.__calloutTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__calloutTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); - json.calloutText = cleanedHtml; - }); - } - - return json; - } - - getDataset() { - const dataset = super.getDataset(); - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.calloutTextEditor = self.__calloutTextEditor; - dataset.calloutTextEditorInitialState = self.__calloutTextEditorInitialState; - - return dataset; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createCalloutNode = (dataset) => { - return new CalloutNode(dataset); -}; - -export function $isCalloutNode(node) { - return node instanceof CalloutNode; -} diff --git a/packages/koenig-lexical/src/nodes/CalloutNode.tsx b/packages/koenig-lexical/src/nodes/CalloutNode.tsx new file mode 100644 index 0000000000..56db90e47e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CalloutNode.tsx @@ -0,0 +1,91 @@ +import CalloutCardIcon from '../assets/icons/kg-card-type-callout.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import MINIMAL_NODES from './MinimalNodes'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {CalloutNode as BaseCalloutNode} from '@tryghost/kg-default-nodes'; +import {CalloutNodeComponent} from './CalloutNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_CALLOUT_COMMAND = createCommand(); + +export class CalloutNode extends BaseCalloutNode { + __calloutTextEditor!: LexicalEditor; + __calloutTextEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Callout', + desc: 'Info boxes that stand out', + Icon: CalloutCardIcon, + insertCommand: INSERT_CALLOUT_COMMAND, + matches: ['callout'], + priority: 9, + shortcut: '/callout' + }]; + + getIcon() { + return CalloutCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + // set up nested editor instances + setupNestedEditor(this, '__calloutTextEditor', {editor: dataset.calloutTextEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.calloutTextEditor && dataset.calloutText) { + populateNestedEditor(this, '__calloutTextEditor', `${dataset.calloutText}`); // we serialize with no wrapper + } + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instance back into HTML because `text` may not + // be automatically updated when the nested editor changes + if (this.__calloutTextEditor) { + this.__calloutTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__calloutTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); + json.calloutText = cleanedHtml; + }); + } + + return json; + } + + getDataset() { + const dataset = super.getDataset(); + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.calloutTextEditor = self.__calloutTextEditor; + dataset.calloutTextEditorInitialState = self.__calloutTextEditorInitialState; + + return dataset; + } + + decorate() { + return ( + + + + ); + } +} + +export const $createCalloutNode = (dataset: Record) => { + return new CalloutNode(dataset); +}; + +export function $isCalloutNode(node: unknown): node is CalloutNode { + return node instanceof CalloutNode; +} diff --git a/packages/koenig-lexical/src/nodes/CalloutNodeComponent.jsx b/packages/koenig-lexical/src/nodes/CalloutNodeComponent.jsx deleted file mode 100644 index 4371881551..0000000000 --- a/packages/koenig-lexical/src/nodes/CalloutNodeComponent.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {CalloutCard} from '../components/ui/cards/CalloutCard'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {sanitizeHtml} from '../utils/sanitize-html'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function CalloutNodeComponent({nodeKey, textEditor, textEditorInitialState, backgroundColor, calloutEmoji}) { - const [editor] = useLexicalComposerContext(); - - const {isSelected, isEditing, setEditing} = React.useContext(CardContext); - const {cardConfig} = React.useContext(KoenigComposerContext); - const [showEmojiPicker, setShowEmojiPicker] = React.useState(false); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - const [emoji, setEmoji] = React.useState(calloutEmoji); - const [hasEmoji, setHasEmoji] = React.useState(calloutEmoji ? true : false); - - const toggleEmoji = (event) => { - event.stopPropagation(); - setEditing(true); // keep card selected when toggling emoji (else we lose the settings pane on deselection) - editor.update(() => { - const node = $getNodeByKey(nodeKey); - setHasEmoji(event.target.checked); - if (event.target.checked && emoji === '') { - node.calloutEmoji = '💡'; - } else { - node.calloutEmoji = event.target.checked ? emoji : ''; - } - }); - }; - - const handleColorChange = (color) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundColor = color; - }); - }; - - const handleEmojiChange = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - setEmoji(event.native); - node.calloutEmoji = event.native; - toggleEmojiPicker(); - }); - }; - - const toggleEmojiPicker = () => { - if (!isEditing) { - setEditing(true); - } - - if (showEmojiPicker) { - textEditor.focus(); - } - setShowEmojiPicker(!showEmojiPicker); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - React.useEffect(() => { - textEditor.setEditable(isEditing); - }, [isEditing, textEditor]); - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/CalloutNodeComponent.tsx b/packages/koenig-lexical/src/nodes/CalloutNodeComponent.tsx new file mode 100644 index 0000000000..635faa4f26 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CalloutNodeComponent.tsx @@ -0,0 +1,130 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {CalloutCard} from '../components/ui/cards/CalloutCard'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {CalloutColor} from '../components/ui/cards/CalloutCard'; +import type {CalloutNode} from '@tryghost/kg-default-nodes'; +import type {LexicalEditor} from 'lexical'; + +interface CalloutNodeComponentProps { + nodeKey: string; + textEditor: LexicalEditor; + textEditorInitialState: unknown; + backgroundColor: string; + calloutEmoji: string; +} + +export function CalloutNodeComponent({nodeKey, textEditor, textEditorInitialState, backgroundColor, calloutEmoji}: CalloutNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + + const {isSelected, isEditing, setEditing} = React.useContext(CardContext); + const {cardConfig} = React.useContext(KoenigComposerContext); + const [showEmojiPicker, setShowEmojiPicker] = React.useState(false); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + const [emoji, setEmoji] = React.useState(calloutEmoji); + const [hasEmoji, setHasEmoji] = React.useState(calloutEmoji ? true : false); + + const toggleEmoji = (event: React.ChangeEvent) => { + event.stopPropagation(); + setEditing(true); // keep card selected when toggling emoji (else we lose the settings pane on deselection) + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CalloutNode | null; + if (!node) {return;} + setHasEmoji(event.target.checked); + if (event.target.checked && emoji === '') { + node.calloutEmoji = '💡'; + } else { + node.calloutEmoji = event.target.checked ? emoji : ''; + } + }); + }; + + const handleColorChange = (color: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CalloutNode | null; + if (!node) {return;} + node.backgroundColor = color; + }); + }; + + const handleEmojiChange = (event: {native: string}) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CalloutNode | null; + if (!node) {return;} + setEmoji(event.native); + node.calloutEmoji = event.native; + toggleEmojiPicker(); + }); + }; + + const toggleEmojiPicker = () => { + if (!isEditing) { + setEditing(true); + } + + if (showEmojiPicker) { + textEditor.focus(); + } + setShowEmojiPicker(!showEmojiPicker); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + React.useEffect(() => { + textEditor.setEditable(isEditing); + }, [isEditing, textEditor]); + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/CodeBlockNode.jsx b/packages/koenig-lexical/src/nodes/CodeBlockNode.jsx deleted file mode 100644 index 908beb0861..0000000000 --- a/packages/koenig-lexical/src/nodes/CodeBlockNode.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import CodeBlockIcon from '../assets/icons/kg-card-type-gen-embed.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {CodeBlockNode as BaseCodeBlockNode} from '@tryghost/kg-default-nodes'; -import {CodeBlockNodeComponent} from './CodeBlockNodeComponent'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_CODE_BLOCK_COMMAND = createCommand(); - -export class CodeBlockNode extends BaseCodeBlockNode { - // transient properties used to control node behaviour - __openInEditMode = false; - __captionEditor; - __captionEditorInitialState; - - constructor(dataset = {}, key) { - super(dataset, key); - - const {_openInEditMode} = dataset; - this.__openInEditMode = _openInEditMode || false; - - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.captionEditor && dataset.caption) { - populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper - } - } - - getIcon() { - return CodeBlockIcon; - } - - clearOpenInEditMode() { - const self = this.getWritable(); - self.__openInEditMode = false; - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createCodeBlockNode(dataset) { - return new CodeBlockNode(dataset); -} - -export function $isCodeBlockNode(node) { - return node instanceof CodeBlockNode; -} diff --git a/packages/koenig-lexical/src/nodes/CodeBlockNode.tsx b/packages/koenig-lexical/src/nodes/CodeBlockNode.tsx new file mode 100644 index 0000000000..a7b72067d4 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CodeBlockNode.tsx @@ -0,0 +1,90 @@ +import CodeBlockIcon from '../assets/icons/kg-card-type-gen-embed.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {CodeBlockNode as BaseCodeBlockNode} from '@tryghost/kg-default-nodes'; +import {CodeBlockNodeComponent} from './CodeBlockNodeComponent'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_CODE_BLOCK_COMMAND = createCommand(); + +export class CodeBlockNode extends BaseCodeBlockNode { + // transient properties used to control node behaviour + __openInEditMode = false; + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + const {_openInEditMode} = dataset; + this.__openInEditMode = !!_openInEditMode; + + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.captionEditor && dataset.caption) { + populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper + } + } + + getIcon() { + return CodeBlockIcon; + } + + clearOpenInEditMode() { + const self = this.getWritable(); + self.__openInEditMode = false; + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html); + json.caption = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createCodeBlockNode(dataset: Record) { + return new CodeBlockNode(dataset); +} + +export function $isCodeBlockNode(node: unknown): node is CodeBlockNode { + return node instanceof CodeBlockNode; +} diff --git a/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.jsx b/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.jsx deleted file mode 100644 index 3ad32d4b3c..0000000000 --- a/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {CodeBlockCard} from '../components/ui/cards/CodeBlockCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function CodeBlockNodeComponent({nodeKey, captionEditor, captionEditorInitialState, code, language}) { - const [editor] = useLexicalComposerContext(); - const {isEditing, setEditing, isSelected} = React.useContext(CardContext); - const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const updateCode = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.code = value; - }); - }; - - const updateLanguage = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.language = value; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.tsx b/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.tsx new file mode 100644 index 0000000000..9e4026c125 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.tsx @@ -0,0 +1,88 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {CodeBlockCard} from '../components/ui/cards/CodeBlockCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {CodeBlockNode} from '@tryghost/kg-default-nodes'; +import type {LexicalEditor} from 'lexical'; + +interface CodeBlockNodeComponentProps { + nodeKey: string; + captionEditor: LexicalEditor; + captionEditorInitialState: unknown; + code: string; + language: string; +} + +export function CodeBlockNodeComponent({nodeKey, captionEditor, captionEditorInitialState, code, language}: CodeBlockNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {isEditing, setEditing, isSelected} = React.useContext(CardContext); + const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const updateCode = (value: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CodeBlockNode | null; + if (!node) {return;} + node.code = value; + }); + }; + + const updateLanguage = (value: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as CodeBlockNode | null; + if (!node) {return;} + node.language = value; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/DefaultNodes.js b/packages/koenig-lexical/src/nodes/DefaultNodes.ts similarity index 100% rename from packages/koenig-lexical/src/nodes/DefaultNodes.js rename to packages/koenig-lexical/src/nodes/DefaultNodes.ts diff --git a/packages/koenig-lexical/src/nodes/EmailCtaNode.jsx b/packages/koenig-lexical/src/nodes/EmailCtaNode.jsx deleted file mode 100644 index 3840f39b1a..0000000000 --- a/packages/koenig-lexical/src/nodes/EmailCtaNode.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import EmailCtaCardIcon from '../assets/icons/kg-card-type-email-cta.svg?react'; -import EmailIndicatorIcon from '../assets/icons/kg-indicator-email.svg?react'; -import {$canShowPlaceholderCurry} from '@lexical/text'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BASIC_NODES, KoenigCardWrapper} from '../index.js'; -import {EmailCtaNode as BaseEmailCtaNode} from '@tryghost/kg-default-nodes'; -import {EmailCtaNodeComponent} from './EmailCtaNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_EMAIL_CTA_COMMAND = createCommand(); - -export class EmailCtaNode extends BaseEmailCtaNode { - __htmlEditor; - __htmlEditorInitialState; - - static kgMenu = { - label: 'Email call to action', - desc: 'Target free or paid members with a CTA', - Icon: EmailCtaCardIcon, - insertCommand: INSERT_EMAIL_CTA_COMMAND, - matches: ['email', 'cta', 'email-cta'], - priority: 7, - postType: 'post', - shortcut: '/email-cta', - isHidden: ({config}) => { - return config?.deprecated?.emailCta ?? true; - } - }; - - getIcon() { - return EmailCtaCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - // set up nested editor instances - setupNestedEditor(this, '__htmlEditor', {editor: dataset.htmlEditor, nodes: BASIC_NODES}); - - // populate nested editors on initial construction - if (!dataset.htmlEditor) { - populateNestedEditor(this, '__htmlEditor', dataset.html); - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.htmlEditor = self.__htmlEditor; - dataset.htmlEditorInitialState = self.__htmlEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__htmlEditor) { - this.__htmlEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__htmlEditor, null); - const cleanedHtml = cleanBasicHtml(html, {removeCodeWrappers: true, allowBr: true}); - json.html = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isHtmlEmpty = this.__htmlEditor.getEditorState().read($canShowPlaceholderCurry(false)); - return isHtmlEmpty && (!this.showButton || (!this.buttonText && !this.buttonUrl)); - } -} - -export function $createEmailCtaNode() { - return new EmailCtaNode(); -} - -export function $isEmailCtaNode(node) { - return node instanceof EmailCtaNode; -} diff --git a/packages/koenig-lexical/src/nodes/EmailCtaNode.tsx b/packages/koenig-lexical/src/nodes/EmailCtaNode.tsx new file mode 100644 index 0000000000..ddc3b9f453 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmailCtaNode.tsx @@ -0,0 +1,112 @@ +import EmailCtaCardIcon from '../assets/icons/kg-card-type-email-cta.svg?react'; +import EmailIndicatorIcon from '../assets/icons/kg-indicator-email.svg?react'; +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BASIC_NODES, KoenigCardWrapper} from '../index'; +import {EmailCtaNode as BaseEmailCtaNode} from '@tryghost/kg-default-nodes'; +import {EmailCtaNodeComponent} from './EmailCtaNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_EMAIL_CTA_COMMAND = createCommand(); + +export class EmailCtaNode extends BaseEmailCtaNode { + __htmlEditor!: LexicalEditor; + __htmlEditorInitialState: unknown; + + static kgMenu = { + label: 'Email call to action', + desc: 'Target free or paid members with a CTA', + Icon: EmailCtaCardIcon, + insertCommand: INSERT_EMAIL_CTA_COMMAND, + matches: ['email', 'cta', 'email-cta'], + priority: 7, + postType: 'post', + shortcut: '/email-cta', + isHidden: ({config}: {config?: Record}) => { + return (config?.deprecated as Record | undefined)?.emailCta ?? true; + } + }; + + getIcon() { + return EmailCtaCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + // set up nested editor instances + setupNestedEditor(this, '__htmlEditor', {editor: dataset.htmlEditor, nodes: BASIC_NODES}); + + // populate nested editors on initial construction + if (!dataset.htmlEditor) { + populateNestedEditor(this, '__htmlEditor', dataset.html as string | undefined); + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.htmlEditor = self.__htmlEditor; + dataset.htmlEditorInitialState = self.__htmlEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__htmlEditor) { + this.__htmlEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__htmlEditor, null); + const cleanedHtml = cleanBasicHtml(html, {removeCodeWrappers: true, allowBr: true}); + json.html = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isHtmlEmpty = this.__htmlEditor.getEditorState().read($canShowPlaceholderCurry(false)); + return isHtmlEmpty && (!this.showButton || (!this.buttonText && !this.buttonUrl)); + } +} + +export function $createEmailCtaNode() { + return new EmailCtaNode(); +} + +export function $isEmailCtaNode(node: unknown): node is EmailCtaNode { + return node instanceof EmailCtaNode; +} diff --git a/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.jsx b/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.jsx deleted file mode 100644 index 3c2032266f..0000000000 --- a/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; -import {EmailCtaCard} from '../components/ui/cards/EmailCtaCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function EmailCtaNodeComponent({ - nodeKey, - alignment, - htmlEditor, - htmlEditorInitialState, - segment, - showDividers, - showButton, - buttonText, - buttonUrl -}) { - const [editor] = useLexicalComposerContext(); - const cardContext = React.useContext(CardContext); - const {cardConfig} = React.useContext(KoenigComposerContext); - const {isEditing, isSelected} = cardContext; - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - const handleSegmentChange = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.segment = value; - }); - }; - - React.useEffect(() => { - htmlEditor.setEditable(isEditing); - }, [isEditing, htmlEditor]); - - const updateAlignment = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alignment = value; - }); - }; - - const toggleDividers = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.showDividers = event.target.checked; - }); - }; - - const updateShowButton = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.showButton = event.target.checked; - }); - }; - - const updateButtonText = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const updateButtonUrl = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = val; - }); - }; - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.tsx b/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.tsx new file mode 100644 index 0000000000..59edc0afe9 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.tsx @@ -0,0 +1,153 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {EmailCtaCard} from '../components/ui/cards/EmailCtaCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {EmailCtaNode} from './EmailCtaNode'; +import type {LexicalEditor} from 'lexical'; + +function $getEmailCtaNodeByKey(nodeKey: string): EmailCtaNode | null { + return $getNodeByKey(nodeKey) as EmailCtaNode | null; +} + +interface EmailCtaNodeComponentProps { + nodeKey: string; + alignment: string; + htmlEditor: LexicalEditor; + htmlEditorInitialState: string | undefined; + segment: string; + showDividers: boolean; + showButton: boolean; + buttonText: string; + buttonUrl: string; +} + +export function EmailCtaNodeComponent({ + nodeKey, + alignment, + htmlEditor, + htmlEditorInitialState, + segment, + showDividers, + showButton, + buttonText, + buttonUrl +}: EmailCtaNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const cardContext = React.useContext(CardContext); + const {cardConfig} = React.useContext(KoenigComposerContext); + const {isEditing, isSelected} = cardContext; + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + const handleSegmentChange = (value: string) => { + if (value !== 'status:free' && value !== 'status:-free') {return;} + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.segment = value; + }); + }; + + React.useEffect(() => { + htmlEditor.setEditable(isEditing); + }, [isEditing, htmlEditor]); + + const updateAlignment = (value: string) => { + if (value !== 'left' && value !== 'center') {return;} + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.alignment = value; + }); + }; + + const toggleDividers = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.showDividers = event.target.checked; + }); + }; + + const updateShowButton = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.showButton = event.target.checked; + }); + }; + + const updateButtonText = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = event.target.value; + }); + }; + + const updateButtonUrl = (val: string) => { + editor.update(() => { + const node = $getEmailCtaNodeByKey(nodeKey); + if (!node) {return;} + node.buttonUrl = val; + }); + }; + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/EmailEditorNodes.js b/packages/koenig-lexical/src/nodes/EmailEditorNodes.ts similarity index 100% rename from packages/koenig-lexical/src/nodes/EmailEditorNodes.js rename to packages/koenig-lexical/src/nodes/EmailEditorNodes.ts diff --git a/packages/koenig-lexical/src/nodes/EmailEmbedNode.jsx b/packages/koenig-lexical/src/nodes/EmailEmbedNode.jsx deleted file mode 100644 index 95c5215dd6..0000000000 --- a/packages/koenig-lexical/src/nodes/EmailEmbedNode.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import {EmbedNode} from './EmbedNode'; - -export class EmailEmbedNode extends EmbedNode { - // Only show embed and youtube cards in the email editor - static kgMenu = EmbedNode.kgMenu.filter( - item => item.matches?.includes('embed') || item.matches?.includes('youtube') - ); -} - -export const emailEmbedNodeReplacement = {replace: EmbedNode, with: (node) => { - return new EmailEmbedNode(node.exportJSON()); -}}; diff --git a/packages/koenig-lexical/src/nodes/EmailEmbedNode.tsx b/packages/koenig-lexical/src/nodes/EmailEmbedNode.tsx new file mode 100644 index 0000000000..45c3889f3e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmailEmbedNode.tsx @@ -0,0 +1,12 @@ +import {EmbedNode} from './EmbedNode'; + +export class EmailEmbedNode extends EmbedNode { + // Only show embed and youtube cards in the email editor + static kgMenu = EmbedNode.kgMenu.filter( + item => item.matches?.includes('embed') || item.matches?.includes('youtube') + ); +} + +export const emailEmbedNodeReplacement = {replace: EmbedNode, with: (node: InstanceType) => { + return new EmailEmbedNode(node.exportJSON()); +}}; diff --git a/packages/koenig-lexical/src/nodes/EmailNode.jsx b/packages/koenig-lexical/src/nodes/EmailNode.jsx deleted file mode 100644 index df01ae4770..0000000000 --- a/packages/koenig-lexical/src/nodes/EmailNode.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import EmailCardIcon from '../assets/icons/kg-card-type-email.svg?react'; -import EmailIndicatorIcon from '../assets/icons/kg-indicator-email.svg?react'; -import {$canShowPlaceholderCurry} from '@lexical/text'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BASIC_NODES, KoenigCardWrapper} from '../index.js'; -import {EmailNode as BaseEmailNode} from '@tryghost/kg-default-nodes'; -import {EmailNodeComponent} from './EmailNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_EMAIL_COMMAND = createCommand(); - -export class EmailNode extends BaseEmailNode { - __htmlEditor; - __htmlEditorInitialState; - - static kgMenu = [{ - label: 'Email content', - desc: 'Only visible when delivered by email', - Icon: EmailCardIcon, - insertCommand: INSERT_EMAIL_COMMAND, - matches: ['email content', 'only email'], - priority: 8, - postType: 'post', - shortcut: '/email' - }]; - - getIcon() { - return EmailCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - // set up nested editor instances - setupNestedEditor(this, '__htmlEditor', {editor: dataset.htmlEditor, nodes: BASIC_NODES}); - - // populate nested editors on initial construction - if (!dataset.htmlEditor) { - populateNestedEditor(this, '__htmlEditor', dataset.html || '

Hey {first_name, "there"},

'); - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.htmlEditor = self.__htmlEditor; - dataset.htmlEditorInitialState = self.__htmlEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__htmlEditor) { - this.__htmlEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__htmlEditor, null); - const cleanedHtml = cleanBasicHtml(html, {removeCodeWrappers: false, allowBr: true}); - json.html = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isHtmlEmpty = this.__htmlEditor.getEditorState().read($canShowPlaceholderCurry(false)); - return isHtmlEmpty; - } -} - -export const $createEmailNode = (dataset) => { - return new EmailNode(dataset); -}; - -export function $isEmailNode(node) { - return node instanceof EmailNode; -} diff --git a/packages/koenig-lexical/src/nodes/EmailNode.tsx b/packages/koenig-lexical/src/nodes/EmailNode.tsx new file mode 100644 index 0000000000..972bcec718 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmailNode.tsx @@ -0,0 +1,103 @@ +import EmailCardIcon from '../assets/icons/kg-card-type-email.svg?react'; +import EmailIndicatorIcon from '../assets/icons/kg-indicator-email.svg?react'; +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BASIC_NODES, KoenigCardWrapper} from '../index'; +import {EmailNode as BaseEmailNode} from '@tryghost/kg-default-nodes'; +import {EmailNodeComponent} from './EmailNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_EMAIL_COMMAND = createCommand(); + +export class EmailNode extends BaseEmailNode { + __htmlEditor!: LexicalEditor; + __htmlEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Email content', + desc: 'Only visible when delivered by email', + Icon: EmailCardIcon, + insertCommand: INSERT_EMAIL_COMMAND, + matches: ['email content', 'only email'], + priority: 8, + postType: 'post', + shortcut: '/email' + }]; + + getIcon() { + return EmailCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + // set up nested editor instances + setupNestedEditor(this, '__htmlEditor', {editor: dataset.htmlEditor, nodes: BASIC_NODES}); + + // populate nested editors on initial construction + if (!dataset.htmlEditor) { + populateNestedEditor(this, '__htmlEditor', typeof dataset.html === 'string' ? dataset.html : '

Hey {first_name, "there"},

'); + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.htmlEditor = self.__htmlEditor; + dataset.htmlEditorInitialState = self.__htmlEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__htmlEditor) { + this.__htmlEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__htmlEditor, null); + const cleanedHtml = cleanBasicHtml(html, {removeCodeWrappers: false, allowBr: true}); + json.html = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isHtmlEmpty = this.__htmlEditor.getEditorState().read($canShowPlaceholderCurry(false)); + return isHtmlEmpty; + } +} + +export const $createEmailNode = (dataset: Record) => { + return new EmailNode(dataset); +}; + +export function $isEmailNode(node: unknown): node is EmailNode { + return node instanceof EmailNode; +} diff --git a/packages/koenig-lexical/src/nodes/EmailNodeComponent.jsx b/packages/koenig-lexical/src/nodes/EmailNodeComponent.jsx deleted file mode 100644 index 9429fc4d40..0000000000 --- a/packages/koenig-lexical/src/nodes/EmailNodeComponent.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; -import {EmailCard} from '../components/ui/cards/EmailCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function EmailNodeComponent({nodeKey, htmlEditor, htmlEditorInitialState}) { - const [editor] = useLexicalComposerContext(); - const cardContext = React.useContext(CardContext); - const {cardConfig} = React.useContext(KoenigComposerContext); - const {isEditing, isSelected} = cardContext; - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - React.useEffect(() => { - htmlEditor.setEditable(isEditing); - }, [isEditing, htmlEditor]); - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/EmailNodeComponent.tsx b/packages/koenig-lexical/src/nodes/EmailNodeComponent.tsx new file mode 100644 index 0000000000..9c26d12462 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmailNodeComponent.tsx @@ -0,0 +1,69 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {EmailCard} from '../components/ui/cards/EmailCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + +interface EmailNodeComponentProps { + nodeKey: string; + htmlEditor: LexicalEditor; + htmlEditorInitialState: unknown; +} + +export function EmailNodeComponent({nodeKey, htmlEditor, htmlEditorInitialState}: EmailNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const cardContext = React.useContext(CardContext); + const {cardConfig} = React.useContext(KoenigComposerContext); + const {isEditing, isSelected} = cardContext; + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + React.useEffect(() => { + htmlEditor.setEditable(isEditing); + }, [isEditing, htmlEditor]); + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/EmailNodes.js b/packages/koenig-lexical/src/nodes/EmailNodes.ts similarity index 100% rename from packages/koenig-lexical/src/nodes/EmailNodes.js rename to packages/koenig-lexical/src/nodes/EmailNodes.ts diff --git a/packages/koenig-lexical/src/nodes/EmbedNode.jsx b/packages/koenig-lexical/src/nodes/EmbedNode.jsx deleted file mode 100644 index 32fcca0c1b..0000000000 --- a/packages/koenig-lexical/src/nodes/EmbedNode.jsx +++ /dev/null @@ -1,175 +0,0 @@ -import CodePenIcon from '../assets/icons/kg-card-type-codepen.svg?react'; -import EmbedCardIcon from '../assets/icons/kg-card-type-other.svg?react'; -import SoundCloudIcon from '../assets/icons/kg-card-type-soundcloud.svg?react'; -import SpotifyIcon from '../assets/icons/kg-card-type-spotify.svg?react'; -import VimeoIcon from '../assets/icons/kg-card-type-vimeo.svg?react'; -import XIcon from '../assets/icons/kg-card-type-x.svg?react'; -import YouTubeIcon from '../assets/icons/kg-card-type-youtube.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {EmbedNode as BaseEmbedNode} from '@tryghost/kg-default-nodes'; -import {EmbedNodeComponent} from './EmbedNodeComponent'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_EMBED_COMMAND = createCommand(); - -export class EmbedNode extends BaseEmbedNode { - __captionEditor; - __captionEditorInitialState; - __createdWithUrl; - - static kgMenu = [{ - section: 'Embeds', - label: 'Other...', - desc: '/embed [url]', - Icon: EmbedCardIcon, - insertCommand: INSERT_EMBED_COMMAND, - matches: ['embed'], - queryParams: ['url'], - priority: 100, - shortcut: '/embed [url]', - isHidden: ({config}) => config?.editorType === 'email' - }, - { - section: 'Embeds', - label: 'YouTube', - desc: '/youtube [video url]', - Icon: YouTubeIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['youtube'], - priority: 1, - shortcut: '/youtube [url]' - }, - { - section: 'Embeds', - label: 'X (formerly Twitter)', - desc: '/twitter [tweet url]', - Icon: XIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['twitter', 'x'], - priority: 3, - shortcut: '/twitter [url]', - isHidden: ({config}) => config?.editorType === 'email' - }, - { - section: 'Embeds', - label: 'Vimeo', - desc: '/vimeo [video url]', - Icon: VimeoIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['vimeo'], - priority: 4, - shortcut: '/vimeo [url]', - isHidden: ({config}) => config?.editorType === 'email' - }, - { - section: 'Embeds', - label: 'CodePen', - desc: '/codepen [pen url]', - Icon: CodePenIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['codepen'], - priority: 5, - shortcut: '/codepen [url]', - isHidden: ({config}) => config?.editorType === 'email' - }, - { - section: 'Embeds', - label: 'Spotify', - desc: '/spotify [track or playlist url]', - Icon: SpotifyIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['spotify'], - priority: 6, - shortcut: '/spotify [url]', - isHidden: ({config}) => config?.editorType === 'email' - }, - { - section: 'Embeds', - label: 'SoundCloud', - desc: '/soundcloud [track or playlist url]', - Icon: SoundCloudIcon, - insertCommand: INSERT_EMBED_COMMAND, - queryParams: ['url'], - matches: ['soundcloud'], - priority: 7, - shortcut: '/soundcloud [url]', - isHidden: ({config}) => config?.editorType === 'email' - }]; - - getIcon() { - return EmbedCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - this.__createdWithUrl = !!dataset.url && !dataset.html; - - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.captionEditor && dataset.caption) { - populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createEmbedNode = (dataset) => { - return new EmbedNode(dataset); -}; - -export function $isEmbedNode(node) { - return node instanceof EmbedNode; -} diff --git a/packages/koenig-lexical/src/nodes/EmbedNode.tsx b/packages/koenig-lexical/src/nodes/EmbedNode.tsx new file mode 100644 index 0000000000..0005697a55 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmbedNode.tsx @@ -0,0 +1,176 @@ +import CodePenIcon from '../assets/icons/kg-card-type-codepen.svg?react'; +import EmbedCardIcon from '../assets/icons/kg-card-type-other.svg?react'; +import SoundCloudIcon from '../assets/icons/kg-card-type-soundcloud.svg?react'; +import SpotifyIcon from '../assets/icons/kg-card-type-spotify.svg?react'; +import VimeoIcon from '../assets/icons/kg-card-type-vimeo.svg?react'; +import XIcon from '../assets/icons/kg-card-type-x.svg?react'; +import YouTubeIcon from '../assets/icons/kg-card-type-youtube.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {EmbedNode as BaseEmbedNode} from '@tryghost/kg-default-nodes'; +import {EmbedNodeComponent} from './EmbedNodeComponent'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_EMBED_COMMAND = createCommand(); + +export class EmbedNode extends BaseEmbedNode { + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + __createdWithUrl: unknown; + + static kgMenu = [{ + section: 'Embeds', + label: 'Other...', + desc: '/embed [url]', + Icon: EmbedCardIcon, + insertCommand: INSERT_EMBED_COMMAND, + matches: ['embed'], + queryParams: ['url'], + priority: 100, + shortcut: '/embed [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }, + { + section: 'Embeds', + label: 'YouTube', + desc: '/youtube [video url]', + Icon: YouTubeIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['youtube'], + priority: 1, + shortcut: '/youtube [url]' + }, + { + section: 'Embeds', + label: 'X (formerly Twitter)', + desc: '/twitter [tweet url]', + Icon: XIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['twitter', 'x'], + priority: 3, + shortcut: '/twitter [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }, + { + section: 'Embeds', + label: 'Vimeo', + desc: '/vimeo [video url]', + Icon: VimeoIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['vimeo'], + priority: 4, + shortcut: '/vimeo [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }, + { + section: 'Embeds', + label: 'CodePen', + desc: '/codepen [pen url]', + Icon: CodePenIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['codepen'], + priority: 5, + shortcut: '/codepen [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }, + { + section: 'Embeds', + label: 'Spotify', + desc: '/spotify [track or playlist url]', + Icon: SpotifyIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['spotify'], + priority: 6, + shortcut: '/spotify [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }, + { + section: 'Embeds', + label: 'SoundCloud', + desc: '/soundcloud [track or playlist url]', + Icon: SoundCloudIcon, + insertCommand: INSERT_EMBED_COMMAND, + queryParams: ['url'], + matches: ['soundcloud'], + priority: 7, + shortcut: '/soundcloud [url]', + isHidden: ({config}: {config?: Record}) => config?.editorType === 'email' + }]; + + getIcon() { + return EmbedCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + this.__createdWithUrl = !!dataset.url && !dataset.html; + + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.captionEditor && dataset.caption) { + populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html); + json.caption = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } +} + +export const $createEmbedNode = (dataset: Record) => { + return new EmbedNode(dataset); +}; + +export function $isEmbedNode(node: unknown): node is EmbedNode { + return node instanceof EmbedNode; +} diff --git a/packages/koenig-lexical/src/nodes/EmbedNodeComponent.jsx b/packages/koenig-lexical/src/nodes/EmbedNodeComponent.jsx deleted file mode 100644 index b6ef1eea7c..0000000000 --- a/packages/koenig-lexical/src/nodes/EmbedNodeComponent.jsx +++ /dev/null @@ -1,171 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$createBookmarkNode} from './BookmarkNode'; -import {$createLinkNode} from '@lexical/link'; -import {$createParagraphNode, $createTextNode, $getNodeByKey, $isParagraphNode} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {EmbedCard} from '../components/ui/cards/EmbedCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem} from '../components/ui/ToolbarMenu.jsx'; -import {useCallback} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function EmbedNodeComponent({nodeKey, url, html, createdWithUrl, embedType, metadata, captionEditor, captionEditorInitialState}) { - const [editor] = useLexicalComposerContext(); - - const {cardConfig} = React.useContext(KoenigComposerContext); - const {isSelected} = React.useContext(CardContext); - const [urlInputValue, setUrlInputValue] = React.useState(''); - const [loading, setLoading] = React.useState(false); - const [urlError, setUrlError] = React.useState(false); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleUrlChange = (event) => { - setUrlInputValue(event.target.value); - }; - - const handleUrlSubmit = async (event) => { - if (event.key === 'Enter') { - fetchMetadata(event.target.value); - } - }; - - const handleRetry = async () => { - setUrlError(false); - }; - - const handlePasteAsLink = useCallback((href) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - if (!node) { - return; - } - const paragraph = $createParagraphNode() - .append($createLinkNode(href) - .append($createTextNode(href))); - node.replace(paragraph); - - if (!paragraph.getNextSibling()) { - paragraph.insertAfter($createParagraphNode()); - } - paragraph.selectNext(); - }); - }, [editor, nodeKey]); - - const handleClose = useCallback(() => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - const nextSibling = node.getNextSibling(); - if (nextSibling && $isParagraphNode(nextSibling) && nextSibling.getTextContentSize() === 0) { - node.remove(); - nextSibling.selectEnd(); - } else { - const paragraph = $createParagraphNode(); - node.replace(paragraph); - paragraph.selectEnd(); - } - }); - }, [editor, nodeKey]); - - const fetchMetadata = async (href) => { - setLoading(true); - let response; - try { - // set the test data return values in fetchEmbed.js - response = await cardConfig.fetchEmbed(href, {}); - // we may end up with a bookmark return if the url is valid but doesn't return an embed - if (response.type === 'bookmark') { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - const bookmarkNode = $createBookmarkNode({url: response.url, metadata: response.metadata}); - node.replace(bookmarkNode); - }); - return; - } - } catch (e) { - if (createdWithUrl) { - setLoading(false); - handlePasteAsLink(href); - - return; - } - setLoading(false); - setUrlError(true); - return; - } - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.url = href; - node.metadata = response; - node.embedType = response.type; - node.html = response.html; - - // select next node if card was pasted from link - if (createdWithUrl) { - node.selectNext(); - } - }); - setLoading(false); - // We only do this for init - - }; - - React.useEffect(() => { - if (createdWithUrl) { - // keep value in sync in case the user goes to retry to paste as link - setUrlInputValue(url); - try { - fetchMetadata(url); - } catch { - handlePasteAsLink(url); - } - } - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/EmbedNodeComponent.tsx b/packages/koenig-lexical/src/nodes/EmbedNodeComponent.tsx new file mode 100644 index 0000000000..b88b68799e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/EmbedNodeComponent.tsx @@ -0,0 +1,185 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext, {type EmbedResponse} from '../context/KoenigComposerContext'; +import React from 'react'; +import {$createBookmarkNode} from './BookmarkNode'; +import {$createLinkNode} from '@lexical/link'; +import {$createParagraphNode, $createTextNode, $getNodeByKey, $isParagraphNode} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EmbedCard} from '../components/ui/cards/EmbedCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem} from '../components/ui/ToolbarMenu'; +import {useCallback} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {EmbedNode} from '@tryghost/kg-default-nodes'; +import type {LexicalEditor} from 'lexical'; + +interface EmbedNodeComponentProps { + nodeKey: string; + url: string; + html: string; + createdWithUrl: unknown; + embedType: string; + metadata: unknown; + captionEditor: LexicalEditor; + captionEditorInitialState: unknown; +} + +export function EmbedNodeComponent({nodeKey, url, html, createdWithUrl, embedType: _embedType, metadata: _metadata, captionEditor, captionEditorInitialState}: EmbedNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + + const {cardConfig} = React.useContext(KoenigComposerContext); + const {isSelected} = React.useContext(CardContext); + const [urlInputValue, setUrlInputValue] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [urlError, setUrlError] = React.useState(false); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleUrlChange = (event: React.ChangeEvent) => { + setUrlInputValue(event.target.value); + }; + + const handleUrlSubmit = async (event: KeyboardEvent | React.KeyboardEvent) => { + if (event.key === 'Enter') { + fetchMetadata((event.target as HTMLInputElement).value); + } + }; + + const handleRetry = async () => { + setUrlError(false); + }; + + const handlePasteAsLink = useCallback((href?: string) => { + if (!href) {return;} + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!node) {return;} + const paragraph = $createParagraphNode() + .append($createLinkNode(href) + .append($createTextNode(href))); + node.replace(paragraph); + + if (!paragraph.getNextSibling()) { + paragraph.insertAfter($createParagraphNode()); + } + paragraph.selectNext(); + }); + }, [editor, nodeKey]); + + const handleClose = useCallback(() => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!node) {return;} + const nextSibling = node.getNextSibling(); + if (nextSibling && $isParagraphNode(nextSibling) && nextSibling.getTextContentSize() === 0) { + node.remove(); + nextSibling.selectEnd(); + } else { + const paragraph = $createParagraphNode(); + node.replace(paragraph); + paragraph.selectEnd(); + } + }); + }, [editor, nodeKey]); + + const fetchMetadata = async (href: string) => { + if (!cardConfig.fetchEmbed) {return;} + setLoading(true); + let response: EmbedResponse; + try { + // set the test data return values in fetchEmbed.js + response = await cardConfig.fetchEmbed(href, {}); + // we may end up with a bookmark return if the url is valid but doesn't return an embed + if (response.type === 'bookmark') { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!node) {return;} + const bookmarkNode = $createBookmarkNode({url: response.url, metadata: response.metadata}); + node.replace(bookmarkNode); + }); + return; + } + } catch { + if (createdWithUrl) { + setLoading(false); + handlePasteAsLink(href); + + return; + } + setLoading(false); + setUrlError(true); + return; + } + editor.update(() => { + const node = $getNodeByKey(nodeKey) as EmbedNode | null; + if (!node) {return;} + node.url = href; + node.metadata = response; + node.embedType = response.type ?? ""; + node.html = response.html ?? ""; + + // select next node if card was pasted from link + if (createdWithUrl) { + node.selectNext(); + } + }); + setLoading(false); + // We only do this for init + + }; + + React.useEffect(() => { + if (createdWithUrl) { + // keep value in sync in case the user goes to retry to paste as link + setUrlInputValue(url); + try { + fetchMetadata(url); + } catch { + handlePasteAsLink(url); + } + } + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/FileNode.jsx b/packages/koenig-lexical/src/nodes/FileNode.jsx deleted file mode 100644 index 772d44f29a..0000000000 --- a/packages/koenig-lexical/src/nodes/FileNode.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import FileCardIcon from '../assets/icons/kg-card-type-file.svg?react'; -import FileNodeComponent from './FileNodeComponent'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {FileNode as BaseFileNode} from '@tryghost/kg-default-nodes'; -import {createCommand} from 'lexical'; - -export const INSERT_FILE_COMMAND = createCommand(); - -export class FileNode extends BaseFileNode { - __triggerFileDialog = false; - __initialFile = null; - - static kgMenu = [{ - label: 'File', - desc: 'Upload a downloadable file', - Icon: FileCardIcon, - insertCommand: INSERT_FILE_COMMAND, - insertParams: { - triggerFileDialog: true - }, - matches: ['file'], - priority: 15, - shortcut: '/file' - }]; - - static uploadType = 'file'; - - constructor(dataset = {}, key) { - super(dataset, key); - - const {triggerFileDialog, initialFile} = dataset; - - // don't trigger the file dialog when rendering if we've already been given a url - this.__triggerFileDialog = (!dataset.src && triggerFileDialog) || false; - this.__initialFile = initialFile || null; - } - - getIcon() { - return FileCardIcon; - } - - set triggerFileDialog(shouldTrigger) { - const writable = this.getWritable(); - writable.__triggerFileDialog = shouldTrigger; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createFileNode = (dataset) => { - return new FileNode(dataset); -}; - -export function $isFileNode(node) { - return node instanceof FileNode; -} diff --git a/packages/koenig-lexical/src/nodes/FileNode.tsx b/packages/koenig-lexical/src/nodes/FileNode.tsx new file mode 100644 index 0000000000..d7fd5b3888 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/FileNode.tsx @@ -0,0 +1,75 @@ +import FileCardIcon from '../assets/icons/kg-card-type-file.svg?react'; +import FileNodeComponent from './FileNodeComponent'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {FileNode as BaseFileNode} from '@tryghost/kg-default-nodes'; +import {createCommand} from 'lexical'; + +export const INSERT_FILE_COMMAND = createCommand(); + +export class FileNode extends BaseFileNode { + __triggerFileDialog: boolean = false; + __initialFile: File | null = null; + + static kgMenu = [{ + label: 'File', + desc: 'Upload a downloadable file', + Icon: FileCardIcon, + insertCommand: INSERT_FILE_COMMAND, + insertParams: { + triggerFileDialog: true + }, + matches: ['file'], + priority: 15, + shortcut: '/file' + }]; + + static uploadType = 'file'; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + const {triggerFileDialog, initialFile} = dataset; + + // don't trigger the file dialog when rendering if we've already been given a url + this.__triggerFileDialog = !!(!dataset.src && triggerFileDialog); + this.__initialFile = (initialFile as File | null) || null; + } + + getIcon() { + return FileCardIcon; + } + + set triggerFileDialog(shouldTrigger: boolean) { + const writable = this.getWritable(); + writable.__triggerFileDialog = shouldTrigger; + } + + decorate() { + return ( + + + + ); + } +} + +export const $createFileNode = (dataset: Record) => { + return new FileNode(dataset); +}; + +export function $isFileNode(node: unknown): node is FileNode { + return node instanceof FileNode; +} diff --git a/packages/koenig-lexical/src/nodes/FileNodeComponent.jsx b/packages/koenig-lexical/src/nodes/FileNodeComponent.jsx deleted file mode 100644 index fb7137399d..0000000000 --- a/packages/koenig-lexical/src/nodes/FileNodeComponent.jsx +++ /dev/null @@ -1,175 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {FileCard} from '../components/ui/cards/FileCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {fileUploadHandler} from '../utils/fileUploadHandler'; -import {openFileSelection} from '../utils/openFileSelection'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function FileNodeComponent({ - fileDesc, - fileDescPlaceholder, - fileName, - fileSize, - fileTitle, - fileTitlePlaceholder, - fileSrc, - nodeKey, - triggerFileDialog, - initialFile - -}) { - const [editor] = useLexicalComposerContext(); - const [isPopulated, setIsPopulated] = React.useState(false); - const {fileUploader} = React.useContext(KoenigComposerContext); - const {isSelected, isEditing} = React.useContext(CardContext); - const fileInputRef = React.useRef(); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const uploader = fileUploader.useFileUpload('file'); - const fileDragHandler = useFileDragAndDrop({handleDrop: handleFileDrop}); - - React.useEffect(() => { - const uploadInitialFile = async (file) => { - if (file && !fileSrc) { - await fileUploadHandler([file], nodeKey, editor, uploader.upload); - } - }; - - uploadInitialFile(initialFile); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onFileChange = async (e) => { - const files = e.target.files; - - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.src = ''; - }); - - return await fileUploadHandler(files, nodeKey, editor, uploader.upload); - }; - - React.useEffect(() => { - // it should always be populated if it has a fileSrc, fileSize and fileName - if (fileSrc && fileSize && fileName) { - setIsPopulated(true); - } - }, [fileName, fileSize, fileSrc]); - - // const onFileInputRef = (element) => { - // fileInputRef.current = element; - // }; - - const enableEditing = (e) => { - e.preventDefault(); - // prevent card from propagating click event to the editor - e.stopPropagation(); - // TODO make it go to the first input field in the card - }; - - const handleFileTitle = (e) => { - const title = e.target.value; - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.fileTitle = title; - }); - }; - - const handleFileDesc = (e) => { - const desc = e.target.value; - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.fileCaption = desc; - }); - }; - - // when card is inserted from the card menu or slash command we want to show the file picker immediately - // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 - React.useEffect(() => { - if (!triggerFileDialog) { - return; - } - - const renderTimeout = setTimeout(() => { - // trigger dialog - openFileSelection({fileInputRef: fileInputRef}); - - // clear the property on the node so we don't accidentally trigger anything with a re-render - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.triggerFileDialog = false; - }); - }); - - return (() => { - clearTimeout(renderTimeout); - }); - - // absolutely no idea why [openFileSelection] is needed here but not - // in some other card's dialog trigger useEffects 🤷‍♂️ - // without it the dialog doesn't open when the card is inserted from the card menu - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openFileSelection]); - - async function handleFileDrop(files) { - await fileUploadHandler(files, nodeKey, editor, uploader.upload); - } - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} - -export default FileNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/FileNodeComponent.tsx b/packages/koenig-lexical/src/nodes/FileNodeComponent.tsx new file mode 100644 index 0000000000..9d2a212070 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/FileNodeComponent.tsx @@ -0,0 +1,193 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import {$getNodeByKey} from 'lexical'; +import {$isFileNode} from './FileNode'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {FileCard} from '../components/ui/cards/FileCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {fileUploadHandler} from '../utils/fileUploadHandler'; +import {openFileSelection} from '../utils/openFileSelection'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +interface FileNodeComponentProps { + fileDesc: string; + fileDescPlaceholder: string; + fileName: string; + fileSize: string; + fileTitle: string; + fileTitlePlaceholder: string; + fileSrc: string; + nodeKey: string; + triggerFileDialog: boolean; + initialFile: unknown; +} + +function FileNodeComponent({ + fileDesc, + fileDescPlaceholder, + fileName, + fileSize, + fileTitle, + fileTitlePlaceholder, + fileSrc, + nodeKey, + triggerFileDialog, + initialFile + +}: FileNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const [isPopulated, setIsPopulated] = React.useState(false); + const {fileUploader} = React.useContext(KoenigComposerContext); + const {isSelected, isEditing} = React.useContext(CardContext); + const fileInputRef = React.useRef(null); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const uploader = fileUploader.useFileUpload('file'); + const fileDragHandler = useFileDragAndDrop({handleDrop: handleFileDrop}); + + React.useEffect(() => { + const uploadInitialFile = async (file: unknown) => { + if (file && !fileSrc) { + await fileUploadHandler([file as File], nodeKey, editor, uploader.upload); + } + }; + + uploadInitialFile(initialFile); + + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + + // reset original src so it can be replaced with preview and upload progress + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isFileNode(node)) {return;} + node.src = ''; + }); + + return await fileUploadHandler(Array.from(files!), nodeKey, editor, uploader.upload); + }; + + React.useEffect(() => { + // it should always be populated if it has a fileSrc, fileSize and fileName + if (fileSrc && fileSize && fileName) { + setIsPopulated(true); + } + }, [fileName, fileSize, fileSrc]); + + // const onFileInputRef = (element) => { + // fileInputRef.current = element; + // }; + + const enableEditing = (e: React.MouseEvent) => { + e.preventDefault(); + // prevent card from propagating click event to the editor + e.stopPropagation(); + // TODO make it go to the first input field in the card + }; + + const handleFileTitle = (e: React.ChangeEvent) => { + const title = e.target.value; + + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isFileNode(node)) {return;} + node.fileTitle = title; + }); + }; + + const handleFileDesc = (e: React.ChangeEvent) => { + const desc = e.target.value; + + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isFileNode(node)) {return;} + node.fileCaption = desc; + }); + }; + + // when card is inserted from the card menu or slash command we want to show the file picker immediately + // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 + React.useEffect(() => { + if (!triggerFileDialog) { + return; + } + + const renderTimeout = setTimeout(() => { + // trigger dialog + openFileSelection({fileInputRef: fileInputRef}); + + // clear the property on the node so we don't accidentally trigger anything with a re-render + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (!$isFileNode(node)) {return;} + node.triggerFileDialog = false; + }); + }); + + return (() => { + clearTimeout(renderTimeout); + }); + + // absolutely no idea why [openFileSelection] is needed here but not + // in some other card's dialog trigger useEffects 🤷‍♂️ + // without it the dialog doesn't open when the card is inserted from the card menu + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openFileSelection]); + + async function handleFileDrop(files: FileList | File[]) { + await fileUploadHandler(Array.from(files), nodeKey, editor, uploader.upload); + } + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} + +export default FileNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/GalleryNode.jsx b/packages/koenig-lexical/src/nodes/GalleryNode.jsx deleted file mode 100644 index 8c95fe76a9..0000000000 --- a/packages/koenig-lexical/src/nodes/GalleryNode.jsx +++ /dev/null @@ -1,123 +0,0 @@ -import GalleryCardIcon from '../assets/icons/kg-card-type-gallery.svg?react'; -import pick from 'lodash/pick'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {GalleryNode as BaseGalleryNode} from '@tryghost/kg-default-nodes'; -import {GalleryNodeComponent} from './GalleryNodeComponent'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_GALLERY_COMMAND = createCommand(); - -export const MAX_IMAGES = 9; -export const MAX_PER_ROW = 3; - -// ensure we don't save client-side only properties such as preview blob urls to the server -export const ALLOWED_IMAGE_PROPS = ['row', 'src', 'width', 'height', 'alt', 'caption', 'fileName']; - -export function recalculateImageRows(images) { - images.forEach((image, idx) => { - image.row = Math.ceil((idx + 1) / MAX_PER_ROW) - 1; - }); -} - -export class GalleryNode extends BaseGalleryNode { - __captionEditor; - __captionEditorInitialState; - - static kgMenu = [{ - label: 'Gallery', - desc: 'Create an image gallery', - Icon: GalleryCardIcon, - insertCommand: INSERT_GALLERY_COMMAND, - insertParams: { - triggerFileDialog: true - }, - matches: ['gallery'], - priority: 5, - shortcut: '/gallery' - }]; - - constructor(dataset = {}, key) { - super(dataset, key); - - const {caption} = dataset; - - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - // populate nested editors on initial construction - if (!dataset.captionEditor && caption) { - populateNestedEditor(this, '__captionEditor', `${caption}`); - } - } - - getIcon() { - return GalleryCardIcon; - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } - - // TODO: move to kg-default-nodes? - setImages(images) { - const datasetImages = images - .slice(0, MAX_IMAGES) - .map(image => pick(image, ALLOWED_IMAGE_PROPS)); - - recalculateImageRows(datasetImages); - this.images = datasetImages; - } - - addImages(images) { - const datasetImages = [...this.images, ...images] - .slice(0, MAX_IMAGES) - .map(image => pick(image, ALLOWED_IMAGE_PROPS)); - - recalculateImageRows(datasetImages); - this.images = datasetImages; - } -} - -export const $createGalleryNode = (dataset) => { - return new GalleryNode(dataset); -}; - -export function $isGalleryNode(node) { - return node instanceof GalleryNode; -} diff --git a/packages/koenig-lexical/src/nodes/GalleryNode.tsx b/packages/koenig-lexical/src/nodes/GalleryNode.tsx new file mode 100644 index 0000000000..dac91592a6 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/GalleryNode.tsx @@ -0,0 +1,125 @@ +import GalleryCardIcon from '../assets/icons/kg-card-type-gallery.svg?react'; +import pick from 'lodash/pick'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {GalleryNode as BaseGalleryNode} from '@tryghost/kg-default-nodes'; +import {GalleryNodeComponent} from './GalleryNodeComponent'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {GalleryImage} from '../types/GalleryImage'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_GALLERY_COMMAND = createCommand(); + +export const MAX_IMAGES = 9; +export const MAX_PER_ROW = 3; + +// ensure we don't save client-side only properties such as preview blob urls to the server +export const ALLOWED_IMAGE_PROPS = ['row', 'src', 'width', 'height', 'alt', 'caption', 'fileName']; + +export function recalculateImageRows(images: GalleryImage[]) { + images.forEach((image, idx) => { + image.row = Math.ceil((idx + 1) / MAX_PER_ROW) - 1; + }); +} + +export class GalleryNode extends BaseGalleryNode { + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Gallery', + desc: 'Create an image gallery', + Icon: GalleryCardIcon, + insertCommand: INSERT_GALLERY_COMMAND, + insertParams: { + triggerFileDialog: true + }, + matches: ['gallery'], + priority: 5, + shortcut: '/gallery' + }]; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + const {caption} = dataset; + + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + // populate nested editors on initial construction + if (!dataset.captionEditor && caption) { + populateNestedEditor(this, '__captionEditor', `${caption}`); + } + } + + getIcon() { + return GalleryCardIcon; + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html); + json.caption = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } + + // TODO: move to kg-default-nodes? + setImages(images: GalleryImage[]) { + const datasetImages = images + .slice(0, MAX_IMAGES) + .map(image => pick(image, ALLOWED_IMAGE_PROPS) as GalleryImage); + + recalculateImageRows(datasetImages); + this.images = datasetImages; + } + + addImages(images: GalleryImage[]) { + const datasetImages = [...(this.images as GalleryImage[]), ...images] + .slice(0, MAX_IMAGES) + .map(image => pick(image, ALLOWED_IMAGE_PROPS) as GalleryImage); + + recalculateImageRows(datasetImages); + this.images = datasetImages; + } +} + +export const $createGalleryNode = (dataset: Record) => { + return new GalleryNode(dataset); +}; + +export function $isGalleryNode(node: unknown): node is GalleryNode { + return node instanceof GalleryNode; +} diff --git a/packages/koenig-lexical/src/nodes/GalleryNodeComponent.jsx b/packages/koenig-lexical/src/nodes/GalleryNodeComponent.jsx deleted file mode 100644 index d121b62df9..0000000000 --- a/packages/koenig-lexical/src/nodes/GalleryNodeComponent.jsx +++ /dev/null @@ -1,186 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import useGalleryReorder from '../hooks/useGalleryReorder'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {GalleryCard} from '../components/ui/cards/GalleryCard'; -import {MAX_IMAGES, recalculateImageRows} from './GalleryNode'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {getImageDimensions} from '../utils/getImageDimensions'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function GalleryNodeComponent({nodeKey, captionEditor, captionEditorInitialState}) { - const [editor] = useLexicalComposerContext(); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const {isSelected} = React.useContext(CardContext); - const fileInputRef = React.useRef(); - const [errorMessage, setErrorMessage] = React.useState(null); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - const [images, setImages] = React.useState(() => { - const existingImages = editor.getEditorState().read(() => { - const node = $getNodeByKey(nodeKey); - return node.images; - }); - return existingImages; - }); - - const galleryReorder = useGalleryReorder({images, updateImages: reorderImages, isSelected}); - const imageUploader = fileUploader.useFileUpload('image'); - const imageFilesDropper = useFileDragAndDrop({handleDrop: handleImageFilesDrop}); - - function reorderImages(newImages) { - recalculateImageRows(newImages); - setImages(newImages); - setNodeImages(newImages); - } - - function setNodeImages(newImages) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.setImages(newImages); - }); - } - - const deleteImage = (imageToDelete) => { - const newImages = images.filter(image => image.fileName !== imageToDelete.fileName); - recalculateImageRows(newImages); - setImages(newImages); - setNodeImages(newImages); - }; - - const handleImageUploads = async (files) => { - const currentCount = images.length; - const allowedCount = (MAX_IMAGES - currentCount); - - const strippedFiles = Array.prototype.slice.call(files, 0, allowedCount); - if (strippedFiles.length < files.length) { - setErrorMessage('Galleries are limited to 9 images'); - } - - if (strippedFiles.length === 0) { - return; - } - - const newImages = [...images]; - - // create preview images and capture dimensions - for (const file of strippedFiles) { - const previewSrc = URL.createObjectURL(file); - const {width, height} = await getImageDimensions(previewSrc); - - newImages.push({ - fileName: file.name, - previewSrc, - width, - height - }); - } - - recalculateImageRows(newImages); - - // show preview images immediately - setImages(newImages); - - // start uploads - const uploadResult = await imageUploader.upload(strippedFiles); - const uploadedImages = [...newImages]; - - if (!uploadResult) { - setErrorMessage('Something went wrong while uploading images. Please refresh the page and try again'); - return; - } - - uploadResult.forEach((result) => { - const image = uploadedImages.find(i => i.fileName === result.fileName); - - if (!image) { - console.error('Uploaded image not found in images array. Filename:', result.fileName); - return; - } - - image.src = result.url; - }); - - // update local state (it's not updated from Lexical state aside from initial setup) - // then update Lexical state which will trigger a save - setImages(newImages); - setNodeImages(newImages); - }; - - const onFileChange = async (e) => { - const files = e.target.files; - - if (!files || !files.length) { - return; - } - - return await handleImageUploads(files); - }; - - async function handleImageFilesDrop(files) { - await handleImageUploads(files); - } - - function handleToolbarAdd(event) { - event.preventDefault(); - fileInputRef.current.click(); - } - - const clearErrorMessage = () => { - setErrorMessage(null); - }; - - const hideToolbar = - !isSelected || - imageFilesDropper.isDraggedOver || - galleryReorder.isDraggedOver || - images.length <= 0; - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/GalleryNodeComponent.tsx b/packages/koenig-lexical/src/nodes/GalleryNodeComponent.tsx new file mode 100644 index 0000000000..6950fa44c8 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/GalleryNodeComponent.tsx @@ -0,0 +1,201 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import useGalleryReorder from '../hooks/useGalleryReorder'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {GalleryCard} from '../components/ui/cards/GalleryCard'; +import {MAX_IMAGES, recalculateImageRows} from './GalleryNode'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {getImageDimensions} from '../utils/getImageDimensions'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {GalleryImage} from '../types/GalleryImage'; +import type {GalleryNode} from './GalleryNode'; +import type {LexicalEditor} from 'lexical'; + +function $getGalleryNodeByKey(nodeKey: string): GalleryNode | null { + return $getNodeByKey(nodeKey) as GalleryNode | null; +} + +interface GalleryNodeComponentProps { + nodeKey: string; + captionEditor: LexicalEditor; + captionEditorInitialState: string | undefined; +} + +export function GalleryNodeComponent({nodeKey, captionEditor, captionEditorInitialState}: GalleryNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const {isSelected} = React.useContext(CardContext); + const fileInputRef = React.useRef(null); + const [errorMessage, setErrorMessage] = React.useState(null); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + const [images, setImages] = React.useState(() => { + const existingImages = editor.getEditorState().read(() => { + const node = $getGalleryNodeByKey(nodeKey); + if (!node) {return [];} + return node.images as GalleryImage[]; + }); + return existingImages || []; + }); + + const galleryReorder = useGalleryReorder({images, updateImages: reorderImages, isSelected}); + const imageUploader = fileUploader.useFileUpload('image'); + const imageFilesDropper = useFileDragAndDrop({handleDrop: handleImageFilesDrop}); + + function reorderImages(newImages: GalleryImage[]) { + recalculateImageRows(newImages); + setImages(newImages); + setNodeImages(newImages); + } + + function setNodeImages(newImages: GalleryImage[]) { + editor.update(() => { + const node = $getGalleryNodeByKey(nodeKey); + if (!node) {return;} + node.setImages(newImages); + }); + } + + const deleteImage = (imageToDelete: GalleryImage) => { + const newImages = images.filter(image => image.fileName !== imageToDelete.fileName); + recalculateImageRows(newImages); + setImages(newImages); + setNodeImages(newImages); + }; + + const handleImageUploads = async (files: File[] | FileList) => { + const currentCount = images.length; + const allowedCount = (MAX_IMAGES - currentCount); + + const strippedFiles = Array.prototype.slice.call(files, 0, allowedCount); + if (strippedFiles.length < files.length) { + setErrorMessage('Galleries are limited to 9 images'); + } + + if (strippedFiles.length === 0) { + return; + } + + const newImages = [...images]; + + // create preview images and capture dimensions + for (const file of strippedFiles) { + const previewSrc = URL.createObjectURL(file); + const {width, height} = await getImageDimensions(previewSrc); + + newImages.push({ + fileName: file.name, + previewSrc, + width, + height + }); + } + + recalculateImageRows(newImages); + + // show preview images immediately + setImages(newImages); + + // start uploads + const uploadResult = await imageUploader.upload(strippedFiles); + const uploadedImages = [...newImages]; + + if (!uploadResult) { + setErrorMessage('Something went wrong while uploading images. Please refresh the page and try again'); + return; + } + + (uploadResult as {fileName: string; url: string}[]).forEach((result) => { + const image = uploadedImages.find(i => i.fileName === result.fileName); + + if (!image) { + console.error('Uploaded image not found in images array. Filename:', result.fileName); + return; + } + + image.src = result.url; + }); + + // update local state (it's not updated from Lexical state aside from initial setup) + // then update Lexical state which will trigger a save + setImages(newImages); + setNodeImages(newImages); + }; + + const onFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + + if (!files || !files.length) { + return; + } + + return await handleImageUploads(files); + }; + + async function handleImageFilesDrop(files: File[]) { + await handleImageUploads(files); + } + + function handleToolbarAdd(event: React.MouseEvent) { + event.preventDefault(); + fileInputRef.current?.click(); + } + + const clearErrorMessage = () => { + setErrorMessage(null); + }; + + const hideToolbar = + !isSelected || + imageFilesDropper.isDraggedOver || + galleryReorder.isDraggedOver || + images.length <= 0; + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/HeaderNode.jsx b/packages/koenig-lexical/src/nodes/HeaderNode.jsx deleted file mode 100644 index 92a83acc04..0000000000 --- a/packages/koenig-lexical/src/nodes/HeaderNode.jsx +++ /dev/null @@ -1,189 +0,0 @@ -import HeaderCardIcon from '../assets/icons/kg-card-type-header.svg?react'; -import HeaderNodeComponent from './header/v2/HeaderNodeComponent'; -import HeaderNodeComponentV1 from './header/v1/HeaderNodeComponent'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import MINIMAL_NODES from './MinimalNodes'; -import {$canShowPlaceholderCurry} from '@lexical/text'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {HeaderNode as BaseHeaderNode} from '@tryghost/kg-default-nodes'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_HEADER_COMMAND = createCommand(); - -export class HeaderNode extends BaseHeaderNode { - __headerTextEditor; - __subheaderTextEditor; - __headerTextEditorInitialState; - __subheaderTextEditorInitialState; - - // We keep Header v1 here for testing and backwards compatibility - // we keep it hidden in the Menu in Ghost but visible in the Demo to ensure it remains tested till we full deprecate it in the future - static kgMenu = [ - { - label: 'Header1', - desc: 'Add a header', - Icon: HeaderCardIcon, - insertCommand: INSERT_HEADER_COMMAND, - matches: ['v1_header', 'v1_heading'], - priority: 11, - insertParams: () => ({ - version: 1 - }), - isHidden: ({config}) => { - return config?.deprecated?.headerV1 ?? true; - }, - shortcut: '/header' - }, - { - label: 'Header', - desc: 'Add a header', - Icon: HeaderCardIcon, - insertCommand: INSERT_HEADER_COMMAND, - matches: ['header', 'heading'], - priority: 11, - insertParams: () => ({ - version: 2 - }), - shortcut: '/header' - } - ]; - - getIcon() { - return HeaderCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - setupNestedEditor(this, '__headerTextEditor', {editor: dataset.headerTextEditor, nodes: MINIMAL_NODES}); - setupNestedEditor(this, '__subheaderTextEditor', {editor: dataset.subheaderTextEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.headerTextEditor && dataset.header) { - populateNestedEditor(this, '__headerTextEditor', `${dataset.header}`); // we serialize with no wrapper - } - if (!dataset.subheaderTextEditor && dataset.subheader) { - populateNestedEditor(this, '__subheaderTextEditor', `${dataset.subheader}`); // we serialize with no wrapper - } - } - - exportJSON() { - const json = super.exportJSON(); - - if (this.__headerTextEditor) { - this.__headerTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__headerTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.header = cleanedHtml; - }); - } - - if (this.__subheaderTextEditor) { - this.__subheaderTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__subheaderTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.subheader = cleanedHtml; - }); - } - - return json; - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.headerTextEditor = self.__headerTextEditor; - dataset.subheaderTextEditor = self.__subheaderTextEditor; - return dataset; - } - - getCardWidth() { - const version = this.version; - - if (version === 1) { - return 'full'; - } - - if (version === 2) { - const layout = this.layout; - return layout === 'split' ? 'full' : layout; - } - } - - decorate() { - // for backwards compatibility with v1 cards - if (this.version === 1) { - return ( - - - - ); - } - - if (this.version === 2) { - return ( - - - - ); - } - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isHtmlEmpty = this.__headerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); - const isSubHtmlEmpty = this.__subheaderTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); - return isHtmlEmpty && isSubHtmlEmpty && (!this.buttonEnabled || (!this.buttonText && !this.buttonUrl)) && !this.backgroundImageSrc; - } -} - -export const $createHeaderNode = (dataset) => { - return new HeaderNode(dataset); -}; - -export function $isHeaderNode(node) { - return node instanceof HeaderNode; -} diff --git a/packages/koenig-lexical/src/nodes/HeaderNode.tsx b/packages/koenig-lexical/src/nodes/HeaderNode.tsx new file mode 100644 index 0000000000..35db7296cd --- /dev/null +++ b/packages/koenig-lexical/src/nodes/HeaderNode.tsx @@ -0,0 +1,208 @@ +import HeaderCardIcon from '../assets/icons/kg-card-type-header.svg?react'; +import HeaderNodeComponent from './header/v2/HeaderNodeComponent'; +import HeaderNodeComponentV1 from './header/v1/HeaderNodeComponent'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import MINIMAL_NODES from './MinimalNodes'; +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {HeaderNode as BaseHeaderNode, normalizeCardWidth, type CardWidth, type HeaderData} from '@tryghost/kg-default-nodes'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +type Alignment = 'left' | 'center'; +type BackgroundSize = 'cover' | 'contain'; +type Layout = 'regular' | 'wide' | 'full' | 'split'; +type HeaderSize = 'small' | 'medium' | 'large'; +type HeaderStyle = 'dark' | 'light' | 'accent' | 'image'; + +export type HeaderNodeData = HeaderData & { + headerTextEditor?: LexicalEditor; + headerTextEditorInitialState?: unknown; + subheaderTextEditor?: LexicalEditor; + subheaderTextEditorInitialState?: unknown; +}; + +interface HeaderMenuConfig { + deprecated?: { + headerV1?: boolean; + }; +} + +export const INSERT_HEADER_COMMAND = createCommand(); + +export class HeaderNode extends BaseHeaderNode { + __headerTextEditor!: LexicalEditor; + __subheaderTextEditor!: LexicalEditor; + __headerTextEditorInitialState: unknown; + __subheaderTextEditorInitialState: unknown; + + // We keep Header v1 here for testing and backwards compatibility + // we keep it hidden in the Menu in Ghost but visible in the Demo to ensure it remains tested till we full deprecate it in the future + static kgMenu = [ + { + label: 'Header1', + desc: 'Add a header', + Icon: HeaderCardIcon, + insertCommand: INSERT_HEADER_COMMAND, + matches: ['v1_header', 'v1_heading'], + priority: 11, + insertParams: () => ({ + version: 1 + }), + isHidden: ({config}: {config?: HeaderMenuConfig}) => { + return config?.deprecated?.headerV1 ?? true; + }, + shortcut: '/header' + }, + { + label: 'Header', + desc: 'Add a header', + Icon: HeaderCardIcon, + insertCommand: INSERT_HEADER_COMMAND, + matches: ['header', 'heading'], + priority: 11, + insertParams: () => ({ + version: 2 + }), + shortcut: '/header' + } + ]; + + getIcon() { + return HeaderCardIcon; + } + + constructor(dataset: HeaderNodeData = {}, key?: string) { + super(dataset, key); + + setupNestedEditor(this, '__headerTextEditor', {editor: dataset.headerTextEditor, nodes: MINIMAL_NODES}); + setupNestedEditor(this, '__subheaderTextEditor', {editor: dataset.subheaderTextEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.headerTextEditor && dataset.header) { + populateNestedEditor(this, '__headerTextEditor', `${dataset.header}`); // we serialize with no wrapper + } + if (!dataset.subheaderTextEditor && dataset.subheader) { + populateNestedEditor(this, '__subheaderTextEditor', `${dataset.subheader}`); // we serialize with no wrapper + } + } + + exportJSON() { + const json = super.exportJSON(); + + if (this.__headerTextEditor) { + this.__headerTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__headerTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.header = cleanedHtml; + }); + } + + if (this.__subheaderTextEditor) { + this.__subheaderTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__subheaderTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.subheader = cleanedHtml; + }); + } + + return json; + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.headerTextEditor = self.__headerTextEditor; + dataset.subheaderTextEditor = self.__subheaderTextEditor; + return dataset; + } + + getCardWidth(): CardWidth | undefined { + const version = this.version; + + if (version === 1) { + return 'full'; + } + + if (version === 2) { + const layout = this.layout; + return normalizeCardWidth(layout === 'split' ? 'full' : layout); + } + } + + decorate() { + // for backwards compatibility with v1 cards + if (this.version === 1) { + return ( + + + + ); + } + + if (this.version === 2) { + return ( + + + + ); + } + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isHtmlEmpty = this.__headerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); + const isSubHtmlEmpty = this.__subheaderTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); + return isHtmlEmpty && isSubHtmlEmpty && (!this.buttonEnabled || (!this.buttonText && !this.buttonUrl)) && !this.backgroundImageSrc; + } +} + +export const $createHeaderNode = (dataset: HeaderNodeData = {}) => { + return new HeaderNode(dataset); +}; + +export function $isHeaderNode(node: unknown): node is HeaderNode { + return node instanceof HeaderNode; +} diff --git a/packages/koenig-lexical/src/nodes/HorizontalRuleNode.jsx b/packages/koenig-lexical/src/nodes/HorizontalRuleNode.jsx deleted file mode 100644 index 192b2dbad9..0000000000 --- a/packages/koenig-lexical/src/nodes/HorizontalRuleNode.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import DividerCardIcon from '../assets/icons/kg-card-type-divider.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {HorizontalRuleNode as BaseHorizontalRuleNode} from '@tryghost/kg-default-nodes'; -import {HorizontalRuleCard} from '../components/ui/cards/HorizontalRuleCard'; -import {createCommand} from 'lexical'; - -export const INSERT_HORIZONTAL_RULE_COMMAND = createCommand(); - -export class HorizontalRuleNode extends BaseHorizontalRuleNode { - static kgMenu = { - label: 'Divider', - desc: 'Insert a dividing line', - Icon: DividerCardIcon, - insertCommand: INSERT_HORIZONTAL_RULE_COMMAND, - matches: ['divider', 'horizontal-rule', 'hr'], - priority: 2, - shortcut: '/hr' - }; - - getIcon() { - return DividerCardIcon; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createHorizontalRuleNode() { - return new HorizontalRuleNode(); -} - -export function $isHorizontalRuleNode(node) { - return node instanceof HorizontalRuleNode; -} diff --git a/packages/koenig-lexical/src/nodes/HorizontalRuleNode.tsx b/packages/koenig-lexical/src/nodes/HorizontalRuleNode.tsx new file mode 100644 index 0000000000..952ce2674e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/HorizontalRuleNode.tsx @@ -0,0 +1,39 @@ +import DividerCardIcon from '../assets/icons/kg-card-type-divider.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {HorizontalRuleNode as BaseHorizontalRuleNode} from '@tryghost/kg-default-nodes'; +import {HorizontalRuleCard} from '../components/ui/cards/HorizontalRuleCard'; +import {createCommand} from 'lexical'; + +export const INSERT_HORIZONTAL_RULE_COMMAND = createCommand(); + +export class HorizontalRuleNode extends BaseHorizontalRuleNode { + static kgMenu = { + label: 'Divider', + desc: 'Insert a dividing line', + Icon: DividerCardIcon, + insertCommand: INSERT_HORIZONTAL_RULE_COMMAND, + matches: ['divider', 'horizontal-rule', 'hr'], + priority: 2, + shortcut: '/hr' + }; + + getIcon() { + return DividerCardIcon; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createHorizontalRuleNode() { + return new HorizontalRuleNode(); +} + +export function $isHorizontalRuleNode(node: unknown): node is HorizontalRuleNode { + return node instanceof HorizontalRuleNode; +} diff --git a/packages/koenig-lexical/src/nodes/HtmlNode.jsx b/packages/koenig-lexical/src/nodes/HtmlNode.jsx deleted file mode 100644 index 9fe75082c7..0000000000 --- a/packages/koenig-lexical/src/nodes/HtmlNode.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import HtmlCardIcon from '../assets/icons/kg-card-type-html.svg?react'; -import HtmlIndicatorIcon from '../assets/icons/kg-indicator-html.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {HtmlNode as BaseHtmlNode} from '@tryghost/kg-default-nodes'; -import {HtmlNodeComponent} from './HtmlNodeComponent'; -import {createCommand} from 'lexical'; - -export const INSERT_HTML_COMMAND = createCommand(); - -export class HtmlNode extends BaseHtmlNode { - static kgMenu = { - label: 'HTML', - desc: 'Insert a HTML editor card', - Icon: HtmlCardIcon, - insertCommand: INSERT_HTML_COMMAND, - matches: ['html'], - priority: 18, - shortcut: '/html' - }; - - getIcon() { - return HtmlCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - } - - decorate() { - return ( - - - - ); - } -} - -export function $createHtmlNode(dataset) { - return new HtmlNode(dataset); -} - -export function $isHtmlNode(node) { - return node instanceof HtmlNode; -} diff --git a/packages/koenig-lexical/src/nodes/HtmlNode.tsx b/packages/koenig-lexical/src/nodes/HtmlNode.tsx new file mode 100644 index 0000000000..a9c38fb6fb --- /dev/null +++ b/packages/koenig-lexical/src/nodes/HtmlNode.tsx @@ -0,0 +1,51 @@ +import HtmlCardIcon from '../assets/icons/kg-card-type-html.svg?react'; +import HtmlIndicatorIcon from '../assets/icons/kg-indicator-html.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {HtmlNode as BaseHtmlNode} from '@tryghost/kg-default-nodes'; +import {HtmlNodeComponent} from './HtmlNodeComponent'; +import {createCommand} from 'lexical'; + +export const INSERT_HTML_COMMAND = createCommand(); + +export class HtmlNode extends BaseHtmlNode { + static kgMenu = { + label: 'HTML', + desc: 'Insert a HTML editor card', + Icon: HtmlCardIcon, + insertCommand: INSERT_HTML_COMMAND, + matches: ['html'], + priority: 18, + shortcut: '/html' + }; + + getIcon() { + return HtmlCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + } + + decorate() { + return ( + + + + ); + } +} + +export function $createHtmlNode(dataset: Record) { + return new HtmlNode(dataset); +} + +export function $isHtmlNode(node: unknown): node is HtmlNode { + return node instanceof HtmlNode; +} diff --git a/packages/koenig-lexical/src/nodes/HtmlNodeComponent.jsx b/packages/koenig-lexical/src/nodes/HtmlNodeComponent.jsx deleted file mode 100644 index 455d8733d1..0000000000 --- a/packages/koenig-lexical/src/nodes/HtmlNodeComponent.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {DESELECT_CARD_COMMAND, EDIT_CARD_COMMAND, SHOW_CARD_VISIBILITY_SETTINGS_COMMAND} from '../plugins/KoenigBehaviourPlugin.jsx'; -import {HtmlCard} from '../components/ui/cards/HtmlCard'; -import {SettingsPanel} from '../components/ui/SettingsPanel.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {VisibilitySettings} from '../components/ui/VisibilitySettings.jsx'; -import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext.jsx'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useVisibilityToggle} from '../hooks/useVisibilityToggle.js'; - -export function HtmlNodeComponent({nodeKey, html}) { - const [editor] = useLexicalComposerContext(); - const cardContext = React.useContext(CardContext); - const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const {showVisibilitySettings} = useKoenigSelectedCardContext(); - - const {isVisibilityEnabled, visibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); - - const settingsTabs = [ - {id: 'visibility', label: 'Visibility'} - ]; - - const updateHtml = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.html = value; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - // TODO: this isn't used? does not have a prop for `onBlur` - const onBlur = (event) => { - if (event?.relatedTarget?.className !== 'kg-prose') { - editor.dispatchCommand(DESELECT_CARD_COMMAND, {cardKey: nodeKey}); - } - }; - - const visibilitySettings = ( - - ); - - const handleVisibilityToggle = React.useCallback((event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(SHOW_CARD_VISIBILITY_SETTINGS_COMMAND, {cardKey: nodeKey}); - }, [editor, nodeKey]); - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - {isVisibilityEnabled && ( - <> - - - - )} - - setShowSnippetToolbar(true)} - /> - - - - {isVisibilityEnabled && showVisibilitySettings && cardContext.isSelected && ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - {{ - visibility: visibilitySettings - }} - - )} - - ); -} diff --git a/packages/koenig-lexical/src/nodes/HtmlNodeComponent.tsx b/packages/koenig-lexical/src/nodes/HtmlNodeComponent.tsx new file mode 100644 index 0000000000..15d91daae5 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/HtmlNodeComponent.tsx @@ -0,0 +1,128 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND, SHOW_CARD_VISIBILITY_SETTINGS_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {HtmlCard} from '../components/ui/cards/HtmlCard'; +import {SettingsPanel} from '../components/ui/SettingsPanel'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {VisibilitySettings} from '../components/ui/VisibilitySettings'; +import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useVisibilityToggle} from '../hooks/useVisibilityToggle'; +import type {HtmlNode} from '@tryghost/kg-default-nodes'; + +interface HtmlNodeComponentProps { + nodeKey: string; + html: string; +} + +export function HtmlNodeComponent({nodeKey, html}: HtmlNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const cardContext = React.useContext(CardContext); + const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const {showVisibilitySettings} = useKoenigSelectedCardContext(); + + const {isVisibilityEnabled, visibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); + + const settingsTabs = [ + {id: 'visibility', label: 'Visibility'} + ]; + + const updateHtml = (value: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as HtmlNode | null; + if (!node) {return;} + node.html = value; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + const visibilitySettings = ( + + ); + + const handleVisibilityToggle = React.useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(SHOW_CARD_VISIBILITY_SETTINGS_COMMAND, {cardKey: nodeKey}); + }, [editor, nodeKey]); + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + {isVisibilityEnabled && ( + <> + + + + )} + + setShowSnippetToolbar(true)} + /> + + + + {isVisibilityEnabled && showVisibilitySettings && cardContext.isSelected && ( + + {{ + visibility: visibilitySettings + }} + + )} + + ); +} diff --git a/packages/koenig-lexical/src/nodes/ImageNode.jsx b/packages/koenig-lexical/src/nodes/ImageNode.jsx deleted file mode 100644 index deb1dd525f..0000000000 --- a/packages/koenig-lexical/src/nodes/ImageNode.jsx +++ /dev/null @@ -1,176 +0,0 @@ -import GIFIcon from '../assets/icons/kg-card-type-gif.svg?react'; -import ImageCardIcon from '../assets/icons/kg-card-type-image.svg?react'; -import UnsplashIcon from '../assets/icons/kg-card-type-unsplash.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {ImageNode as BaseImageNode} from '@tryghost/kg-default-nodes'; -import {ImageNodeComponent} from './ImageNodeComponent'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {OPEN_GIF_SELECTOR_COMMAND, OPEN_UNSPLASH_SELECTOR_COMMAND} from '../plugins/KoenigSelectorPlugin.jsx'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_IMAGE_COMMAND = createCommand(); - -export class ImageNode extends BaseImageNode { - // transient properties used to control node behaviour - __triggerFileDialog = false; - __previewSrc = null; - __captionEditor; - __captionEditorInitialState; - - static kgMenu = [{ - label: 'Image', - desc: 'Upload, or embed with /image [url]', - Icon: ImageCardIcon, - insertCommand: INSERT_IMAGE_COMMAND, - insertParams: { - triggerFileDialog: true - }, - matches: ['image', 'img'], - queryParams: ['src'], - priority: 1, - shortcut: '/image' - }, - { - section: 'Embeds', - label: 'Unsplash', - desc: '/unsplash [search term or url]', - Icon: UnsplashIcon, - insertCommand: OPEN_UNSPLASH_SELECTOR_COMMAND, - insertParams: { - triggerFileDialog: false - }, - isHidden: ({config}) => !config?.unsplash, - matches: ['unsplash', 'uns'], - queryParams: ['src'], - priority: 3, - shortcut: '/unsplash' - }, - { - label: 'GIF', - desc: 'Search and embed gifs', - Icon: GIFIcon, - insertCommand: OPEN_GIF_SELECTOR_COMMAND, - insertParams: { - triggerFileDialog: false - }, - matches: ['gif', 'giphy', 'tenor', 'klipy'], - priority: 17, - queryParams: ['src'], - isHidden: ({config}) => !config?.tenor && !config?.klipy, - shortcut: '/gif' - }]; - - static uploadType = 'image'; - - constructor(dataset = {}, key) { - super(dataset, key); - - const {previewSrc, triggerFileDialog, initialFile, selector, isImageHidden} = dataset; - - this.__previewSrc = previewSrc || ''; - // don't trigger the file dialog when rendering if we've already been given a url - this.__triggerFileDialog = (!dataset.src && triggerFileDialog) || false; - - // passed via INSERT_MEDIA_COMMAND on drag+drop or paste - this.__initialFile = initialFile || null; - - this.__selector = selector; - this.__isImageHidden = isImageHidden; - - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.captionEditor && dataset.caption) { - populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper - } - } - - getIcon() { - return ImageCardIcon; - } - - getDataset() { - const dataset = super.getDataset(); - - dataset.__previewSrc = this.__previewSrc; - dataset.__triggerFileDialog = this.__triggerFileDialog; - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - get previewSrc() { - const self = this.getLatest(); - return self.__previewSrc; - } - - set previewSrc(previewSrc) { - const writable = this.getWritable(); - writable.__previewSrc = previewSrc; - } - - set triggerFileDialog(shouldTrigger) { - const writable = this.getWritable(); - writable.__triggerFileDialog = shouldTrigger; - } - - createDOM() { - return document.createElement('div'); - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true}); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - const Selector = this.__selector; - - return ( - - {this.__selector && } - - { - !this.__isImageHidden && ( - - ) - } - - ); - } -} - -export const $createImageNode = (dataset) => { - return new ImageNode(dataset); -}; - -export function $isImageNode(node) { - return node instanceof ImageNode; -} diff --git a/packages/koenig-lexical/src/nodes/ImageNode.tsx b/packages/koenig-lexical/src/nodes/ImageNode.tsx new file mode 100644 index 0000000000..97e54cea72 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ImageNode.tsx @@ -0,0 +1,177 @@ +import GIFIcon from '../assets/icons/kg-card-type-gif.svg?react'; +import ImageCardIcon from '../assets/icons/kg-card-type-image.svg?react'; +import UnsplashIcon from '../assets/icons/kg-card-type-unsplash.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {ImageNode as BaseImageNode, normalizeCardWidth} from '@tryghost/kg-default-nodes'; +import {ImageNodeComponent} from './ImageNodeComponent'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {OPEN_GIF_SELECTOR_COMMAND, OPEN_UNSPLASH_SELECTOR_COMMAND} from '../plugins/KoenigSelectorPlugin'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_IMAGE_COMMAND = createCommand(); + +export class ImageNode extends BaseImageNode { + // transient properties used to control node behaviour + __triggerFileDialog: boolean = false; + __previewSrc: string = ''; + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Image', + desc: 'Upload, or embed with /image [url]', + Icon: ImageCardIcon, + insertCommand: INSERT_IMAGE_COMMAND, + insertParams: { + triggerFileDialog: true + }, + matches: ['image', 'img'], + queryParams: ['src'], + priority: 1, + shortcut: '/image' + }, + { + section: 'Embeds', + label: 'Unsplash', + desc: '/unsplash [search term or url]', + Icon: UnsplashIcon, + insertCommand: OPEN_UNSPLASH_SELECTOR_COMMAND, + insertParams: { + triggerFileDialog: false + }, + isHidden: ({config}: {config?: Record}) => !config?.unsplash, + matches: ['unsplash', 'uns'], + queryParams: ['src'], + priority: 3, + shortcut: '/unsplash' + }, + { + label: 'GIF', + desc: 'Search and embed gifs', + Icon: GIFIcon, + insertCommand: OPEN_GIF_SELECTOR_COMMAND, + insertParams: { + triggerFileDialog: false + }, + matches: ['gif', 'giphy', 'tenor', 'klipy'], + priority: 17, + queryParams: ['src'], + isHidden: ({config}: {config?: Record}) => !config?.tenor && !config?.klipy, + shortcut: '/gif' + }]; + + static uploadType = 'image'; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + const {previewSrc, triggerFileDialog, initialFile, selector, isImageHidden} = dataset; + + this.__previewSrc = (previewSrc as string) || ''; + // don't trigger the file dialog when rendering if we've already been given a url + this.__triggerFileDialog = !!(!dataset.src && triggerFileDialog); + + // passed via INSERT_MEDIA_COMMAND on drag+drop or paste + this.__initialFile = initialFile || null; + + this.__selector = selector; + this.__isImageHidden = isImageHidden; + + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.captionEditor && dataset.caption) { + populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); // we serialize with no wrapper + } + } + + getIcon() { + return ImageCardIcon; + } + + getDataset() { + const dataset = super.getDataset(); + + dataset.__previewSrc = this.__previewSrc; + dataset.__triggerFileDialog = this.__triggerFileDialog; + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + get previewSrc() { + const self = this.getLatest(); + return self.__previewSrc; + } + + set previewSrc(previewSrc) { + const writable = this.getWritable(); + writable.__previewSrc = previewSrc; + } + + set triggerFileDialog(shouldTrigger: boolean) { + const writable = this.getWritable(); + writable.__triggerFileDialog = shouldTrigger; + } + + createDOM() { + return document.createElement('div'); + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true}); + json.caption = cleanedHtml ?? ""; + }); + } + + return json; + } + + decorate() { + const Selector = this.__selector as React.ComponentType<{nodeKey: string}>; + + return ( + + {Selector && } + + { + !this.__isImageHidden && ( + + ) + } + + ); + } +} + +export const $createImageNode = (dataset: Record) => { + return new ImageNode(dataset); +}; + +export function $isImageNode(node: unknown): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/packages/koenig-lexical/src/nodes/ImageNodeComponent.jsx b/packages/koenig-lexical/src/nodes/ImageNodeComponent.jsx deleted file mode 100644 index d0a83faa15..0000000000 --- a/packages/koenig-lexical/src/nodes/ImageNodeComponent.jsx +++ /dev/null @@ -1,314 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import useCardDragAndDrop from '../hooks/useCardDragAndDrop'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import usePinturaEditor from '../hooks/usePinturaEditor'; -import {$createGalleryNode} from './GalleryNode'; -import {$createNodeSelection, $getNodeByKey, $setSelection} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {ImageCard} from '../components/ui/cards/ImageCard'; -import {ImageUploadForm} from '../components/ui/ImageUploadForm'; -import {LinkInput} from '../components/ui/LinkInput'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {dataSrcToFile} from '../utils/dataSrcToFile.js'; -import {getAllowedImageCardWidths, getDefaultImageCardWidth} from '../utils/image-card-widths'; -import {getImageDimensions} from '../utils/getImageDimensions.js'; -import {getImageFilenameFromSrc} from '../utils/getImageFilenameFromSrc'; -import {imageUploadHandler} from '../utils/imageUploadHandler'; -import {isGif} from '../utils/isGif'; -import {openFileSelection} from '../utils/openFileSelection'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function ImageNodeComponent({nodeKey, initialFile, src, altText, captionEditor, captionEditorInitialState, triggerFileDialog, previewSrc, href}) { - const [editor] = useLexicalComposerContext(); - const [showLink, setShowLink] = React.useState(false); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const {isSelected, cardWidth, setCardWidth} = React.useContext(CardContext); - const fileInputRef = React.useRef(); - const toolbarFileInputRef = React.useRef(); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const imageUploader = fileUploader.useFileUpload('image'); - const imageFileDragHandler = useFileDragAndDrop({handleDrop: handleImageDrop}); - - // stable fn refs to avoid excessive re-inits of the drag/drop handler effects - // which can cause unexpected side-effects with event handling - const canDropImageCard = React.useCallback((draggable) => { - return draggable.type === 'card' - && draggable.cardName === 'image' - && draggable.nodeKey !== nodeKey; - }, [nodeKey]); - const onDropImageCard = React.useCallback((draggable) => { - const {type, cardName, nodeKey: draggedNodeKey, dataset} = draggable; - - if (type === 'card' && cardName === 'image' && draggedNodeKey && dataset) { - editor.update(() => { - const targetImageNode = $getNodeByKey(nodeKey); - const droppedImageNode = $getNodeByKey(draggedNodeKey); - const galleryNode = $createGalleryNode(); - - // images don't contain the filename dataset property so we need to add it - dataset.fileName = dataset?.fileName || getImageFilenameFromSrc(dataset.src); - const targetImageDataset = targetImageNode.getDataset(); - targetImageDataset.fileName = targetImageDataset?.fileName || getImageFilenameFromSrc(targetImageDataset.src); - - galleryNode.addImages([targetImageDataset, dataset]); - - targetImageNode.replace(galleryNode); - droppedImageNode.remove(); - }); - } - }, [editor, nodeKey]); - const imageCardDragHandler = useCardDragAndDrop({ - canDrop: canDropImageCard, - onDrop: onDropImageCard - }); - - const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} - = usePinturaEditor({config: cardConfig.pinturaConfig}); - - const allowedImageCardWidths = React.useMemo(() => { - return getAllowedImageCardWidths(cardConfig?.image?.allowedWidths); - }, [cardConfig?.image?.allowedWidths]); - const defaultImageCardWidth = React.useMemo(() => { - return getDefaultImageCardWidth(allowedImageCardWidths); - }, [allowedImageCardWidths]); - const hasMultipleImageCardWidths = allowedImageCardWidths.length > 1; - - React.useEffect(() => { - if (!src?.startsWith('data:') || imageUploader.isLoading) { - return; - } - - let isMounted = true; - - // When copy/pasting from Google Docs it's possible for images to be transferred with data: URLs. - // Convert `data:` URL to File and upload it - const uploadFile = async () => { - const file = await dataSrcToFile(src); - if (isMounted) { - await imageUploadHandler([file], nodeKey, editor, imageUploader.upload); - } - }; - - uploadFile(); - - return () => isMounted = false; - }, [editor, imageUploader.isLoading, imageUploader.upload, nodeKey, src]); - - React.useEffect(() => { - // If an initial file is provided, upload it - const uploadInitialFile = async (file) => { - if (file && !src) { - await imageUploadHandler([file], nodeKey, editor, imageUploader.upload); - } - }; - - uploadInitialFile(initialFile); - - // Populate missing image dimensions, occurs when images are - // pasted/dragged/inserted as external or when loaded from serialized - // state that has missing images - const populateImageDimensions = async () => { - if (src && !initialFile && !triggerFileDialog) { - const {width, height} = await getImageDimensions(src); - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.width = width; - node.height = height; - }); - } - }; - - const hasMissingDimensions = editor.getEditorState().read(() => { - const node = $getNodeByKey(nodeKey); - if (!node.width || !node.height) { - return true; - } - return false; - }); - - if (hasMissingDimensions) { - populateImageDimensions(); - } - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onFileChange = async (e) => { - const files = e.target.files; - - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.src = ''; - }); - - return await imageUploadHandler(files, nodeKey, editor, imageUploader.upload); - }; - - const setHref = (newHref) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.href = newHref; - }); - }; - - const setAltText = (newAltText) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alt = newAltText; - }); - }; - - // when card is inserted from the card menu or slash command we want to show the file picker immediately - // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 - React.useEffect(() => { - if (!triggerFileDialog) { - return; - } - - const renderTimeout = setTimeout(() => { - // trigger dialog - openFileSelection({fileInputRef}); - - // clear the property on the node so we don't accidentally trigger anything with a re-render - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.triggerFileDialog = false; - }); - }); - - return (() => { - clearTimeout(renderTimeout); - }); - }); - - const handleImageCardResize = React.useCallback((newWidth) => { - if (!allowedImageCardWidths.includes(newWidth)) { - return; - } - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.cardWidth = newWidth; // this is a property on the node, not the card - setCardWidth(newWidth); // sets the state of the toolbar component - }); - }, [allowedImageCardWidths, editor, nodeKey, setCardWidth]); - - React.useEffect(() => { - if (!allowedImageCardWidths.includes(cardWidth)) { - handleImageCardResize(defaultImageCardWidth); - } - }, [allowedImageCardWidths, cardWidth, defaultImageCardWidth, handleImageCardResize]); - - const cancelLinkAndReselect = () => { - setShowLink(false); - reselectImageCard(); - }; - - const reselectImageCard = () => { - editor.update(() => { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(nodeKey); - $setSelection(nodeSelection); - }); - }; - - async function handleImageDrop(files) { - await imageUploadHandler(files, nodeKey, editor, imageUploader.upload); - } - - return ( - <> - - - - { - setHref(_href); - cancelLinkAndReselect(); - }} - /> - - - - setShowSnippetToolbar(false)} /> - - - - - - handleImageCardResize('regular')} - /> - handleImageCardResize('wide')} - /> - handleImageCardResize('full')} - /> - - { - setShowLink(true); - }} /> - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx b/packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx new file mode 100644 index 0000000000..676c1cb327 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx @@ -0,0 +1,350 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import useCardDragAndDrop from '../hooks/useCardDragAndDrop'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import usePinturaEditor from '../hooks/usePinturaEditor'; +import {$createGalleryNode} from './GalleryNode'; +import {$createNodeSelection, $getNodeByKey, $setSelection} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {ImageCard} from '../components/ui/cards/ImageCard'; +import {ImageUploadForm} from '../components/ui/ImageUploadForm'; +import {LinkInput} from '../components/ui/LinkInput'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {dataSrcToFile} from '../utils/dataSrcToFile'; +import {getAllowedImageCardWidths, getDefaultImageCardWidth} from '../utils/image-card-widths'; +import {getImageDimensions} from '../utils/getImageDimensions'; +import {getImageFilenameFromSrc} from '../utils/getImageFilenameFromSrc'; +import {imageUploadHandler} from '../utils/imageUploadHandler'; +import {isCardWidth} from '@tryghost/kg-default-nodes'; +import {isGif} from '../utils/isGif'; +import {openFileSelection} from '../utils/openFileSelection'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {DraggableInfo} from '../utils/draggable/ScrollHandler'; +import type {ImageNode} from './ImageNode'; +import type {LexicalEditor} from 'lexical'; + +function $getImageNodeByKey(nodeKey: string): ImageNode | null { + return $getNodeByKey(nodeKey) as ImageNode | null; +} + +interface ImageNodeComponentProps { + nodeKey: string; + initialFile: unknown; + src: string; + altText: string; + captionEditor: LexicalEditor; + captionEditorInitialState: string | undefined; + triggerFileDialog: boolean; + previewSrc: string | undefined; + href: string; +} + +export function ImageNodeComponent({nodeKey, initialFile, src, altText, captionEditor, captionEditorInitialState, triggerFileDialog, previewSrc, href}: ImageNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const [showLink, setShowLink] = React.useState(false); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const {isSelected, cardWidth, setCardWidth} = React.useContext(CardContext); + const fileInputRef = React.useRef(null); + const toolbarFileInputRef = React.useRef(null); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const imageUploader = fileUploader.useFileUpload('image'); + const imageFileDragHandler = useFileDragAndDrop({handleDrop: handleImageDrop}); + + // stable fn refs to avoid excessive re-inits of the drag/drop handler effects + // which can cause unexpected side-effects with event handling + const canDropImageCard = React.useCallback((draggable: DraggableInfo) => { + return draggable.type === 'card' + && draggable.cardName === 'image' + && draggable.nodeKey !== nodeKey; + }, [nodeKey]); + const onDropImageCard = React.useCallback((draggable: DraggableInfo) => { + const {type, cardName, nodeKey: draggedNodeKey, dataset} = draggable; + + if (type === 'card' && cardName === 'image' && draggedNodeKey && dataset) { + editor.update(() => { + const targetImageNode = $getImageNodeByKey(nodeKey); + const droppedImageNode = $getNodeByKey(draggedNodeKey); + if (!targetImageNode || !droppedImageNode) {return;} + const galleryNode = $createGalleryNode({}); + + // images don't contain the filename dataset property so we need to add it + dataset.fileName = dataset?.fileName || getImageFilenameFromSrc(dataset.src as string); + const targetImageDataset = targetImageNode.getDataset(); + targetImageDataset.fileName = targetImageDataset?.fileName || getImageFilenameFromSrc(targetImageDataset.src as string); + + galleryNode.addImages([targetImageDataset, dataset]); + + targetImageNode.replace(galleryNode); + droppedImageNode.remove(); + }); + } + }, [editor, nodeKey]); + const imageCardDragHandler = useCardDragAndDrop({ + canDrop: canDropImageCard, + onDrop: onDropImageCard, + draggableSelector: '[data-kg-card]', + droppableSelector: '[data-kg-card]' + }); + + const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} + = usePinturaEditor({config: cardConfig.pinturaConfig}); + + const allowedImageCardWidths = React.useMemo(() => { + return getAllowedImageCardWidths(cardConfig?.image?.allowedWidths); + }, [cardConfig?.image?.allowedWidths]); + const defaultImageCardWidth = React.useMemo(() => { + return getDefaultImageCardWidth(allowedImageCardWidths); + }, [allowedImageCardWidths]); + const hasMultipleImageCardWidths = allowedImageCardWidths.length > 1; + + React.useEffect(() => { + if (!src?.startsWith('data:') || imageUploader.isLoading) { + return; + } + + let isMounted = true; + + // When copy/pasting from Google Docs it's possible for images to be transferred with data: URLs. + // Convert `data:` URL to File and upload it + const uploadFile = async () => { + const file = await dataSrcToFile(src); + if (isMounted && file) { + await imageUploadHandler([file], nodeKey, editor, imageUploader.upload); + } + }; + + uploadFile(); + + return () => { isMounted = false; }; + }, [editor, imageUploader.isLoading, imageUploader.upload, nodeKey, src]); + + React.useEffect(() => { + // If an initial file is provided, upload it + const uploadInitialFile = async (file: unknown) => { + if (file && !src) { + await imageUploadHandler([file as File], nodeKey, editor, imageUploader.upload); + } + }; + + uploadInitialFile(initialFile); + + // Populate missing image dimensions, occurs when images are + // pasted/dragged/inserted as external or when loaded from serialized + // state that has missing images + const populateImageDimensions = async () => { + if (src && !initialFile && !triggerFileDialog) { + const {width, height} = await getImageDimensions(src); + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.width = width; + node.height = height; + }); + } + }; + + const hasMissingDimensions = editor.getEditorState().read(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node.width || !node.height) { + return true; + } + return false; + }); + + if (hasMissingDimensions) { + populateImageDimensions(); + } + + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + + // reset original src so it can be replaced with preview and upload progress + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.src = ''; + }); + + return await imageUploadHandler(files!, nodeKey, editor, imageUploader.upload); + }; + + const setHref = (newHref: string) => { + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.href = newHref; + }); + }; + + const setAltText = (newAltText: string) => { + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.alt = newAltText; + }); + }; + + // when card is inserted from the card menu or slash command we want to show the file picker immediately + // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 + React.useEffect(() => { + if (!triggerFileDialog) { + return; + } + + const renderTimeout = setTimeout(() => { + // trigger dialog + openFileSelection({fileInputRef}); + + // clear the property on the node so we don't accidentally trigger anything with a re-render + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.triggerFileDialog = false; + }); + }); + + return (() => { + clearTimeout(renderTimeout); + }); + }); + + const handleImageCardResize = React.useCallback((newWidth: string) => { + if (!isCardWidth(newWidth) || !allowedImageCardWidths.includes(newWidth)) { + return; + } + + editor.update(() => { + const node = $getImageNodeByKey(nodeKey); + if (!node) {return;} + if (!node) {return;} + node.cardWidth = newWidth; // this is a property on the node, not the card + setCardWidth(newWidth); // sets the state of the toolbar component + }); + }, [allowedImageCardWidths, editor, nodeKey, setCardWidth]); + + React.useEffect(() => { + if (!allowedImageCardWidths.includes(cardWidth)) { + handleImageCardResize(defaultImageCardWidth); + } + }, [allowedImageCardWidths, cardWidth, defaultImageCardWidth, handleImageCardResize]); + + const cancelLinkAndReselect = () => { + setShowLink(false); + reselectImageCard(); + }; + + const reselectImageCard = () => { + editor.update(() => { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(nodeKey); + $setSelection(nodeSelection); + }); + }; + + async function handleImageDrop(files: File[]) { + await imageUploadHandler(files, nodeKey, editor, imageUploader.upload); + } + + return ( + <> + + + + { + setHref(_href); + cancelLinkAndReselect(); + }} + /> + + + + setShowSnippetToolbar(false)} /> + + + + } + mimeTypes={fileUploader.fileTypes.image?.mimeTypes} + onFileChange={onFileChange} + /> + + handleImageCardResize('regular')} + /> + handleImageCardResize('wide')} + /> + handleImageCardResize('full')} + /> + + { + setShowLink(true); + }} /> + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/MarkdownNode.jsx b/packages/koenig-lexical/src/nodes/MarkdownNode.jsx deleted file mode 100644 index 1f92173d0d..0000000000 --- a/packages/koenig-lexical/src/nodes/MarkdownNode.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import MarkdownCardIcon from '../assets/icons/kg-card-type-markdown.svg?react'; -import MarkdownIndicatorIcon from '../assets/icons/kg-indicator-markdown.svg?react'; -import {MarkdownNode as BaseMarkdownNode} from '@tryghost/kg-default-nodes'; -import {MarkdownNodeComponent} from './MarkdownNodeComponent'; -import {createCommand} from 'lexical'; - -export const INSERT_MARKDOWN_COMMAND = createCommand(); - -export class MarkdownNode extends BaseMarkdownNode { - static kgMenu = { - label: 'Markdown', - desc: 'Insert a Markdown editor card', - Icon: MarkdownCardIcon, - insertCommand: INSERT_MARKDOWN_COMMAND, - matches: ['markdown', 'md'], - priority: 19, - shortcut: '/md' - }; - - getIcon() { - return MarkdownCardIcon; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createMarkdownNode(dataset) { - return new MarkdownNode(dataset); -} - -export function $isMarkdownNode(node) { - return node instanceof MarkdownNode; -} diff --git a/packages/koenig-lexical/src/nodes/MarkdownNode.tsx b/packages/koenig-lexical/src/nodes/MarkdownNode.tsx new file mode 100644 index 0000000000..5a5d0a1a4e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/MarkdownNode.tsx @@ -0,0 +1,47 @@ +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import MarkdownCardIcon from '../assets/icons/kg-card-type-markdown.svg?react'; +import MarkdownIndicatorIcon from '../assets/icons/kg-indicator-markdown.svg?react'; +import {MarkdownNode as BaseMarkdownNode} from '@tryghost/kg-default-nodes'; +import {MarkdownNodeComponent} from './MarkdownNodeComponent'; +import {createCommand} from 'lexical'; + +export const INSERT_MARKDOWN_COMMAND = createCommand(); + +export class MarkdownNode extends BaseMarkdownNode { + static kgMenu = { + label: 'Markdown', + desc: 'Insert a Markdown editor card', + Icon: MarkdownCardIcon, + insertCommand: INSERT_MARKDOWN_COMMAND, + matches: ['markdown', 'md'], + priority: 19, + shortcut: '/md' + }; + + getIcon() { + return MarkdownCardIcon; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createMarkdownNode(dataset: Record) { + return new MarkdownNode(dataset); +} + +export function $isMarkdownNode(node: unknown): node is MarkdownNode { + return node instanceof MarkdownNode; +} diff --git a/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.jsx b/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.jsx deleted file mode 100644 index efd4b27c80..0000000000 --- a/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin.jsx'; -import {MarkdownCard} from '../components/ui/cards/MarkdownCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function MarkdownNodeComponent({nodeKey, markdown}) { - const [editor] = useLexicalComposerContext(); - const cardContext = React.useContext(CardContext); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const updateMarkdown = (value) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.markdown = value; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.tsx b/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.tsx new file mode 100644 index 0000000000..dff3991a82 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/MarkdownNodeComponent.tsx @@ -0,0 +1,74 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {MarkdownCard} from '../components/ui/cards/MarkdownCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {MarkdownNode} from '@tryghost/kg-default-nodes'; + +interface MarkdownNodeComponentProps { + nodeKey: string; + markdown: string; +} + +export function MarkdownNodeComponent({nodeKey, markdown}: MarkdownNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const cardContext = React.useContext(CardContext); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const updateMarkdown = (value: string) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey) as MarkdownNode | null; + if (!node) {return;} + node.markdown = value; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/nodes/MinimalNodes.js b/packages/koenig-lexical/src/nodes/MinimalNodes.ts similarity index 100% rename from packages/koenig-lexical/src/nodes/MinimalNodes.js rename to packages/koenig-lexical/src/nodes/MinimalNodes.ts diff --git a/packages/koenig-lexical/src/nodes/PaywallNode.jsx b/packages/koenig-lexical/src/nodes/PaywallNode.jsx deleted file mode 100644 index c5fbc4aea8..0000000000 --- a/packages/koenig-lexical/src/nodes/PaywallNode.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import DividerCardIcon from '../assets/icons/kg-card-type-preview.svg?react'; -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import {PaywallNode as BasePaywallNode} from '@tryghost/kg-default-nodes'; -import {PaywallCard} from '../components/ui/cards/PaywallCard'; -import {createCommand} from 'lexical'; - -export const INSERT_PAYWALL_COMMAND = createCommand(); - -export class PaywallNode extends BasePaywallNode { - static kgMenu = { - label: 'Public preview', - desc: 'Attract signups with a public intro', - Icon: DividerCardIcon, - insertCommand: INSERT_PAYWALL_COMMAND, - matches: ['public preview','preview', 'public intro', 'members only', 'paywall'], - priority: 6, - shortcut: '/paywall' - }; - - getIcon() { - return DividerCardIcon; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createPaywallNode() { - return new PaywallNode(); -} - -export function $isPaywallNode(node) { - return node instanceof PaywallNode; -} diff --git a/packages/koenig-lexical/src/nodes/PaywallNode.tsx b/packages/koenig-lexical/src/nodes/PaywallNode.tsx new file mode 100644 index 0000000000..e65f090297 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/PaywallNode.tsx @@ -0,0 +1,39 @@ +import DividerCardIcon from '../assets/icons/kg-card-type-preview.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {PaywallNode as BasePaywallNode} from '@tryghost/kg-default-nodes'; +import {PaywallCard} from '../components/ui/cards/PaywallCard'; +import {createCommand} from 'lexical'; + +export const INSERT_PAYWALL_COMMAND = createCommand(); + +export class PaywallNode extends BasePaywallNode { + static kgMenu = { + label: 'Public preview', + desc: 'Attract signups with a public intro', + Icon: DividerCardIcon, + insertCommand: INSERT_PAYWALL_COMMAND, + matches: ['public preview','preview', 'public intro', 'members only', 'paywall'], + priority: 6, + shortcut: '/paywall' + }; + + getIcon() { + return DividerCardIcon; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createPaywallNode() { + return new PaywallNode(); +} + +export function $isPaywallNode(node: unknown): node is PaywallNode { + return node instanceof PaywallNode; +} diff --git a/packages/koenig-lexical/src/nodes/ProductNode.jsx b/packages/koenig-lexical/src/nodes/ProductNode.jsx deleted file mode 100644 index c5fe35dd06..0000000000 --- a/packages/koenig-lexical/src/nodes/ProductNode.jsx +++ /dev/null @@ -1,126 +0,0 @@ -import ProductCardIcon from '../assets/icons/kg-card-type-product.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BASIC_NODES, KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {ProductNode as BaseProductNode} from '@tryghost/kg-default-nodes'; -import {ProductNodeComponent} from './ProductNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {isEditorEmpty} from '../utils/isEditorEmpty'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors.js'; - -// re-export here, so we don't need to import from multiple places throughout the app -export const INSERT_PRODUCT_COMMAND = createCommand(); -export class ProductNode extends BaseProductNode { - __productTitleEditor; - __productTitleEditorInitialState; - __productDescriptionEditor; - __productDescriptionEditorInitialState; - - static kgMenu = [{ - label: 'Product', - desc: 'Add a product recommendation', - Icon: ProductCardIcon, - insertCommand: INSERT_PRODUCT_COMMAND, - matches: ['product'], - priority: 16, - shortcut: '/product' - }]; - - getIcon() { - return ProductCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - // set up nested editor instances - setupNestedEditor(this, '__productTitleEditor', {editor: dataset.productTitleEditor, nodes: MINIMAL_NODES}); - setupNestedEditor(this, '__productDescriptionEditor', {editor: dataset.productDescriptionEditor, nodes: BASIC_NODES}); - - // populate nested editors on initial construction - if (!dataset.productTitleEditor && dataset.productTitle) { - populateNestedEditor(this, '__productTitleEditor', `${dataset.productTitle}`); // we serialize with no wrapper - } - if (!dataset.productDescriptionEditor) { - populateNestedEditor(this, '__productDescriptionEditor', dataset.productDescription); - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.productTitleEditor = self.__productTitleEditor; - dataset.productTitleEditorInitialState = self.__productTitleEditorInitialState; - dataset.productDescriptionEditor = self.__productDescriptionEditor; - dataset.productDescriptionEditorInitialState = self.__productDescriptionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__productTitleEditor) { - this.__productTitleEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__productTitleEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.productTitle = cleanedHtml; - }); - } - if (this.__productDescriptionEditor) { - this.__productDescriptionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__productDescriptionEditor, null); - const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); - json.productDescription = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isTitleEmpty = isEditorEmpty(this.__productTitleEditor); - const isDescriptionEmpty = isEditorEmpty(this.__productDescriptionEditor); - const isButtonFilled = this.productButtonEnabled && this.productUrl && this.productButton; - - return isTitleEmpty && isDescriptionEmpty && !isButtonFilled && !this.productImageSrc && !this.productRatingEnabled; - } -} - -export const $createProductNode = (dataset) => { - return new ProductNode(dataset); -}; - -export function $isProductNode(node) { - return node instanceof ProductNode; -} diff --git a/packages/koenig-lexical/src/nodes/ProductNode.tsx b/packages/koenig-lexical/src/nodes/ProductNode.tsx new file mode 100644 index 0000000000..98d253b7cd --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ProductNode.tsx @@ -0,0 +1,135 @@ +import ProductCardIcon from '../assets/icons/kg-card-type-product.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BASIC_NODES, KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {ProductNode as BaseProductNode, type ProductData} from '@tryghost/kg-default-nodes'; +import {ProductNodeComponent} from './ProductNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {isEditorEmpty} from '../utils/isEditorEmpty'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export type ProductNodeData = ProductData & { + productDescriptionEditor?: LexicalEditor; + productDescriptionEditorInitialState?: unknown; + productTitleEditor?: LexicalEditor; + productTitleEditorInitialState?: unknown; +}; + +// re-export here, so we don't need to import from multiple places throughout the app +export const INSERT_PRODUCT_COMMAND = createCommand(); + +export class ProductNode extends BaseProductNode { + __productTitleEditor!: LexicalEditor; + __productTitleEditorInitialState: unknown; + __productDescriptionEditor!: LexicalEditor; + __productDescriptionEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Product', + desc: 'Add a product recommendation', + Icon: ProductCardIcon, + insertCommand: INSERT_PRODUCT_COMMAND, + matches: ['product'], + priority: 16, + shortcut: '/product' + }]; + + getIcon() { + return ProductCardIcon; + } + + constructor(dataset: ProductNodeData = {}, key?: string) { + super(dataset, key); + + // set up nested editor instances + setupNestedEditor(this, '__productTitleEditor', {editor: dataset.productTitleEditor, nodes: MINIMAL_NODES}); + setupNestedEditor(this, '__productDescriptionEditor', {editor: dataset.productDescriptionEditor, nodes: BASIC_NODES}); + + // populate nested editors on initial construction + if (!dataset.productTitleEditor && dataset.productTitle) { + populateNestedEditor(this, '__productTitleEditor', `${dataset.productTitle}`); // we serialize with no wrapper + } + if (!dataset.productDescriptionEditor) { + populateNestedEditor(this, '__productDescriptionEditor', dataset.productDescription as string | undefined); + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.productTitleEditor = self.__productTitleEditor; + dataset.productTitleEditorInitialState = self.__productTitleEditorInitialState; + dataset.productDescriptionEditor = self.__productDescriptionEditor; + dataset.productDescriptionEditorInitialState = self.__productDescriptionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__productTitleEditor) { + this.__productTitleEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__productTitleEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.productTitle = cleanedHtml ?? ""; + }); + } + if (this.__productDescriptionEditor) { + this.__productDescriptionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__productDescriptionEditor, null); + const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); + json.productDescription = cleanedHtml ?? ""; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isTitleEmpty = isEditorEmpty(this.__productTitleEditor); + const isDescriptionEmpty = isEditorEmpty(this.__productDescriptionEditor); + const isButtonFilled = this.productButtonEnabled && this.productUrl && this.productButton; + + return isTitleEmpty && isDescriptionEmpty && !isButtonFilled && !this.productImageSrc && !this.productRatingEnabled; + } +} + +export const $createProductNode = (dataset: ProductNodeData = {}) => { + return new ProductNode(dataset); +}; + +export function $isProductNode(node: unknown): node is ProductNode { + return node instanceof ProductNode; +} diff --git a/packages/koenig-lexical/src/nodes/ProductNodeComponent.jsx b/packages/koenig-lexical/src/nodes/ProductNodeComponent.jsx deleted file mode 100644 index a2a58aef90..0000000000 --- a/packages/koenig-lexical/src/nodes/ProductNodeComponent.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import usePinturaEditor from '../hooks/usePinturaEditor'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {ProductCard} from '../components/ui/cards/ProductCard'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {getImageDimensions} from '../utils/getImageDimensions'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function ProductNodeComponent({ - nodeKey, - buttonText, - buttonUrl, - imgHeight, - imgSrc, - imgWidth, - isButtonEnabled, - isRatingEnabled, - starRating, - title, - titleEditor, - titleEditorInitialState, - descriptionEditor, - descriptionEditorInitialState, - description -}) { - const [editor] = useLexicalComposerContext(); - const {isEditing, isSelected, setEditing} = React.useContext(CardContext); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const imgMimeTypes = fileUploader.fileTypes.image?.mimeTypes || ['image/*']; - const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); - const imgDragHandler = useFileDragAndDrop({handleDrop: handleImgDrop, disabled: !isEditing}); - const imgUploader = fileUploader.useFileUpload('image'); - const [imgPreview, setImgPreview] = React.useState(''); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - React.useEffect(() => { - titleEditor.setEditable(isEditing); - descriptionEditor.setEditable(isEditing); - }, [isEditing, titleEditor, descriptionEditor]); - - const handleImgUpload = async (files) => { - const imgPreviewUrl = URL.createObjectURL(files[0]); - setImgPreview(imgPreviewUrl); - - const {width, height} = await getImageDimensions(imgPreviewUrl); - const imgUploadResult = await imgUploader.upload(files); - const imageUrl = imgUploadResult?.[0]?.url; - - if (imageUrl) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productImageSrc = imageUrl; - node.productImageHeight = height; - node.productImageWidth = width; - }); - } - - setImgPreview(''); - URL.revokeObjectURL(imgPreviewUrl); - }; - - const handleImgChange = async (e) => { - const file = e.target.files[0]; - if (!file) { - return; - } - await handleImgUpload(e.target.files); - }; - - const onRemoveImage = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productImageSrc = ''; - }); - }; - - async function handleImgDrop(files) { - await handleImgUpload(files); - } - - const handleButtonToggle = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productButtonEnabled = event.target.checked; - }); - }; - - const handleButtonTextChange = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productButton = event.target.value; - }); - }; - - const handleButtonUrlChange = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productUrl = val; - }); - }; - - const handleRatingToggle = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productRatingEnabled = event.target.checked; - }); - }; - - const handleRatingChange = (rating) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.productStarRating = rating; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx b/packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx new file mode 100644 index 0000000000..dc5de76c26 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx @@ -0,0 +1,212 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import usePinturaEditor from '../hooks/usePinturaEditor'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {ProductCard} from '../components/ui/cards/ProductCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {getImageDimensions} from '../utils/getImageDimensions'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; +import type {ProductNode} from './ProductNode'; + +function $getProductNodeByKey(nodeKey: string): ProductNode | null { + return $getNodeByKey(nodeKey) as ProductNode | null; +} + +interface ProductNodeComponentProps { + nodeKey: string; + buttonText: string; + buttonUrl: string; + imgHeight: number | null; + imgSrc: string; + imgWidth: number | null; + isButtonEnabled: boolean; + isRatingEnabled: boolean; + starRating: number; + title: string; + titleEditor: LexicalEditor; + titleEditorInitialState: string | undefined; + descriptionEditor: LexicalEditor; + descriptionEditorInitialState: string | undefined; + description: string; +} + +export function ProductNodeComponent({ + nodeKey, + buttonText, + buttonUrl, + imgHeight: _imgHeight, + imgSrc, + imgWidth: _imgWidth, + isButtonEnabled, + isRatingEnabled, + starRating, + title: _title, + titleEditor, + titleEditorInitialState, + descriptionEditor, + descriptionEditorInitialState, + description: _description +}: ProductNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {isEditing, isSelected, setEditing} = React.useContext(CardContext); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const imgMimeTypes = fileUploader.fileTypes.image?.mimeTypes || ['image/*']; + const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); + const imgDragHandler = useFileDragAndDrop({handleDrop: handleImgDrop, disabled: !isEditing}); + const imgUploader = fileUploader.useFileUpload('image'); + const [imgPreview, setImgPreview] = React.useState(''); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + React.useEffect(() => { + titleEditor.setEditable(isEditing); + descriptionEditor.setEditable(isEditing); + }, [isEditing, titleEditor, descriptionEditor]); + + const handleImgUpload = async (files: File[] | FileList) => { + const imgPreviewUrl = URL.createObjectURL(files[0]); + setImgPreview(imgPreviewUrl); + + const {width, height} = await getImageDimensions(imgPreviewUrl); + const imgUploadResult = await imgUploader.upload(Array.from(files)); + const imageUrl = imgUploadResult?.[0]?.url; + + if (imageUrl) { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productImageSrc = imageUrl; + node.productImageHeight = height; + node.productImageWidth = width; + }); + } + + setImgPreview(''); + URL.revokeObjectURL(imgPreviewUrl); + }; + + const handleImgChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + await handleImgUpload(e.target.files!); + }; + + const onRemoveImage = () => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productImageSrc = ''; + }); + }; + + async function handleImgDrop(files: File[]) { + await handleImgUpload(files); + } + + const handleButtonToggle = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productButtonEnabled = event.target.checked; + }); + }; + + const handleButtonTextChange = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productButton = event.target.value; + }); + }; + + const handleButtonUrlChange = (val: string) => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productUrl = val; + }); + }; + + const handleRatingToggle = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productRatingEnabled = event.target.checked; + }); + }; + + const handleRatingChange = (rating: number) => { + editor.update(() => { + const node = $getProductNodeByKey(nodeKey); + if (!node) {return;} + node.productStarRating = rating; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/SignupNode.jsx b/packages/koenig-lexical/src/nodes/SignupNode.jsx deleted file mode 100644 index 1d8cc93170..0000000000 --- a/packages/koenig-lexical/src/nodes/SignupNode.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import MINIMAL_NODES from './MinimalNodes'; -import SignupCardIcon from '../assets/icons/kg-card-type-signup.svg?react'; -import SignupNodeComponent from './SignupNodeComponent'; -import {$canShowPlaceholderCurry} from '@lexical/text'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {SignupNode as BaseSignupNode} from '@tryghost/kg-default-nodes'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const {INSERT_SIGNUP_COMMAND} = createCommand(); - -export class SignupNode extends BaseSignupNode { - __disclaimerTextEditor; - __disclaimerTextEditorInitialState; - __headerTextEditor; - __headerTextEditorInitialState; - __subheaderTextEditor; - __subheaderTextEditorInitialState; - - static kgMenu = { - label: 'Signup', - desc: 'Convert visitors into members', - Icon: SignupCardIcon, - insertCommand: INSERT_SIGNUP_COMMAND, - matches: ['signup', 'subscribe'], - priority: 10, - isHidden: ({config}) => { - const isMembersEnabled = config?.membersEnabled; - return !(isMembersEnabled); - }, - insertParams: ({config}) => ({ - header: config?.siteTitle ? `Sign up for ${config.siteTitle}` : '', - subheader: config?.siteDescription || '', - disclaimer: 'No spam. Unsubscribe anytime.' - }), - shortcut: '/signup' - }; - - getIcon() { - return SignupCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - setupNestedEditor(this, '__headerTextEditor', {editor: dataset.headerTextEditor, nodes: MINIMAL_NODES}); - setupNestedEditor(this, '__subheaderTextEditor', {editor: dataset.subheaderTextEditor, nodes: MINIMAL_NODES}); - setupNestedEditor(this, '__disclaimerTextEditor', {editor: dataset.disclaimerTextEditor, nodes: MINIMAL_NODES}); - - // populate nested editors on initial construction - if (!dataset.headerTextEditor && dataset.header) { - populateNestedEditor(this, '__headerTextEditor', `${dataset.header}`); - } - - // populate nested editors on initial construction - if (!dataset.subheaderTextEditor && dataset.subheader) { - populateNestedEditor(this, '__subheaderTextEditor', `${dataset.subheader}`); - } - - // populate nested editors on initial construction - if (!dataset.disclaimerTextEditor && dataset.disclaimer) { - populateNestedEditor(this, '__disclaimerTextEditor', `${dataset.disclaimer}`); - } - } - - exportJSON() { - const json = super.exportJSON(); - - if (this.__disclaimerTextEditor) { - this.__disclaimerTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__disclaimerTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.disclaimer = cleanedHtml; - }); - } - - if (this.__headerTextEditor) { - this.__headerTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__headerTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.header = cleanedHtml; - }); - } - - if (this.__subheaderTextEditor) { - this.__subheaderTextEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__subheaderTextEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.subheader = cleanedHtml; - }); - } - - return json; - } - - createDOM() { - return document.createElement('div'); - } - - getDataset() { - const dataset = super.getDataset(); - const self = this.getLatest(); - - dataset.disclaimerTextEditor = self.__disclaimerTextEditor; - dataset.headerTextEditor = self.__headerTextEditor; - dataset.subheaderTextEditor = self.__subheaderTextEditor; - - return dataset; - } - - getCardWidth() { - const layout = this.layout; - return layout === 'split' ? 'full' : layout; - } - - decorate() { - return ( - - - - ); - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isHeaderEmpty = this.__headerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); - const isSubheaderEmpty = this.__subheaderTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); - const isDisclaimerEmpty = this.__disclaimerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); - - return !this.__backgroundColor && - !this.__backgroundImageSrc && - !this.__buttonColor && - !this.__buttonText && - isDisclaimerEmpty && - isHeaderEmpty && - !this.__labels.length && - isSubheaderEmpty; - } -} - -export const $createSignupNode = (dataset) => { - return new SignupNode(dataset); -}; - -export function $isSignupNode(node) { - return node instanceof SignupNode; -} diff --git a/packages/koenig-lexical/src/nodes/SignupNode.tsx b/packages/koenig-lexical/src/nodes/SignupNode.tsx new file mode 100644 index 0000000000..cf9d35fe9e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/SignupNode.tsx @@ -0,0 +1,192 @@ +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import MINIMAL_NODES from './MinimalNodes'; +import SignupCardIcon from '../assets/icons/kg-card-type-signup.svg?react'; +import SignupNodeComponent from './SignupNodeComponent'; +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {SignupNode as BaseSignupNode, normalizeCardWidth, type CardWidth, type SignupData} from '@tryghost/kg-default-nodes'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +type Alignment = 'left' | 'center'; +type BackgroundSize = 'cover' | 'contain'; +type Layout = 'regular' | 'wide' | 'full' | 'split'; + +export type SignupNodeData = SignupData & { + disclaimerTextEditor?: LexicalEditor; + disclaimerTextEditorInitialState?: unknown; + headerTextEditor?: LexicalEditor; + headerTextEditorInitialState?: unknown; + subheaderTextEditor?: LexicalEditor; + subheaderTextEditorInitialState?: unknown; +}; + +interface SignupMenuConfig { + membersEnabled?: boolean; + siteDescription?: string; + siteTitle?: string; +} + +export const INSERT_SIGNUP_COMMAND = createCommand(); + +export class SignupNode extends BaseSignupNode { + __disclaimerTextEditor!: LexicalEditor; + __disclaimerTextEditorInitialState: unknown; + __headerTextEditor!: LexicalEditor; + __headerTextEditorInitialState: unknown; + __subheaderTextEditor!: LexicalEditor; + __subheaderTextEditorInitialState: unknown; + + static kgMenu = { + label: 'Signup', + desc: 'Convert visitors into members', + Icon: SignupCardIcon, + insertCommand: INSERT_SIGNUP_COMMAND, + matches: ['signup', 'subscribe'], + priority: 10, + isHidden: ({config}: {config?: SignupMenuConfig}) => { + const isMembersEnabled = config?.membersEnabled; + return !(isMembersEnabled); + }, + insertParams: ({config}: {config?: SignupMenuConfig}) => ({ + header: config?.siteTitle ? `Sign up for ${config.siteTitle}` : '', + subheader: config?.siteDescription || '', + disclaimer: 'No spam. Unsubscribe anytime.' + }), + shortcut: '/signup' + }; + + getIcon() { + return SignupCardIcon; + } + + constructor(dataset: SignupNodeData = {}, key?: string) { + super(dataset, key); + + setupNestedEditor(this, '__headerTextEditor', {editor: dataset.headerTextEditor, nodes: MINIMAL_NODES}); + setupNestedEditor(this, '__subheaderTextEditor', {editor: dataset.subheaderTextEditor, nodes: MINIMAL_NODES}); + setupNestedEditor(this, '__disclaimerTextEditor', {editor: dataset.disclaimerTextEditor, nodes: MINIMAL_NODES}); + + // populate nested editors on initial construction + if (!dataset.headerTextEditor && dataset.header) { + populateNestedEditor(this, '__headerTextEditor', `${dataset.header}`); + } + + // populate nested editors on initial construction + if (!dataset.subheaderTextEditor && dataset.subheader) { + populateNestedEditor(this, '__subheaderTextEditor', `${dataset.subheader}`); + } + + // populate nested editors on initial construction + if (!dataset.disclaimerTextEditor && dataset.disclaimer) { + populateNestedEditor(this, '__disclaimerTextEditor', `${dataset.disclaimer}`); + } + } + + exportJSON() { + const json: ReturnType & {disclaimer?: string; header?: string; subheader?: string} = super.exportJSON(); + + if (this.__disclaimerTextEditor) { + this.__disclaimerTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__disclaimerTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.disclaimer = cleanedHtml; + }); + } + + if (this.__headerTextEditor) { + this.__headerTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__headerTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.header = cleanedHtml; + }); + } + + if (this.__subheaderTextEditor) { + this.__subheaderTextEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__subheaderTextEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.subheader = cleanedHtml; + }); + } + + return json; + } + + createDOM() { + return document.createElement('div'); + } + + getDataset() { + const dataset: ReturnType & {disclaimerTextEditor?: LexicalEditor; headerTextEditor?: LexicalEditor; subheaderTextEditor?: LexicalEditor} = super.getDataset(); + const self = this.getLatest(); + + dataset.disclaimerTextEditor = self.__disclaimerTextEditor; + dataset.headerTextEditor = self.__headerTextEditor; + dataset.subheaderTextEditor = self.__subheaderTextEditor; + + return dataset; + } + + getCardWidth(): CardWidth | undefined { + const layout = this.layout; + return normalizeCardWidth(layout === 'split' ? 'full' : layout); + } + + decorate() { + return ( + + + + ); + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isHeaderEmpty = this.__headerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); + const isSubheaderEmpty = this.__subheaderTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); + const isDisclaimerEmpty = this.__disclaimerTextEditor.getEditorState().read($canShowPlaceholderCurry(false)); + + return !this.__backgroundColor && + !this.__backgroundImageSrc && + !this.__buttonColor && + !this.__buttonText && + isDisclaimerEmpty && + isHeaderEmpty && + !(this.__labels as string[]).length && + isSubheaderEmpty; + } +} + +export const $createSignupNode = (dataset: SignupNodeData = {}) => { + return new SignupNode(dataset); +}; + +export function $isSignupNode(node: unknown): node is SignupNode { + return node instanceof SignupNode; +} diff --git a/packages/koenig-lexical/src/nodes/SignupNodeComponent.jsx b/packages/koenig-lexical/src/nodes/SignupNodeComponent.jsx deleted file mode 100644 index bd9e9419ca..0000000000 --- a/packages/koenig-lexical/src/nodes/SignupNodeComponent.jsx +++ /dev/null @@ -1,294 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import usePinturaEditor from '../hooks/usePinturaEditor'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; -import {SignupCard} from '../components/ui/cards/SignupCard.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {backgroundImageUploadHandler} from '../utils/imageUploadHandler'; -import {openFileSelection} from '../utils/openFileSelection'; -import {useContext, useEffect, useRef, useState} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function SignupNodeComponent({ - alignment, - backgroundColor, - backgroundImageSrc, - backgroundSize, - buttonColor, - buttonText, - buttonTextColor, - nodeKey, - disclaimer, - disclaimerTextEditor, - disclaimerTextEditorInitialState, - header, - headerTextEditor, - headerTextEditorInitialState, - labels, - layout, - subheader, - subheaderTextEditor, - subheaderTextEditorInitialState, - textColor, - isSwapped -}) { - const [editor] = useLexicalComposerContext(); - const {cardConfig} = useContext(KoenigComposerContext); - const {fileUploader} = useContext(KoenigComposerContext); - const {isEditing, isSelected} = useContext(CardContext); - const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); - const [availableLabels, setAvailableLabels] = useState([]); - const [showBackgroundImage, setShowBackgroundImage] = useState(Boolean(backgroundImageSrc)); - const [lastBackgroundImage, setLastBackgroundImage] = useState(backgroundImageSrc); - - // this is used to determine if the image was deliberately removed by the user or not, for some UX finesse - const [imageRemoved, setImageRemoved] = useState(false); - - const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); - const fileInputRef = useRef(null); - - useEffect(() => { - if (cardConfig.renderLabels && cardConfig.fetchLabels) { - cardConfig.fetchLabels().then((options) => { - setAvailableLabels(options); - }); - } - }, [cardConfig]); - - useEffect(() => { - if (layout !== 'split') { - setShowBackgroundImage(Boolean(backgroundImageSrc)); - } - - if (layout === 'split' && !backgroundImageSrc && lastBackgroundImage) { - handleShowBackgroundImage(); - } - // We just want to reset the show background image state when the layout changes, not when the image changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout]); - - const handleAlignment = (a) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alignment = a; - }); - }; - - const handleBackgroundSize = (a) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundSize = a; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - const imageUploader = fileUploader.useFileUpload('image'); - - const handleImageChange = async (files) => { - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - - const {imageSrc} = await backgroundImageUploadHandler(files, imageUploader.upload); - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = imageSrc; - }); - - setLastBackgroundImage(imageSrc); - setImageRemoved(false); - }; - - const onFileChange = async (e) => { - handleImageChange(e.target.files); - }; - - const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageChange}); - - const handleLayout = (l) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.layout = l; - }); - }; - - const handleButtonText = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const handleButtonTextBlur = (event) => { - if (!event.target.value) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = 'Subscribe'; - }); - } - }; - - const handleClearBackgroundImage = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - setImageRemoved(true); - }; - - const handleShowBackgroundImage = () => { - setShowBackgroundImage(true); - - if (lastBackgroundImage && !imageRemoved) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = lastBackgroundImage; - }); - } else { - openFileSelection({fileInputRef}); - } - }; - - const handleHideBackgroundImage = () => { - setShowBackgroundImage(false); - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - }; - - const handleBackgroundColor = (color, matchingTextColor) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundColor = color; - node.textColor = matchingTextColor; - - if (layout !== 'split') { - handleHideBackgroundImage(); - } - }); - }; - - const handleTextColor = (color) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.textColor = color; - }); - }; - - const handleButtonColor = (color, matchingTextColor) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonColor = color; - node.buttonTextColor = matchingTextColor; - }); - }; - - const handleLabels = (newLabels) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.setLabels(newLabels); - }); - }; - - const handleSwapLayout = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.swapped = !isSwapped; - }); - }; - - useEffect(() => { - headerTextEditor.setEditable(isEditing); - subheaderTextEditor.setEditable(isEditing); - disclaimerTextEditor.setEditable(isEditing); - }, [isEditing, headerTextEditor, subheaderTextEditor, disclaimerTextEditor]); - - return ( - <> - fileInputRef.current = ref} - showBackgroundImage={showBackgroundImage} - subheader={subheader} - subheaderTextEditor={subheaderTextEditor} - subheaderTextEditorInitialState={subheaderTextEditorInitialState} - textColor={textColor} - onFileChange={onFileChange} - /> - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} - -export default SignupNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/SignupNodeComponent.tsx b/packages/koenig-lexical/src/nodes/SignupNodeComponent.tsx new file mode 100644 index 0000000000..ae92f95550 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/SignupNodeComponent.tsx @@ -0,0 +1,344 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import usePinturaEditor from '../hooks/usePinturaEditor'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {SignupCard} from '../components/ui/cards/SignupCard'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {backgroundImageUploadHandler} from '../utils/imageUploadHandler'; +import {openFileSelection} from '../utils/openFileSelection'; +import {useContext, useEffect, useRef, useState} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; +import type {SignupNode} from './SignupNode'; + +function $getSignupNodeByKey(nodeKey: string): SignupNode | null { + return $getNodeByKey(nodeKey) as SignupNode | null; +} + +type Layout = 'regular' | 'wide' | 'full' | 'split'; +type Alignment = 'left' | 'center'; +type BackgroundSize = 'cover' | 'contain'; + +interface SignupNodeComponentProps { + alignment: Alignment; + backgroundColor: string; + backgroundImageSrc: string; + backgroundSize: BackgroundSize; + buttonColor: string; + buttonText: string; + buttonTextColor: string; + nodeKey: string; + disclaimer: string; + disclaimerTextEditor: LexicalEditor; + disclaimerTextEditorInitialState: string | undefined; + header: string; + headerTextEditor: LexicalEditor; + headerTextEditorInitialState: string | undefined; + labels: string[]; + layout: Layout; + subheader: string; + subheaderTextEditor: LexicalEditor; + subheaderTextEditorInitialState: string | undefined; + textColor: string; + isSwapped: boolean; +} + +function SignupNodeComponent({ + alignment, + backgroundColor, + backgroundImageSrc, + backgroundSize, + buttonColor, + buttonText, + buttonTextColor, + nodeKey, + disclaimer: _disclaimer, + disclaimerTextEditor, + disclaimerTextEditorInitialState, + header: _header, + headerTextEditor, + headerTextEditorInitialState, + labels, + layout, + subheader: _subheader, + subheaderTextEditor, + subheaderTextEditorInitialState, + textColor, + isSwapped +}: SignupNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {cardConfig} = useContext(KoenigComposerContext); + const {fileUploader} = useContext(KoenigComposerContext); + const {isEditing, isSelected} = useContext(CardContext); + const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); + const [availableLabels, setAvailableLabels] = useState([]); + const [showBackgroundImage, setShowBackgroundImage] = useState(Boolean(backgroundImageSrc)); + const [lastBackgroundImage, setLastBackgroundImage] = useState(backgroundImageSrc); + + // this is used to determine if the image was deliberately removed by the user or not, for some UX finesse + const [imageRemoved, setImageRemoved] = useState(false); + + const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); + const fileInputRef = useRef(null); + + useEffect(() => { + if (cardConfig.renderLabels && cardConfig.fetchLabels) { + cardConfig.fetchLabels().then((options: string[]) => { + setAvailableLabels(options); + }); + } + }, [cardConfig]); + + useEffect(() => { + if (layout !== 'split') { + setShowBackgroundImage(Boolean(backgroundImageSrc)); + } + + if (layout === 'split' && !backgroundImageSrc && lastBackgroundImage) { + handleShowBackgroundImage(); + } + // We just want to reset the show background image state when the layout changes, not when the image changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout]); + + const handleAlignment = (a: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.alignment = a; + }); + }; + + const handleBackgroundSize = (a: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundSize = a; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + const imageUploader = fileUploader.useFileUpload('image'); + + const handleImageChange = async (files: File[] | FileList | null) => { + // reset original src so it can be replaced with preview and upload progress + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + + const result = await backgroundImageUploadHandler(Array.from(files!), imageUploader.upload); + if (!result) {return;} + const {imageSrc} = result; + + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = imageSrc ?? ""; + }); + + setLastBackgroundImage(imageSrc as string); + setImageRemoved(false); + }; + + const onFileChange = async (e: React.ChangeEvent) => { + handleImageChange(e.target.files); + }; + + const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageChange}); + + const handleLayout = (l: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.layout = l; + }); + }; + + const handleButtonText = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = event.target.value; + }); + }; + + const handleButtonTextBlur = (event: React.FocusEvent) => { + if (!event.target.value) { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = 'Subscribe'; + }); + } + }; + + const handleClearBackgroundImage = () => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + setImageRemoved(true); + }; + + const handleShowBackgroundImage = () => { + setShowBackgroundImage(true); + + if (lastBackgroundImage && !imageRemoved) { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = lastBackgroundImage; + }); + } else { + openFileSelection({fileInputRef}); + } + }; + + const handleHideBackgroundImage = () => { + setShowBackgroundImage(false); + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + }; + + const handleBackgroundColor = (color: string, matchingTextColor: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundColor = color; + node.textColor = matchingTextColor; + + if (layout !== 'split') { + handleHideBackgroundImage(); + } + }); + }; + + const handleTextColor = (color: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.textColor = color; + }); + }; + + const handleButtonColor = (color: string, matchingTextColor: string) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.buttonColor = color; + node.buttonTextColor = matchingTextColor; + }); + }; + + const handleLabels = (newLabels: string[]) => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.setLabels(newLabels); + }); + }; + + const handleSwapLayout = () => { + editor.update(() => { + const node = $getSignupNodeByKey(nodeKey); + if (!node) {return;} + node.swapped = !isSwapped; + }); + }; + + useEffect(() => { + headerTextEditor.setEditable(isEditing); + subheaderTextEditor.setEditable(isEditing); + disclaimerTextEditor.setEditable(isEditing); + }, [isEditing, headerTextEditor, subheaderTextEditor, disclaimerTextEditor]); + + return ( + <> + { + fileInputRef.current = ref; + }} + showBackgroundImage={showBackgroundImage} + subheaderTextEditor={subheaderTextEditor} + subheaderTextEditorInitialState={subheaderTextEditorInitialState} + textColor={textColor} + onFileChange={onFileChange} + /> + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} + +export default SignupNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/ToggleNode.jsx b/packages/koenig-lexical/src/nodes/ToggleNode.jsx deleted file mode 100644 index 3edbfa11b6..0000000000 --- a/packages/koenig-lexical/src/nodes/ToggleNode.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import ToggleCardIcon from '../assets/icons/kg-card-type-toggle.svg?react'; -import {$canShowPlaceholderCurry} from '@lexical/text'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {BASIC_NODES, KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {ToggleNode as BaseToggleNode} from '@tryghost/kg-default-nodes'; -import {ToggleNodeComponent} from './ToggleNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_TOGGLE_COMMAND = createCommand(); - -export class ToggleNode extends BaseToggleNode { - __headingEditor; - __headingEditorInitialState; - __contentEditor; - __contentEditorInitialState; - - static kgMenu = [{ - label: 'Toggle', - desc: 'Add collapsible content', - Icon: ToggleCardIcon, - insertCommand: INSERT_TOGGLE_COMMAND, - matches: ['toggle', 'collapse'], - priority: 12, - shortcut: '/toggle' - }]; - - getIcon() { - return ToggleCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - setupNestedEditor(this, '__headingEditor', {editor: dataset.headingEditor, nodes: MINIMAL_NODES}); - setupNestedEditor(this, '__contentEditor', {editor: dataset.contentEditor, nodes: BASIC_NODES}); - - // populate nested editors on initial construction - if (!dataset.headingEditor && dataset.heading) { - populateNestedEditor(this, '__headingEditor', `${dataset.heading}`); - } - if (!dataset.contentEditor && dataset.content) { - populateNestedEditor(this, '__contentEditor', dataset.content); - } - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.headingEditor = self.__headingEditor; - dataset.headingEditorInitialState = self.__headingEditorInitialState; - dataset.contentEditor = self.__contentEditor; - dataset.contentEditorInitialState = self.__contentEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__headingEditor) { - this.__headingEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__headingEditor, null); - const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); - json.heading = cleanedHtml; - }); - } - if (this.__contentEditor) { - this.__contentEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__contentEditor, null); - const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); - - json.content = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } - - // override the default `isEmpty` check because we need to check the nested editors - // rather than the data properties themselves - isEmpty() { - const isHeadingEmpty = this.__headingEditor.getEditorState().read($canShowPlaceholderCurry(false)); - const isContentEmpty = this.__contentEditor.getEditorState().read($canShowPlaceholderCurry(false)); - - return isHeadingEmpty && isContentEmpty; - } -} - -export const $createToggleNode = (dataset) => { - return new ToggleNode(dataset); -}; - -export function $isToggleNode(node) { - return node instanceof ToggleNode; -} diff --git a/packages/koenig-lexical/src/nodes/ToggleNode.tsx b/packages/koenig-lexical/src/nodes/ToggleNode.tsx new file mode 100644 index 0000000000..fc771db839 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ToggleNode.tsx @@ -0,0 +1,116 @@ +import ToggleCardIcon from '../assets/icons/kg-card-type-toggle.svg?react'; +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {BASIC_NODES, KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {ToggleNode as BaseToggleNode} from '@tryghost/kg-default-nodes'; +import {ToggleNodeComponent} from './ToggleNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_TOGGLE_COMMAND = createCommand(); + +export class ToggleNode extends BaseToggleNode { + __headingEditor!: LexicalEditor; + __headingEditorInitialState: unknown; + __contentEditor!: LexicalEditor; + __contentEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Toggle', + desc: 'Add collapsible content', + Icon: ToggleCardIcon, + insertCommand: INSERT_TOGGLE_COMMAND, + matches: ['toggle', 'collapse'], + priority: 12, + shortcut: '/toggle' + }]; + + getIcon() { + return ToggleCardIcon; + } + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + + setupNestedEditor(this, '__headingEditor', {editor: dataset.headingEditor, nodes: MINIMAL_NODES}); + setupNestedEditor(this, '__contentEditor', {editor: dataset.contentEditor, nodes: BASIC_NODES}); + + // populate nested editors on initial construction + if (!dataset.headingEditor && dataset.heading) { + populateNestedEditor(this, '__headingEditor', `${dataset.heading}`); + } + if (!dataset.contentEditor && dataset.content) { + populateNestedEditor(this, '__contentEditor', dataset.content as string); + } + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.headingEditor = self.__headingEditor; + dataset.headingEditorInitialState = self.__headingEditorInitialState; + dataset.contentEditor = self.__contentEditor; + dataset.contentEditorInitialState = self.__contentEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__headingEditor) { + this.__headingEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__headingEditor, null); + const cleanedHtml = cleanBasicHtml(html, {firstChildInnerContent: true, allowBr: true}); + json.heading = cleanedHtml; + }); + } + if (this.__contentEditor) { + this.__contentEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__contentEditor, null); + const cleanedHtml = cleanBasicHtml(html, {allowBr: true}); + + json.content = cleanedHtml; + }); + } + + return json; + } + + decorate() { + return ( + + + + ); + } + + // override the default `isEmpty` check because we need to check the nested editors + // rather than the data properties themselves + isEmpty() { + const isHeadingEmpty = this.__headingEditor.getEditorState().read($canShowPlaceholderCurry(false)); + const isContentEmpty = this.__contentEditor.getEditorState().read($canShowPlaceholderCurry(false)); + + return isHeadingEmpty && isContentEmpty; + } +} + +export const $createToggleNode = (dataset: Record) => { + return new ToggleNode(dataset); +}; + +export function $isToggleNode(node: unknown): node is ToggleNode { + return node instanceof ToggleNode; +} diff --git a/packages/koenig-lexical/src/nodes/ToggleNodeComponent.jsx b/packages/koenig-lexical/src/nodes/ToggleNodeComponent.jsx deleted file mode 100644 index 22754ce696..0000000000 --- a/packages/koenig-lexical/src/nodes/ToggleNodeComponent.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {ActionToolbar} from '../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToggleCard} from '../components/ui/cards/ToggleCard'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function ToggleNodeComponent({nodeKey, headingEditor, headingEditorInitialState, contentEditor, contentEditorInitialState}) { - const [editor] = useLexicalComposerContext(); - const cardContext = React.useContext(CardContext); - const {cardConfig} = React.useContext(KoenigComposerContext); - const {isEditing, isSelected} = cardContext; - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - React.useEffect(() => { - headingEditor.setEditable(isEditing); - contentEditor.setEditable(isEditing); - }, [isEditing, headingEditor, contentEditor]); - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/ToggleNodeComponent.tsx b/packages/koenig-lexical/src/nodes/ToggleNodeComponent.tsx new file mode 100644 index 0000000000..6df5bf4035 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/ToggleNodeComponent.tsx @@ -0,0 +1,76 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../plugins/KoenigBehaviourPlugin'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToggleCard} from '../components/ui/cards/ToggleCard'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + +interface ToggleNodeComponentProps { + nodeKey: string; + headingEditor: LexicalEditor; + headingEditorInitialState: unknown; + contentEditor: LexicalEditor; + contentEditorInitialState: unknown; +} + +export function ToggleNodeComponent({nodeKey, headingEditor, headingEditorInitialState, contentEditor, contentEditorInitialState}: ToggleNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const cardContext = React.useContext(CardContext); + const {cardConfig} = React.useContext(KoenigComposerContext); + const {isEditing, isSelected} = cardContext; + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + React.useEffect(() => { + headingEditor.setEditable(isEditing); + contentEditor.setEditable(isEditing); + }, [isEditing, headingEditor, contentEditor]); + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/TransistorNode.jsx b/packages/koenig-lexical/src/nodes/TransistorNode.jsx deleted file mode 100644 index 6a43d6590c..0000000000 --- a/packages/koenig-lexical/src/nodes/TransistorNode.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import KoenigCardWrapper from '../components/KoenigCardWrapper'; -import TransistorIcon from '../assets/icons/kg-card-type-transistor.svg?react'; -import {TransistorNode as BaseTransistorNode} from '@tryghost/kg-default-nodes'; -import {TransistorNodeComponent} from './TransistorNodeComponent'; -import {createCommand} from 'lexical'; - -export const INSERT_TRANSISTOR_COMMAND = createCommand(); - -export class TransistorNode extends BaseTransistorNode { - static kgMenu = [{ - section: 'Embeds', - label: 'Transistor', - desc: 'Embed a Transistor podcast player', - Icon: TransistorIcon, - insertCommand: INSERT_TRANSISTOR_COMMAND, - matches: ['transistor', 'podcast'], - priority: 2, - shortcut: '/transistor', - isHidden: ({config}) => { - return !(config?.feature?.transistor === true); - } - }]; - - constructor(dataset = {}, key) { - super(dataset, key); - } - - getIcon() { - return TransistorIcon; - } - - decorate() { - return ( - - - - ); - } -} - -export function $createTransistorNode(dataset) { - return new TransistorNode(dataset); -} - -export function $isTransistorNode(node) { - return node instanceof TransistorNode; -} diff --git a/packages/koenig-lexical/src/nodes/TransistorNode.tsx b/packages/koenig-lexical/src/nodes/TransistorNode.tsx new file mode 100644 index 0000000000..841e5fd7b0 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/TransistorNode.tsx @@ -0,0 +1,51 @@ +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import TransistorIcon from '../assets/icons/kg-card-type-transistor.svg?react'; +import {TransistorNode as BaseTransistorNode} from '@tryghost/kg-default-nodes'; +import {TransistorNodeComponent} from './TransistorNodeComponent'; +import {createCommand} from 'lexical'; + +export const INSERT_TRANSISTOR_COMMAND = createCommand(); + +export class TransistorNode extends BaseTransistorNode { + static kgMenu = [{ + section: 'Embeds', + label: 'Transistor', + desc: 'Embed a Transistor podcast player', + Icon: TransistorIcon, + insertCommand: INSERT_TRANSISTOR_COMMAND, + matches: ['transistor', 'podcast'], + priority: 2, + shortcut: '/transistor', + isHidden: ({config}: {config?: Record}) => { + return !((config?.feature as Record | undefined)?.transistor === true); + } + }]; + + constructor(dataset: Record = {}, key?: string) { + super(dataset, key); + } + + getIcon() { + return TransistorIcon; + } + + decorate() { + return ( + + + + ); + } +} + +export function $createTransistorNode(dataset: Record) { + return new TransistorNode(dataset); +} + +export function $isTransistorNode(node: unknown): node is TransistorNode { + return node instanceof TransistorNode; +} diff --git a/packages/koenig-lexical/src/nodes/TransistorNodeComponent.jsx b/packages/koenig-lexical/src/nodes/TransistorNodeComponent.jsx deleted file mode 100644 index 140b8369f2..0000000000 --- a/packages/koenig-lexical/src/nodes/TransistorNodeComponent.jsx +++ /dev/null @@ -1,122 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -// TODO: Re-enable when design tab is implemented -// import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {SettingsPanel} from '../components/ui/SettingsPanel.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {TransistorCard} from '../components/ui/cards/TransistorCard.jsx'; -import {VisibilitySettings} from '../components/ui/VisibilitySettings.jsx'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useVisibilityToggle} from '../hooks/useVisibilityToggle.js'; - -export const TransistorNodeComponent = ({ - nodeKey, - accentColor, - backgroundColor -}) => { - const [editor] = useLexicalComposerContext(); - const {isEditing, isSelected, setEditing} = React.useContext(CardContext); - const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const {isVisibilityEnabled, visibilityOptions: rawVisibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); - - // Filter out nonMembers option - Transistor requires a member UUID so public visitors can't see it - const visibilityOptions = React.useMemo(() => { - return rawVisibilityOptions.map(group => ({ - ...group, - toggles: group.toggles.filter(toggle => toggle.key !== 'nonMembers') - })); - }, [rawVisibilityOptions]); - - const settingsTabs = [ - {id: 'visibility', label: 'Visibility'} - ]; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - setEditing(true); - }; - - // TODO: Re-enable when design tab is implemented - // const handleAccentColorChange = (val) => { - // editor.update(() => { - // const node = $getNodeByKey(nodeKey); - // node.accentColor = val; - // }); - // }; - - // const handleBackgroundColorChange = (val) => { - // editor.update(() => { - // const node = $getNodeByKey(nodeKey); - // node.backgroundColor = val; - // }); - // }; - - const visibilitySettings = ( - - ); - - return ( - <> - - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - {isVisibilityEnabled && isEditing && ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - {{ - visibility: visibilitySettings - }} - - )} - - ); -}; diff --git a/packages/koenig-lexical/src/nodes/TransistorNodeComponent.tsx b/packages/koenig-lexical/src/nodes/TransistorNodeComponent.tsx new file mode 100644 index 0000000000..e3af546ea4 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/TransistorNodeComponent.tsx @@ -0,0 +1,126 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +// TODO: Re-enable when design tab is implemented +// import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {SettingsPanel} from '../components/ui/SettingsPanel'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {TransistorCard} from '../components/ui/cards/TransistorCard'; +import {VisibilitySettings} from '../components/ui/VisibilitySettings'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useVisibilityToggle} from '../hooks/useVisibilityToggle'; + +interface TransistorNodeComponentProps { + nodeKey: string; + accentColor: string; + backgroundColor: string; +} + +export const TransistorNodeComponent = ({ + + nodeKey, + accentColor, + backgroundColor + +}: TransistorNodeComponentProps) => { + const [editor] = useLexicalComposerContext(); + const {isEditing, isSelected, setEditing} = React.useContext(CardContext); + const {cardConfig, darkMode} = React.useContext(KoenigComposerContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const {isVisibilityEnabled, visibilityOptions: rawVisibilityOptions, toggleVisibility} = useVisibilityToggle(editor, nodeKey, cardConfig); + + // Filter out nonMembers option - Transistor requires a member UUID so public visitors can't see it + const visibilityOptions = React.useMemo(() => { + return rawVisibilityOptions.map(group => ({ + ...group, + toggles: group.toggles.filter(toggle => toggle.key !== 'nonMembers') + })); + }, [rawVisibilityOptions]); + + const settingsTabs = [ + {id: 'visibility', label: 'Visibility'} + ]; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setEditing(true); + }; + + // TODO: Re-enable when design tab is implemented + // const handleAccentColorChange = (val) => { + // editor.update(() => { + // const node = $getNodeByKey(nodeKey); + // node.accentColor = val; + // }); + // }; + + // const handleBackgroundColorChange = (val) => { + // editor.update(() => { + // const node = $getNodeByKey(nodeKey); + // node.backgroundColor = val; + // }); + // }; + + const visibilitySettings = ( + + ); + + return ( + <> + + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + {isVisibilityEnabled && isEditing && ( + + {{ + visibility: visibilitySettings + }} + + )} + + ); +}; diff --git a/packages/koenig-lexical/src/nodes/VideoNode.jsx b/packages/koenig-lexical/src/nodes/VideoNode.jsx deleted file mode 100644 index 21f4ba826d..0000000000 --- a/packages/koenig-lexical/src/nodes/VideoNode.jsx +++ /dev/null @@ -1,113 +0,0 @@ -import VideoCardIcon from '../assets/icons/kg-card-type-video.svg?react'; -import {$generateHtmlFromNodes} from '@lexical/html'; -import {VideoNode as BaseVideoNode} from '@tryghost/kg-default-nodes'; -import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js'; -import {VideoNodeComponent} from './VideoNodeComponent'; -import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; -import {createCommand} from 'lexical'; -import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; - -export const INSERT_VIDEO_COMMAND = createCommand(); - -export class VideoNode extends BaseVideoNode { - // transient properties used to control node behaviour - __triggerFileDialog = false; - __initialFile = null; - __captionEditor; - __captionEditorInitialState; - - static kgMenu = [{ - label: 'Video', - desc: 'Upload and play a video file', - Icon: VideoCardIcon, - insertCommand: INSERT_VIDEO_COMMAND, - insertParams: { - triggerFileDialog: true - }, - matches: ['video'], - priority: 13, - shortcut: '/video' - }]; - - static uploadType = 'video'; - - getIcon() { - return VideoCardIcon; - } - - constructor(dataset = {}, key) { - super(dataset, key); - - const {triggerFileDialog, initialFile} = dataset; - - // don't trigger the file dialog when rendering if we've already been given a url - this.__triggerFileDialog = !dataset.src && triggerFileDialog; - - this.__initialFile = initialFile || null; - - setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); - // populate nested editors on initial construction - if (!dataset.captionEditor && dataset.caption) { - populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); - } - } - - set triggerFileDialog(shouldTrigger) { - const writable = this.getWritable(); - writable.__triggerFileDialog = shouldTrigger; - } - - getDataset() { - const dataset = super.getDataset(); - - // client-side only data properties such as nested editors - const self = this.getLatest(); - dataset.captionEditor = self.__captionEditor; - dataset.captionEditorInitialState = self.__captionEditorInitialState; - - return dataset; - } - - exportJSON() { - const json = super.exportJSON(); - - // convert nested editor instances back into HTML because their content may not - // be automatically updated when the nested editor changes - if (this.__captionEditor) { - this.__captionEditor.getEditorState().read(() => { - const html = $generateHtmlFromNodes(this.__captionEditor, null); - const cleanedHtml = cleanBasicHtml(html); - json.caption = cleanedHtml; - }); - } - - return json; - } - - decorate() { - return ( - - - - ); - } -} - -export const $createVideoNode = (dataset) => { - return new VideoNode(dataset); -}; - -export function $isVideoNode(node) { - return node instanceof VideoNode; -} diff --git a/packages/koenig-lexical/src/nodes/VideoNode.tsx b/packages/koenig-lexical/src/nodes/VideoNode.tsx new file mode 100644 index 0000000000..9aedda5445 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/VideoNode.tsx @@ -0,0 +1,123 @@ +import VideoCardIcon from '../assets/icons/kg-card-type-video.svg?react'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {VideoNode as BaseVideoNode, normalizeCardWidth, type VideoData} from '@tryghost/kg-default-nodes'; +import {KoenigCardWrapper, MINIMAL_NODES} from '../index'; +import {VideoNodeComponent} from './VideoNodeComponent'; +import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html'; +import {createCommand} from 'lexical'; +import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors'; +import type {LexicalEditor} from 'lexical'; + +export type VideoNodeData = VideoData & { + captionEditor?: LexicalEditor; + captionEditorInitialState?: unknown; + initialFile?: File | null; + triggerFileDialog?: boolean; +}; + +export const INSERT_VIDEO_COMMAND = createCommand(); + +export class VideoNode extends BaseVideoNode { + // transient properties used to control node behaviour + __triggerFileDialog: boolean = false; + __initialFile: File | null = null; + __captionEditor!: LexicalEditor; + __captionEditorInitialState: unknown; + + static kgMenu = [{ + label: 'Video', + desc: 'Upload and play a video file', + Icon: VideoCardIcon, + insertCommand: INSERT_VIDEO_COMMAND, + insertParams: { + triggerFileDialog: true + }, + matches: ['video'], + priority: 13, + shortcut: '/video' + }]; + + static uploadType = 'video'; + + getIcon() { + return VideoCardIcon; + } + + constructor(dataset: VideoNodeData = {}, key?: string) { + super(dataset, key); + + const {triggerFileDialog, initialFile} = dataset; + + // don't trigger the file dialog when rendering if we've already been given a url + this.__triggerFileDialog = !dataset.src && !!triggerFileDialog; + + this.__initialFile = initialFile || null; + + setupNestedEditor(this, '__captionEditor', {editor: dataset.captionEditor, nodes: MINIMAL_NODES}); + // populate nested editors on initial construction + if (!dataset.captionEditor && dataset.caption) { + populateNestedEditor(this, '__captionEditor', `${dataset.caption}`); + } + } + + set triggerFileDialog(shouldTrigger: boolean) { + const writable = this.getWritable(); + writable.__triggerFileDialog = shouldTrigger; + } + + getDataset() { + const dataset = super.getDataset(); + + // client-side only data properties such as nested editors + const self = this.getLatest(); + dataset.captionEditor = self.__captionEditor; + dataset.captionEditorInitialState = self.__captionEditorInitialState; + + return dataset; + } + + exportJSON() { + const json = super.exportJSON(); + + // convert nested editor instances back into HTML because their content may not + // be automatically updated when the nested editor changes + if (this.__captionEditor) { + this.__captionEditor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(this.__captionEditor, null); + const cleanedHtml = cleanBasicHtml(html); + json.caption = cleanedHtml ?? ""; + }); + } + + return json; + } + + decorate() { + const cardWidth = normalizeCardWidth(this.cardWidth) ?? 'regular'; + + return ( + + + + ); + } +} + +export const $createVideoNode = (dataset: VideoNodeData = {}) => { + return new VideoNode(dataset); +}; + +export function $isVideoNode(node: unknown): node is VideoNode { + return node instanceof VideoNode; +} diff --git a/packages/koenig-lexical/src/nodes/VideoNodeComponent.jsx b/packages/koenig-lexical/src/nodes/VideoNodeComponent.jsx deleted file mode 100644 index e7c02cc8e0..0000000000 --- a/packages/koenig-lexical/src/nodes/VideoNodeComponent.jsx +++ /dev/null @@ -1,252 +0,0 @@ -import CardContext from '../context/CardContext'; -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React, {useState} from 'react'; -import extractVideoMetadata from '../utils/extractVideoMetadata'; -import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; -import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx'; -import {VideoCard} from '../components/ui/cards/VideoCard'; -import {getImageDimensions} from '../utils/getImageDimensions'; -import {openFileSelection} from '../utils/openFileSelection'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export function VideoNodeComponent({ - nodeKey, - thumbnail, - customThumbnail, - captionEditor, - captionEditorInitialState, - totalDuration, - cardWidth, - triggerFileDialog, - isLoopChecked, - initialFile -}) { - const [editor] = useLexicalComposerContext(); - const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); - const cardContext = React.useContext(CardContext); - const videoFileInputRef = React.useRef(); - const [previewThumbnail, setPreviewThumbnail] = useState(''); - const videoUploader = fileUploader.useFileUpload('video'); - const thumbnailUploader = fileUploader.useFileUpload('mediaThumbnail'); - const customThumbnailUploader = fileUploader.useFileUpload('image'); - - const videoDragHandler = useFileDragAndDrop({handleDrop: handleVideoDrop}); - const thumbnailDragHandler = useFileDragAndDrop({handleDrop: handleThumbnailDrop}); - const [metadataExtractionErrors, setMetadataExtractionErrors] = useState([]); - const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); - - const videoMimeTypes = fileUploader.fileTypes.video?.mimeTypes || ['video/*']; - - React.useEffect(() => { - const uploadInitialFiles = async (file) => { - if (file && !videoUploader.isLoading) { - await handleVideoUpload([file]); - } - }; - uploadInitialFiles(initialFile); - - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleVideoUpload = async (files) => { - const file = files[0]; - if (!file) { - return; - } - let thumbnailBlob, duration, width, height, mimeType; - try { - ({thumbnailBlob, duration, width, height, mimeType} = await extractVideoMetadata(file)); - } catch (error) { - setMetadataExtractionErrors([{ - name: file.name, - message: `The file type you uploaded is not supported. Please use .${videoMimeTypes.join(', .').toUpperCase()}` - }]); - } - - setPreviewThumbnail(URL.createObjectURL(thumbnailBlob)); - - const videoUploadResult = await videoUploader.upload([file]); - const videoUrl = videoUploadResult?.[0]?.url; - - if (!videoUrl) { - setPreviewThumbnail(''); - return; - } - - if (videoUrl) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.src = videoUrl; - node.duration = duration; - node.fileName = file.name; - node.width = width; - node.height = height; - node.mimeType = mimeType; - if (!node.customThumbnailSrc) { - node.thumbnailWidth = width; - node.thumbnailHeight = height; - } - }); - } - - const thumbnailFile = new File([thumbnailBlob], `${file.name}.jpg`, {type: 'image/jpeg'}); - const imageUploadResult = await thumbnailUploader.upload([thumbnailFile], {formData: {url: videoUrl}}); - const imageUrl = imageUploadResult?.[0]?.url; - - if (imageUrl) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.thumbnailSrc = imageUrl; - }); - } - - setPreviewThumbnail(''); - }; - - const onVideoFileChange = async (e) => { - const file = e.target.files[0]; - if (!file) { - return; - } - await handleVideoUpload(e.target.files); - }; - - const handleCustomThumbnailChange = async (files) => { - const customThumbnailUploadResult = await customThumbnailUploader.upload(files); - const imageUrl = customThumbnailUploadResult?.[0]?.url; - const {width, height} = await getImageDimensions(imageUrl); - - if (imageUrl) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.customThumbnailSrc = imageUrl; - node.thumbnailWidth = width; - node.thumbnailHeight = height; - }); - } - }; - - const onCustomThumbnailChange = async (e) => { - await handleCustomThumbnailChange(e.target.files); - }; - - async function handleVideoDrop(files) { - await handleVideoUpload(files); - } - - async function handleThumbnailDrop(files) { - await handleCustomThumbnailChange(files); - } - - const onRemoveCustomThumbnail = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.customThumbnailSrc = ''; - node.thumbnailHeight = node.height; - node.thumbnailWidth = node.width; - }); - }; - - const onLoopChange = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.loop = event.target.checked; - }); - }; - - const onCardWidthChange = (width) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.cardWidth = width; - cardContext.setCardWidth(width); - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - cardContext.setEditing(true); - }; - - // when card is inserted from the card menu or slash command we want to show the file picker immediately - // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 - React.useEffect(() => { - if (!triggerFileDialog) { - return; - } - - const renderTimeout = setTimeout(() => { - // trigger dialog - openFileSelection({fileInputRef: videoFileInputRef}); - - // clear the property on the node so we don't accidentally trigger anything with a re-render - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.triggerFileDialog = false; - }); - }); - - return (() => { - clearTimeout(renderTimeout); - }); - }); - - const isCardPopulated = customThumbnail || thumbnail; - - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} diff --git a/packages/koenig-lexical/src/nodes/VideoNodeComponent.tsx b/packages/koenig-lexical/src/nodes/VideoNodeComponent.tsx new file mode 100644 index 0000000000..2392ab431b --- /dev/null +++ b/packages/koenig-lexical/src/nodes/VideoNodeComponent.tsx @@ -0,0 +1,292 @@ +import CardContext from '../context/CardContext'; +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React, {useState} from 'react'; +import extractVideoMetadata from '../utils/extractVideoMetadata'; +import useFileDragAndDrop from '../hooks/useFileDragAndDrop'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../components/ui/ActionToolbar'; +import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu'; +import {VideoCard} from '../components/ui/cards/VideoCard'; +import {getImageDimensions} from '../utils/getImageDimensions'; +import {isCardWidth, type CardWidth} from '@tryghost/kg-default-nodes'; +import {openFileSelection} from '../utils/openFileSelection'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; +import type {VideoNode} from './VideoNode'; + +function $getVideoNodeByKey(nodeKey: string): VideoNode | null { + return $getNodeByKey(nodeKey) as VideoNode | null; +} + +interface VideoNodeComponentProps { + nodeKey: string; + thumbnail: string; + customThumbnail: string; + captionEditor: LexicalEditor; + captionEditorInitialState: string | undefined; + totalDuration: string; + cardWidth: CardWidth; + triggerFileDialog: boolean; + isLoopChecked: boolean; + initialFile: File | null; +} + +export function VideoNodeComponent({ + nodeKey, + thumbnail, + customThumbnail, + captionEditor, + captionEditorInitialState, + totalDuration, + cardWidth, + triggerFileDialog, + isLoopChecked, + initialFile +}: VideoNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {fileUploader, cardConfig} = React.useContext(KoenigComposerContext); + const cardContext = React.useContext(CardContext); + const videoFileInputRef = React.useRef(null); + const [previewThumbnail, setPreviewThumbnail] = useState(''); + const videoUploader = fileUploader.useFileUpload('video'); + const thumbnailUploader = fileUploader.useFileUpload('mediaThumbnail'); + const customThumbnailUploader = fileUploader.useFileUpload('image'); + + const videoDragHandler = useFileDragAndDrop({handleDrop: handleVideoDrop}); + const thumbnailDragHandler = useFileDragAndDrop({handleDrop: handleThumbnailDrop}); + const [metadataExtractionErrors, setMetadataExtractionErrors] = useState<{name: string; message: string}[]>([]); + const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); + + const videoMimeTypes = fileUploader.fileTypes.video?.mimeTypes || ['video/*']; + + React.useEffect(() => { + const uploadInitialFiles = async (file: File | null) => { + if (file && !videoUploader.isLoading) { + await handleVideoUpload([file]); + } + }; + uploadInitialFiles(initialFile); + + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleVideoUpload = async (files: File[] | FileList) => { + const file = files[0]; + if (!file) { + return; + } + let thumbnailBlob: Blob | null | undefined; + let duration: number | undefined; + let width: number | undefined; + let height: number | undefined; + let mimeType: string | undefined; + try { + ({thumbnailBlob, duration, width, height, mimeType} = await extractVideoMetadata(file)); + } catch { + setMetadataExtractionErrors([{ + name: file.name, + message: `The file type you uploaded is not supported. Please use .${videoMimeTypes.join(', .').toUpperCase()}` + }]); + return; + } + + if (thumbnailBlob) { + setPreviewThumbnail(URL.createObjectURL(thumbnailBlob)); + } + + const videoUploadResult = await videoUploader.upload([file]); + const videoUrl = videoUploadResult?.[0]?.url; + + if (!videoUrl) { + setPreviewThumbnail(''); + return; + } + + if (videoUrl) { + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.src = videoUrl; + node.duration = duration ?? 0; + node.fileName = file.name; + node.width = width ?? null; + node.height = height ?? null; + node.mimeType = mimeType ?? ''; + if (!node.customThumbnailSrc) { + node.thumbnailWidth = width ?? null; + node.thumbnailHeight = height ?? null; + } + }); + } + + const thumbnailFile = thumbnailBlob ? new File([thumbnailBlob], `${file.name}.jpg`, {type: 'image/jpeg'}) : null; + if (!thumbnailFile) {return;} + const imageUploadResult = await thumbnailUploader.upload([thumbnailFile], {formData: {url: videoUrl}}); + const imageUrl = imageUploadResult?.[0]?.url; + + if (imageUrl) { + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.thumbnailSrc = imageUrl; + }); + } + + setPreviewThumbnail(''); + }; + + const onVideoFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || !files[0]) { + return; + } + await handleVideoUpload(files); + }; + + const handleCustomThumbnailChange = async (files: File[] | FileList) => { + const customThumbnailUploadResult = await customThumbnailUploader.upload(Array.from(files)); + const imageUrl = customThumbnailUploadResult?.[0]?.url; + const {width, height} = await getImageDimensions(imageUrl!); + + if (imageUrl) { + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.customThumbnailSrc = imageUrl; + node.thumbnailWidth = width; + node.thumbnailHeight = height; + }); + } + }; + + const onCustomThumbnailChange = async (e: React.ChangeEvent) => { + if (!e.target.files) {return;} + await handleCustomThumbnailChange(e.target.files); + }; + + async function handleVideoDrop(files: File[]) { + await handleVideoUpload(files); + } + + async function handleThumbnailDrop(files: File[]) { + await handleCustomThumbnailChange(files); + } + + const onRemoveCustomThumbnail = () => { + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.customThumbnailSrc = ''; + node.thumbnailHeight = node.height; + node.thumbnailWidth = node.width; + }); + }; + + const onLoopChange = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.loop = event.target.checked; + }); + }; + + const onCardWidthChange = (width: string) => { + if (!isCardWidth(width)) { + return; + } + + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.cardWidth = width; + cardContext.setCardWidth(width); + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + cardContext.setEditing(true); + }; + + // when card is inserted from the card menu or slash command we want to show the file picker immediately + // uses a setTimeout to avoid issues with React rendering the component twice in dev mode 🙈 + React.useEffect(() => { + if (!triggerFileDialog) { + return; + } + + const renderTimeout = setTimeout(() => { + // trigger dialog + openFileSelection({fileInputRef: videoFileInputRef}); + + // clear the property on the node so we don't accidentally trigger anything with a re-render + editor.update(() => { + const node = $getVideoNodeByKey(nodeKey); + if (!node) {return;} + node.triggerFileDialog = false; + }); + }); + + return (() => { + clearTimeout(renderTimeout); + }); + }); + + const isCardPopulated = customThumbnail || thumbnail; + + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} diff --git a/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.jsx b/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.jsx deleted file mode 100644 index 148a9843d2..0000000000 --- a/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.jsx +++ /dev/null @@ -1,171 +0,0 @@ -import CardContext from '../../../context/CardContext'; -import KoenigComposerContext from '../../../context/KoenigComposerContext'; -import React from 'react'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../../../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../../../plugins/KoenigBehaviourPlugin'; -import {HeaderCard} from '../../../components/ui/cards/HeaderCard/v1/HeaderCard'; -import {SnippetActionToolbar} from '../../../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../../../components/ui/ToolbarMenu'; -import {backgroundImageUploadHandler} from '../../../utils/imageUploadHandler'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function HeaderNodeComponent({ - nodeKey, - backgroundImageSrc, - button, - subheaderTextEditorInitialState, - buttonText, - buttonUrl, - type, - headerTextEditorInitialState, - header, - subheader, - headerTextEditor, - subheaderTextEditor, - size -}) { - const [editor] = useLexicalComposerContext(); - const {cardConfig} = React.useContext(KoenigComposerContext); - const {fileUploader} = React.useContext(KoenigComposerContext); - const {isEditing, setEditing, isSelected} = React.useContext(CardContext); - const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - const imageUploader = fileUploader.useFileUpload('image'); - - const onFileChange = async (e) => { - const files = e.target.files; - - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - - const {imageSrc} = await backgroundImageUploadHandler(files, imageUploader.upload); - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = imageSrc; - }); - }; - - const fileInputRef = React.useRef(null); - - const openFilePicker = () => { - fileInputRef.current.click(); - }; - - const handleColorSelector = (color) => { - if (color === 'image' && backgroundImageSrc === ''){ - openFilePicker(); - } - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.style = color; - }); - }; - - const handleSizeSelector = (s) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.size = s; - }); - }; - - const handleButtonToggle = (event) => { - event.stopPropagation(); - setEditing(true); // kinda weird but this avoids the card from unselecting itself when toggling. - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonEnabled = event.target.checked; - }); - }; - - const handleButtonText = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const handleButtonUrl = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = val; - }); - }; - - const handleClearBackgroundImage = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - }; - - React.useEffect(() => { - headerTextEditor.setEditable(isEditing); - subheaderTextEditor.setEditable(isEditing); - }, [isEditing, headerTextEditor, subheaderTextEditor]); - return ( - <> - - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} - -export default HeaderNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.tsx b/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.tsx new file mode 100644 index 0000000000..cd78a77e60 --- /dev/null +++ b/packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.tsx @@ -0,0 +1,206 @@ +import CardContext from '../../../context/CardContext'; +import KoenigComposerContext from '../../../context/KoenigComposerContext'; +import React from 'react'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../../../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../../../plugins/KoenigBehaviourPlugin'; +import {HeaderCard} from '../../../components/ui/cards/HeaderCard/v1/HeaderCard'; +import {SnippetActionToolbar} from '../../../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../../../components/ui/ToolbarMenu'; +import {backgroundImageUploadHandler} from '../../../utils/imageUploadHandler'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {HeaderNode} from '../../HeaderNode'; +import type {LexicalEditor} from 'lexical'; + +function $getHeaderNodeByKey(nodeKey: string): HeaderNode | null { + return $getNodeByKey(nodeKey) as HeaderNode | null; +} + +interface HeaderNodeComponentProps { + nodeKey: string; + backgroundImageSrc: string; + button: boolean; + subheaderTextEditorInitialState: string | undefined; + buttonText: string; + buttonUrl: string; + type: 'dark' | 'light' | 'accent' | 'image'; + headerTextEditorInitialState: string | undefined; + header: string; + subheader: string; + headerTextEditor: LexicalEditor; + subheaderTextEditor: LexicalEditor; + size: 'small' | 'medium' | 'large'; +} + +function HeaderNodeComponent({ + nodeKey, + backgroundImageSrc, + button, + subheaderTextEditorInitialState, + buttonText, + buttonUrl, + type, + headerTextEditorInitialState, + header, + subheader, + headerTextEditor, + subheaderTextEditor, + size +}: HeaderNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {cardConfig} = React.useContext(KoenigComposerContext); + const {fileUploader} = React.useContext(KoenigComposerContext); + const {isEditing, setEditing, isSelected} = React.useContext(CardContext); + const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + const imageUploader = fileUploader.useFileUpload('image'); + + const onFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) { + return; + } + + // reset original src so it can be replaced with preview and upload progress + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + + const result = await backgroundImageUploadHandler(Array.from(files), imageUploader.upload); + if (!result) {return;} + const {imageSrc} = result; + + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = imageSrc ?? ""; + }); + }; + + const fileInputRef = React.useRef(null); + + const openFilePicker = () => { + fileInputRef.current?.click(); + }; + + const handleColorSelector = (color: string) => { + if (color === 'image' && backgroundImageSrc === ''){ + openFilePicker(); + } + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.style = color; + }); + }; + + const handleSizeSelector = (s: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.size = s; + }); + }; + + const handleButtonToggle = (event: React.ChangeEvent) => { + event.stopPropagation(); + setEditing(true); // kinda weird but this avoids the card from unselecting itself when toggling. + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonEnabled = event.target.checked; + }); + }; + + const handleButtonText = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = event.target.value; + }); + }; + + const handleButtonUrl = (val: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonUrl = val; + }); + }; + + const handleClearBackgroundImage = () => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + }; + + React.useEffect(() => { + headerTextEditor.setEditable(isEditing); + subheaderTextEditor.setEditable(isEditing); + }, [isEditing, headerTextEditor, subheaderTextEditor]); + return ( + <> + + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} + +export default HeaderNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.jsx b/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.jsx deleted file mode 100644 index 80d18a2b47..0000000000 --- a/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.jsx +++ /dev/null @@ -1,318 +0,0 @@ -import CardContext from '../../../context/CardContext'; -import KoenigComposerContext from '../../../context/KoenigComposerContext'; -import useFileDragAndDrop from '../../../hooks/useFileDragAndDrop'; -import usePinturaEditor from '../../../hooks/usePinturaEditor'; -import {$getNodeByKey} from 'lexical'; -import {ActionToolbar} from '../../../components/ui/ActionToolbar'; -import {EDIT_CARD_COMMAND} from '../../../plugins/KoenigBehaviourPlugin'; -// import {SignupCard} from '../components/ui/cards/SignupCard.jsx'; -import {HeaderCard} from '../../../components/ui/cards/HeaderCard/v2/HeaderCard'; -import {SnippetActionToolbar} from '../../../components/ui/SnippetActionToolbar.jsx'; -import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../../../components/ui/ToolbarMenu'; -import {backgroundImageUploadHandler} from '../../../utils/imageUploadHandler'; -import {getAccentColor} from '../../../utils/getAccentColor'; -import {openFileSelection} from '../../../utils/openFileSelection'; -import {useContext, useEffect, useRef, useState} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -// {name: 'size', default: 'small'}, // v1 -// {name: 'style', default: 'dark'}, // v1 -// do we need these? - -// this is v2 of the header card -function HeaderNodeComponent({ - alignment, - backgroundColor, - backgroundImageSrc, - backgroundImageWidth, - backgroundImageHeight, - backgroundSize, - buttonColor, - buttonText, - buttonTextColor, - buttonUrl, - buttonEnabled, - nodeKey, - header, - headerTextEditor, - headerTextEditorInitialState, - layout, - subheader, - subheaderTextEditor, - subheaderTextEditorInitialState, - textColor, - isSwapped, - accentColor -}) { - const [editor] = useLexicalComposerContext(); - const {cardConfig} = useContext(KoenigComposerContext); - const {fileUploader} = useContext(KoenigComposerContext); - const {isEditing, isSelected} = useContext(CardContext); - const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); - const [showBackgroundImage, setShowBackgroundImage] = useState(Boolean(backgroundImageSrc)); - const [lastBackgroundImage, setLastBackgroundImage] = useState(backgroundImageSrc); - - // this is used to determine if the image was deliberately removed by the user or not, for some UX finesse - const [imageRemoved, setImageRemoved] = useState(false); - - const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); - const fileInputRef = useRef(null); - - useEffect(() => { - if (layout !== 'split') { - setShowBackgroundImage(Boolean(backgroundImageSrc)); - } - - if (layout === 'split' && !backgroundImageSrc && lastBackgroundImage) { - handleShowBackgroundImage(); - } - // We just want to reset the show background image state when the layout changes, not when the image changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout]); - - useEffect(() => { - let accent = getAccentColor(); - - if (accent) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.accentColor = accent; - }); - } - }, [editor, nodeKey]); - - const handleAlignment = (a) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.alignment = a; - }); - }; - - const handleBackgroundSize = (a) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundSize = a; - }); - }; - - const handleToolbarEdit = (event) => { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); - }; - - const imageUploader = fileUploader.useFileUpload('image'); - - const handleImageChange = async (files) => { - // reset original src so it can be replaced with preview and upload progress - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - - const {imageSrc, width, height} = await backgroundImageUploadHandler(files, imageUploader.upload); - - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = imageSrc; - node.backgroundImageWidth = width; - node.backgroundImageHeight = height; - }); - - setLastBackgroundImage(imageSrc); - setImageRemoved(false); - }; - - const onFileChange = async (e) => { - handleImageChange(e.target.files); - }; - - const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageChange}); - - const handleLayout = (l) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.layout = l; - }); - }; - - const handleButtonText = (event) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = event.target.value; - }); - }; - - const handleButtonTextBlur = (event) => { - if (!event.target.value) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonText = ''; - }); - } - }; - - const handleClearBackgroundImage = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - setImageRemoved(true); - }; - - const handleShowBackgroundImage = () => { - setShowBackgroundImage(true); - - if (lastBackgroundImage && !imageRemoved) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = lastBackgroundImage; - }); - } else { - openFileSelection({fileInputRef}); - } - }; - - const handleHideBackgroundImage = () => { - setShowBackgroundImage(false); - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundImageSrc = ''; - }); - }; - - const handleBackgroundColor = (color, matchingTextColor) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.backgroundColor = color; - node.textColor = matchingTextColor; - - if (layout !== 'split') { - handleHideBackgroundImage(); - } - }); - }; - - const handleTextColor = (color) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.textColor = color; - }); - }; - - const handleButtonColor = (color, matchingTextColor) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonColor = color; - node.buttonTextColor = matchingTextColor; - }); - }; - - const handleSwapLayout = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.swapped = !isSwapped; - }); - }; - - const handleButtonEnabled = () => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonEnabled = !buttonEnabled; - }); - }; - - const handleButtonUrl = (val) => { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = val; - }); - }; - - const handleButtonUrlBlur = (event) => { - if (!event.target.value) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.buttonUrl = 'https://'; - }); - } - }; - - useEffect(() => { - headerTextEditor.setEditable(isEditing); - subheaderTextEditor.setEditable(isEditing); - }, [isEditing, headerTextEditor, subheaderTextEditor]); - - return ( - <> - fileInputRef.current = ref} - showBackgroundImage={showBackgroundImage} - subheader={subheader} - subheaderTextEditor={subheaderTextEditor} - subheaderTextEditorInitialState={subheaderTextEditorInitialState} - textColor={textColor} - onFileChange={onFileChange} - /> - - setShowSnippetToolbar(false)} /> - - - - - - - setShowSnippetToolbar(true)} - /> - - - - ); -} - -export default HeaderNodeComponent; diff --git a/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx b/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx new file mode 100644 index 0000000000..2640686c3d --- /dev/null +++ b/packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx @@ -0,0 +1,369 @@ +import CardContext from '../../../context/CardContext'; +import KoenigComposerContext from '../../../context/KoenigComposerContext'; +import useFileDragAndDrop from '../../../hooks/useFileDragAndDrop'; +import usePinturaEditor from '../../../hooks/usePinturaEditor'; +import {$getNodeByKey} from 'lexical'; +import {ActionToolbar} from '../../../components/ui/ActionToolbar'; +import {EDIT_CARD_COMMAND} from '../../../plugins/KoenigBehaviourPlugin'; +import type {HeaderNode} from '../../HeaderNode'; +import type {LexicalEditor} from 'lexical'; +// import {SignupCard} from '../components/ui/cards/SignupCard'; +import {HeaderCard} from '../../../components/ui/cards/HeaderCard/v2/HeaderCard'; +import {SnippetActionToolbar} from '../../../components/ui/SnippetActionToolbar'; +import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../../../components/ui/ToolbarMenu'; +import {backgroundImageUploadHandler} from '../../../utils/imageUploadHandler'; +import {getAccentColor} from '../../../utils/getAccentColor'; +import {openFileSelection} from '../../../utils/openFileSelection'; +import {useContext, useEffect, useRef, useState} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +// {name: 'size', default: 'small'}, // v1 +// {name: 'style', default: 'dark'}, // v1 +// do we need these? + +function $getHeaderNodeByKey(nodeKey: string): HeaderNode | null { + return $getNodeByKey(nodeKey) as HeaderNode | null; +} + +// this is v2 of the header card +type Layout = 'regular' | 'wide' | 'full' | 'split'; +type Alignment = 'left' | 'center'; +type BackgroundSize = 'cover' | 'contain'; + +interface HeaderNodeComponentProps { + alignment: Alignment; + backgroundColor: string; + backgroundImageSrc: string; + backgroundImageWidth: number | null; + backgroundImageHeight: number | null; + backgroundSize: BackgroundSize; + buttonColor: string; + buttonText: string; + buttonTextColor: string; + buttonUrl: string; + buttonEnabled: boolean; + nodeKey: string; + header: string; + headerTextEditor: LexicalEditor; + headerTextEditorInitialState: string | undefined; + layout: Layout; + subheader: string; + subheaderTextEditor: LexicalEditor; + subheaderTextEditorInitialState: string | undefined; + textColor: string; + isSwapped: boolean; + accentColor: string; +} + +function HeaderNodeComponent({ + alignment, + backgroundColor, + backgroundImageSrc, + backgroundImageWidth: _backgroundImageWidth, + backgroundImageHeight: _backgroundImageHeight, + backgroundSize, + buttonColor, + buttonText, + buttonTextColor, + buttonUrl, + buttonEnabled, + nodeKey, + header: _header, + headerTextEditor, + headerTextEditorInitialState, + layout, + subheader: _subheader, + subheaderTextEditor, + subheaderTextEditorInitialState, + textColor, + isSwapped, + accentColor: _accentColor +}: HeaderNodeComponentProps) { + const [editor] = useLexicalComposerContext(); + const {cardConfig} = useContext(KoenigComposerContext); + const {fileUploader} = useContext(KoenigComposerContext); + const {isEditing, isSelected} = useContext(CardContext); + const [showSnippetToolbar, setShowSnippetToolbar] = useState(false); + const [showBackgroundImage, setShowBackgroundImage] = useState(Boolean(backgroundImageSrc)); + const [lastBackgroundImage, setLastBackgroundImage] = useState(backgroundImageSrc); + + // this is used to determine if the image was deliberately removed by the user or not, for some UX finesse + const [imageRemoved, setImageRemoved] = useState(false); + + const {isEnabled: isPinturaEnabled, openEditor: openImageEditor} = usePinturaEditor({config: cardConfig.pinturaConfig}); + const fileInputRef = useRef(null); + + useEffect(() => { + if (layout !== 'split') { + setShowBackgroundImage(Boolean(backgroundImageSrc)); + } + + if (layout === 'split' && !backgroundImageSrc && lastBackgroundImage) { + handleShowBackgroundImage(); + } + // We just want to reset the show background image state when the layout changes, not when the image changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout]); + + useEffect(() => { + const accent = getAccentColor(); + + if (accent) { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.accentColor = accent; + }); + } + }, [editor, nodeKey]); + + const handleAlignment = (a: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.alignment = a; + }); + }; + + const handleBackgroundSize = (a: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundSize = a; + }); + }; + + const handleToolbarEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey: nodeKey, focusEditor: false}); + }; + + const imageUploader = fileUploader.useFileUpload('image'); + + const handleImageChange = async (files: File[] | FileList | null) => { + if (!files || files.length === 0) { + return; + } + + const result = await backgroundImageUploadHandler(Array.from(files), imageUploader.upload); + if (!result) {return;} + const {width, height} = result; + const imageSrc = result.imageSrc ?? ""; + + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = imageSrc; + node.backgroundImageWidth = width; + node.backgroundImageHeight = height; + }); + + setLastBackgroundImage(imageSrc); + setImageRemoved(false); + }; + + const onFileChange = async (e: React.ChangeEvent) => { + handleImageChange(e.target.files); + }; + + const imageDragHandler = useFileDragAndDrop({handleDrop: handleImageChange}); + + const handleLayout = (l: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.layout = l; + }); + }; + + const handleButtonText = (event: React.ChangeEvent) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = event.target.value; + }); + }; + + const handleButtonTextBlur = (event: React.FocusEvent) => { + if (!event.target.value) { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonText = ''; + }); + } + }; + + const handleClearBackgroundImage = () => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + setImageRemoved(true); + }; + + const handleShowBackgroundImage = () => { + setShowBackgroundImage(true); + + if (lastBackgroundImage && !imageRemoved) { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = lastBackgroundImage; + }); + } else { + openFileSelection({fileInputRef}); + } + }; + + const handleHideBackgroundImage = () => { + setShowBackgroundImage(false); + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundImageSrc = ''; + }); + }; + + const handleBackgroundColor = (color: string, matchingTextColor: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.backgroundColor = color; + node.textColor = matchingTextColor; + + if (layout !== 'split') { + handleHideBackgroundImage(); + } + }); + }; + + const handleTextColor = (color: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.textColor = color; + }); + }; + + const handleButtonColor = (color: string, matchingTextColor: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonColor = color; + node.buttonTextColor = matchingTextColor; + }); + }; + + const handleSwapLayout = () => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.swapped = !isSwapped; + }); + }; + + const handleButtonEnabled = () => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonEnabled = !buttonEnabled; + }); + }; + + const handleButtonUrl = (val: string) => { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonUrl = val; + }); + }; + + const handleButtonUrlBlur = (event: React.FocusEvent) => { + if (!event.target.value) { + editor.update(() => { + const node = $getHeaderNodeByKey(nodeKey); + if (!node) {return;} + node.buttonUrl = 'https://'; + }); + } + }; + + useEffect(() => { + headerTextEditor.setEditable(isEditing); + subheaderTextEditor.setEditable(isEditing); + }, [isEditing, headerTextEditor, subheaderTextEditor]); + + return ( + <> + fileInputRef.current = ref} + showBackgroundImage={showBackgroundImage} + subheaderTextEditor={subheaderTextEditor} + subheaderTextEditorInitialState={subheaderTextEditorInitialState} + textColor={textColor} + onFileChange={onFileChange} + /> + + setShowSnippetToolbar(false)} /> + + + + + + + setShowSnippetToolbar(true)} + /> + + + + ); +} + +export default HeaderNodeComponent; diff --git a/packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx b/packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx deleted file mode 100644 index d4e40ac153..0000000000 --- a/packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import AtLinkPlugin from './AtLinkPlugin.jsx'; -import CallToActionPlugin from '../plugins/CallToActionPlugin'; -import EmEnDashPlugin from '../plugins/EmEnDashPlugin'; -import HorizontalRulePlugin from '../plugins/HorizontalRulePlugin'; -import HtmlPlugin from './HtmlPlugin'; -import ImagePlugin from '../plugins/ImagePlugin'; -import KoenigSelectorPlugin from './KoenigSelectorPlugin.jsx'; -import MarkdownPlugin from '../plugins/MarkdownPlugin'; -import {AudioPlugin} from '../plugins/AudioPlugin'; -import {BookmarkPlugin} from '../plugins/BookmarkPlugin'; -import {ButtonPlugin} from '../plugins/ButtonPlugin'; -import {CalloutPlugin} from '../plugins/CalloutPlugin'; -import {CardMenuPlugin} from '../plugins/CardMenuPlugin'; -import {EmailCtaPlugin} from '../plugins/EmailCtaPlugin'; -import {EmailPlugin} from '../plugins/EmailPlugin'; -import {EmbedPlugin} from '../plugins/EmbedPlugin'; -import {EmojiPickerPlugin} from './EmojiPickerPlugin'; -import {FilePlugin} from '../plugins/FilePlugin'; -import {GalleryPlugin} from '../plugins/GalleryPlugin'; -import {HeaderPlugin} from '../plugins/HeaderPlugin'; -import {KoenigSnippetPlugin} from '../plugins/KoenigSnippetPlugin'; -import {ListPlugin} from '@lexical/react/LexicalListPlugin'; -import {PaywallPlugin} from '../plugins/PaywallPlugin'; -import {ProductPlugin} from '../plugins/ProductPlugin'; -import {SignupPlugin} from '../plugins/SignupPlugin'; -import {TogglePlugin} from '../plugins/TogglePlugin'; -import {TransistorPlugin} from '../plugins/TransistorPlugin'; -import {VideoPlugin} from '../plugins/VideoPlugin'; - -export const AllDefaultPlugins = () => { - return ( - <> - {/* Lexical Plugins */} - {/* adds indent/outdent/remove etc support */} - {/* tab/shift+tab triggers indent/outdent */} - - {/* Koenig Plugins */} - - - {/* Gif/Unsplash selectors */} - - - - {/* Card Plugins */} - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default AllDefaultPlugins; diff --git a/packages/koenig-lexical/src/plugins/AllDefaultPlugins.tsx b/packages/koenig-lexical/src/plugins/AllDefaultPlugins.tsx new file mode 100644 index 0000000000..d3fe53c294 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/AllDefaultPlugins.tsx @@ -0,0 +1,71 @@ +import AtLinkPlugin from './AtLinkPlugin'; +import CallToActionPlugin from '../plugins/CallToActionPlugin'; +import EmEnDashPlugin from '../plugins/EmEnDashPlugin'; +import HorizontalRulePlugin from '../plugins/HorizontalRulePlugin'; +import HtmlPlugin from './HtmlPlugin'; +import ImagePlugin from '../plugins/ImagePlugin'; +import KoenigSelectorPlugin from './KoenigSelectorPlugin'; +import MarkdownPlugin from '../plugins/MarkdownPlugin'; +import {AudioPlugin} from '../plugins/AudioPlugin'; +import {BookmarkPlugin} from '../plugins/BookmarkPlugin'; +import {ButtonPlugin} from '../plugins/ButtonPlugin'; +import {CalloutPlugin} from '../plugins/CalloutPlugin'; +import {CardMenuPlugin} from '../plugins/CardMenuPlugin'; +import {EmailCtaPlugin} from '../plugins/EmailCtaPlugin'; +import {EmailPlugin} from '../plugins/EmailPlugin'; +import {EmbedPlugin} from '../plugins/EmbedPlugin'; +import {EmojiPickerPlugin} from './EmojiPickerPlugin'; +import {FilePlugin} from '../plugins/FilePlugin'; +import {GalleryPlugin} from '../plugins/GalleryPlugin'; +import {HeaderPlugin} from '../plugins/HeaderPlugin'; +import {KoenigSnippetPlugin} from '../plugins/KoenigSnippetPlugin'; +import {ListPlugin} from '@lexical/react/LexicalListPlugin'; +import {PaywallPlugin} from '../plugins/PaywallPlugin'; +import {ProductPlugin} from '../plugins/ProductPlugin'; +import {SignupPlugin} from '../plugins/SignupPlugin'; +import {TogglePlugin} from '../plugins/TogglePlugin'; +import {TransistorPlugin} from '../plugins/TransistorPlugin'; +import {VideoPlugin} from '../plugins/VideoPlugin'; + +export const AllDefaultPlugins = () => { + return ( + <> + {/* Lexical Plugins */} + {/* adds indent/outdent/remove etc support */} + {/* tab/shift+tab triggers indent/outdent */} + + {/* Koenig Plugins */} + + + {/* Gif/Unsplash selectors */} + + + + {/* Card Plugins */} + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default AllDefaultPlugins; diff --git a/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx b/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx deleted file mode 100644 index 44cbe9fa01..0000000000 --- a/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx +++ /dev/null @@ -1,493 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import Portal from '../components/ui/Portal'; -import React from 'react'; -import trackEvent from '../utils/analytics'; -import { - $createAtLinkNode, - $createAtLinkSearchNode, - $createZWNJNode, - $isAtLinkNode, - $isAtLinkSearchNode, - $isZWNJNode, - AtLinkNode, - AtLinkSearchNode -} from '@tryghost/kg-default-nodes'; -import {$createBookmarkNode} from '../nodes/BookmarkNode'; -import {$createLinkNode} from '@lexical/link'; -import { - $createTextNode, - $getSelection, - $isRangeSelection, - $isTextNode, - $nodesOfType, - COMMAND_PRIORITY_HIGH, - DELETE_CHARACTER_COMMAND, - FORMAT_ELEMENT_COMMAND, - FORMAT_TEXT_COMMAND, - KEY_ESCAPE_COMMAND, - PASTE_COMMAND -} from 'lexical'; -import {$insertFirst, mergeRegister} from '@lexical/utils'; -import {AtLinkResultsPopup} from '../components/ui/AtLinkResultsPopup'; -import {isInternalUrl} from '../utils/isInternalUrl'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useSearchLinks} from '../hooks/useSearchLinks'; - -function $removeAtLink(node, {focus = false} = {}) { - if (!$isAtLinkNode(node)) { - - console.warn('$removeAtLink called on a non-at-link node', node); - return; - } - - const searchNode = node.getChildAtIndex(1); - - const textNode = $createTextNode('@' + searchNode.getTextContent()); - textNode.setFormat(node.getLinkFormat()); - node.replace(textNode); - - if (focus) { - textNode.selectEnd(); - } -} - -function noResultOptions() { - return [{ - label: 'No results found' - }]; -} - -// Manages at-link search nodes and display of the search results panel when appropriate -export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { - const [editor] = useLexicalComposerContext(); - const [focusedAtLinkNode, setFocusedAtLinkNode] = React.useState(null); - const [query, setQuery] = React.useState(''); - const searchOptions = React.useMemo(() => ({noResultOptions}), []); - const {isSearching, listOptions} = useSearchLinks(query, searchLinks, searchOptions); - - // register an event listener to detect '@' character being typed - // - we only ever want to convert an '@' to an at-link node when it's typed - // so a native event listener makes more sense than a lexical update listener - // that would need to constantly compare against current and previous states - // - '@' must be preceded by beginning of line, whitespace, or br - // - '@' must be followed by whitespace, end of line, or br - React.useEffect(() => { - const rootElement = editor.getRootElement(); - - const handleAtInsert = (event) => { - if (event.isComposing) { - return; - } - - if (event.inputType === 'insertText' && event.data === '@') { - let replaceAt = false; - - editor.getEditorState().read(() => { - // get the current selection - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return; - } - - const anchor = selection.anchor; - if (anchor.type !== 'text') { - return; - } - - const anchorNode = anchor.getNode(); - if (!anchorNode.isSimpleText()) { - return; - } - - let anchorOffset = anchor.offset; - let textBeforeAnchor = anchorNode.getTextContent().slice(0, anchorOffset); - let textAfterAnchor = anchorNode.getTextContent().slice(anchorOffset); - - // adjust before/after text if we're immediately preceded/followed by a text node - // because that content needs to be accounted for in our regex match - const prevSibling = anchorNode.getPreviousSibling(); - const nextSibling = anchorNode.getNextSibling(); - - if (anchorOffset === 0 && $isTextNode(prevSibling)) { - textBeforeAnchor = prevSibling.getTextContent(); - } - - if (anchorOffset === anchorNode.getTextContent().length && $isTextNode(nextSibling)) { - textAfterAnchor = nextSibling.getTextContent(); - } - - const textBeforeRegExp = /(^|\s)@$/; - const textAfterRegExp = /^($|\s|\.)/; - - if ( - textBeforeRegExp.test(textBeforeAnchor) - && textAfterRegExp.test(textAfterAnchor) - ) { - replaceAt = true; - } - }); - - if (replaceAt) { - editor.update(() => { - // selection should now be where the '@' character was - const selection = $getSelection(); - - // store current node's format so it can be re-applied to the eventual link node - const linkFormat = selection.anchor.getNode().getFormat(); - - // delete the '@' character - selection.deleteCharacter(true); - - // prep the at-link node - const atLinkNode = $createAtLinkNode(); - atLinkNode.setLinkFormat(linkFormat); - const zwnjNode = $createZWNJNode(); - atLinkNode.append(zwnjNode); - const atLinkSearchNode = $createAtLinkSearchNode(''); - atLinkNode.append(atLinkSearchNode); - - // insert it - selection.insertNodes([atLinkNode]); - - // ensure we still have a cursor and it's inside the search node - atLinkNode.select(1, 1); - - const searchNode = atLinkNode.getChildAtIndex(1); - const rangeSelection = $getSelection(); - if ($isRangeSelection(rangeSelection)) { - rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); - rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); - } - }); - } - } - }; - - // weirdly the 'input' event doesn't fire for the first character typed in a paragraph - const handleAtBeforeInput = (event) => { - if (event.inputType === 'insertText' && event.data === '@') { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection) && selection.isCollapsed() && !selection.anchor.getNode().getPreviousSibling()) { - handleAtInsert(event); - } - }); - } - }; - - rootElement.addEventListener('input', handleAtInsert); - rootElement.addEventListener('beforeinput', handleAtBeforeInput); - - return () => { - rootElement.removeEventListener('input', handleAtInsert); - rootElement.removeEventListener('beforeinput', handleAtBeforeInput); - }; - }, [editor]); - - // register an update listener - // - update plugin state with a focused at-link node - // - update plugin state with search query based on at-link-search node text content - // - remove at-link nodes when they don't have focus (i.e. using arrow keys to move out of them) - React.useEffect(() => { - return editor.registerUpdateListener(() => { - // do nothing if we're in the middle of composing text - if (editor.isComposing()) { - return; - } - - editor.update(() => { - const atLinkNodes = $nodesOfType(AtLinkNode); - const selection = $getSelection(); - - // we don't have a normal selection so we don't have a cursor inside - // an at-link node, remove all of them - if (!$isRangeSelection(selection)) { - atLinkNodes.forEach($removeAtLink); - setFocusedAtLinkNode(null); - setQuery(''); - return; - } - - // we have a collapsed selection, remove any at-link nodes that don't have focus - // handles cursor movement out of at-link nodes - if (selection.isCollapsed()) { - const anchorNode = selection.anchor.getNode(); - let selectedAtLinkNode; - - if ($isAtLinkNode(anchorNode)) { - selectedAtLinkNode = anchorNode; - } - if ($isAtLinkNode(anchorNode.getParent())) { - selectedAtLinkNode = anchorNode.getParent(); - } - - atLinkNodes.forEach((atLinkNode) => { - if (atLinkNode !== selectedAtLinkNode) { - $removeAtLink(atLinkNode); - } - }); - - if (selectedAtLinkNode) { - // search node is focused, update our search query - setFocusedAtLinkNode(selectedAtLinkNode); - - // at-link nodes always have a ZWNJ node followed by an at-link-search node - const searchNode = selectedAtLinkNode.getChildAtIndex(1); - const searchNodeText = searchNode?.getTextContent?.(); - - setQuery(searchNodeText); - - // normalize selection to be inside the search node when on zwnj - // - handles case where text is backspaced to empty - if ($isZWNJNode(selection.focus.getNode()) && window.getSelection().anchorOffset === 0) { - selectedAtLinkNode.select(1, 1); - const rangeSelection = $getSelection(); - if ($isRangeSelection(rangeSelection)) { - rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); - rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); - } - } - - // if the search node is already empty but active, remove the at-link node on backspace - if (searchNodeText === '' && $isZWNJNode(selection.anchor.getNode())) { - $removeAtLink(selectedAtLinkNode, {focus: true}); - } - } else { - // search node isn't focused, reset plugin state - setFocusedAtLinkNode(null); - setQuery(''); - } - - return; - } - - // TODO: prevent range selection spanning outside of at-link node - }); - }); - }, [editor]); - - // register some command handlers to avoid certain actions happening whilst - // an at-link-search node is focused - React.useEffect(() => { - function $skipFormatCommandIfNeeded() { - const selection = $getSelection(); - if ($isRangeSelection(selection) && $isAtLinkSearchNode(selection.anchor.getNode())) { - return true; - } - return false; - } - - return mergeRegister( - // revert to '@' when pressing escape with a focused at-link node - editor.registerCommand( - KEY_ESCAPE_COMMAND, - () => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const anchorNode = selection.anchor.getNode(); - if ($isAtLinkNode(anchorNode)) { - $removeAtLink(anchorNode, {focus: true}); - return true; - } - if ($isAtLinkSearchNode(anchorNode) || ($isZWNJNode(anchorNode) && $isAtLinkNode(anchorNode.getParent()))) { - $removeAtLink(anchorNode.getParent(), {focus: true}); - return true; - } - } - return false; - }, - COMMAND_PRIORITY_HIGH - ), - // revert to '@' when backspacing or deleting chars at the beginning/end of an at-link node - editor.registerCommand( - DELETE_CHARACTER_COMMAND, - (isBackward) => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const anchorNode = selection.anchor.getNode(); - if ($isAtLinkSearchNode(anchorNode) || ($isZWNJNode(anchorNode) && $isAtLinkNode(anchorNode.getParent()))) { - const anchorOffset = selection.anchor.offset; - if (isBackward && anchorOffset === 0) { - $removeAtLink(anchorNode.getParent(), {focus: true}); - return true; - } - if (!isBackward && anchorOffset === anchorNode.getTextContentSize()) { - $removeAtLink(anchorNode.getParent(), {focus: true}); - return true; - } - } - } - return false; - }, - COMMAND_PRIORITY_HIGH - ), - // prevent formatting commands when an at-link-search node is focused - editor.registerCommand( - FORMAT_TEXT_COMMAND, - $skipFormatCommandIfNeeded, - COMMAND_PRIORITY_HIGH - ), - editor.registerCommand( - FORMAT_ELEMENT_COMMAND, - $skipFormatCommandIfNeeded, - COMMAND_PRIORITY_HIGH - ), - // prevent paste in the search node triggering external paste handlers - editor.registerCommand( - PASTE_COMMAND, - (clipboardEvent) => { - const selection = $getSelection(); - - if (!selection || document.activeElement !== editor.getRootElement()) { - return false; - } - - const anchorNode = selection.anchor.getNode(); - if ($isRangeSelection(selection) && ($isAtLinkNode(anchorNode) || $isAtLinkSearchNode(anchorNode))) { - clipboardEvent.preventDefault(); - - const atLinkSearchNode = $isAtLinkSearchNode(anchorNode) ? anchorNode : anchorNode.getChildAtIndex(1); - const text = clipboardEvent.clipboardData.getData('text/plain'); - - if (text) { - atLinkSearchNode.setTextContent(atLinkSearchNode.getTextContent() + text); - atLinkSearchNode.selectEnd(); - } - - return true; - } - return false; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }); - - // register transforms to ensure at-link node trees are valid - React.useEffect(() => { - return editor.registerNodeTransform(AtLinkNode, (atLinkNode) => { - // first child should always be a ZWNJ - if (!$isZWNJNode(atLinkNode.getFirstChild())) { - const zwnjNode = $createZWNJNode(); - $insertFirst(atLinkNode, zwnjNode); - } - - // second child should be a search node - if (!$isAtLinkSearchNode(atLinkNode.getChildAtIndex(1))) { - const atLinkSearchNode = $createAtLinkSearchNode(''); - atLinkNode.append(atLinkSearchNode); - } - - // we only want one search node, remove or replace any non-search nodes - atLinkNode.getChildren().forEach((child, index) => { - if (index > 0 && !$isAtLinkSearchNode(child)) { - const text = child.getTextContent?.(); - - if (!text) { - child.remove(); - } else { - const atLinkSearchNode = $createAtLinkSearchNode(text); - child.replace(atLinkSearchNode); - } - } - }); - - // consolidate multiple search nodes from previous step into single node - const searchNode = atLinkNode.getChildAtIndex(1); - const currentText = searchNode.getTextContent(); - let consolidatedText = currentText; - atLinkNode.getChildren().forEach((child, index) => { - if (index > 1) { - consolidatedText += child.getTextContent(); - child.remove(); - } - }); - if (consolidatedText !== currentText) { - searchNode.setTextContent(consolidatedText); - } - }); - }, [editor]); - - // when a search result is selected, replace the at-link node with a link node - const onItemSelect = React.useCallback((item) => { - editor.update(() => { - if (!item?.value) { - $removeAtLink(focusedAtLinkNode, {focus: true}); - return; - } - - const parent = focusedAtLinkNode.getParent(); - // we have to get the children nodes - const children = parent.getChildren(); - - let isTextLink = (children.length !== 1 || !$isAtLinkNode(children[0])); - - if (isTextLink) { - const linkNode = $createLinkNode(item.value); - const textNode = $createTextNode(item.label); - linkNode.append(textNode); - linkNode.setFormat(focusedAtLinkNode.getLinkFormat()); - - focusedAtLinkNode.replace(linkNode); - linkNode.selectEnd(); - - setQuery(''); - setFocusedAtLinkNode(null); - } else { - const bookmarkNode = $createBookmarkNode({ - url: item.value, - title: item.label - }); - focusedAtLinkNode.replace(bookmarkNode); - bookmarkNode.selectEnd(); - } - - if (item.type === 'internal' || item.type === 'default') { - trackEvent('Link dropdown: Internal link chosen', {context: 'at-link', fromLatest: item.type === 'default', isBookmark: !isTextLink}); - } else { - let linkTarget = isInternalUrl(item.value, siteUrl) ? 'internal' : 'external'; - trackEvent('Link dropdown: URL entered', {context: 'at-link', target: linkTarget, isBookmark: !isTextLink}); - } - }); - }, [editor, focusedAtLinkNode, siteUrl]); - - // render nothing when we don't have a focused at-link node - if (!focusedAtLinkNode) { - return null; - } - - // otherwise render search results popup - return ( - - - - ); -}; - -// wrapping KoenigAtLinkPlugin means we can ensure all dependencies are available -// before rendering the plugin, avoiding complex conditionals in the plugin itself -export const AtLinkPlugin = () => { - const {cardConfig} = React.useContext(KoenigComposerContext); - const [editor] = useLexicalComposerContext(); - - // do nothing if we haven't been passed a way to search internal links - const enabled = typeof cardConfig?.searchLinks === 'function'; - if (!enabled) { - return null; - } - - // do nothing if the required nodes aren't loaded - if (!editor.hasNodes([AtLinkNode, AtLinkSearchNode])) { - return null; - } - - return ; -}; - -export default AtLinkPlugin; diff --git a/packages/koenig-lexical/src/plugins/AtLinkPlugin.tsx b/packages/koenig-lexical/src/plugins/AtLinkPlugin.tsx new file mode 100644 index 0000000000..17b7011580 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/AtLinkPlugin.tsx @@ -0,0 +1,514 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import Portal from '../components/ui/Portal'; +import React from 'react'; +import trackEvent from '../utils/analytics'; +import { + $createAtLinkNode, + $createAtLinkSearchNode, + $createZWNJNode, + $isAtLinkNode, + $isAtLinkSearchNode, + $isZWNJNode, + AtLinkNode, + AtLinkSearchNode +} from '@tryghost/kg-default-nodes'; +import {$createBookmarkNode} from '../nodes/BookmarkNode'; +import {$createLinkNode} from '@lexical/link'; +import { + $createTextNode, + $getSelection, + $isRangeSelection, + $isTextNode, + $nodesOfType, + COMMAND_PRIORITY_HIGH, + DELETE_CHARACTER_COMMAND, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + KEY_ESCAPE_COMMAND, + PASTE_COMMAND +} from 'lexical'; +import {$insertFirst, mergeRegister} from '@lexical/utils'; +import {AtLinkResultsPopup} from '../components/ui/AtLinkResultsPopup'; +import {isInternalUrl} from '../utils/isInternalUrl'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useSearchLinks} from '../hooks/useSearchLinks'; +import type {LexicalNode} from 'lexical'; + +function $removeAtLink(node: unknown, {focus = false} = {}) { + if (!$isAtLinkNode(node)) { + + console.warn('$removeAtLink called on a non-at-link node', node); + return; + } + + const searchNode = node.getChildAtIndex(1); + if (!searchNode) { + return; + } + + const textNode = $createTextNode('@' + searchNode.getTextContent()); + textNode.setFormat(node.getLinkFormat() ?? 0); + node.replace(textNode); + + if (focus) { + textNode.selectEnd(); + } +} + +function noResultOptions() { + return [{ + label: 'No results found', + items: [] + }]; +} + +// Manages at-link search nodes and display of the search results panel when appropriate +export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}: {searchLinks: (term?: string) => Promise; siteUrl?: string}) => { + const [editor] = useLexicalComposerContext(); + const [focusedAtLinkNode, setFocusedAtLinkNode] = React.useState(null); + const [query, setQuery] = React.useState(''); + const searchOptions = React.useMemo(() => ({noResultOptions}), []); + const {isSearching, listOptions} = useSearchLinks(query, searchLinks, searchOptions); + + // register an event listener to detect '@' character being typed + // - we only ever want to convert an '@' to an at-link node when it's typed + // so a native event listener makes more sense than a lexical update listener + // that would need to constantly compare against current and previous states + // - '@' must be preceded by beginning of line, whitespace, or br + // - '@' must be followed by whitespace, end of line, or br + React.useEffect(() => { + const rootElement = editor.getRootElement(); + + const handleAtInsert = (event: Event & {isComposing?: boolean; inputType?: string; data?: string | null}) => { + if (event.isComposing) { + return; + } + + if (event.inputType === 'insertText' && event.data === '@') { + let replaceAt = false; + + editor.getEditorState().read(() => { + // get the current selection + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + const anchor = selection.anchor; + if (anchor.type !== 'text') { + return; + } + + const anchorNode = anchor.getNode(); + if (!anchorNode.isSimpleText()) { + return; + } + + const anchorOffset = anchor.offset; + let textBeforeAnchor = anchorNode.getTextContent().slice(0, anchorOffset); + let textAfterAnchor = anchorNode.getTextContent().slice(anchorOffset); + + // adjust before/after text if we're immediately preceded/followed by a text node + // because that content needs to be accounted for in our regex match + const prevSibling = anchorNode.getPreviousSibling(); + const nextSibling = anchorNode.getNextSibling(); + + if (anchorOffset === 0 && $isTextNode(prevSibling)) { + textBeforeAnchor = prevSibling.getTextContent(); + } + + if (anchorOffset === anchorNode.getTextContent().length && $isTextNode(nextSibling)) { + textAfterAnchor = nextSibling.getTextContent(); + } + + const textBeforeRegExp = /(^|\s)@$/; + const textAfterRegExp = /^($|\s|\.)/; + + if ( + textBeforeRegExp.test(textBeforeAnchor) + && textAfterRegExp.test(textAfterAnchor) + ) { + replaceAt = true; + } + }); + + if (replaceAt) { + editor.update(() => { + // selection should now be where the '@' character was + const selection = $getSelection(); + + // store current node's format so it can be re-applied to the eventual link node + if (!$isRangeSelection(selection)) { + return; + } + const linkFormat = selection.anchor.getNode().getFormat(); + + // delete the '@' character + selection.deleteCharacter(true); + + // prep the at-link node + const atLinkNode = $createAtLinkNode(linkFormat ?? null); + atLinkNode.setLinkFormat(linkFormat); + const zwnjNode = $createZWNJNode(); + atLinkNode.append(zwnjNode); + const atLinkSearchNode = $createAtLinkSearchNode(''); + atLinkNode.append(atLinkSearchNode); + + // insert it + selection.insertNodes([atLinkNode]); + + // ensure we still have a cursor and it's inside the search node + atLinkNode.select(1, 1); + + const searchNode = atLinkNode.getChildAtIndex(1); + const rangeSelection = $getSelection(); + if ($isRangeSelection(rangeSelection) && searchNode) { + rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); + rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); + } + }); + } + } + }; + + // weirdly the 'input' event doesn't fire for the first character typed in a paragraph + const handleAtBeforeInput = (event: Event & {inputType?: string; data?: string | null}) => { + if (event.inputType === 'insertText' && event.data === '@') { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && selection.isCollapsed() && !selection.anchor.getNode().getPreviousSibling()) { + handleAtInsert(event); + } + }); + } + }; + + if (!rootElement) { + return; + } + rootElement.addEventListener('input', handleAtInsert); + rootElement.addEventListener('beforeinput', handleAtBeforeInput); + + return () => { + rootElement.removeEventListener('input', handleAtInsert); + rootElement.removeEventListener('beforeinput', handleAtBeforeInput); + }; + }, [editor]); + + // register an update listener + // - update plugin state with a focused at-link node + // - update plugin state with search query based on at-link-search node text content + // - remove at-link nodes when they don't have focus (i.e. using arrow keys to move out of them) + React.useEffect(() => { + return editor.registerUpdateListener(() => { + // do nothing if we're in the middle of composing text + if (editor.isComposing()) { + return; + } + + editor.update(() => { + const atLinkNodes = $nodesOfType(AtLinkNode); + const selection = $getSelection(); + + // we don't have a normal selection so we don't have a cursor inside + // an at-link node, remove all of them + if (!$isRangeSelection(selection)) { + atLinkNodes.forEach((node: LexicalNode) => $removeAtLink(node)); + setFocusedAtLinkNode(null); + setQuery(''); + return; + } + + // we have a collapsed selection, remove any at-link nodes that don't have focus + // handles cursor movement out of at-link nodes + if (selection.isCollapsed()) { + const anchorNode = selection.anchor.getNode(); + let selectedAtLinkNode: AtLinkNode | null = null; + + if ($isAtLinkNode(anchorNode)) { + selectedAtLinkNode = anchorNode; + } + if ($isAtLinkNode(anchorNode.getParent())) { + selectedAtLinkNode = anchorNode.getParent() as AtLinkNode; + } + + atLinkNodes.forEach((atLinkNode) => { + if (atLinkNode !== selectedAtLinkNode) { + $removeAtLink(atLinkNode); + } + }); + + if (selectedAtLinkNode) { + // search node is focused, update our search query + setFocusedAtLinkNode(selectedAtLinkNode); + + // at-link nodes always have a ZWNJ node followed by an at-link-search node + const searchNode = selectedAtLinkNode.getChildAtIndex(1); + const searchNodeText = searchNode?.getTextContent?.(); + + setQuery(searchNodeText ?? ''); + + // normalize selection to be inside the search node when on zwnj + // - handles case where text is backspaced to empty + if ($isZWNJNode(selection.focus.getNode()) && window.getSelection()?.anchorOffset === 0) { + selectedAtLinkNode.select(1, 1); + const rangeSelection = $getSelection(); + if ($isRangeSelection(rangeSelection) && searchNode) { + rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); + rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); + } + } + + // if the search node is already empty but active, remove the at-link node on backspace + if (searchNodeText === '' && $isZWNJNode(selection.anchor.getNode())) { + $removeAtLink(selectedAtLinkNode, {focus: true}); + } + } else { + // search node isn't focused, reset plugin state + setFocusedAtLinkNode(null); + setQuery(''); + } + + return; + } + + // TODO: prevent range selection spanning outside of at-link node + }); + }); + }, [editor]); + + // register some command handlers to avoid certain actions happening whilst + // an at-link-search node is focused + React.useEffect(() => { + function $skipFormatCommandIfNeeded() { + const selection = $getSelection(); + if ($isRangeSelection(selection) && $isAtLinkSearchNode(selection.anchor.getNode())) { + return true; + } + return false; + } + + return mergeRegister( + // revert to '@' when pressing escape with a focused at-link node + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + if ($isAtLinkNode(anchorNode)) { + $removeAtLink(anchorNode, {focus: true}); + return true; + } + if ($isAtLinkSearchNode(anchorNode) || ($isZWNJNode(anchorNode) && $isAtLinkNode(anchorNode.getParent()))) { + $removeAtLink(anchorNode.getParent(), {focus: true}); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_HIGH + ), + // revert to '@' when backspacing or deleting chars at the beginning/end of an at-link node + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + if ($isAtLinkSearchNode(anchorNode) || ($isZWNJNode(anchorNode) && $isAtLinkNode(anchorNode.getParent()))) { + const anchorOffset = selection.anchor.offset; + if (isBackward && anchorOffset === 0) { + $removeAtLink(anchorNode.getParent(), {focus: true}); + return true; + } + if (!isBackward && anchorOffset === anchorNode.getTextContentSize()) { + $removeAtLink(anchorNode.getParent(), {focus: true}); + return true; + } + } + } + return false; + }, + COMMAND_PRIORITY_HIGH + ), + // prevent formatting commands when an at-link-search node is focused + editor.registerCommand( + FORMAT_TEXT_COMMAND, + $skipFormatCommandIfNeeded, + COMMAND_PRIORITY_HIGH + ), + editor.registerCommand( + FORMAT_ELEMENT_COMMAND, + $skipFormatCommandIfNeeded, + COMMAND_PRIORITY_HIGH + ), + // prevent paste in the search node triggering external paste handlers + editor.registerCommand( + PASTE_COMMAND, + (clipboardEvent: ClipboardEvent) => { + const selection = $getSelection(); + + if (!selection || !$isRangeSelection(selection) || document.activeElement !== editor.getRootElement()) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + if ($isAtLinkNode(anchorNode) || $isAtLinkSearchNode(anchorNode)) { + clipboardEvent.preventDefault(); + + const atLinkSearchNode = $isAtLinkSearchNode(anchorNode) ? anchorNode : (anchorNode as AtLinkNode).getChildAtIndex(1); + const text = clipboardEvent.clipboardData?.getData('text/plain'); + + if (text && atLinkSearchNode && $isAtLinkSearchNode(atLinkSearchNode)) { + atLinkSearchNode.setTextContent(atLinkSearchNode.getTextContent() + text); + atLinkSearchNode.selectEnd(); + } + + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }); + + // register transforms to ensure at-link node trees are valid + React.useEffect(() => { + return editor.registerNodeTransform(AtLinkNode, (atLinkNode) => { + // first child should always be a ZWNJ + if (!$isZWNJNode(atLinkNode.getFirstChild())) { + const zwnjNode = $createZWNJNode(); + $insertFirst(atLinkNode, zwnjNode); + } + + // second child should be a search node + if (!$isAtLinkSearchNode(atLinkNode.getChildAtIndex(1))) { + const atLinkSearchNode = $createAtLinkSearchNode(''); + atLinkNode.append(atLinkSearchNode); + } + + // we only want one search node, remove or replace any non-search nodes + atLinkNode.getChildren().forEach((child: LexicalNode, index: number) => { + if (index > 0 && !$isAtLinkSearchNode(child)) { + const text = child.getTextContent?.(); + + if (!text) { + child.remove(); + } else { + const atLinkSearchNode = $createAtLinkSearchNode(text); + child.replace(atLinkSearchNode); + } + } + }); + + // consolidate multiple search nodes from previous step into single node + const searchNode = atLinkNode.getChildAtIndex(1); + if (!searchNode) { + return; + } + const currentText = searchNode.getTextContent(); + let consolidatedText = currentText; + atLinkNode.getChildren().forEach((child: LexicalNode, index: number) => { + if (index > 1) { + consolidatedText += child.getTextContent(); + child.remove(); + } + }); + if (consolidatedText !== currentText && $isAtLinkSearchNode(searchNode)) { + searchNode.setTextContent(consolidatedText); + } + }); + }, [editor]); + + // when a search result is selected, replace the at-link node with a link node + const onItemSelect = React.useCallback((rawItem: unknown) => { + const item = rawItem as {value?: string; label?: string; type?: string}; + editor.update(() => { + if (!item?.value || !focusedAtLinkNode) { + $removeAtLink(focusedAtLinkNode, {focus: true}); + return; + } + + const parent = focusedAtLinkNode.getParent(); + if (!parent) { + return; + } + // we have to get the children nodes + const children = parent.getChildren(); + + const isTextLink = (children.length !== 1 || !$isAtLinkNode(children[0])); + + if (isTextLink) { + const linkNode = $createLinkNode(item.value!); + const textNode = $createTextNode(item.label ?? ''); + // getLinkFormat() is a TextNode format bitmask captured from the + // text the at-link was created in, so apply it to the text node + // (an element's setFormat expects an alignment string, not a bitmask) + textNode.setFormat(focusedAtLinkNode.getLinkFormat() ?? 0); + linkNode.append(textNode); + + focusedAtLinkNode.replace(linkNode); + linkNode.selectEnd(); + + setQuery(''); + setFocusedAtLinkNode(null); + } else { + const bookmarkNode = $createBookmarkNode({ + url: item.value, + title: item.label + }); + focusedAtLinkNode.replace(bookmarkNode); + bookmarkNode.selectEnd(); + } + + if (item.type === 'internal' || item.type === 'default') { + trackEvent('Link dropdown: Internal link chosen', {context: 'at-link', fromLatest: item.type === 'default', isBookmark: !isTextLink}); + } else { + const linkTarget = isInternalUrl(item.value ?? '', siteUrl ?? '') ? 'internal' : 'external'; + trackEvent('Link dropdown: URL entered', {context: 'at-link', target: linkTarget, isBookmark: !isTextLink}); + } + }); + }, [editor, focusedAtLinkNode, siteUrl]); + + // render nothing when we don't have a focused at-link node + if (!focusedAtLinkNode) { + return null; + } + + // otherwise render search results popup + return ( + + + + ); +}; + +// wrapping KoenigAtLinkPlugin means we can ensure all dependencies are available +// before rendering the plugin, avoiding complex conditionals in the plugin itself +export const AtLinkPlugin = () => { + const {cardConfig} = React.useContext(KoenigComposerContext); + const [editor] = useLexicalComposerContext(); + + // do nothing if we haven't been passed a way to search internal links + const enabled = typeof cardConfig?.searchLinks === 'function'; + if (!enabled) { + return null; + } + + // do nothing if the required nodes aren't loaded + if (!editor.hasNodes([AtLinkNode, AtLinkSearchNode])) { + return null; + } + + return Promise} siteUrl={cardConfig.siteUrl} />; +}; + +export default AtLinkPlugin; diff --git a/packages/koenig-lexical/src/plugins/AudioPlugin.jsx b/packages/koenig-lexical/src/plugins/AudioPlugin.jsx deleted file mode 100644 index 1995139f71..0000000000 --- a/packages/koenig-lexical/src/plugins/AudioPlugin.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import {$createAudioNode, AudioNode, INSERT_AUDIO_COMMAND} from '../nodes/AudioNode'; -import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const AudioPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([AudioNode])){ - console.error('AudioPlugin: AudioNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_AUDIO_COMMAND, - async (dataset) => { - const cardNode = $createAudioNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - INSERT_MEDIA_COMMAND, - async (dataset) => { - if (dataset.type === 'audio') { - editor.dispatchCommand(INSERT_AUDIO_COMMAND, {initialFile: dataset.file}); - return true; - } - return false; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor]); - - return null; -}; - -export default AudioPlugin; diff --git a/packages/koenig-lexical/src/plugins/AudioPlugin.tsx b/packages/koenig-lexical/src/plugins/AudioPlugin.tsx new file mode 100644 index 0000000000..03567e3416 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/AudioPlugin.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import {$createAudioNode, AudioNode, INSERT_AUDIO_COMMAND} from '../nodes/AudioNode'; +import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const AudioPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([AudioNode])){ + console.error('AudioPlugin: AudioNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_AUDIO_COMMAND, + (dataset: Record) => { + const cardNode = $createAudioNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + INSERT_MEDIA_COMMAND, + (dataset: Record) => { + if (dataset.type === 'audio') { + editor.dispatchCommand(INSERT_AUDIO_COMMAND, {initialFile: dataset.file}); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor]); + + return null; +}; + +export default AudioPlugin; diff --git a/packages/koenig-lexical/src/plugins/BookmarkPlugin.jsx b/packages/koenig-lexical/src/plugins/BookmarkPlugin.jsx deleted file mode 100644 index 4b6822f536..0000000000 --- a/packages/koenig-lexical/src/plugins/BookmarkPlugin.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import {$createBookmarkNode, BookmarkNode, INSERT_BOOKMARK_COMMAND} from '../nodes/BookmarkNode'; -import { - $getSelection, - $isRangeSelection, - COMMAND_PRIORITY_HIGH -} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const BookmarkPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([BookmarkNode])){ - console.error('BookmarkPlugin: BookmarkNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_BOOKMARK_COMMAND, - async (dataset) => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return false; - } - - const focusNode = selection.focus.getNode(); - if (focusNode !== null) { - const cardNode = $createBookmarkNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - } - - return true; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor]); - - return null; -}; - -export default BookmarkPlugin; diff --git a/packages/koenig-lexical/src/plugins/BookmarkPlugin.tsx b/packages/koenig-lexical/src/plugins/BookmarkPlugin.tsx new file mode 100644 index 0000000000..b47e014ece --- /dev/null +++ b/packages/koenig-lexical/src/plugins/BookmarkPlugin.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {$createBookmarkNode, BookmarkNode, INSERT_BOOKMARK_COMMAND} from '../nodes/BookmarkNode'; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_HIGH +} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const BookmarkPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([BookmarkNode])){ + console.error('BookmarkPlugin: BookmarkNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_BOOKMARK_COMMAND, + (dataset: Record) => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + const focusNode = selection.focus.getNode(); + if (focusNode !== null) { + const cardNode = $createBookmarkNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + } + + return true; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor]); + + return null; +}; + +export default BookmarkPlugin; diff --git a/packages/koenig-lexical/src/plugins/ButtonPlugin.jsx b/packages/koenig-lexical/src/plugins/ButtonPlugin.jsx deleted file mode 100644 index 63516490a9..0000000000 --- a/packages/koenig-lexical/src/plugins/ButtonPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createButtonNode, ButtonNode, INSERT_BUTTON_COMMAND} from '../nodes/ButtonNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const ButtonPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([ButtonNode])){ - console.error('ButtonPlugin: ButtonNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_BUTTON_COMMAND, - async (dataset) => { - const cardNode = $createButtonNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default ButtonPlugin; diff --git a/packages/koenig-lexical/src/plugins/ButtonPlugin.tsx b/packages/koenig-lexical/src/plugins/ButtonPlugin.tsx new file mode 100644 index 0000000000..945a44a485 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/ButtonPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createButtonNode, ButtonNode, INSERT_BUTTON_COMMAND} from '../nodes/ButtonNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const ButtonPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([ButtonNode])){ + console.error('ButtonPlugin: ButtonNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_BUTTON_COMMAND, + (dataset: Record) => { + const cardNode = $createButtonNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default ButtonPlugin; diff --git a/packages/koenig-lexical/src/plugins/CallToActionPlugin.jsx b/packages/koenig-lexical/src/plugins/CallToActionPlugin.jsx deleted file mode 100644 index 03c52f6714..0000000000 --- a/packages/koenig-lexical/src/plugins/CallToActionPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createCallToActionNode, CallToActionNode, INSERT_CALL_TO_ACTION_COMMAND} from '../nodes/CallToActionNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const CallToActionPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([CallToActionNode])){ - console.error('CallToActionPlugin: CallToActionNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_CALL_TO_ACTION_COMMAND, - async (dataset) => { - const cardNode = $createCallToActionNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default CallToActionPlugin; diff --git a/packages/koenig-lexical/src/plugins/CallToActionPlugin.tsx b/packages/koenig-lexical/src/plugins/CallToActionPlugin.tsx new file mode 100644 index 0000000000..750709498d --- /dev/null +++ b/packages/koenig-lexical/src/plugins/CallToActionPlugin.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {$createCallToActionNode, CallToActionNode, INSERT_CALL_TO_ACTION_COMMAND} from '../nodes/CallToActionNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {CallToActionNodeData} from '../nodes/CallToActionNode'; + +export const CallToActionPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([CallToActionNode])){ + console.error('CallToActionPlugin: CallToActionNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_CALL_TO_ACTION_COMMAND, + (dataset: CallToActionNodeData) => { + const cardNode = $createCallToActionNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default CallToActionPlugin; diff --git a/packages/koenig-lexical/src/plugins/CalloutPlugin.jsx b/packages/koenig-lexical/src/plugins/CalloutPlugin.jsx deleted file mode 100644 index 454d015452..0000000000 --- a/packages/koenig-lexical/src/plugins/CalloutPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createCalloutNode, CalloutNode, INSERT_CALLOUT_COMMAND} from '../nodes/CalloutNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const CalloutPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([CalloutNode])){ - console.error('CalloutPlugin: CalloutNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_CALLOUT_COMMAND, - async (dataset) => { - const cardNode = $createCalloutNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }); - - return null; -}; - -export default CalloutPlugin; diff --git a/packages/koenig-lexical/src/plugins/CalloutPlugin.tsx b/packages/koenig-lexical/src/plugins/CalloutPlugin.tsx new file mode 100644 index 0000000000..bfb489acb5 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/CalloutPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createCalloutNode, CalloutNode, INSERT_CALLOUT_COMMAND} from '../nodes/CalloutNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const CalloutPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([CalloutNode])){ + console.error('CalloutPlugin: CalloutNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_CALLOUT_COMMAND, + (dataset: Record) => { + const cardNode = $createCalloutNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }); + + return null; +}; + +export default CalloutPlugin; diff --git a/packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx b/packages/koenig-lexical/src/plugins/CardMenuPlugin.tsx similarity index 100% rename from packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx rename to packages/koenig-lexical/src/plugins/CardMenuPlugin.tsx diff --git a/packages/koenig-lexical/src/plugins/DragDropPastePlugin.jsx b/packages/koenig-lexical/src/plugins/DragDropPastePlugin.jsx deleted file mode 100644 index 9bb918e6e4..0000000000 --- a/packages/koenig-lexical/src/plugins/DragDropPastePlugin.jsx +++ /dev/null @@ -1,163 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import {$getRoot, $getSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND} from 'lexical'; -import {$insertDataTransferForRichText} from '@lexical/clipboard'; -import {DRAG_DROP_PASTE} from '@lexical/rich-text'; -import {createCommand} from 'lexical'; -import {getEditorCardNodes} from '../utils/getEditorCardNodes'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const INSERT_MEDIA_COMMAND = createCommand(); - -function isMimeType(file, acceptableMimeTypes) { - const mimeType = file.type; - let key = Object.keys(acceptableMimeTypes).find(k => acceptableMimeTypes[k].includes(mimeType)); - return key; -} - -function mediaFileReader(files, acceptableMimeTypes) { - const filesIterator = files[Symbol.iterator](); - return new Promise((resolve, reject) => { - const processed = []; - const handleNextFile = () => { - const {done, value: file} = filesIterator.next(); - if (done) { - return resolve({processed}); - } - const fileReader = new FileReader(); - fileReader.addEventListener('error', reject); - fileReader.addEventListener('load', () => { - const result = fileReader.result; - const nodeType = isMimeType(file, acceptableMimeTypes); - if (typeof result === 'string') { - processed.push({type: nodeType, file: file}); - } - handleNextFile(); - }); - const nodeType = isMimeType(file, acceptableMimeTypes); - if (nodeType) { - fileReader.readAsDataURL(file); - } else { - console.error('unsupported file type'); - handleNextFile(); - } - }; - handleNextFile(); - }); -} - -async function getListOfAcceptableMimeTypes(editor, uploadFileTypes) { - const nodes = getEditorCardNodes(editor); - let acceptableMimeTypes = {}; - for (const [nodeType, node] of nodes) { - if (nodeType && node.uploadType) { - acceptableMimeTypes[nodeType] = uploadFileTypes[node.uploadType].mimeTypes; - } - } - return { - acceptableMimeTypes - }; -} - -function DragDropPastePlugin() { - const [editor] = useLexicalComposerContext(); - const {fileUploader} = React.useContext(KoenigComposerContext); - - const handleFileUpload = React.useCallback(async (files) => { - if (!fileUploader) { - return; - } - - const {acceptableMimeTypes} = await getListOfAcceptableMimeTypes(editor, fileUploader.fileTypes); - const {processed} = await mediaFileReader(files, acceptableMimeTypes); - processed.forEach((item) => { - editor.dispatchCommand(INSERT_MEDIA_COMMAND, item); - }); - }, [editor, fileUploader]); - - // override the default Lexical drop handler because we always want to insert - // where the selection was left rather than where the drop happened (matches mobiledoc editor) - React.useEffect(() => { - return editor.registerCommand( - DROP_COMMAND, - (event) => { - const files = Array.from(event.dataTransfer.files); - - if (files.length > 0) { - event.preventDefault(); - event.stopPropagation(); - editor.dispatchCommand(DRAG_DROP_PASTE, files); - return true; - } - - return false; - }, - COMMAND_PRIORITY_HIGH - ); - }, [editor]); - - // prevent drag over moving the cursor - our drops use the original selection - // rather than the drop location - React.useEffect(() => { - const rootElement = editor.getRootElement(); - const handleDragOver = (event) => { - if (!event.dataTransfer || event.target.closest('[data-kg-card]')) { - return; - } - - event.stopPropagation(); - event.preventDefault(); - }; - - const handleDragLeave = (event) => { - event.preventDefault(); - }; - - const handleDrop = (event) => { - // handle image drop from a browser window - const html = event.dataTransfer.getData('text/html'); - if (html) { - event.preventDefault(); - - editor.update(() => { - editor.focus(); - let selection = $getSelection(); - if (!selection) { - $getRoot().selectEnd(); - selection = $getSelection(); - } - $insertDataTransferForRichText(event.dataTransfer, selection, editor); - }); - } - }; - - rootElement.addEventListener('dragover', handleDragOver); - rootElement.addEventListener('dragleave', handleDragLeave); - rootElement.addEventListener('drop', handleDrop); - - return () => { - rootElement.removeEventListener('dragover', handleDragOver); - rootElement.removeEventListener('dragleave', handleDragLeave); - rootElement.removeEventListener('drop', handleDrop); - }; - }, [editor]); - - React.useEffect(() => { - return editor.registerCommand( - DRAG_DROP_PASTE, - async (files) => { - try { - editor.focus(); - return await handleFileUpload(files); - } catch (error) { - console.error(error); - } - }, - COMMAND_PRIORITY_LOW - ); - }, [editor, handleFileUpload]); - - return null; -} - -export default DragDropPastePlugin; diff --git a/packages/koenig-lexical/src/plugins/DragDropPastePlugin.tsx b/packages/koenig-lexical/src/plugins/DragDropPastePlugin.tsx new file mode 100644 index 0000000000..4751d5ef8e --- /dev/null +++ b/packages/koenig-lexical/src/plugins/DragDropPastePlugin.tsx @@ -0,0 +1,176 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getRoot, $getSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND} from 'lexical'; +import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import {DRAG_DROP_PASTE} from '@lexical/rich-text'; +import {createCommand} from 'lexical'; +import {getEditorCardNodes} from '../utils/getEditorCardNodes'; +import {getKoenigCardNodeClass} from '../utils/koenig-node-class'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + +export const INSERT_MEDIA_COMMAND = createCommand(); + +export interface ProcessedMedia { + type: string | undefined; + file: File; +} + +function isMimeType(file: File, acceptableMimeTypes: Record): string | undefined { + const mimeType = file.type; + const key = Object.keys(acceptableMimeTypes).find(k => acceptableMimeTypes[k].includes(mimeType)); + return key; +} + +function mediaFileReader(files: File[], acceptableMimeTypes: Record): Promise<{processed: ProcessedMedia[]}> { + const filesIterator = files[Symbol.iterator](); + return new Promise((resolve, reject) => { + const processed: ProcessedMedia[] = []; + const handleNextFile = () => { + const {done, value: file} = filesIterator.next(); + if (done) { + return resolve({processed}); + } + const fileReader = new FileReader(); + fileReader.addEventListener('error', reject); + fileReader.addEventListener('load', () => { + const result = fileReader.result; + const nodeType = isMimeType(file, acceptableMimeTypes); + if (typeof result === 'string') { + processed.push({type: nodeType, file: file}); + } + handleNextFile(); + }); + const nodeType = isMimeType(file, acceptableMimeTypes); + if (nodeType) { + fileReader.readAsDataURL(file); + } else { + console.error('unsupported file type'); + handleNextFile(); + } + }; + handleNextFile(); + }); +} + +async function getListOfAcceptableMimeTypes(editor: LexicalEditor, uploadFileTypes: Record) { + const nodes = getEditorCardNodes(editor); + const acceptableMimeTypes: Record = {}; + for (const [nodeType, node] of nodes) { + const nodeWithUpload = getKoenigCardNodeClass(node); + const uploadConfig = nodeWithUpload.uploadType ? uploadFileTypes[nodeWithUpload.uploadType] : undefined; + if (nodeType && uploadConfig) { + acceptableMimeTypes[nodeType] = uploadConfig.mimeTypes; + } + } + return { + acceptableMimeTypes + }; +} + +function DragDropPastePlugin() { + const [editor] = useLexicalComposerContext(); + const {fileUploader} = React.useContext(KoenigComposerContext); + + const handleFileUpload = React.useCallback(async (files: File[]) => { + if (!fileUploader) { + return; + } + + const {acceptableMimeTypes} = await getListOfAcceptableMimeTypes(editor, fileUploader.fileTypes); + const result = await mediaFileReader(files, acceptableMimeTypes); + result.processed.forEach((item: ProcessedMedia) => { + editor.dispatchCommand(INSERT_MEDIA_COMMAND, item); + }); + }, [editor, fileUploader]); + + // override the default Lexical drop handler because we always want to insert + // where the selection was left rather than where the drop happened (matches mobiledoc editor) + React.useEffect(() => { + return editor.registerCommand( + DROP_COMMAND, + (event: DragEvent) => { + const files = Array.from(event.dataTransfer?.files ?? []); + + if (files.length > 0) { + event.preventDefault(); + event.stopPropagation(); + editor.dispatchCommand(DRAG_DROP_PASTE, files); + return true; + } + + return false; + }, + COMMAND_PRIORITY_HIGH + ); + }, [editor]); + + // prevent drag over moving the cursor - our drops use the original selection + // rather than the drop location + React.useEffect(() => { + const rootElement = editor.getRootElement(); + const handleDragOver = (event: DragEvent) => { + if (!event.dataTransfer || (event.target as Element)?.closest?.('[data-kg-card]')) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + }; + + const handleDrop = (event: DragEvent) => { + // handle image drop from a browser window + const html = event.dataTransfer?.getData('text/html'); + if (html) { + event.preventDefault(); + + editor.update(() => { + editor.focus(); + let selection = $getSelection(); + if (!selection) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + if (event.dataTransfer && selection) { + $insertDataTransferForRichText(event.dataTransfer, selection, editor); + } + }); + } + }; + + if (!rootElement) { + return; + } + rootElement.addEventListener('dragover', handleDragOver); + rootElement.addEventListener('dragleave', handleDragLeave); + rootElement.addEventListener('drop', handleDrop); + + return () => { + rootElement.removeEventListener('dragover', handleDragOver); + rootElement.removeEventListener('dragleave', handleDragLeave); + rootElement.removeEventListener('drop', handleDrop); + }; + }, [editor]); + + React.useEffect(() => { + return editor.registerCommand( + DRAG_DROP_PASTE, + (files: File[]) => { + editor.focus(); + handleFileUpload(files).catch((error: unknown) => { + console.error(error); + }); + return true; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor, handleFileUpload]); + + return null; +} + +export default DragDropPastePlugin; diff --git a/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.jsx b/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.jsx deleted file mode 100644 index e7ead9045d..0000000000 --- a/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.jsx +++ /dev/null @@ -1,251 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$createImageNode} from '../nodes/ImageNode.jsx'; -import {$createNodeSelection, $getNearestNodeFromDOMNode, $getNodeByKey, $setSelection} from 'lexical'; -import {DragDropHandler} from '../utils/draggable/DragDropHandler.jsx'; -import {createRoot} from 'react-dom/client'; -import {flushSync} from 'react-dom'; -import {isCardDropAllowed} from '../utils/draggable/draggable-utils.js'; -import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function preventDefault(event) { - event.preventDefault(); -} - -function useDragDropReorder(editor, isEditable) { - const koenig = React.useContext(KoenigComposerContext); - const {setIsDragging, isEditingCard} = useKoenigSelectedCardContext(); - - const cardContainer = React.useRef(null); - const skipOnDropEnd = React.useRef(false); - - // useRef because we need stable function references to pass into the drag drop container instance - const onDragStart = React.useRef(() => { - cardContainer.current.refresh(); - setIsDragging(true); - }); - - const onDragEnd = React.useRef(() => { - setIsDragging(false); - }); - - const getDraggableInfo = React.useRef((draggableElement) => { - let draggableInfo; - - editor.update(() => { - const cardNode = $getNearestNodeFromDOMNode(draggableElement); - - if (cardNode) { - draggableInfo = { - type: 'card', - nodeKey: cardNode.getKey(), - cardName: cardNode.getType(), - dataset: cardNode.getDataset?.(), - Icon: cardNode.getIcon() - }; - } - }); - - return draggableInfo || false; - }); - - const createCardDragElement = React.useRef((draggableInfo) => { - const {cardName, Icon} = draggableInfo; - - if (!cardName || cardName === 'image') { - return; - } - - const style = { - top: '0', - left: '-100%', - zIndex: 10001, - willChange: 'transform' - }; - - const ghost = document.createElement('div'); - // classes kept so Tailwind picks up usage - ghost.className = 'absolute flex size-16 flex-col items-center justify-center rounded bg-white shadow-sm'; - Object.assign(ghost.style, style); - - const iconWrapper = document.createElement('div'); - iconWrapper.className = 'flex items-center'; - ghost.appendChild(iconWrapper); - - // Icon is a React component — render synchronously via flushSync - const iconRoot = document.createElement('div'); - iconWrapper.appendChild(iconRoot); - const reactRoot = createRoot(iconRoot); - flushSync(() => { - reactRoot.render(); - }); - - // Store the React root so DragDropHandler can unmount it on cleanup - ghost.__reactRoot = reactRoot; - - return ghost; - }); - - const getDropIndicatorPosition = React.useRef((draggableInfo, droppableElem, position) => { - const droppables = Array.from(editor.getRootElement().querySelectorAll(':scope > *')); - const droppableIndex = droppables.indexOf(droppableElem); - const draggableIndex = droppables.indexOf(draggableInfo.element); - - // only allow card and image drops (images can be dragged out of a gallery) - if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { - return false; - } - - if (isCardDropAllowed(draggableIndex, droppableIndex, position)) { - let insertIndex = droppableIndex; - if (position.match(/bottom/)) { - insertIndex += 1; - } - - let beforeElems, afterElems; - if (position.match(/bottom/)) { - beforeElems = droppables.slice(0, droppableIndex + 1); - afterElems = droppables.slice(droppableIndex + 1); - } else { - beforeElems = droppables.slice(0, droppableIndex); - afterElems = droppables.slice(droppableIndex); - } - - return { - direction: 'vertical', - position: position.match(/top/) ? 'top' : 'bottom', - beforeElems, - afterElems, - insertIndex: insertIndex - }; - } - - return false; - }); - - const onCardDrop = React.useRef((draggableInfo) => { - if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { - return false; - } - - const droppables = Array.from(editor.getRootElement().querySelectorAll(':scope > *')); - const draggableIndex = droppables.indexOf(draggableInfo.element); - - if (isCardDropAllowed(draggableIndex, draggableInfo.insertIndex)) { - let returnValue; - - editor.update(() => { - // change card order on card drops - if (draggableInfo.type === 'card') { - const draggedNode = $getNodeByKey(draggableInfo.nodeKey); - - if (draggableInfo.insertIndex >= droppables.length) { - // drop at end of document - const targetNode = $getNearestNodeFromDOMNode(droppables[droppables.length - 1]); - targetNode.insertAfter(draggedNode); - } else { - const targetNode = $getNearestNodeFromDOMNode(droppables[draggableInfo.insertIndex]); - targetNode.insertBefore(draggedNode); - } - - // clear selection so we don't show any toolbars immediately and the - // cursor isn't left stranded somewhere else in the document - $setSelection(null); - - // skip card removal as we're not moving a card inside another card - skipOnDropEnd.current = true; - - returnValue = true; - return; - } - - // insert new image node on image drops - if (draggableInfo.type === 'image') { - const targetNode = $getNearestNodeFromDOMNode(droppables[draggableInfo.insertIndex]); - const imageNode = $createImageNode(draggableInfo.dataset); - targetNode.insertBefore(imageNode); - - // select the newly inserted image card - const nodeSelection = $createNodeSelection(); - nodeSelection.add(imageNode.getKey()); - $setSelection(nodeSelection); - - returnValue = true; - return; - } - }); - - return returnValue; - } - }); - - // a card can be dropped into another card which means we need to remove the original - const onDropEnd = React.useRef((draggableInfo, success) => { - // avoid removing the card if it's just a re-order or no move occurred - if (skipOnDropEnd.current || !success || draggableInfo.type !== 'card') { - skipOnDropEnd.current = false; - return; - } - - editor.update(() => { - const cardNode = $getNodeByKey(draggableInfo.nodeKey); - cardNode.remove(false); - }); - }); - - React.useEffect(() => { - koenig.dragDropHandler = new DragDropHandler({ - editorContainerElement: koenig.editorContainerRef.current - }); - - cardContainer.current = koenig.dragDropHandler.registerContainer(editor.getRootElement(), { - draggableSelector: ':scope > div', // cards - droppableSelector: ':scope > *', // all block elements - onDragStart: onDragStart.current, - onDragEnd: onDragEnd.current, - getDraggableInfo: getDraggableInfo.current, - createGhostElement: createCardDragElement.current, - getIndicatorPosition: getDropIndicatorPosition.current, - onDrop: onCardDrop.current, - onDropEnd: onDropEnd.current - }); - - return () => { - cardContainer.current = null; - koenig.dragDropHandler?.destroy(); - delete koenig.dragDropHandler; - }; - }, [editor, koenig]); - - React.useEffect(() => { - return editor.registerUpdateListener(() => { - // refresh drag/drop - // TODO: can be made more performant by only refreshing when droppable - // order changes or when sections are added/removed - cardContainer.current?.refresh(); - }); - }, [editor]); - - // disable normal drag start events so they don't interfere with our custom drag handling - React.useEffect(() => { - return editor.registerRootListener((rootElement, prevRootElement) => { - rootElement?.addEventListener('dragstart', preventDefault); - prevRootElement?.removeEventListener('dragstart', preventDefault); - }); - }, [editor]); - - // Disable drag-drop-reorder when editing a card - React.useEffect(() => { - if (isEditingCard) { - cardContainer.current?.disableDrag(); - } else { - cardContainer.current?.enableDrag(); - } - }, [isEditingCard]); -} - -export default function DragDropReorderPlugin() { - const [editor] = useLexicalComposerContext(); - return useDragDropReorder(editor, editor._editable); -} diff --git a/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.tsx b/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.tsx new file mode 100644 index 0000000000..d1b5c16f6e --- /dev/null +++ b/packages/koenig-lexical/src/plugins/DragDropReorderPlugin.tsx @@ -0,0 +1,274 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$createImageNode} from '../nodes/ImageNode'; +import {$createNodeSelection, $getNearestNodeFromDOMNode, $getNodeByKey, $setSelection} from 'lexical'; +import {DragDropHandler} from '../utils/draggable/DragDropHandler'; +import {createRoot} from 'react-dom/client'; +import {flushSync} from 'react-dom'; +import {isCardDropAllowed} from '../utils/draggable/draggable-utils'; +import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +import type {DraggableInfo} from '../utils/draggable/ScrollHandler'; +import type {LexicalEditor, LexicalNode} from 'lexical'; + +interface DragDropContainer { + refresh(): void; + disableDrag(): void; + enableDrag(): void; +} + +function preventDefault(event: Event) { + event.preventDefault(); +} + +function useDragDropReorder(editor: LexicalEditor, _isEditable: boolean) { + const koenig = React.useContext(KoenigComposerContext); + const {setIsDragging, isEditingCard} = useKoenigSelectedCardContext(); + + const cardContainer = React.useRef(null); + const skipOnDropEnd = React.useRef(false); + + // useRef because we need stable function references to pass into the drag drop container instance + const onDragStart = React.useRef(() => { + cardContainer.current?.refresh(); + setIsDragging(true); + }); + + const onDragEnd = React.useRef(() => { + setIsDragging(false); + }); + + const getDraggableInfo = React.useRef((draggableElement: HTMLElement) => { + let draggableInfo; + + editor.update(() => { + const cardNode = $getNearestNodeFromDOMNode(draggableElement); + + if (cardNode) { + const cardWithMethods = cardNode as LexicalNode & {getDataset?: () => Record; getIcon?: () => React.ComponentType<{className?: string}>}; + draggableInfo = { + type: 'card', + nodeKey: cardNode.getKey(), + cardName: cardNode.getType(), + dataset: cardWithMethods.getDataset?.(), + Icon: cardWithMethods.getIcon?.() + }; + } + }); + + return draggableInfo || false; + }); + + + const createCardDragElement = React.useRef((draggableInfo: DraggableInfo) => { + const {cardName, Icon} = draggableInfo; + + if (!cardName || cardName === 'image' || !Icon) { + return; + } + + const style = { + top: '0', + left: '-100%', + zIndex: 10001, + willChange: 'transform' + }; + + const ghost = document.createElement('div'); + // classes kept so Tailwind picks up usage + ghost.className = 'absolute flex size-16 flex-col items-center justify-center rounded bg-white shadow-sm'; + Object.assign(ghost.style, style); + + const iconWrapper = document.createElement('div'); + iconWrapper.className = 'flex items-center'; + ghost.appendChild(iconWrapper); + + // Icon is a React component — render synchronously via flushSync + const iconRoot = document.createElement('div'); + iconWrapper.appendChild(iconRoot); + const reactRoot = createRoot(iconRoot); + flushSync(() => { + reactRoot.render(); + }); + + // Store the React root so DragDropHandler can unmount it on cleanup + (ghost as HTMLElement & {__reactRoot?: ReturnType}).__reactRoot = reactRoot; + + return ghost; + }); + + const getDropIndicatorPosition = React.useRef((draggableInfo: DraggableInfo, droppableElem: Element, position: string) => { + const droppables = Array.from(editor.getRootElement()?.querySelectorAll(':scope > *') ?? []); + const droppableIndex = droppables.indexOf(droppableElem as HTMLElement); + const draggableIndex = draggableInfo.element ? droppables.indexOf(draggableInfo.element) : -1; + + // only allow card and image drops (images can be dragged out of a gallery) + if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { + return false; + } + + if (isCardDropAllowed(draggableIndex, droppableIndex, position)) { + let insertIndex = droppableIndex; + if (position.match(/bottom/)) { + insertIndex += 1; + } + + let beforeElems, afterElems; + if (position.match(/bottom/)) { + beforeElems = droppables.slice(0, droppableIndex + 1); + afterElems = droppables.slice(droppableIndex + 1); + } else { + beforeElems = droppables.slice(0, droppableIndex); + afterElems = droppables.slice(droppableIndex); + } + + return { + direction: 'vertical' as const, + position: position.match(/top/) ? 'top' : 'bottom', + beforeElems, + afterElems, + insertIndex: insertIndex + }; + } + + return false; + }); + + const onCardDrop = React.useRef((draggableInfo: DraggableInfo) => { + if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') { + return false; + } + + const droppables = Array.from(editor.getRootElement()?.querySelectorAll(':scope > *') ?? []); + const draggableIndex = draggableInfo.element ? droppables.indexOf(draggableInfo.element) : -1; + + if (isCardDropAllowed(draggableIndex, draggableInfo.insertIndex ?? 0)) { + let returnValue; + + editor.update(() => { + // change card order on card drops + if (draggableInfo.type === 'card') { + const draggedNode = $getNodeByKey(draggableInfo.nodeKey as string); + if (!draggedNode) { + return; + } + + if ((draggableInfo.insertIndex ?? 0) >= droppables.length) { + // drop at end of document + const targetNode = $getNearestNodeFromDOMNode(droppables[droppables.length - 1] as Node); + targetNode?.insertAfter(draggedNode); + } else { + const targetNode = $getNearestNodeFromDOMNode(droppables[draggableInfo.insertIndex ?? 0] as Node); + targetNode?.insertBefore(draggedNode); + } + + // clear selection so we don't show any toolbars immediately and the + // cursor isn't left stranded somewhere else in the document + $setSelection(null); + + // skip card removal as we're not moving a card inside another card + skipOnDropEnd.current = true; + + returnValue = true; + return; + } + + // insert new image node on image drops + if (draggableInfo.type === 'image') { + const targetNode = $getNearestNodeFromDOMNode(droppables[draggableInfo.insertIndex ?? 0] as Node); + const imageNode = $createImageNode((draggableInfo.dataset as Record) ?? {}); + targetNode?.insertBefore(imageNode); + + // select the newly inserted image card + const nodeSelection = $createNodeSelection(); + nodeSelection.add(imageNode.getKey()); + $setSelection(nodeSelection); + + returnValue = true; + return; + } + }); + + return returnValue; + } + }); + + // a card can be dropped into another card which means we need to remove the original + const onDropEnd = React.useRef((draggableInfo: DraggableInfo, success: boolean) => { + // avoid removing the card if it's just a re-order or no move occurred + if (skipOnDropEnd.current || !success || draggableInfo.type !== 'card') { + skipOnDropEnd.current = false; + return; + } + + editor.update(() => { + if (!draggableInfo.nodeKey) { + return; + } + const cardNode = $getNodeByKey(draggableInfo.nodeKey); + cardNode?.remove(false); + }); + }); + + React.useEffect(() => { + const rootElement = editor.getRootElement(); + if (!rootElement) { + return; + } + + koenig.dragDropHandler = new DragDropHandler({ + editorContainerElement: koenig.editorContainerRef.current + }); + + cardContainer.current = (koenig.dragDropHandler as DragDropHandler).registerContainer(rootElement, { + draggableSelector: ':scope > div', // cards + droppableSelector: ':scope > *', // all block elements + onDragStart: onDragStart.current, + onDragEnd: onDragEnd.current, + getDraggableInfo: getDraggableInfo.current, + createGhostElement: createCardDragElement.current, + getIndicatorPosition: getDropIndicatorPosition.current, + onDrop: onCardDrop.current, + onDropEnd: onDropEnd.current + }); + + return () => { + cardContainer.current = null; + (koenig.dragDropHandler as DragDropHandler)?.destroy(); + delete koenig.dragDropHandler; + }; + }, [editor, koenig]); + + React.useEffect(() => { + return editor.registerUpdateListener(() => { + // refresh drag/drop + // TODO: can be made more performant by only refreshing when droppable + // order changes or when sections are added/removed + cardContainer.current?.refresh(); + }); + }, [editor]); + + // disable normal drag start events so they don't interfere with our custom drag handling + React.useEffect(() => { + return editor.registerRootListener((rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => { + rootElement?.addEventListener('dragstart', preventDefault); + prevRootElement?.removeEventListener('dragstart', preventDefault); + }); + }, [editor]); + + // Disable drag-drop-reorder when editing a card + React.useEffect(() => { + if (isEditingCard) { + cardContainer.current?.disableDrag(); + } else { + cardContainer.current?.enableDrag(); + } + }, [isEditingCard]); +} + +export default function DragDropReorderPlugin() { + const [editor] = useLexicalComposerContext(); + useDragDropReorder(editor, editor._editable); + return null; +} diff --git a/packages/koenig-lexical/src/plugins/EmEnDashPlugin.jsx b/packages/koenig-lexical/src/plugins/EmEnDashPlugin.jsx deleted file mode 100644 index 883cc0755d..0000000000 --- a/packages/koenig-lexical/src/plugins/EmEnDashPlugin.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import { - $getSelection, - $isRangeSelection, - $isTextNode -} from 'lexical'; -import {getSelectedNode} from '../utils/getSelectedNode.js'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -// TODO: this update breaks the undo functionality... - -export const EmEnDashPlugin = () => { - const [editor] = useLexicalComposerContext(); - - // added markdown shortcut to divider card - useEffect(() => { - return editor.registerUpdateListener(() => { - editor.update(() => { - // don't do anything when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.type === 'text' || !selection.isCollapsed()) { - return; - } - - // need to detect regexp match for dashes - // const genericDashRegExp = /---?.$/; // only matches end of line, not end of word/string - const genericDashRegExp = /---?./; - const node = getSelectedNode(selection); - const text = node.getTextContent(); - if (!node || !$isTextNode(node) || !text?.match || !text.match(genericDashRegExp)) { - return; - } - - /// ??? - const nativeSelection = window.getSelection(); - const anchorNode = nativeSelection.anchorNode; - const rootElement = editor.getRootElement(); - - if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement.contains(anchorNode)) { - return; - } - - // figure out which dash matches - const emDashRegExp = /---([^-])/; - const enDashRegExp = /[^-]--(\s)/; - - const emDashMatch = text.match(emDashRegExp); - if (emDashMatch) { - const index = emDashMatch?.index; - const newText = text.slice(0,index) + '—' + text.slice(index + 3); - node.setTextContent(newText); - selection.anchor.offset = index + 2; - selection.focus.offset = index + 2; - return; - } - - const enDashMatch = text.match(enDashRegExp); - if (enDashMatch) { - const index = enDashMatch?.index; - const newText = text.slice(0,index + 1) + '–' + text.slice(index + 3); - node.setTextContent(newText); - selection.anchor.offset = index + 3; - selection.focus.offset = index + 3; - return; - } - - return; - }, {tag: 'history-merge'}); // this makes it so the transform isn't added to the undo stack - breaks undo without this - }); - }, [editor]); - - return null; -}; - -export default EmEnDashPlugin; diff --git a/packages/koenig-lexical/src/plugins/EmEnDashPlugin.tsx b/packages/koenig-lexical/src/plugins/EmEnDashPlugin.tsx new file mode 100644 index 0000000000..5627cedfff --- /dev/null +++ b/packages/koenig-lexical/src/plugins/EmEnDashPlugin.tsx @@ -0,0 +1,82 @@ +import { + $getSelection, + $isRangeSelection, + $isTextNode +} from 'lexical'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +// TODO: this update breaks the undo functionality... + +export const EmEnDashPlugin = () => { + const [editor] = useLexicalComposerContext(); + + // added markdown shortcut to divider card + useEffect(() => { + return editor.registerUpdateListener(() => { + editor.update(() => { + // don't do anything when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + // need to detect regexp match for dashes + // const genericDashRegExp = /---?.$/; // only matches end of line, not end of word/string + const genericDashRegExp = /---?./; + const node = getSelectedNode(selection); + const text = node.getTextContent(); + if (!node || !$isTextNode(node) || !text?.match || !text.match(genericDashRegExp)) { + return; + } + + /// ??? + const nativeSelection = window.getSelection(); + if (!nativeSelection) { + return; + } + const anchorNode = nativeSelection.anchorNode; + const rootElement = editor.getRootElement(); + + if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement?.contains(anchorNode)) { + return; + } + + // figure out which dash matches + const emDashRegExp = /---([^-])/; + const enDashRegExp = /[^-]--(\s)/; + + const emDashMatch = text.match(emDashRegExp); + if (emDashMatch) { + const index = emDashMatch.index!; + const newText = text.slice(0,index) + '—' + text.slice(index + 3); + node.setTextContent(newText); + selection.anchor.offset = index + 2; + selection.focus.offset = index + 2; + return; + } + + const enDashMatch = text.match(enDashRegExp); + if (enDashMatch) { + const index = enDashMatch.index!; + const newText = text.slice(0,index + 1) + '–' + text.slice(index + 3); + node.setTextContent(newText); + selection.anchor.offset = index + 3; + selection.focus.offset = index + 3; + return; + } + + return; + }, {tag: 'history-merge'}); // this makes it so the transform isn't added to the undo stack - breaks undo without this + }); + }, [editor]); + + return null; +}; + +export default EmEnDashPlugin; diff --git a/packages/koenig-lexical/src/plugins/EmailCtaPlugin.jsx b/packages/koenig-lexical/src/plugins/EmailCtaPlugin.jsx deleted file mode 100644 index 122e7f3257..0000000000 --- a/packages/koenig-lexical/src/plugins/EmailCtaPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createEmailCtaNode, EmailCtaNode, INSERT_EMAIL_CTA_COMMAND} from '../nodes/EmailCtaNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const EmailCtaPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([EmailCtaNode])){ - console.error('EmailPlugin: EmailCtaNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_EMAIL_CTA_COMMAND, - async (dataset) => { - const cardNode = $createEmailCtaNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default EmailCtaPlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/EmailCtaPlugin.tsx b/packages/koenig-lexical/src/plugins/EmailCtaPlugin.tsx new file mode 100644 index 0000000000..8ff5677639 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/EmailCtaPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createEmailCtaNode, EmailCtaNode, INSERT_EMAIL_CTA_COMMAND} from '../nodes/EmailCtaNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const EmailCtaPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([EmailCtaNode])){ + console.error('EmailPlugin: EmailCtaNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_EMAIL_CTA_COMMAND, + (_dataset: Record) => { + const cardNode = $createEmailCtaNode(); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default EmailCtaPlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/EmailPlugin.jsx b/packages/koenig-lexical/src/plugins/EmailPlugin.jsx deleted file mode 100644 index 3036879989..0000000000 --- a/packages/koenig-lexical/src/plugins/EmailPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createEmailNode, EmailNode, INSERT_EMAIL_COMMAND} from '../nodes/EmailNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const EmailPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([EmailNode])){ - console.error('EmailPlugin: EmailNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_EMAIL_COMMAND, - async (dataset) => { - const cardNode = $createEmailNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default EmailPlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/EmailPlugin.tsx b/packages/koenig-lexical/src/plugins/EmailPlugin.tsx new file mode 100644 index 0000000000..69ef5e784f --- /dev/null +++ b/packages/koenig-lexical/src/plugins/EmailPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createEmailNode, EmailNode, INSERT_EMAIL_COMMAND} from '../nodes/EmailNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const EmailPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([EmailNode])){ + console.error('EmailPlugin: EmailNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_EMAIL_COMMAND, + (dataset: Record) => { + const cardNode = $createEmailNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default EmailPlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/EmbedPlugin.jsx b/packages/koenig-lexical/src/plugins/EmbedPlugin.jsx deleted file mode 100644 index a12fb848da..0000000000 --- a/packages/koenig-lexical/src/plugins/EmbedPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createEmbedNode, EmbedNode, INSERT_EMBED_COMMAND} from '../nodes/EmbedNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const EmbedPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([EmbedNode])){ - console.error('EmbedPlugin: EmbedNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_EMBED_COMMAND, - async (dataset) => { - const cardNode = $createEmbedNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default EmbedPlugin; diff --git a/packages/koenig-lexical/src/plugins/EmbedPlugin.tsx b/packages/koenig-lexical/src/plugins/EmbedPlugin.tsx new file mode 100644 index 0000000000..b4d31b0b6a --- /dev/null +++ b/packages/koenig-lexical/src/plugins/EmbedPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createEmbedNode, EmbedNode, INSERT_EMBED_COMMAND} from '../nodes/EmbedNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const EmbedPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([EmbedNode])){ + console.error('EmbedPlugin: EmbedNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_EMBED_COMMAND, + (dataset: Record) => { + const cardNode = $createEmbedNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default EmbedPlugin; diff --git a/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.jsx b/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.jsx deleted file mode 100644 index ed600d88d3..0000000000 --- a/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.jsx +++ /dev/null @@ -1,211 +0,0 @@ -import Portal from '../components/ui/Portal'; -import React from 'react'; -import emojiData from '@emoji-mart/data'; -import trackEvent from '../utils/analytics'; -import useTypeaheadTriggerMatch from '../hooks/useTypeaheadTriggerMatch'; -import {$createTextNode, $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND} from 'lexical'; -import {LexicalTypeaheadMenuPlugin} from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import {SearchIndex, init} from 'emoji-mart'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -init({data: emojiData}); - -const EmojiMenuItem = function ({index, isSelected, onClick, onMouseEnter, emoji}) { - // we need to manually set this unless we import the MenuOption type and extend it (see LexicalTypeaheadMenuPlugin) - const ref = React.useRef(null); - emoji.ref = ref; - return ( -
  • - {emoji.skins[0].native} - {emoji.id} -
  • - ); -}; - -export function EmojiPickerPlugin() { - const [editor] = useLexicalComposerContext(); - const [queryString, setQueryString] = React.useState(null); - const [searchResults, setSearchResults] = React.useState(null); - - const checkForTriggerMatch = useTypeaheadTriggerMatch(':', {minLength: 1}); - - const cursorInInlineCodeBlock = () => { - return editor.getEditorState().read(() => { - const selection = $getSelection(); - const node = selection.anchor.getNode(); - if (node && $isTextNode(node) && node.hasFormat('code')) { - return true; - } - return false; - }); - }; - - // handle exact match typed like :emoji: - // the typeahead menu does not account for exact matches/closing characters - React.useEffect(() => { - return mergeRegister( - editor.registerCommand( - KEY_DOWN_COMMAND, - async (event) => { - if (!queryString) { - return false; - } - if (event.key === ':') { - if (cursorInInlineCodeBlock() === true) { - return false; - } - const emojis = await SearchIndex.search(queryString); - if (emojis.length === 0) { - return; - } - const emojiMatch = emojis?.[0].id === queryString; // only look for exact match - if (emojiMatch) { - handleCompletionInsertion(emojis[0]); - event.preventDefault(); - return true; - } - } - return false; - }, - COMMAND_PRIORITY_HIGH - ), - ); - }); - - const handleCompletionInsertion = React.useCallback((emoji) => { - editor.update(() => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || emoji === null) { - return; - } - - const currentNode = selection.anchor.getNode(); - // need to replace the last text matching the :test: pattern with a single emoji - const shortcodeLength = emoji.id.length + 1; // +1 for the end colon - const textNode = currentNode.spliceText(selection.anchor.offset - shortcodeLength, shortcodeLength, emoji.skins[0].native, true); - textNode.setFormat(selection.format); - - trackEvent('Emoji Inserted', {method: 'completed'}); - }); - }, [editor]); - - React.useEffect(() => { - if (!queryString) { - setSearchResults(null); - return; - } - - async function searchEmojis() { - let filteredEmojis = []; - if ([')','-)'].includes(queryString)) { - filteredEmojis = await SearchIndex.search('smile'); - } else if (['(','-('].includes(queryString)) { - filteredEmojis = await SearchIndex.search('frown'); - } else { - filteredEmojis = await SearchIndex.search(queryString); - } - setSearchResults(filteredEmojis); - } - - searchEmojis(); - }, [queryString]); - - const onEmojiSelect = React.useCallback((selectedOption, nodeToRemove, closeMenu) => { - editor.update(() => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || selectedOption === null) { - return; - } - - if (nodeToRemove) { - nodeToRemove.remove(); - } - - const emojiNode = $createTextNode(selectedOption.skins[0].native); - emojiNode.setFormat(selection.format); - - selection.insertNodes([emojiNode]); - - closeMenu(); - - trackEvent('Emoji Inserted', {method: 'selected'}); - }); - }, [editor]); - - // close menu on escape - React.useEffect(() => { - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - setSearchResults(null); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }); - - function getPositionStyles() { - const selectedRange = window.getSelection().getRangeAt(0); - const rangeRect = selectedRange.getBoundingClientRect(); - - return { - marginTop: `${rangeRect.height}px` - }; - } - - return ( - { - if (anchorElementRef.current === null || !searchResults || searchResults.length === 0) { - return null; - } - return ( - -
      - {searchResults.map((emoji, index) => ( -
      - { - setHighlightedIndex(index); - selectOptionAndCleanUp(emoji); - event.stopPropagation(); - event.preventDefault(); - }} - onMouseEnter={() => { - setHighlightedIndex(index); - }} - /> -
      - ))} -
    -
    - ); - }} - options={searchResults} - triggerFn={checkForTriggerMatch} - onQueryChange={setQueryString} - onSelectOption={onEmojiSelect} - /> - ); -} - -export default EmojiPickerPlugin; diff --git a/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx b/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx new file mode 100644 index 0000000000..249982661c --- /dev/null +++ b/packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx @@ -0,0 +1,224 @@ +import Portal from '../components/ui/Portal'; +import React from 'react'; +import emojiData from '@emoji-mart/data'; +import trackEvent from '../utils/analytics'; +import useTypeaheadTriggerMatch from '../hooks/useTypeaheadTriggerMatch'; +import {$createTextNode, $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND} from 'lexical'; +import {LexicalTypeaheadMenuPlugin, MenuOption} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import {SearchIndex, init} from 'emoji-mart'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +init({data: emojiData}); + +class Emoji extends MenuOption { + id: string; + skins: Array<{native: string}>; + + constructor(emoji: {id: string; skins: Array<{native: string}>}) { + super(emoji.id); + this.id = emoji.id; + this.skins = emoji.skins; + } +} + +const EmojiMenuItem = function ({index, isSelected, onClick, onMouseEnter, emoji}: {index: number; isSelected: boolean; onClick: (e: React.MouseEvent) => void; onMouseEnter: () => void; emoji: Emoji}) { + return ( +
  • emoji.setRefElement(el)} + aria-selected={isSelected} + className={`mb-0 flex cursor-pointer items-center gap-2 whitespace-nowrap rounded-md px-2 py-1 font-sans text-sm leading-[1.65] tracking-wide text-grey-800 dark:text-grey-200 ${isSelected ? 'bg-grey-100 text-grey-900 dark:bg-grey-900 dark:text-white' : ''}`} + data-testid={'emoji-option-' + index} + id={'emoji-option-' + index} + role="option" + tabIndex={-1} + onClick={onClick} + onMouseEnter={onMouseEnter} + > + {emoji.skins[0].native} + {emoji.id} +
  • + ); +}; + +export function EmojiPickerPlugin() { + const [editor] = useLexicalComposerContext(); + const [queryString, setQueryString] = React.useState(null); + const [searchResults, setSearchResults] = React.useState(null); + + const checkForTriggerMatch = useTypeaheadTriggerMatch(':', {minLength: 1}); + + const cursorInInlineCodeBlock = () => { + return editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + const node = selection.anchor.getNode(); + if (node && $isTextNode(node) && node.hasFormat('code')) { + return true; + } + return false; + }); + }; + + // handle exact match typed like :emoji: + // the typeahead menu does not account for exact matches/closing characters + React.useEffect(() => { + return mergeRegister( + editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + if (!queryString) { + return false; + } + if (event.key === ':') { + if (cursorInInlineCodeBlock() === true) { + return false; + } + // only swallow the closing colon when there's an exact shortcode match + const emojiMatch = searchResults?.[0]; + if (!emojiMatch || emojiMatch.id !== queryString) { + return false; + } + handleCompletionInsertion(emojiMatch); + event.preventDefault(); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ), + ); + }); + + const handleCompletionInsertion = React.useCallback((emoji: Emoji) => { + editor.update(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || emoji === null) { + return; + } + + const currentNode = selection.anchor.getNode(); + // need to replace the last text matching the :test: pattern with a single emoji + const shortcodeLength = emoji.id.length + 1; // +1 for the end colon + const textNode = currentNode.spliceText(selection.anchor.offset - shortcodeLength, shortcodeLength, emoji.skins[0].native, true); + textNode.setFormat(selection.format); + + trackEvent('Emoji Inserted', {method: 'completed'}); + }); + }, [editor]); + + React.useEffect(() => { + if (!queryString) { + setSearchResults(null); + return; + } + + async function searchEmojis() { + let results: Array<{id: string; skins: Array<{native: string}>}> = []; + if ([')','-)'].includes(queryString!)) { + results = await SearchIndex.search('smile'); + } else if (['(','-('].includes(queryString!)) { + results = await SearchIndex.search('frown'); + } else { + results = await SearchIndex.search(queryString); + } + setSearchResults(results.map(emoji => new Emoji(emoji))); + } + + searchEmojis(); + }, [queryString]); + + const onEmojiSelect = React.useCallback((selectedOption: Emoji, nodeToRemove: {remove: () => void} | null, closeMenu: () => void) => { + editor.update(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || selectedOption === null) { + return; + } + + if (nodeToRemove) { + nodeToRemove.remove(); + } + + const emojiNode = $createTextNode(selectedOption.skins[0].native); + emojiNode.setFormat(selection.format); + + selection.insertNodes([emojiNode]); + + closeMenu(); + + trackEvent('Emoji Inserted', {method: 'selected'}); + }); + }, [editor]); + + // close menu on escape + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSearchResults(null); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }); + + function getPositionStyles() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return {marginTop: '0px'}; + } + const selectedRange = selection.getRangeAt(0); + const rangeRect = selectedRange.getBoundingClientRect(); + + return { + marginTop: `${rangeRect.height}px` + }; + } + + return ( + { + if (anchorElementRef.current === null || !searchResults || searchResults.length === 0) { + return null; + } + return ( + +
      + {searchResults.map((emoji, index) => ( +
      + { + setHighlightedIndex(index); + selectOptionAndCleanUp(emoji); + event.stopPropagation(); + event.preventDefault(); + }} + onMouseEnter={() => { + setHighlightedIndex(index); + }} + /> +
      + ))} +
    +
    + ); + }} + options={searchResults ?? []} + triggerFn={checkForTriggerMatch} + onQueryChange={setQueryString} + onSelectOption={onEmojiSelect} + /> + ); +} + +export default EmojiPickerPlugin; diff --git a/packages/koenig-lexical/src/plugins/ExternalControlPlugin.jsx b/packages/koenig-lexical/src/plugins/ExternalControlPlugin.jsx deleted file mode 100644 index 9a903b8ab9..0000000000 --- a/packages/koenig-lexical/src/plugins/ExternalControlPlugin.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import {$canShowPlaceholder} from '@lexical/text'; -import {$createParagraphNode, $getRoot, $isDecoratorNode} from 'lexical'; -import {$selectDecoratorNode} from '../utils/$selectDecoratorNode'; -import {DRAG_DROP_PASTE} from '@lexical/rich-text'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -// used to register a minimal API for controlling the editor from the consuming app -// designed to allow typical behaviours without the consuming app needing to bundle the lexical library -export const ExternalControlPlugin = ({registerAPI}) => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!registerAPI) { - return; - } - - const API = { - // give access to the editor instance so the Lexical API can be used directly if needed - editorInstance: editor, - // simplified API methods for typical consumer app actions - serialize() { - return JSON.stringify(editor.getEditorState()); - }, - editorIsEmpty() { - let isEmpty; - editor.update(() => { - isEmpty = $canShowPlaceholder(false, true); - }); - return isEmpty; - }, - focusEditor({position = 'bottom'} = {}) { - const editorFocusOptions = { - defaultSelection: position === 'top' ? 'rootStart' : null - }; - - editor.focus(() => {}, editorFocusOptions); - - if (position === 'top') { - // Lexical does not automatically select a decorator node - editor.update(() => { - const root = $getRoot(); - const firstChild = root.getFirstChild(); - - if ($isDecoratorNode(firstChild)) { - $selectDecoratorNode(firstChild); - // selecting a decorator node does not change the - // window selection (there's no caret) so we need - // to manually move focus to the editor element - editor.getRootElement().focus(); - } - }); - } - if (position === 'bottom') { - // Lexical does not automatically select a decorator node - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - - if ($isDecoratorNode(lastChild)) { - $selectDecoratorNode(lastChild); - // selecting a decorator node does not change the - // window selection (there's no caret) so we need - // to manually move focus to the editor element - editor.getRootElement().focus(); - } else { - lastChild.select(); - } - }); - } - }, - blurEditor() { - editor.blur(); - }, - insertParagraphAtTop({focus = true} = {}) { - editor.update(() => { - const paragraphNode = $createParagraphNode(); - const [firstChild] = $getRoot().getChildren(); - firstChild.insertBefore(paragraphNode); - - if (focus) { - paragraphNode.selectStart(); - } - }); - }, - insertParagraphAtBottom({focus = true} = {}) { - editor.update(() => { - const paragraphNode = $createParagraphNode(); - $getRoot().append(paragraphNode); - - if (focus) { - paragraphNode.selectStart(); - } - }); - }, - insertFiles(files) { - editor.dispatchCommand(DRAG_DROP_PASTE, files); - }, - lastNodeIsDecorator() { - let isDecorator = false; - editor.getEditorState().read(() => { - const nodes = $getRoot().getChildren(); - const lastNode = nodes[nodes.length - 1]; - - isDecorator = lastNode && $isDecoratorNode(lastNode); - }); - return isDecorator; - } - }; - - registerAPI(API); - - return () => { - registerAPI?.(null); - }; - }, [editor, registerAPI]); -}; - -export default ExternalControlPlugin; diff --git a/packages/koenig-lexical/src/plugins/ExternalControlPlugin.tsx b/packages/koenig-lexical/src/plugins/ExternalControlPlugin.tsx new file mode 100644 index 0000000000..6b23c50fa9 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/ExternalControlPlugin.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import {$canShowPlaceholder} from '@lexical/text'; +import {$createParagraphNode, $getRoot, $isDecoratorNode} from 'lexical'; +import {$selectDecoratorNode} from '../utils/$selectDecoratorNode'; +import {DRAG_DROP_PASTE} from '@lexical/rich-text'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +// used to register a minimal API for controlling the editor from the consuming app +// designed to allow typical behaviours without the consuming app needing to bundle the lexical library +export const ExternalControlPlugin = ({registerAPI}: {registerAPI?: (api: unknown) => void}) => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!registerAPI) { + return; + } + + const API = { + // give access to the editor instance so the Lexical API can be used directly if needed + editorInstance: editor, + // simplified API methods for typical consumer app actions + serialize() { + return JSON.stringify(editor.getEditorState()); + }, + editorIsEmpty() { + let isEmpty; + editor.update(() => { + isEmpty = $canShowPlaceholder(false); + }); + return isEmpty; + }, + focusEditor({position = 'bottom'} = {}) { + const editorFocusOptions = position === 'top' + ? {defaultSelection: 'rootStart' as const} + : {}; + + editor.focus(() => {}, editorFocusOptions); + + if (position === 'top') { + // Lexical does not automatically select a decorator node + editor.update(() => { + const root = $getRoot(); + const firstChild = root.getFirstChild(); + + if ($isDecoratorNode(firstChild)) { + $selectDecoratorNode(firstChild); + // selecting a decorator node does not change the + // window selection (there's no caret) so we need + // to manually move focus to the editor element + editor.getRootElement()?.focus(); + } + }); + } + if (position === 'bottom') { + // Lexical does not automatically select a decorator node + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + + if ($isDecoratorNode(lastChild)) { + $selectDecoratorNode(lastChild); + // selecting a decorator node does not change the + // window selection (there's no caret) so we need + // to manually move focus to the editor element + editor.getRootElement()?.focus(); + } else { + (lastChild as import('lexical').ElementNode).select(); + } + }); + } + }, + blurEditor() { + editor.blur(); + }, + insertParagraphAtTop({focus = true} = {}) { + editor.update(() => { + const paragraphNode = $createParagraphNode(); + const [firstChild] = $getRoot().getChildren(); + firstChild.insertBefore(paragraphNode); + + if (focus) { + paragraphNode.selectStart(); + } + }); + }, + insertParagraphAtBottom({focus = true} = {}) { + editor.update(() => { + const paragraphNode = $createParagraphNode(); + $getRoot().append(paragraphNode); + + if (focus) { + paragraphNode.selectStart(); + } + }); + }, + insertFiles(files: File[]) { + editor.dispatchCommand(DRAG_DROP_PASTE, files); + }, + lastNodeIsDecorator() { + let isDecorator = false; + editor.getEditorState().read(() => { + const nodes = $getRoot().getChildren(); + const lastNode = nodes[nodes.length - 1]; + + isDecorator = lastNode && $isDecoratorNode(lastNode); + }); + return isDecorator; + } + }; + + registerAPI(API); + + return () => { + registerAPI?.(null); + }; + }, [editor, registerAPI]); + + return null; +}; + +export default ExternalControlPlugin; diff --git a/packages/koenig-lexical/src/plugins/FilePlugin.jsx b/packages/koenig-lexical/src/plugins/FilePlugin.jsx deleted file mode 100644 index 8a69ca2db9..0000000000 --- a/packages/koenig-lexical/src/plugins/FilePlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createFileNode, FileNode, INSERT_FILE_COMMAND} from '../nodes/FileNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const FilePlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([FileNode])){ - console.error('FilePlugin: FileNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_FILE_COMMAND, - async (dataset) => { - const cardNode = $createFileNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }); - - return null; -}; - -export default FilePlugin; diff --git a/packages/koenig-lexical/src/plugins/FilePlugin.tsx b/packages/koenig-lexical/src/plugins/FilePlugin.tsx new file mode 100644 index 0000000000..5c68ce95e0 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/FilePlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createFileNode, FileNode, INSERT_FILE_COMMAND} from '../nodes/FileNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const FilePlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([FileNode])){ + console.error('FilePlugin: FileNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_FILE_COMMAND, + (dataset: Record) => { + const cardNode = $createFileNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }); + + return null; +}; + +export default FilePlugin; diff --git a/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.jsx b/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.jsx deleted file mode 100644 index 5a752aa8c6..0000000000 --- a/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; -import {$getSelection, $isParagraphNode, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, KEY_MODIFIER_COMMAND} from 'lexical'; -import {$isAtLinkSearchNode} from '@tryghost/kg-default-nodes'; -import {$isLinkNode} from '@lexical/link'; -import {FloatingFormatToolbar, toolbarItemTypes} from '../components/ui/FloatingFormatToolbar'; -import {FloatingLinkToolbar} from '../components/ui/FloatingLinkToolbar'; -import {getSelectedNode} from '../utils/getSelectedNode'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export default function FloatingToolbarPlugin({anchorElem = document.body, isSnippetsEnabled, hiddenFormats = []}) { - const [editor] = useLexicalComposerContext(); - return useFloatingFormatToolbar(editor, anchorElem, isSnippetsEnabled, hiddenFormats); -} - -function useFloatingFormatToolbar(editor, anchorElem, isSnippetsEnabled, hiddenFormats = []) { - const [toolbarItemType, setToolbarItemType] = React.useState(null); - const [href, setHref] = React.useState(null); - - const setToolbarType = React.useCallback(() => { - editor.getEditorState().read(() => { - // Should not to pop up the floating toolbar when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - const nativeSelection = window.getSelection(); - const rootElement = editor.getRootElement(); - - // close toolbar if selection was outside of editor - if ( - nativeSelection !== null && - ( - !$isRangeSelection(selection) || - rootElement === null || - !rootElement.contains(nativeSelection.anchorNode) - ) - ) { - setToolbarItemType(null); - return; - } - - if (!$isRangeSelection(selection) || $isAtLinkSearchNode(selection.anchor.getNode())) { - if (toolbarItemType) { - setToolbarItemType(null); - } - return; - } - - const anchorNode = getSelectedNode(selection); - const parent = anchorNode.getParent(); - - if ($isLinkNode(parent)) { - setHref(parent.getURL()); - } else if ($isLinkNode(anchorNode)) { - setHref(anchorNode.getURL()); - } else { - setHref(''); - } - - if (selection.getTextContent().trim() !== '' && ($isTextNode(anchorNode) || $isParagraphNode(anchorNode))) { - setToolbarItemType(toolbarItemTypes.text); - return; - } - - setToolbarItemType(null); - }); - }, [editor, toolbarItemType]); - - React.useEffect(() => { - // Add a listener if the text toolbar is active. It helps to prevent events bubbling - // when a user is interacting with inputs in the link/snippets toolbar - if (!!toolbarItemType && toolbarItemType !== toolbarItemTypes.text) { - return; - } - document.addEventListener('selectionchange', setToolbarType); - return () => { - document.removeEventListener('selectionchange', setToolbarType); - }; - }, [setToolbarType, toolbarItemType]); - - React.useEffect(() => { - editor.registerCommand( - KEY_MODIFIER_COMMAND, - (event) => { - const {keyCode, ctrlKey, metaKey, shiftKey} = event; - // ctrl/cmd K with selected text should prompt for link insertion - if (!shiftKey && keyCode === 75 && (ctrlKey || metaKey)) { - const selection = $getSelection(); - if ($isRangeSelection(selection) && !selection.isCollapsed()) { - setToolbarItemType(toolbarItemTypes.link); - event.preventDefault(); - return true; - } - } - return false; - }, - COMMAND_PRIORITY_LOW - ); - }, [editor]); - - // use native mousedown event so the toolbar can close when something is - // clicked outside of the editor and the selection is lost - React.useEffect(() => { - const handleMousedown = (event) => { - if (!anchorElem.contains(event.target)) { - setToolbarItemType(null); - } - }; - - document.addEventListener('mousedown', handleMousedown); - - return () => { - document.removeEventListener('mousedown', handleMousedown); - }; - }); - - const handleLinkEdit = (data) => { - setToolbarItemType(toolbarItemTypes.link); - setHref(data?.href); - }; - - return ( - <> - - - - - ); -} diff --git a/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx b/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx new file mode 100644 index 0000000000..d6f1cdc73e --- /dev/null +++ b/packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import {$getSelection, $isParagraphNode, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, KEY_MODIFIER_COMMAND} from 'lexical'; +import {$isAtLinkSearchNode} from '@tryghost/kg-default-nodes'; +import {$isLinkNode} from '@lexical/link'; +import {FloatingFormatToolbar, toolbarItemTypes} from '../components/ui/FloatingFormatToolbar'; +import {FloatingLinkToolbar} from '../components/ui/FloatingLinkToolbar'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + +export default function FloatingToolbarPlugin({anchorElem = document.body, isSnippetsEnabled, hiddenFormats = []}: {anchorElem?: HTMLElement; isSnippetsEnabled?: boolean; hiddenFormats?: string[]}) { + const [editor] = useLexicalComposerContext(); + return useFloatingFormatToolbar(editor, anchorElem, isSnippetsEnabled, hiddenFormats); +} + +function useFloatingFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement, isSnippetsEnabled?: boolean, hiddenFormats: string[] = []) { + const [toolbarItemType, setToolbarItemType] = React.useState(null); + const [href, setHref] = React.useState(null); + + const setToolbarType = React.useCallback(() => { + editor.getEditorState().read(() => { + // Should not to pop up the floating toolbar when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + + // close toolbar if selection was outside of editor + if ( + nativeSelection !== null && + ( + !$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode) + ) + ) { + setToolbarItemType(null); + return; + } + + if (!$isRangeSelection(selection) || $isAtLinkSearchNode(selection.anchor.getNode())) { + if (toolbarItemType) { + setToolbarItemType(null); + } + return; + } + + const anchorNode = getSelectedNode(selection); + const parent = anchorNode.getParent(); + + if ($isLinkNode(parent)) { + setHref(parent.getURL()); + } else if ($isLinkNode(anchorNode)) { + setHref(anchorNode.getURL()); + } else { + setHref(''); + } + + if (selection.getTextContent().trim() !== '' && ($isTextNode(anchorNode) || $isParagraphNode(anchorNode))) { + setToolbarItemType(toolbarItemTypes.text); + return; + } + + setToolbarItemType(null); + }); + }, [editor, toolbarItemType]); + + React.useEffect(() => { + // Add a listener if the text toolbar is active. It helps to prevent events bubbling + // when a user is interacting with inputs in the link/snippets toolbar + if (!!toolbarItemType && toolbarItemType !== toolbarItemTypes.text) { + return; + } + document.addEventListener('selectionchange', setToolbarType); + return () => { + document.removeEventListener('selectionchange', setToolbarType); + }; + }, [setToolbarType, toolbarItemType]); + + React.useEffect(() => { + editor.registerCommand( + KEY_MODIFIER_COMMAND, + (event: KeyboardEvent) => { + const {keyCode, ctrlKey, metaKey, shiftKey} = event; + // ctrl/cmd K with selected text should prompt for link insertion + if (!shiftKey && keyCode === 75 && (ctrlKey || metaKey)) { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + setToolbarItemType(toolbarItemTypes.link); + event.preventDefault(); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor]); + + // use native mousedown event so the toolbar can close when something is + // clicked outside of the editor and the selection is lost + React.useEffect(() => { + const handleMousedown = (event: MouseEvent) => { + if (!anchorElem.contains(event.target as Node)) { + setToolbarItemType(null); + } + }; + + document.addEventListener('mousedown', handleMousedown); + + return () => { + document.removeEventListener('mousedown', handleMousedown); + }; + }); + + const handleLinkEdit = (data: {href?: string}) => { + setToolbarItemType(toolbarItemTypes.link); + setHref(data?.href ?? null); + }; + + return ( + <> + + + + + ); +} diff --git a/packages/koenig-lexical/src/plugins/GalleryPlugin.jsx b/packages/koenig-lexical/src/plugins/GalleryPlugin.jsx deleted file mode 100644 index a6215102c9..0000000000 --- a/packages/koenig-lexical/src/plugins/GalleryPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createGalleryNode, GalleryNode, INSERT_GALLERY_COMMAND} from '../nodes/GalleryNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const GalleryPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([GalleryNode])) { - console.error('GalleryPlugin: GalleryNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_GALLERY_COMMAND, - async (dataset) => { - const cardNode = $createGalleryNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default GalleryPlugin; diff --git a/packages/koenig-lexical/src/plugins/GalleryPlugin.tsx b/packages/koenig-lexical/src/plugins/GalleryPlugin.tsx new file mode 100644 index 0000000000..7ac1639c93 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/GalleryPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createGalleryNode, GalleryNode, INSERT_GALLERY_COMMAND} from '../nodes/GalleryNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const GalleryPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([GalleryNode])) { + console.error('GalleryPlugin: GalleryNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_GALLERY_COMMAND, + (dataset: Record) => { + const cardNode = $createGalleryNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default GalleryPlugin; diff --git a/packages/koenig-lexical/src/plugins/HeaderPlugin.jsx b/packages/koenig-lexical/src/plugins/HeaderPlugin.jsx deleted file mode 100644 index 21bbb0165c..0000000000 --- a/packages/koenig-lexical/src/plugins/HeaderPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createHeaderNode, HeaderNode, INSERT_HEADER_COMMAND} from '../nodes/HeaderNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const HeaderPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([HeaderNode])){ - console.error('HeaderPlugin: HeaderNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_HEADER_COMMAND, - async (dataset) => { - const cardNode = $createHeaderNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }); - - return null; -}; - -export default HeaderPlugin; diff --git a/packages/koenig-lexical/src/plugins/HeaderPlugin.tsx b/packages/koenig-lexical/src/plugins/HeaderPlugin.tsx new file mode 100644 index 0000000000..7dc5e6d6a2 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/HeaderPlugin.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {$createHeaderNode, HeaderNode, INSERT_HEADER_COMMAND} from '../nodes/HeaderNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {HeaderNodeData} from '../nodes/HeaderNode'; + +export const HeaderPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([HeaderNode])){ + console.error('HeaderPlugin: HeaderNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_HEADER_COMMAND, + (dataset: HeaderNodeData) => { + const cardNode = $createHeaderNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }); + + return null; +}; + +export default HeaderPlugin; diff --git a/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.jsx b/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.jsx deleted file mode 100644 index 3930f32bf6..0000000000 --- a/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.jsx +++ /dev/null @@ -1,101 +0,0 @@ -import {$createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND} from '../nodes/HorizontalRuleNode'; -import { - $createParagraphNode, - $getSelection, - $isParagraphNode, - $isRangeSelection, - COMMAND_PRIORITY_EDITOR -} from 'lexical'; -import {getSelectedNode} from '../utils/getSelectedNode.js'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const HorizontalRulePlugin = () => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([])) { - console.error('HorizontalRulePlugin: HorizontalRuleNode not registered'); - return; - } - return editor.registerCommand( - INSERT_HORIZONTAL_RULE_COMMAND, - () => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return false; - } - - const focusNode = selection.focus.getNode(); - - if (focusNode !== null) { - const horizontalRuleNode = $createHorizontalRuleNode(); - - // insert a paragraph unless we're already on a blank paragraph - const selectedNode = selection.focus.getNode(); - if ($isParagraphNode(selectedNode) && selectedNode.getTextContent() !== '') { - selection.insertParagraph(); - } - - // insert the horizontal rule before the current/inserted paragraph - // so the cursor stays on the blank paragraph - selection.focus - .getNode() - .getTopLevelElementOrThrow() - .insertBefore(horizontalRuleNode); - } - - return true; - }, - COMMAND_PRIORITY_EDITOR - ); - }, [editor]); - - // added markdown shortcut to divider card - useEffect(() => { - return editor.registerUpdateListener(() => { - editor.update(() => { - // don't do anything when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.type === 'text' || !selection.isCollapsed()) { - return; - } - - const dividerRegExp = /^(---|\*\*\*|___)\s?$/; - const node = getSelectedNode(selection).getTopLevelElement(); - if (!node || !$isParagraphNode(node) || !node.getTextContent().match(dividerRegExp)) { - return; - } - - const nativeSelection = window.getSelection(); - const anchorNode = nativeSelection.anchorNode; - const rootElement = editor.getRootElement(); - - if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement.contains(anchorNode)) { - return; - } - - const line = $createHorizontalRuleNode(); - const parentNode = node.getTopLevelElement(); - - if (parentNode.getNextSibling()) { - parentNode.replace(line); - } else { - parentNode.insertBefore(line); - parentNode.replace($createParagraphNode()); - } - - line.selectNext(); - }); - }); - }, [editor]); - - return null; -}; - -export default HorizontalRulePlugin; diff --git a/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.tsx b/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.tsx new file mode 100644 index 0000000000..2e4f87b15b --- /dev/null +++ b/packages/koenig-lexical/src/plugins/HorizontalRulePlugin.tsx @@ -0,0 +1,104 @@ +import {$createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND} from '../nodes/HorizontalRuleNode'; +import { + $createParagraphNode, + $getSelection, + $isParagraphNode, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR +} from 'lexical'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const HorizontalRulePlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([])) { + console.error('HorizontalRulePlugin: HorizontalRuleNode not registered'); + return; + } + return editor.registerCommand( + INSERT_HORIZONTAL_RULE_COMMAND, + () => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + const focusNode = selection.focus.getNode(); + + if (focusNode !== null) { + const horizontalRuleNode = $createHorizontalRuleNode(); + + // insert a paragraph unless we're already on a blank paragraph + const selectedNode = selection.focus.getNode(); + if ($isParagraphNode(selectedNode) && selectedNode.getTextContent() !== '') { + selection.insertParagraph(); + } + + // insert the horizontal rule before the current/inserted paragraph + // so the cursor stays on the blank paragraph + selection.focus + .getNode() + .getTopLevelElementOrThrow() + .insertBefore(horizontalRuleNode); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR + ); + }, [editor]); + + // added markdown shortcut to divider card + useEffect(() => { + return editor.registerUpdateListener(() => { + editor.update(() => { + // don't do anything when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + const dividerRegExp = /^(---|\*\*\*|___)\s?$/; + const node = getSelectedNode(selection).getTopLevelElement(); + if (!node || !$isParagraphNode(node) || !node.getTextContent().match(dividerRegExp)) { + return; + } + + const nativeSelection = window.getSelection(); + if (!nativeSelection) { + return; + } + const anchorNode = nativeSelection.anchorNode; + const rootElement = editor.getRootElement(); + + if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement?.contains(anchorNode)) { + return; + } + + const line = $createHorizontalRuleNode(); + const parentNode = node.getTopLevelElement(); + + if (parentNode.getNextSibling()) { + parentNode.replace(line); + } else { + parentNode.insertBefore(line); + parentNode.replace($createParagraphNode()); + } + + line.selectNext(); + }); + }); + }, [editor]); + + return null; +}; + +export default HorizontalRulePlugin; diff --git a/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.jsx b/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.jsx deleted file mode 100644 index b18fff6996..0000000000 --- a/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; -import {$getRoot, $insertNodes} from 'lexical'; -import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const HtmlOutputPlugin = ({html = '', setHtml}) => { - const [editor] = useLexicalComposerContext(); - const isFirstRender = React.useRef(true); - - React.useLayoutEffect(() => { - if (!isFirstRender.current) { - return; - } - - isFirstRender.current = false; - - if (!html) { - return; - } - - editor.update(() => { - const parser = new DOMParser(); - const dom = parser.parseFromString(html, 'text/html'); - - const nodes = $generateNodesFromDOM(editor, dom); - - // There are few recent issues related to $generateNodesFromDOM - // https://github.com/facebook/lexical/issues/2807 - // As a temporary fix, checking node content to remove additional spaces and br - const filteredNodes = nodes.filter(n => n.getTextContent().trim()); - - // Select the root - $getRoot().select(); - $getRoot().clear(); - - // Insert them at a selection. - $insertNodes(filteredNodes); - }); - // We only do this for init - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onChange = React.useCallback(() => { - editor.update(() => { - const htmlString = $generateHtmlFromNodes(editor, null); - // htmlString will be an empty paragraph with line break if a caption is set and removed - const captionText = new DOMParser().parseFromString(htmlString, 'text/html').documentElement.textContent; - if (captionText) { - setHtml?.(htmlString); - } else { - setHtml(''); - } - }); - }, [editor, setHtml]); - - return ( - - ); -}; - -export default HtmlOutputPlugin; diff --git a/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx b/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx new file mode 100644 index 0000000000..388b170099 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import {$getRoot, $insertNodes} from 'lexical'; +import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const HtmlOutputPlugin = ({html = '', setHtml}: {html?: string; setHtml: (html: string) => void}) => { + const [editor] = useLexicalComposerContext(); + const isFirstRender = React.useRef(true); + + React.useLayoutEffect(() => { + if (!isFirstRender.current) { + return; + } + + isFirstRender.current = false; + + if (!html) { + return; + } + + editor.update(() => { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + + const nodes = $generateNodesFromDOM(editor, dom); + + // There are few recent issues related to $generateNodesFromDOM + // https://github.com/facebook/lexical/issues/2807 + // As a temporary fix, checking node content to remove additional spaces and br + const filteredNodes = nodes.filter(n => n.getTextContent().trim()); + + // Select the root + $getRoot().select(); + $getRoot().clear(); + + // Insert them at a selection. + $insertNodes(filteredNodes); + }); + // We only do this for init + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onChange = React.useCallback(() => { + editor.update(() => { + const htmlString = $generateHtmlFromNodes(editor, null); + // htmlString will be an empty paragraph with line break if a caption is set and removed + const captionText = new DOMParser().parseFromString(htmlString, 'text/html').documentElement.textContent; + if (captionText) { + setHtml?.(htmlString); + } else { + setHtml(''); + } + }); + }, [editor, setHtml]); + + return ( + + ); +}; + +export default HtmlOutputPlugin; diff --git a/packages/koenig-lexical/src/plugins/HtmlPlugin.jsx b/packages/koenig-lexical/src/plugins/HtmlPlugin.jsx deleted file mode 100644 index bb556f8b24..0000000000 --- a/packages/koenig-lexical/src/plugins/HtmlPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createHtmlNode, HtmlNode, INSERT_HTML_COMMAND} from '../nodes/HtmlNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const HtmlPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([HtmlNode])){ - console.error('HtmlPlugin: HtmlNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_HTML_COMMAND, - async (dataset) => { - const cardNode = $createHtmlNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default HtmlPlugin; diff --git a/packages/koenig-lexical/src/plugins/HtmlPlugin.tsx b/packages/koenig-lexical/src/plugins/HtmlPlugin.tsx new file mode 100644 index 0000000000..27a94a3234 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/HtmlPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createHtmlNode, HtmlNode, INSERT_HTML_COMMAND} from '../nodes/HtmlNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const HtmlPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([HtmlNode])){ + console.error('HtmlPlugin: HtmlNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_HTML_COMMAND, + (dataset: Record) => { + const cardNode = $createHtmlNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default HtmlPlugin; diff --git a/packages/koenig-lexical/src/plugins/ImagePlugin.jsx b/packages/koenig-lexical/src/plugins/ImagePlugin.jsx deleted file mode 100644 index 1b6817b2a3..0000000000 --- a/packages/koenig-lexical/src/plugins/ImagePlugin.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import {$createImageNode, INSERT_IMAGE_COMMAND, ImageNode} from '../nodes/ImageNode'; -import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; -import {imageUploadHandler} from '../utils/imageUploadHandler'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const ImagePlugin = () => { - const [editor] = useLexicalComposerContext(); - const {fileUploader} = React.useContext(KoenigComposerContext); - - const imageUploader = fileUploader.useFileUpload('image'); - - const handleImageUpload = React.useCallback(async (files, imageNodeKey) => { - if (files?.length > 0) { - return await imageUploadHandler(files, imageNodeKey, editor, imageUploader.upload); - } - }, [imageUploader.upload, editor]); - - React.useEffect(() => { - if (!editor.hasNodes([ImageNode])){ - console.error('ImagePlugin: ImageNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_IMAGE_COMMAND, - async (dataset) => { - const cardNode = $createImageNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - INSERT_MEDIA_COMMAND, - async (dataset) => { - if (dataset.type === 'image') { - editor.dispatchCommand(INSERT_IMAGE_COMMAND, {initialFile: dataset.file}); - return true; - } - return false; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor, fileUploader, handleImageUpload]); - - return null; -}; - -export default ImagePlugin; diff --git a/packages/koenig-lexical/src/plugins/ImagePlugin.tsx b/packages/koenig-lexical/src/plugins/ImagePlugin.tsx new file mode 100644 index 0000000000..cc432b75a5 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/ImagePlugin.tsx @@ -0,0 +1,56 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$createImageNode, INSERT_IMAGE_COMMAND, ImageNode} from '../nodes/ImageNode'; +import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; +import {imageUploadHandler} from '../utils/imageUploadHandler'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const ImagePlugin = () => { + const [editor] = useLexicalComposerContext(); + const {fileUploader} = React.useContext(KoenigComposerContext); + + const imageUploader = fileUploader.useFileUpload('image'); + + const handleImageUpload = React.useCallback(async (files: FileList | File[], imageNodeKey: string) => { + if (files?.length > 0) { + return await imageUploadHandler(files, imageNodeKey, editor, imageUploader.upload); + } + }, [imageUploader.upload, editor]); + + React.useEffect(() => { + if (!editor.hasNodes([ImageNode])){ + console.error('ImagePlugin: ImageNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_IMAGE_COMMAND, + (dataset: Record) => { + const cardNode = $createImageNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + INSERT_MEDIA_COMMAND, + (dataset: Record) => { + if (dataset.type === 'image') { + editor.dispatchCommand(INSERT_IMAGE_COMMAND, {initialFile: dataset.file}); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor, fileUploader, handleImageUpload]); + + return null; +}; + +export default ImagePlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx deleted file mode 100644 index 0e389f3c68..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx +++ /dev/null @@ -1,1516 +0,0 @@ -import React from 'react'; -import {$createAsideNode, $isAsideNode} from '../nodes/AsideNode'; -import {$createCodeBlockNode} from '../nodes/CodeBlockNode'; -import {$createEmbedNode} from '../nodes/EmbedNode'; -import {$createHeadingNode, $createQuoteNode, $isQuoteNode, DRAG_DROP_PASTE} from '@lexical/rich-text'; -import {$createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; -import { - $createNodeSelection, - $createParagraphNode, - $createTextNode, - $getNearestNodeFromDOMNode, - $getNodeByKey, - $getRoot, - $getSelection, - $insertNodes, - $isDecoratorNode, - $isElementNode, - $isLineBreakNode, - $isNodeSelection, - $isParagraphNode, - $isRangeSelection, - $isRootNode, - $isTextNode, - $setSelection, - CLICK_COMMAND, - COMMAND_PRIORITY_LOW, - CUT_COMMAND, - DELETE_LINE_COMMAND, - FORMAT_TEXT_COMMAND, - INSERT_PARAGRAPH_COMMAND, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_LEFT_COMMAND, - KEY_ARROW_RIGHT_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_BACKSPACE_COMMAND, - KEY_DELETE_COMMAND, - KEY_DOWN_COMMAND, - KEY_ENTER_COMMAND, - KEY_ESCAPE_COMMAND, - KEY_MODIFIER_COMMAND, - KEY_TAB_COMMAND, - PASTE_COMMAND, - createCommand -} from 'lexical'; -import {$insertAndSelectNode} from '../utils/$insertAndSelectNode'; -import { - $isAtStartOfDocument, - $isAtTopOfNode, - $selectDecoratorNode, - getTopLevelNativeElement -} from '../utils/'; -import {$isHtmlNode} from '../nodes/HtmlNode'; -import {$isKoenigCard} from '@tryghost/kg-default-nodes'; -import {$isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND} from '@lexical/list'; -import {$setBlocksType} from '@lexical/selection'; -import {MIME_TEXT_HTML, MIME_TEXT_PLAIN, PASTE_MARKDOWN_COMMAND} from './MarkdownPastePlugin.jsx'; -import {mergeRegister} from '@lexical/utils'; -import {registerDefaultTransforms} from '@tryghost/kg-default-transforms'; -import {shouldIgnoreEvent} from '../utils/shouldIgnoreEvent'; -import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const INSERT_CARD_COMMAND = createCommand('INSERT_CARD_COMMAND'); -export const SELECT_CARD_COMMAND = createCommand('SELECT_CARD_COMMAND'); -export const DESELECT_CARD_COMMAND = createCommand('DESELECT_CARD_COMMAND'); -export const EDIT_CARD_COMMAND = createCommand('EDIT_CARD_COMMAND'); -export const DELETE_CARD_COMMAND = createCommand('DELETE_CARD_COMMAND'); -export const PASTE_LINK_COMMAND = createCommand('PASTE_LINK_COMMAND'); -export const SHOW_CARD_VISIBILITY_SETTINGS_COMMAND = createCommand('SHOW_CARD_VISIBILITY_SETTINGS_COMMAND'); -export const HIDE_CARD_VISIBILITY_SETTINGS_COMMAND = createCommand('HIDE_CARD_VISIBILITY_SETTINGS_COMMAND'); -const RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX = 10; -const SPECIAL_MARKUPS = { - code: '`', - superscript: '^', - subscript: '~', - strikethrough: '~~' -}; - -function $selectCard(editor, nodeKey) { - const selection = $createNodeSelection(); - selection.add(nodeKey); - $setSelection(selection); - // selecting a decorator node does not change the - // window selection (there's no caret) so we need - // to manually move focus to the editor element - if (document.activeElement !== editor.getRootElement()) { - editor.getRootElement().focus({preventScroll: true}); - } -} - -// remove empty cards when they are deselected -function $deselectCard(editor, nodeKey) { - const cardNode = $getNodeByKey(nodeKey); - if (cardNode?.isEmpty?.()) { - $removeOrReplaceNodeWithParagraph(editor, cardNode); - } -} - -function $removeOrReplaceNodeWithParagraph(editor, node) { - if ($getRoot().getLastChild().is(node)) { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.select(); - } else { - const nextNode = node.getNextSibling(); - if ($isDecoratorNode(nextNode)) { - $selectDecoratorNode(nextNode); - // selecting a decorator node does not change the - // window selection (there's no caret) so we need - // to manually move focus to the editor element - editor.getRootElement().focus(); - } else { - nextNode.selectStart(); - } - } - - node.remove(); -} - -function useKoenigBehaviour({editor, containerElem, cursorDidExitAtTop, isNested}) { - const { - selectedCardKey, - setSelectedCardKey, - isEditingCard, - setIsEditingCard, - setShowVisibilitySettings - } = useKoenigSelectedCardContext(); - - const isShiftPressed = React.useRef(false); - - // Track card selections restored by undo/redo so we can protect them from - // being cleared by decorator reconciliation side-effects. When a 'historic' - // update restores a NodeSelection, subsequent Lexical updates triggered by - // React rendering the decorator component can momentarily change the - // selection, causing the card to appear deselected. We suppress one - // clearing cycle and re-set the selection instead. - const preserveCardSelectionRef = React.useRef(null); - - React.useEffect(() => { - const keyDown = (event) => { - isShiftPressed.current = event.shiftKey; - }; - - const keyUp = (event) => { - isShiftPressed.current = event.shiftKey; - }; - - document.addEventListener('keydown', keyDown); - document.addEventListener('keyup', keyUp); - - return () => { - document.removeEventListener('keydown', keyDown); - document.removeEventListener('keyup', keyUp); - }; - }, []); - - // deselect cards on mousedown outside of the editor container - React.useEffect(() => { - const onMousedown = (event) => { - if (!document.body.contains(event.target)) { - // The event target is no longer in the DOM - // This is possible if we have listeners in the capture phase of the event (e.g. dropdowns) - return; - } - - // clicks outside of editor should deselect cards - // this more generic handling prevents the need to handle blur for codemirror cards (and likely others) - if (containerElem.current && !containerElem.current.contains(event.target)) { - editor.getEditorState().read(() => { - const selection = $getSelection(); - if ($isNodeSelection(selection)) { - const selectedNode = selection.getNodes()[0]; - if ($isKoenigCard(selectedNode)) { - editor.dispatchCommand(DESELECT_CARD_COMMAND, {cardKey: selectedNode.getKey()}); - } - } - }); - } - }; - - if (!isNested) { - window.addEventListener('mousedown', onMousedown); - } - - return () => { - window.removeEventListener('mousedown', onMousedown); - }; - }, [editor, containerElem, isNested]); - - // Override built-in keyboard movement around card (DecoratorNode) boundaries, - // cards should be selected on up/down and when deleting content around them. - // Trigger `cursorDidExitAtTop` prop if present and cursor at beginning of doc - React.useEffect(() => { - return mergeRegister( - editor.registerUpdateListener(({editorState, tags}) => { - // ignore updates triggered by other users or by card node exportJSON calls - if (tags.has('collaboration') || tags.has('card-export')) { - return; - } - - // ignore selections inside of nested editors otherwise we'll - // mistakenly deselect the card containing the nested editor - if (isNested || document.activeElement.closest('[data-lexical-decorator]')) { - return; - } - - // trigger card selection/deselection when selection changes - const {isCardSelected, cardKey, cardNode} = editorState.read(() => { - const selection = $getSelection(); - - const hasCardSelection = $isNodeSelection(selection) && - selection.getNodes().length === 1 && - $isKoenigCard(selection.getNodes()[0]); - - if (hasCardSelection) { - const selectedNode = selection.getNodes()[0]; - return {isCardSelected: true, cardKey: selectedNode.getKey(), cardNode: selectedNode}; - } else { - return {isCardSelected: false}; - } - }); - - if (isCardSelected && !selectedCardKey) { - setSelectedCardKey(cardKey); - setIsEditingCard(false); - } else if (isCardSelected && selectedCardKey !== cardKey) { - editor.update(() => { - $deselectCard(editor, selectedCardKey); - - setSelectedCardKey(cardKey); - setIsEditingCard(false); - }, {tag: 'history-merge'}); // don't include a history entry for selection change - } - - // When undo/redo restores a card selection, protect it from - // being cleared by side-effects of decorator reconciliation - if (tags.has('historic') && isCardSelected) { - preserveCardSelectionRef.current = cardKey; - } - - // If a non-historic, non-history-merge update arrives with the - // card still selected, reconciliation succeeded without a - // transient deselection so the ref is no longer needed - - // clear it to avoid blocking future legitimate deselections. - // history-merge updates are excluded because they fire as - // internal bookkeeping before decorator reconciliation. - if (!tags.has('historic') && !tags.has('history-merge') && isCardSelected && preserveCardSelectionRef.current === cardKey) { - preserveCardSelectionRef.current = null; - } - - if (!isCardSelected && selectedCardKey) { - // If the selection was just restored by undo/redo, re-set - // it instead of clearing - the deselection is a transient - // side-effect of decorator re-rendering, not a user action. - // Clear the ref after one use so subsequent legitimate - // deselections are not blocked. - if (preserveCardSelectionRef.current === selectedCardKey) { - preserveCardSelectionRef.current = null; - editor.update(() => { - const node = $getNodeByKey(selectedCardKey); - if (node) { - const selection = $createNodeSelection(); - selection.add(selectedCardKey); - $setSelection(selection); - } else { - setSelectedCardKey(null); - setIsEditingCard(false); - } - }, {tag: 'history-merge'}); - return; - } - - editor.update(() => { - $deselectCard(editor, selectedCardKey); - - setSelectedCardKey(null); - setIsEditingCard(false); - }, {tag: 'history-merge'}); // don't include a history entry for selection change - } - - // we have special-case cards that are inserted via markdown - // expansions where we can't use editor commands to open in - // edit mode so we handle that here instead - if (isCardSelected && cardNode.__openInEditMode) { - editor.update(() => { - cardNode.clearOpenInEditMode(); - }, {tag: 'history-merge'}); // don't include a history entry for clearing the open in edit mode prop - - setIsEditingCard(true); - } - }), - editor.registerCommand( - INSERT_CARD_COMMAND, - ({cardNode, openInEditMode}) => { - let focusNode; - - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - focusNode = selection.focus.getNode(); - } else if ($isNodeSelection(selection)) { - focusNode = selection.getNodes()[0]; - } else { - return false; - } - - if (focusNode !== null) { - $insertAndSelectNode({selectedNode: focusNode, newNode: cardNode}); - - setSelectedCardKey(cardNode.getKey()); - - if (openInEditMode) { - setIsEditingCard(true); - } - } - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - SELECT_CARD_COMMAND, - ({cardKey}) => { - // already selected, delete if empty as we're exiting edit mode - if (selectedCardKey === cardKey && isEditingCard) { - const cardNode = $getNodeByKey(cardKey); - if (cardNode.isEmpty?.()) { - editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey}); - return true; - } - } - - if (selectedCardKey && selectedCardKey !== cardKey) { - $deselectCard(editor, selectedCardKey); - // Hide visibility settings when switching to a different card - setShowVisibilitySettings(false); - } - - $selectCard(editor, cardKey); - - setSelectedCardKey(cardKey); - setIsEditingCard(false); - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - EDIT_CARD_COMMAND, - ({cardKey, focusEditor}) => { - if (selectedCardKey && selectedCardKey !== cardKey) { - $deselectCard(editor, selectedCardKey); - } - $selectCard(editor, cardKey); - - setSelectedCardKey(cardKey); - - const cardNode = $getNodeByKey(cardKey); - if (cardNode.hasEditMode?.()) { - setIsEditingCard(true); - } - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - DESELECT_CARD_COMMAND, - ({cardKey}) => { - $deselectCard(editor, cardKey); - - setSelectedCardKey(null); - setIsEditingCard(false); - // Hide visibility settings when deselecting a card - setShowVisibilitySettings(false); - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - DELETE_CARD_COMMAND, - ({cardKey, direction = 'forward'}) => { - const cardNode = $getNodeByKey(cardKey); - const previousSibling = cardNode.getPreviousSibling(); - const nextSibling = cardNode.getNextSibling(); - - if (direction === 'backward' && previousSibling) { - if ($isDecoratorNode(previousSibling)) { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(previousSibling.getKey()); - $setSelection(nodeSelection); - } else if (previousSibling.selectEnd) { // decorator nodes have selectEnd, so this needs to come after that check - previousSibling.selectEnd(); - } else { - cardNode.selectPrevious(); - } - } else if (nextSibling) { - if ($isDecoratorNode(nextSibling)) { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(nextSibling.getKey()); - $setSelection(nodeSelection); - } else if (nextSibling.selectStart) { // decorator nodes have selectStart, so this needs to come after that check - nextSibling.selectStart(); - } else { - cardNode.selectNext(); - } - } else { - // ensure we still have a paragraph if the deleted card was the only node - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.select(); - } - - cardNode.remove(); - - // ensure focus moves back to the editor if we lost it by selecting a card - editor.getRootElement().focus(); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_DOWN_COMMAND, - (event) => { - // Avoid processing custom commands when inside a card's editor. - // This also prevents Lexical calling event.preventDefault on - // cut/copy/paste events letting the browser/inner editors do their thing - if (shouldIgnoreEvent(event)) { - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - // toggle edit mode if a card is selected and ctrl/cmd+enter is pressed - if (selectedCardKey && (event.metaKey || event.ctrlKey)) { - const cardNode = $getNodeByKey(selectedCardKey); - - if (cardNode.hasEditMode?.()) { - event.preventDefault(); - - // when leaving edit mode, ensure focus moves back to the editor - // otherwise focus can be left on removed elements preventing further key events - if (isEditingCard) { - editor.getRootElement().focus({preventScroll: true}); - - if (cardNode.isEmpty?.()) { - if ($getRoot().getLastChild().is(cardNode)) { - // we don't have anything to select after the card, so create a new paragraph - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.select(); - } else { - // reselect card to ensure we have a selection for the next steps - $selectCard(editor, selectedCardKey); - - // select the next paragraph or card - editor.dispatchCommand(KEY_ARROW_DOWN_COMMAND); - } - - cardNode.remove(); - } else { - // re-create the node selection because the focus will place the cursor at - // the beginning of the doc - $selectCard(editor, selectedCardKey); - } - - setIsEditingCard(false); - } else { - setIsEditingCard(true); - } - - return true; - } - } - - // let the browser handle selection when in a card inner element (e.g. nested editor) - // NOTE: must come after ctrl/cmd+enter because that always toggles no matter the selection - if (!event._fromNested && document.activeElement !== editor.getRootElement()) { - return true; - } - - // if a card is selected, insert a new paragraph after it - if (!isNested && selectedCardKey) { - event.preventDefault(); - const cardNode = $getNodeByKey(selectedCardKey); - const paragraphNode = $createParagraphNode(); - // cardNode.getTopLevelElementOrThrow().insertAfter(paragraphNode); - cardNode.insertAfter(paragraphNode); - paragraphNode.select(); - return true; - } - - // code card shortcut - if (!isNested) { - const selection = $getSelection(); - const currentNode = selection?.getNodes()[0]; - if ($isTextNode(currentNode)) { - const textContent = currentNode.getTextContent(); - if (textContent.match(/^```(\w{1,10})?/)) { - event.preventDefault(); - const language = textContent.replace(/^```/,''); - const replacementNode = currentNode.getTopLevelElement().insertAfter($createCodeBlockNode({language, _openInEditMode: true})); - currentNode.getTopLevelElement().remove(); - - // select node when replacing so it immediately renders in editing mode - const replacementSelection = $createNodeSelection(); - replacementSelection.add(replacementNode.getKey()); - $setSelection(replacementSelection); - return true; - } - } - } - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => { - const selection = $getSelection(); - - // if a selection is being made, we need to handle it ourselves (lexical does not handle decorator nodes at this time) - if (event?.shiftKey) { - if ($isRangeSelection(selection)) { - let anchorNode = selection.anchor.getNode(); - - if (!$isRootNode(anchorNode)) { - anchorNode = anchorNode.getTopLevelElement(); - let focusNode = selection.focus.getNode().getTopLevelElement(); - - // treat text nodes as normal - let previousSibling = focusNode.getTopLevelElement().getPreviousSibling(); - if ($isTextNode(focusNode) && $isTextNode(previousSibling)) { - return false; - } - // if on or about to move to decorator node selection, select the entire current node using root node offsets - if ($isDecoratorNode(anchorNode) || $isDecoratorNode(previousSibling)) { - // if at the start of the line, treat that line/node as not selected - if (selection.anchor.offset === 0) { - selection.focus.set('root', focusNode.getIndexWithinParent() - 1, 'element'); - selection.anchor.set('root', anchorNode.getIndexWithinParent(), 'element'); - } else { - selection.focus.set('root', focusNode.getIndexWithinParent(), 'element'); - selection.anchor.set('root', anchorNode.getIndexWithinParent() + 1, 'element'); - } - event.preventDefault(); - return true; - } - } - - // if using the root node, simply add the card above - if ($isRootNode(anchorNode)) { - const offset = selection.focus.offset; - if (offset > 0) { - selection.focus.set('root', selection.focus.offset - 1, 'element'); - } - event.preventDefault(); - return true; - } - } - // use default behavior for other selection - return false; - } - - // if we're in a nested editor, we need to move selection back to the parent editor - if (event?._fromCaptionEditor) { - $selectCard(editor, selectedCardKey); - } - - // avoid processing card behaviours when an inner element has focus (e.g. nested editors) - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - if ($isNodeSelection(selection)) { - const currentNode = selection.getNodes()[0]; - const previousSibling = currentNode.getPreviousSibling(); - - if (!previousSibling && cursorDidExitAtTop) { - selection.clear(); - cursorDidExitAtTop(); - return true; - } - - if ($isDecoratorNode(previousSibling)) { - $selectDecoratorNode(previousSibling); - return true; - } - - // move cursor to end of previous node - event.preventDefault(); - previousSibling.selectEnd(); - return true; - } - - if ($isRangeSelection(selection)) { - if (selection.isCollapsed()) { - const topLevelElement = selection.anchor.getNode().getTopLevelElement(); - const nativeSelection = window.getSelection(); - - if (cursorDidExitAtTop && $isAtStartOfDocument(selection)) { - cursorDidExitAtTop(); - return true; - } - - // empty paragraphs are odd because the native range won't - // have a rect to compare positioning - const onEmptyNode = - topLevelElement?.getTextContent().trim() === '' && - selection.anchor.offset === 0; - - const atStartOfElement = - selection.anchor.offset === 0 && - selection.focus.offset === 0; - - if (onEmptyNode || atStartOfElement) { - const previousSibling = topLevelElement.getPreviousSibling(); - if ($isDecoratorNode(previousSibling)) { - $selectDecoratorNode(previousSibling); - return true; - } - } else { - const atTopOfNode = $isAtTopOfNode(nativeSelection, RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX); - if (atTopOfNode) { - const previousSibling = topLevelElement.getPreviousSibling(); - if ($isDecoratorNode(previousSibling)) { - $selectDecoratorNode(previousSibling); - return true; - } - } - } - } - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => { - const selection = $getSelection(); - - // if a selection is being made, we need to handle it ourselves (lexical does not handle decorator nodes at this time) - if (event?.shiftKey) { - if ($isRangeSelection(selection)) { - let anchorNode = selection.anchor.getNode(); - - if (!$isRootNode(anchorNode)) { - anchorNode = anchorNode.getTopLevelElement(); - let focusNode = selection.focus.getNode().getTopLevelElement(); - - // treat text nodes as normal - let nextSibling = focusNode.getTopLevelElement().getNextSibling(); - if ($isTextNode(focusNode) && $isTextNode(nextSibling)) { - return false; - } - // if on or about to move to decorator node selection, select the entire current node using root node offsets - if ($isDecoratorNode(anchorNode) || $isDecoratorNode(nextSibling)) { - // if at end of a line, treat it as if that line/node is not selected - if (selection.anchor.offset === anchorNode.getTextContentSize()) { - selection.anchor.set('root', anchorNode.getIndexWithinParent() + 1, 'element'); - selection.focus.set('root', focusNode.getIndexWithinParent() + 2, 'element'); - } else { - selection.anchor.set('root', anchorNode.getIndexWithinParent(), 'element'); - selection.focus.set('root', focusNode.getIndexWithinParent() + 1, 'element'); - } - event.preventDefault(); - return true; - } - } - - // if using the root node, simply add the card below - if ($isRootNode(anchorNode)) { - const offset = selection.focus.offset; - if (offset <= anchorNode.getLastChildOrThrow().getIndexWithinParent()) { - selection.focus.set('root', selection.focus.offset + 1, 'element'); - } - event.preventDefault(); - return true; - } - } - // use default behavior for other selection - return false; - } - - // if we're in a nested editor, we need to move selection back to the parent editor - if (event?._fromCaptionEditor) { - $selectCard(editor, selectedCardKey); - } - - // avoid processing card behaviours when an inner element has focus (e.g. nested editors) - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - if ($isNodeSelection(selection)) { - const currentNode = selection.getNodes()[0]; - const nextSibling = currentNode.getNextSibling(); - - // create a new paragraph and select it if selected card is at end of document - if (!nextSibling) { - const paragraph = $createParagraphNode(); - currentNode.insertAfter(paragraph); - paragraph.select(); - return true; - } - - // if next sibling is a card, select it (default Lexical behaviour skips over cards) - if ($isDecoratorNode(nextSibling)) { - $selectDecoratorNode(nextSibling); - return true; - } - - // move cursor to end of previous node - event?.preventDefault(); - nextSibling.selectStart(); - return true; - } - - if ($isRangeSelection(selection)) { - if (selection.isCollapsed()) { - const topLevelElement = selection.anchor.getNode().getTopLevelElement(); - const nativeSelection = window.getSelection(); - const nativeTopLevelElement = getTopLevelNativeElement(nativeSelection.anchorNode); - - // empty paragraphs are odd because the native range won't - // have a rect to compare positioning - const onEmptyNode = - topLevelElement?.getTextContent().trim() === '' && - selection.anchor.offset === 0; - - const atEndOfElement = - nativeSelection.rangeCount !== 0 && - nativeSelection.anchorNode === nativeTopLevelElement && - nativeSelection.anchorOffset === nativeTopLevelElement.children.length - 1 && - nativeSelection.focusOffset === nativeTopLevelElement.children.length - 1; - - if (onEmptyNode || atEndOfElement) { - const nextSibling = topLevelElement.getNextSibling(); - if ($isDecoratorNode(nextSibling)) { - $selectDecoratorNode(nextSibling); - return true; - } - } else { - const range = nativeSelection.getRangeAt(0).cloneRange(); - const rects = range.getClientRects(); - - if (rects.length > 0) { - // rects.length will be 2 if at the start/end of a line and we should default to the new/second line for - // determining if a card is below the cursor - const rangeRect = rects.length > 1 ? rects[1] : rects[0]; - const elemRect = nativeTopLevelElement.getBoundingClientRect(); - - if (Math.abs(rangeRect.bottom - elemRect.bottom) < RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX) { - const nextSibling = topLevelElement.getNextSibling(); - if ($isDecoratorNode(nextSibling)) { - $selectDecoratorNode(nextSibling); - return true; - } - } - } - } - } - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - (event) => { - // avoid processing card behaviours when an inner element has focus - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - const selection = $getSelection(); - - if (cursorDidExitAtTop) { - if ($isNodeSelection(selection)) { - const currentNode = selection.getNodes()[0]; - const previousSibling = currentNode.getPreviousSibling(); - - if (!previousSibling) { - event.preventDefault(); - selection.clear(); - cursorDidExitAtTop?.(); - return true; - } - } else if ($isAtStartOfDocument(selection)) { - event.preventDefault(); - cursorDidExitAtTop(); - return true; - } - } - - if (!$isNodeSelection(selection)) { - return false; - } - - const firstNode = selection.getNodes()[0]; - let previousSibling; - - if (!$isKoenigCard(firstNode)) { - const topLevelElement = firstNode.getTopLevelElement(); - previousSibling = topLevelElement.getPreviousSibling(); - } else { - previousSibling = firstNode.getPreviousSibling(); - } - - if ($isDecoratorNode(previousSibling)) { - event.preventDefault(); - $selectDecoratorNode(previousSibling); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - (event) => { - // avoid processing card behaviours when an inner element has focus - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - const selection = $getSelection(); - - if (!$isNodeSelection(selection)) { - return false; - } - - const selectedNodes = selection.getNodes(); - const lastNode = selectedNodes[selectedNodes.length - 1]; - - let nextSibling; - if ($isKoenigCard(lastNode)) { - nextSibling = lastNode.getNextSibling(); - } else { - const topLevelElement = lastNode.getTopLevelElement(); - nextSibling = topLevelElement.getNextSibling(); - } - - if ($isDecoratorNode(nextSibling)) { - event.preventDefault(); - $selectDecoratorNode(nextSibling); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_MODIFIER_COMMAND, - (event) => { - const {altKey, ctrlKey, metaKey, shiftKey, code, key} = event; - const isArrowUp = key === 'ArrowUp' || event.keyCode === 38; - const isArrowDown = key === 'ArrowDown' || event.keyCode === 40; - - if (metaKey && (isArrowUp || isArrowDown)) { - const selection = $getSelection(); - const isNodeSelected = $isNodeSelection(selection); - const hasCardAtStart = $isDecoratorNode($getRoot().getFirstChild()); - const hasCardAtEnd = $isDecoratorNode($getRoot().getLastChild()); - - if (isNodeSelected || hasCardAtStart || hasCardAtEnd) { - // meta+down on macos moves cursor to end of document - if (isArrowDown) { - event.preventDefault(); - - const lastNode = $getRoot().getLastChild(); - - if ($isDecoratorNode(lastNode)) { - $selectDecoratorNode(lastNode); - return true; - } else { - lastNode.selectEnd(); - return true; - } - } - - // meta+up on macos moves cursor to start of document - if (isArrowUp) { - event.preventDefault(); - - const firstNode = $getRoot().getFirstChild(); - - if ($isDecoratorNode(firstNode)) { - $selectDecoratorNode(firstNode); - return true; - } else { - firstNode.selectStart(); - return true; - } - } - } - } - - if (ctrlKey && code === 'KeyQ') { - // avoid quit behaviour - event.preventDefault(); - - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const firstNode = selection.anchor.getNode().getTopLevelElement(); - - if ($isParagraphNode(firstNode)) { - $setBlocksType(selection, () => $createQuoteNode()); - } else if ($isQuoteNode(firstNode)) { - $setBlocksType(selection, () => $createAsideNode()); - } else if ($isAsideNode(firstNode)) { - $setBlocksType(selection, () => $createParagraphNode()); - } - } - } - - // Ctrl+Option+H to toggle highlight - if ((ctrlKey || metaKey) && altKey && code === 'KeyH') { - event.preventDefault(); - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight'); - return true; - } - - // ctrl shift K should format text as code - if (ctrlKey && shiftKey && code === 'KeyK') { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); - return true; - } - - // ctrl alt U should strikethrough (cmd alt U launches the browser source view) - if (ctrlKey && altKey && code === 'KeyU') { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); - return true; - } - - // ctrl alt 1-6 should create headings - if (ctrlKey && altKey && key.match(/^[1-6]$/)) { - event.preventDefault(); - - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode(`h${key}`)); - } - } - - if (ctrlKey && code === 'KeyL') { - event.preventDefault(); - - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const firstNode = selection.anchor.getNode().getTopLevelElement(); - - if ($isListNode(firstNode)) { - editor.update(() => { - const pNode = $createParagraphNode(); - $setBlocksType(selection, () => pNode); - - // Lexical will automatically indent the paragraph node to the - // list item level but we don't allow indented paragraphs - pNode.setIndent(0); - }); - } else { - if (altKey) { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); - } else { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - } - } - } - } - return false; - }, - COMMAND_PRIORITY_LOW - ), - // backspace when card isn't selected - editor.registerCommand( - KEY_BACKSPACE_COMMAND, - (event) => { - // avoid processing card behaviours when an inner element has focus - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - // delete selected card if we have one - if (!isNested && selectedCardKey) { - event.preventDefault(); - editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: 'backward'}); - return true; - } - - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - if (selection.isCollapsed()) { - const anchor = selection.anchor; - const anchorNode = anchor.getNode(); - const topLevelElement = anchorNode.getTopLevelElement(); - const previousSibling = topLevelElement.getPreviousSibling(); - - const atStartOfElement = - selection.anchor.offset === 0 && - selection.focus.offset === 0; - - // convert empty top level list items to paragraphs - if ( - atStartOfElement && - $isListItemNode(anchorNode) && - anchorNode.getIndent() === 0 && - anchorNode.isEmpty() - ) { - event.preventDefault(); - editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND); - return true; - } - - // see https://github.com/facebook/lexical/issues/5226 - // upstream bug with firefox only - if ( - atStartOfElement && - $isLinkNode(anchorNode.getPreviousSibling()) - ) { - const linkNode = anchorNode.getPreviousSibling(); - const lastDescendent = linkNode.getLastDescendant(); - if ($isTextNode(lastDescendent)) { - lastDescendent.spliceText(lastDescendent.getTextContentSize(), 1, '', true); - return true; - } - } - - // delete empty paragraphs and select card if preceded by card - if ($isParagraphNode(anchorNode) && anchorNode.isEmpty() && $isDecoratorNode(previousSibling)) { - topLevelElement.remove(); - $selectDecoratorNode(previousSibling); - return true; - } - - // convert populated top level list items to paragraphs when cursor is at beginning - if (atStartOfElement && $isListItemNode(anchorNode.getParent())) { - const listItemNode = anchorNode.getParent(); - if (listItemNode.getIndent() === 0) { - event.preventDefault(); - const paragraphNode = $createParagraphNode(); - paragraphNode.append(...listItemNode.getChildren()); - listItemNode.replace(paragraphNode); - return true; - } - } - - const anchorNodeParent = anchorNode.getParent(); - - // convert to paragraph if backspace is at start of the quote/aside block - if ( - atStartOfElement && - ($isQuoteNode(anchorNodeParent) || $isAsideNode(anchorNodeParent)) - ) { - const paragraph = $createParagraphNode(); - anchorNodeParent.getChildren().forEach((child) => { - paragraph.append(child); - }); - anchorNodeParent.replace(paragraph); - paragraph.selectStart(); - event.preventDefault(); - return true; - } - - // delete any previous card keeping caret in place - if ( - atStartOfElement && - $isDecoratorNode(previousSibling) && - anchorNodeParent === topLevelElement && // handles lists, where the parent node is not the paragraph - anchorNodeParent.getFirstChild().is(anchorNode) // handles child nodes in paragraphs, e.g. LinkNode and HorizontalRule - ) { - event.preventDefault(); - previousSibling.remove(); - return true; - } - - const anchorNodeLength = anchorNode.getTextContentSize(); - const atEndOfElement = - selection.anchor.offset === anchorNodeLength && - selection.focus.offset === anchorNodeLength; - - // undo any markdown special formats when deleting at the end of a formatted text node - if (atEndOfElement && $isTextNode(anchorNode)) { - const textContent = anchorNode.getTextContent(); - - for (const tag of Object.keys(SPECIAL_MARKUPS)) { - if (anchorNode.hasFormat(tag)) { - const markup = SPECIAL_MARKUPS[tag]; - // for replacement strings e.g. {{variable}} we shouldn't add the markup (assumes use of ReplacementStringsPlugin) - let newText = textContent; - if (tag === 'code' && textContent.match(/{.*?}(?![A-Za-z\s])/)) { - newText = newText.slice(0,-1); - } else { - newText = markup + newText + markup; - newText = newText.slice(0,-1); // remove last markup character - } - - // manually clear formatting and push offset to accommodate for the added markup - anchorNode.setFormat(0); - anchorNode.setTextContent(newText); - selection.anchor.offset = selection.anchor.offset + newText.length - textContent.length; - selection.focus.offset = selection.focus.offset + newText.length - textContent.length; - - event.preventDefault(); - return true; - } - } - } - } - } - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_DELETE_COMMAND, - (event) => { - // avoid processing card behaviours when an inner element has focus - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - // delete selected card if we have one - if (!isNested && selectedCardKey) { - event.preventDefault(); - editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: 'forward'}); - return true; - } - - // handle card selection around card boundaries - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - if (selection.isCollapsed()) { - const anchor = selection.anchor; - const anchorNode = anchor.getNode(); - const topLevelElement = anchorNode.getTopLevelElement(); - const nextSibling = topLevelElement.getNextSibling(); - - const onEmptyNode = - topLevelElement?.getTextContent().trim() === '' && - selection.anchor.offset === 0; - - if (onEmptyNode && $isDecoratorNode(nextSibling)) { - // delete the empty node and select the previous card - event.preventDefault(); - topLevelElement.remove(); - $selectDecoratorNode(nextSibling); - return true; - } - - const atEndOfNode = (( - anchor.type === 'element' && - $isElementNode(anchorNode) && - anchor.offset === anchorNode.getChildrenSize() - ) || ( - anchor.type === 'text' && - anchor.offset === anchorNode.getTextContentSize() && - anchor.getNode().getParent().getLastChild().is(anchor.getNode()) - )); - - if (atEndOfNode && $isDecoratorNode(nextSibling)) { - // delete the card, keeping selection in place - event.preventDefault(); - nextSibling.remove(); - return true; - } - } - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - DELETE_LINE_COMMAND, - (isBackward) => { - // delete selected card if it's not a nested editor - if (selectedCardKey && document.activeElement === editor.getRootElement() && !isNested) { - editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: isBackward ? 'backward' : 'forward'}); - return true; - } - - // Avoid deleting a card accidentally: - // If a paragraph contains only one line and is next to a card, then by default CMD + Backspace deletes the line + the sibling card - // In that case, we avoid using the default `selection.deleteLine()` from Lexical - // Instead, we remove the topLevelElement and put the selection on the sibling card - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - if (selection.isCollapsed()) { - const anchor = selection.anchor; - const anchorNode = anchor.getNode(); - const topLevelElement = anchorNode.getTopLevelElement(); - const previousSibling = topLevelElement.getPreviousSibling(); - const nextSibling = topLevelElement.getNextSibling(); - const sibling = isBackward ? previousSibling : nextSibling; - - // Find out if the paragraph contains only one line - const nativeSelection = window.getSelection(); - const isFirstLine = $isAtTopOfNode(nativeSelection, RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX); - - if ($isDecoratorNode(sibling) && isFirstLine) { - if (isBackward && $isLineBreakNode(anchorNode.getNextSibling())) { - anchorNode.remove(); - return true; - } - topLevelElement.remove(); - $selectDecoratorNode(sibling); - - return true; - } - } - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_TAB_COMMAND, - (event) => { - // avoid processing card behaviours when an inner element has focus - if (document.activeElement !== editor.getRootElement()) { - return true; - } - - // exit the editor if we're shift tabbing on an element that isn't tabbed - if (event.shiftKey && cursorDidExitAtTop) { - const selection = $getSelection(); - - if ($isNodeSelection(selection)) { - event.preventDefault(); - selection.clear(); - cursorDidExitAtTop(); - return true; - } - - let nodes; - if (selection.isCollapsed()) { - const anchorNode = selection.anchor.getNode(); - nodes = $isTextNode(anchorNode) ? [anchorNode.getParent()] : [anchorNode]; - } else { - nodes = selection.getNodes(); - } - - const hasIndentedNode = nodes.some((node) => { - return node.getIndent && node.getIndent() > 0; - }); - - if (!hasIndentedNode) { - event.preventDefault(); - cursorDidExitAtTop(); - return true; - } - } - - // code card shortcut - if (!isNested) { - const selection = $getSelection(); - const currentNode = selection.getNodes()[0]; - if ($isTextNode(currentNode)) { - const textContent = currentNode.getTextContent(); - if (textContent.match(/^```(\w{1,10})?/)) { - event.preventDefault(); - const language = textContent.replace(/^```/,''); - const replacementNode = currentNode.getTopLevelElement().insertAfter($createCodeBlockNode({language, _openInEditMode: true})); - currentNode.getTopLevelElement().remove(); - - // select node when replacing so it immediately renders in editing mode - const replacementSelection = $createNodeSelection(); - replacementSelection.add(replacementNode.getKey()); - $setSelection(replacementSelection); - return true; - } - } - - // handle indent behavior - if ($isListItemNode(currentNode) || ($isTextNode(currentNode) && $isListItemNode(currentNode.getParent()))) { - event.preventDefault(); - let node = $isTextNode(currentNode) ? currentNode.getParent() : currentNode; - const indent = node.getIndent(); - if (event.shiftKey) { - if (indent > 0) { - node.setIndent(indent - 1); - } - } else { - node.setIndent(indent + 1); - } - return true; - } - - // generally prevent tabs from leaving the editor/interacting with the browser - event.preventDefault(); - return true; - } - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - KEY_ESCAPE_COMMAND, - (event) => { - if (selectedCardKey && isEditingCard) { - (editor._parentEditor || editor).dispatchCommand(SELECT_CARD_COMMAND, {cardKey: selectedCardKey}); - } - - if (editor._parentEditor) { - editor._parentEditor.getRootElement().focus(); - } - - event.preventDefault(); - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - PASTE_COMMAND, - (clipboardEvent) => { - // avoid Koenig behaviours when an inner element (e.g. a card input) has focus - // and event wasn't triggered from nested editor - if (document.activeElement !== editor.getRootElement() && !isNested) { - // ignore default Lexical behaviour when inside an inner input or contenteditable, - // without this paste events inside CodeMirror for example will replace the card - if (shouldIgnoreEvent(clipboardEvent)) { - return true; - } else { - return false; - } - } - - const clipboardData = clipboardEvent.clipboardData; - if (!clipboardData) { - return false; - } - - const text = clipboardData.getData(MIME_TEXT_PLAIN); - - // TODO: replace with better regex to include more protocols like mailto, ftp, etc - const linkMatch = text?.match(/^(https?:\/\/[^\s]+)$/); - if (linkMatch) { - // avoid any conversion if we're pasting onto a card shortcut - const node = $getSelection()?.anchor.getNode(); - if (node && node.getTextContent().startsWith('/')) { - return false; - } - - // we're pasting a URL, convert it to an embed/bookmark/link - clipboardEvent.preventDefault(); - editor.dispatchCommand(PASTE_LINK_COMMAND, {linkMatch}); - - return true; - } - - const html = clipboardData.getData(MIME_TEXT_HTML); - if (text && !html) { - clipboardEvent?.preventDefault(); - editor.dispatchCommand(PASTE_MARKDOWN_COMMAND, {text, allowBr: true}); - - return true; - } - - // Override Lexical's default paste behaviour when copy/pasting images: - // - By default, Lexical ignores files if there is text/html or text/plain content in the clipboard - // - This causes images copied from e.g. Slack to not paste correctly - // - With this override, we allow pasting images when there is a single image file in the clipboard and if the text/html contains a tag - // - // Lexical code: - // https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L492-L494 - // https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L1035 - const files = clipboardData.files ? Array.from(clipboardData.files) : []; - const imageFiles = files.filter(file => file.type.startsWith('image/')); - const imgTagMatch = html && !!html.match(/<\s*img\b/gi); - - if (imageFiles.length === 1 && imgTagMatch) { - clipboardEvent.preventDefault(); - editor.dispatchCommand(DRAG_DROP_PASTE, files); - - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - PASTE_LINK_COMMAND, - ({linkMatch}) => { - const selection = $getSelection(); - const selectionContent = selection.getTextContent(); - const node = selection.anchor.getNode(); - const nodeContent = node.getTextContent(); - - if (selectionContent.length > 0) { - const url = linkMatch[1]; - if ($isRangeSelection(selection)) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, {url, rel: null}); - } - return true; - } - - // if a link is pasted in a populated text node or pasted with Shift pressed, insert a link - if (nodeContent.length > 0 || isShiftPressed.current === true) { - const link = linkMatch[1]; - const linkNode = $createLinkNode(link); - const linkTextNode = $createTextNode(link); - linkNode.append(linkTextNode); - - // add a space after to avoid the rest of the text being linked when inserting - // then immediately remove as we don't want the extra space - // TODO: raise Lexical bug? - const spaceTextNode = $createTextNode(' '); - $insertNodes([linkNode, spaceTextNode]); - spaceTextNode.remove(); - - return true; - } - - // if a link is pasted in a blank text node, insert an embed card (may turn into bookmark) - if (selectionContent.length === 0 && nodeContent.length === 0) { - const url = linkMatch[1]; - const embedNode = $createEmbedNode({url}); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: embedNode, createdWithUrl: true}); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - CLICK_COMMAND, - (event) => { - if (event.target.matches('[data-lexical-decorator="true"]')) { - // clicked on a decorator node, select it - // - only occurs when the padding above a card is clicked as our - // cards have their own click handlers - event.preventDefault(); - const cardNode = $getNearestNodeFromDOMNode(event.target); - $selectCard(editor, cardNode.getKey()); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - CUT_COMMAND, - (event) => { - // prevent cut events inside card editors triggering lexical behaviour - if (shouldIgnoreEvent(event)) { - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - SHOW_CARD_VISIBILITY_SETTINGS_COMMAND, - ({cardKey}) => { - editor.update(() => { - const cardNode = $getNodeByKey(cardKey); - - // If the card is an html card, we toggle the visibility settings differently - // because we want to show the visibility settings panel while in selected mode - // instead of entering edit mode - if ($isHtmlNode(cardNode)) { - setShowVisibilitySettings(true); - if (!selectedCardKey) { - editor.dispatchCommand(SELECT_CARD_COMMAND, {cardKey, focusEditor: true}); - } - } else { - if (cardNode?.hasEditMode?.() && !isEditingCard) { - setShowVisibilitySettings(true); - editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey, focusEditor: true}); - } else if (isEditingCard) { - $deselectCard(editor, cardKey); - } - } - }); - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - HIDE_CARD_VISIBILITY_SETTINGS_COMMAND, - ({cardKey}) => { - editor.update(() => { - setShowVisibilitySettings(false); - editor.dispatchCommand(DESELECT_CARD_COMMAND, {cardKey}); - }); - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }); - - // remove alignment formats, - // denest invalid node nesting, - // merge list nodes of same type - React.useEffect(() => { - return registerDefaultTransforms(editor); - }, [editor]); - - return null; -} - -export default function KoenigBehaviourPlugin({containerElem = document.querySelector('.koenig-editor'), cursorDidExitAtTop, isNested}) { - const [editor] = useLexicalComposerContext(); - return useKoenigBehaviour({editor, containerElem, cursorDidExitAtTop, isNested}); -} diff --git a/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.tsx new file mode 100644 index 0000000000..86a5dc35fb --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.tsx @@ -0,0 +1,1565 @@ +import React from 'react'; +import {$createAsideNode, $isAsideNode} from '../nodes/AsideNode'; +import {$createCodeBlockNode} from '../nodes/CodeBlockNode'; +import {$createEmbedNode} from '../nodes/EmbedNode'; +import {$createHeadingNode, $createQuoteNode, $isQuoteNode, DRAG_DROP_PASTE} from '@lexical/rich-text'; +import {$createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import { + $createNodeSelection, + $createParagraphNode, + $createTextNode, + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + $getSelection, + $insertNodes, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isNodeSelection, + $isParagraphNode, + $isRangeSelection, + $isRootNode, + $isTextNode, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + CUT_COMMAND, + DELETE_LINE_COMMAND, + FORMAT_TEXT_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_DOWN_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_MODIFIER_COMMAND, + KEY_TAB_COMMAND, + PASTE_COMMAND, + createCommand +} from 'lexical'; +import type {EditorState, LexicalEditor, LexicalNode, TextFormatType} from 'lexical'; +import type {HeadingTagType} from '@lexical/rich-text'; + +interface KoenigCardNode extends LexicalNode { + hasEditMode(): boolean; + isEmpty?(): boolean; + __openInEditMode?: boolean; + clearOpenInEditMode?(): void; +} + +function $isKoenigCardNode(node: LexicalNode | null | undefined): node is KoenigCardNode { + return node !== null && node !== undefined && 'hasEditMode' in node; +} +import {$insertAndSelectNode} from '../utils/$insertAndSelectNode'; +import { + $isAtStartOfDocument, + $isAtTopOfNode, + $selectDecoratorNode, + getTopLevelNativeElement +} from '../utils/'; +import {$isHtmlNode} from '../nodes/HtmlNode'; +import {$isKoenigCard} from '@tryghost/kg-default-nodes'; +import {$isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND} from '@lexical/list'; +import {$setBlocksType} from '@lexical/selection'; +import {MIME_TEXT_HTML, MIME_TEXT_PLAIN, PASTE_MARKDOWN_COMMAND} from './MarkdownPastePlugin'; +import {getParentEditor} from '../utils/lexical-internals'; +import {mergeRegister} from '@lexical/utils'; +import {registerDefaultTransforms} from '@tryghost/kg-default-transforms'; +import {shouldIgnoreEvent} from '../utils/shouldIgnoreEvent'; +import {useKoenigSelectedCardContext} from '../context/KoenigSelectedCardContext'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const INSERT_CARD_COMMAND = createCommand('INSERT_CARD_COMMAND'); +export const SELECT_CARD_COMMAND = createCommand('SELECT_CARD_COMMAND'); +export const DESELECT_CARD_COMMAND = createCommand('DESELECT_CARD_COMMAND'); +export const EDIT_CARD_COMMAND = createCommand('EDIT_CARD_COMMAND'); +export const DELETE_CARD_COMMAND = createCommand('DELETE_CARD_COMMAND'); +export const PASTE_LINK_COMMAND = createCommand('PASTE_LINK_COMMAND'); +export const SHOW_CARD_VISIBILITY_SETTINGS_COMMAND = createCommand('SHOW_CARD_VISIBILITY_SETTINGS_COMMAND'); +export const HIDE_CARD_VISIBILITY_SETTINGS_COMMAND = createCommand('HIDE_CARD_VISIBILITY_SETTINGS_COMMAND'); +const RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX = 10; +const SPECIAL_MARKUPS = { + code: '`', + superscript: '^', + subscript: '~', + strikethrough: '~~' +}; + +function $selectCard(editor: LexicalEditor, nodeKey: string) { + const selection = $createNodeSelection(); + selection.add(nodeKey); + $setSelection(selection); + // selecting a decorator node does not change the + // window selection (there's no caret) so we need + // to manually move focus to the editor element + if (document.activeElement !== editor.getRootElement()) { + editor.getRootElement()?.focus({preventScroll: true}); + } +} + +// remove empty cards when they are deselected +function $deselectCard(editor: LexicalEditor, nodeKey: string) { + const cardNode = $getNodeByKey(nodeKey); + if (cardNode && $isKoenigCardNode(cardNode) && cardNode.isEmpty?.()) { + $removeOrReplaceNodeWithParagraph(editor, cardNode); + } +} + +function $removeOrReplaceNodeWithParagraph(editor: LexicalEditor, node: LexicalNode) { + if ($getRoot().getLastChild()?.is(node)) { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.select(); + } else { + const nextNode = node.getNextSibling(); + if ($isDecoratorNode(nextNode)) { + $selectDecoratorNode(nextNode); + // selecting a decorator node does not change the + // window selection (there's no caret) so we need + // to manually move focus to the editor element + editor.getRootElement()?.focus(); + } else if (nextNode) { + nextNode.selectStart(); + } + } + + node.remove(); +} + +function useKoenigBehaviour({editor, containerElem, cursorDidExitAtTop, isNested}: {editor: LexicalEditor; containerElem: React.RefObject; cursorDidExitAtTop?: () => void; isNested?: boolean}) { + const { + selectedCardKey, + setSelectedCardKey, + isEditingCard, + setIsEditingCard, + setShowVisibilitySettings + } = useKoenigSelectedCardContext(); + + const isShiftPressed = React.useRef(false); + + // Track card selections restored by undo/redo so we can protect them from + // being cleared by decorator reconciliation side-effects. When a 'historic' + // update restores a NodeSelection, subsequent Lexical updates triggered by + // React rendering the decorator component can momentarily change the + // selection, causing the card to appear deselected. We suppress one + // clearing cycle and re-set the selection instead. + const preserveCardSelectionRef = React.useRef(null); + + React.useEffect(() => { + const keyDown = (event: KeyboardEvent) => { + isShiftPressed.current = event.shiftKey; + }; + + const keyUp = (event: KeyboardEvent) => { + isShiftPressed.current = event.shiftKey; + }; + + document.addEventListener('keydown', keyDown); + document.addEventListener('keyup', keyUp); + + return () => { + document.removeEventListener('keydown', keyDown); + document.removeEventListener('keyup', keyUp); + }; + }, []); + + // deselect cards on mousedown outside of the editor container + React.useEffect(() => { + const onMousedown = (event: MouseEvent) => { + if (!document.body.contains(event.target as Node)) { + // The event target is no longer in the DOM + // This is possible if we have listeners in the capture phase of the event (e.g. dropdowns) + return; + } + + // clicks outside of editor should deselect cards + // this more generic handling prevents the need to handle blur for codemirror cards (and likely others) + if (containerElem.current && !containerElem.current.contains(event.target as Node)) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + const selectedNode = selection.getNodes()[0]; + if ($isKoenigCard(selectedNode)) { + editor.dispatchCommand(DESELECT_CARD_COMMAND, {cardKey: selectedNode.getKey()}); + } + } + }); + } + }; + + if (!isNested) { + window.addEventListener('mousedown', onMousedown); + } + + return () => { + window.removeEventListener('mousedown', onMousedown); + }; + }, [editor, containerElem, isNested]); + + // Override built-in keyboard movement around card (DecoratorNode) boundaries, + // cards should be selected on up/down and when deleting content around them. + // Trigger `cursorDidExitAtTop` prop if present and cursor at beginning of doc + React.useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({editorState, tags}: {editorState: EditorState; tags: Set}) => { + // ignore updates triggered by other users or by card node exportJSON calls + if (tags.has('collaboration') || tags.has('card-export')) { + return; + } + + // ignore selections inside of nested editors otherwise we'll + // mistakenly deselect the card containing the nested editor + if (isNested || document.activeElement?.closest('[data-lexical-decorator]')) { + return; + } + + // trigger card selection/deselection when selection changes + const {isCardSelected, cardKey, cardNode} = editorState.read(() => { + const selection = $getSelection(); + + const hasCardSelection = $isNodeSelection(selection) && + selection.getNodes().length === 1 && + $isKoenigCard(selection.getNodes()[0]); + + if (hasCardSelection) { + const selectedNode = selection.getNodes()[0]; + return {isCardSelected: true, cardKey: selectedNode.getKey(), cardNode: selectedNode}; + } else { + return {isCardSelected: false}; + } + }); + + if (isCardSelected && !selectedCardKey) { + setSelectedCardKey(cardKey!); + setIsEditingCard(false); + } else if (isCardSelected && selectedCardKey !== cardKey) { + editor.update(() => { + $deselectCard(editor, selectedCardKey!); + + setSelectedCardKey(cardKey!); + setIsEditingCard(false); + }, {tag: 'history-merge'}); // don't include a history entry for selection change + } + + // When undo/redo restores a card selection, protect it from + // being cleared by side-effects of decorator reconciliation + if (tags.has('historic') && isCardSelected) { + preserveCardSelectionRef.current = cardKey ?? null; + } + + // If a non-historic, non-history-merge update arrives with the + // card still selected, reconciliation succeeded without a + // transient deselection so the ref is no longer needed - + // clear it to avoid blocking future legitimate deselections. + // history-merge updates are excluded because they fire as + // internal bookkeeping before decorator reconciliation. + if (!tags.has('historic') && !tags.has('history-merge') && isCardSelected && preserveCardSelectionRef.current === cardKey) { + preserveCardSelectionRef.current = null; + } + + if (!isCardSelected && selectedCardKey) { + // If the selection was just restored by undo/redo, re-set + // it instead of clearing - the deselection is a transient + // side-effect of decorator re-rendering, not a user action. + // Clear the ref after one use so subsequent legitimate + // deselections are not blocked. + if (preserveCardSelectionRef.current === selectedCardKey) { + preserveCardSelectionRef.current = null; + editor.update(() => { + const node = $getNodeByKey(selectedCardKey); + if (node) { + const selection = $createNodeSelection(); + selection.add(selectedCardKey); + $setSelection(selection); + } else { + setSelectedCardKey(null); + setIsEditingCard(false); + } + }, {tag: 'history-merge'}); + return; + } + + editor.update(() => { + $deselectCard(editor, selectedCardKey); + + setSelectedCardKey(null); + setIsEditingCard(false); + }, {tag: 'history-merge'}); // don't include a history entry for selection change + } + + // we have special-case cards that are inserted via markdown + // expansions where we can't use editor commands to open in + // edit mode so we handle that here instead + if (isCardSelected && cardNode && $isKoenigCardNode(cardNode) && cardNode.__openInEditMode) { + editor.update(() => { + cardNode.clearOpenInEditMode?.(); + }, {tag: 'history-merge'}); // don't include a history entry for clearing the open in edit mode prop + + setIsEditingCard(true); + } + }), + editor.registerCommand( + INSERT_CARD_COMMAND, + ({cardNode, openInEditMode}: {cardNode: LexicalNode; openInEditMode?: boolean}) => { + let focusNode; + + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + focusNode = selection.focus.getNode(); + } else if ($isNodeSelection(selection)) { + focusNode = selection.getNodes()[0]; + } else { + return false; + } + + if (focusNode !== null) { + $insertAndSelectNode({selectedNode: focusNode, newNode: cardNode}); + + setSelectedCardKey(cardNode.getKey()); + + if (openInEditMode) { + setIsEditingCard(true); + } + } + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + SELECT_CARD_COMMAND, + ({cardKey}: {cardKey: string}) => { + // already selected, delete if empty as we're exiting edit mode + if (selectedCardKey === cardKey && isEditingCard) { + const cardNode = $getNodeByKey(cardKey); + if (cardNode && $isKoenigCardNode(cardNode) && cardNode.isEmpty?.()) { + editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey}); + return true; + } + } + + if (selectedCardKey && selectedCardKey !== cardKey) { + $deselectCard(editor, selectedCardKey); + // Hide visibility settings when switching to a different card + setShowVisibilitySettings(false); + } + + $selectCard(editor, cardKey); + + setSelectedCardKey(cardKey); + setIsEditingCard(false); + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + EDIT_CARD_COMMAND, + ({cardKey, focusEditor: _focusEditor}: {cardKey: string; focusEditor?: boolean}) => { + if (selectedCardKey && selectedCardKey !== cardKey) { + $deselectCard(editor, selectedCardKey); + } + $selectCard(editor, cardKey); + + setSelectedCardKey(cardKey); + + const cardNode = $getNodeByKey(cardKey); + if (cardNode && $isKoenigCardNode(cardNode) && cardNode.hasEditMode?.()) { + setIsEditingCard(true); + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + DESELECT_CARD_COMMAND, + ({cardKey}: {cardKey: string}) => { + $deselectCard(editor, cardKey); + + setSelectedCardKey(null); + setIsEditingCard(false); + // Hide visibility settings when deselecting a card + setShowVisibilitySettings(false); + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + DELETE_CARD_COMMAND, + ({cardKey, direction = 'forward'}: {cardKey: string; direction?: string}) => { + const cardNode = $getNodeByKey(cardKey); + if (!cardNode) { + return false; + } + const previousSibling = cardNode.getPreviousSibling(); + const nextSibling = cardNode.getNextSibling(); + + if (direction === 'backward' && previousSibling) { + if ($isDecoratorNode(previousSibling)) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(previousSibling.getKey()); + $setSelection(nodeSelection); + } else if (previousSibling.selectEnd) { // decorator nodes have selectEnd, so this needs to come after that check + previousSibling.selectEnd(); + } else { + cardNode.selectPrevious(); + } + } else if (nextSibling) { + if ($isDecoratorNode(nextSibling)) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(nextSibling.getKey()); + $setSelection(nodeSelection); + } else if (nextSibling.selectStart) { // decorator nodes have selectStart, so this needs to come after that check + nextSibling.selectStart(); + } else { + cardNode.selectNext(); + } + } else { + // ensure we still have a paragraph if the deleted card was the only node + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.select(); + } + + cardNode.remove(); + + // ensure focus moves back to the editor if we lost it by selecting a card + editor.getRootElement()?.focus(); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + // Avoid processing custom commands when inside a card's editor. + // This also prevents Lexical calling event.preventDefault on + // cut/copy/paste events letting the browser/inner editors do their thing + if (shouldIgnoreEvent(event)) { + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent) => { + // toggle edit mode if a card is selected and ctrl/cmd+enter is pressed + if (selectedCardKey && (event.metaKey || event.ctrlKey)) { + const cardNode = $getNodeByKey(selectedCardKey); + + if (cardNode && $isKoenigCardNode(cardNode) && cardNode.hasEditMode?.()) { + event.preventDefault(); + + // when leaving edit mode, ensure focus moves back to the editor + // otherwise focus can be left on removed elements preventing further key events + if (isEditingCard) { + editor.getRootElement()?.focus({preventScroll: true}); + + if (cardNode.isEmpty?.()) { + if ($getRoot().getLastChild()?.is(cardNode)) { + // we don't have anything to select after the card, so create a new paragraph + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.select(); + } else { + // reselect card to ensure we have a selection for the next steps + $selectCard(editor, selectedCardKey); + + // select the next paragraph or card + editor.dispatchCommand(KEY_ARROW_DOWN_COMMAND, event); + } + + cardNode.remove(); + } else { + // re-create the node selection because the focus will place the cursor at + // the beginning of the doc + $selectCard(editor, selectedCardKey); + } + + setIsEditingCard(false); + } else { + setIsEditingCard(true); + } + + return true; + } + } + + // let the browser handle selection when in a card inner element (e.g. nested editor) + // NOTE: must come after ctrl/cmd+enter because that always toggles no matter the selection + if (!(event as KeyboardEvent & {_fromNested?: boolean})._fromNested && document.activeElement !== editor.getRootElement()) { + return true; + } + + // if a card is selected, insert a new paragraph after it + if (!isNested && selectedCardKey) { + event.preventDefault(); + const cardNode = $getNodeByKey(selectedCardKey); + if (!cardNode) { + return false; + } + const paragraphNode = $createParagraphNode(); + // cardNode.getTopLevelElementOrThrow().insertAfter(paragraphNode); + cardNode.insertAfter(paragraphNode); + paragraphNode.select(); + return true; + } + + // code card shortcut + if (!isNested) { + const selection = $getSelection(); + const currentNode = selection?.getNodes()[0]; + if ($isTextNode(currentNode)) { + const textContent = currentNode.getTextContent(); + if (textContent.match(/^```(\w{1,10})?/)) { + event.preventDefault(); + const language = textContent.replace(/^```/,''); + const replacementNode = currentNode.getTopLevelElement().insertAfter($createCodeBlockNode({language, _openInEditMode: true})); + currentNode.getTopLevelElement().remove(); + + // select node when replacing so it immediately renders in editing mode + const replacementSelection = $createNodeSelection(); + replacementSelection.add(replacementNode.getKey()); + $setSelection(replacementSelection); + return true; + } + } + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event: KeyboardEvent) => { + const selection = $getSelection(); + + // if a selection is being made, we need to handle it ourselves (lexical does not handle decorator nodes at this time) + if (event?.shiftKey) { + if ($isRangeSelection(selection)) { + let anchorNode = selection.anchor.getNode(); + + if (!$isRootNode(anchorNode)) { + anchorNode = anchorNode.getTopLevelElement(); + const focusNode = selection.focus.getNode().getTopLevelElement(); + + // treat text nodes as normal + const previousSibling = focusNode.getTopLevelElement().getPreviousSibling(); + if ($isTextNode(focusNode) && $isTextNode(previousSibling)) { + return false; + } + // if on or about to move to decorator node selection, select the entire current node using root node offsets + if ($isDecoratorNode(anchorNode) || $isDecoratorNode(previousSibling)) { + // if at the start of the line, treat that line/node as not selected + if (selection.anchor.offset === 0) { + selection.focus.set('root', focusNode.getIndexWithinParent() - 1, 'element'); + selection.anchor.set('root', anchorNode.getIndexWithinParent(), 'element'); + } else { + selection.focus.set('root', focusNode.getIndexWithinParent(), 'element'); + selection.anchor.set('root', anchorNode.getIndexWithinParent() + 1, 'element'); + } + event.preventDefault(); + return true; + } + } + + // if using the root node, simply add the card above + if ($isRootNode(anchorNode)) { + const offset = selection.focus.offset; + if (offset > 0) { + selection.focus.set('root', selection.focus.offset - 1, 'element'); + } + event.preventDefault(); + return true; + } + } + // use default behavior for other selection + return false; + } + + // if we're in a nested editor, we need to move selection back to the parent editor + if ((event as KeyboardEvent & {_fromCaptionEditor?: boolean})?._fromCaptionEditor) { + $selectCard(editor, selectedCardKey!); + } + + // avoid processing card behaviours when an inner element has focus (e.g. nested editors) + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + if ($isNodeSelection(selection)) { + const currentNode = selection.getNodes()[0]; + const previousSibling = currentNode.getPreviousSibling(); + + if (!previousSibling && cursorDidExitAtTop) { + selection.clear(); + cursorDidExitAtTop(); + return true; + } + + if ($isDecoratorNode(previousSibling)) { + $selectDecoratorNode(previousSibling); + return true; + } + + // move cursor to end of previous node + if (previousSibling) { + event.preventDefault(); + previousSibling.selectEnd(); + return true; + } + return false; + } + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + const topLevelElement = selection.anchor.getNode().getTopLevelElement(); + const nativeSelection = window.getSelection(); + + if (cursorDidExitAtTop && $isAtStartOfDocument(selection)) { + cursorDidExitAtTop(); + return true; + } + + // empty paragraphs are odd because the native range won't + // have a rect to compare positioning + const onEmptyNode = + topLevelElement?.getTextContent().trim() === '' && + selection.anchor.offset === 0; + + const atStartOfElement = + selection.anchor.offset === 0 && + selection.focus.offset === 0; + + if (onEmptyNode || atStartOfElement) { + const previousSibling = topLevelElement.getPreviousSibling(); + if ($isDecoratorNode(previousSibling)) { + $selectDecoratorNode(previousSibling); + return true; + } + } else { + const atTopOfNode = nativeSelection && $isAtTopOfNode(nativeSelection, RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX); + if (atTopOfNode) { + const previousSibling = topLevelElement.getPreviousSibling(); + if ($isDecoratorNode(previousSibling)) { + $selectDecoratorNode(previousSibling); + return true; + } + } + } + } + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (event: KeyboardEvent) => { + const selection = $getSelection(); + + // if a selection is being made, we need to handle it ourselves (lexical does not handle decorator nodes at this time) + if (event?.shiftKey) { + if ($isRangeSelection(selection)) { + let anchorNode = selection.anchor.getNode(); + + if (!$isRootNode(anchorNode)) { + anchorNode = anchorNode.getTopLevelElement(); + const focusNode = selection.focus.getNode().getTopLevelElement(); + + // treat text nodes as normal + const nextSibling = focusNode.getTopLevelElement().getNextSibling(); + if ($isTextNode(focusNode) && $isTextNode(nextSibling)) { + return false; + } + // if on or about to move to decorator node selection, select the entire current node using root node offsets + if ($isDecoratorNode(anchorNode) || $isDecoratorNode(nextSibling)) { + // if at end of a line, treat it as if that line/node is not selected + if (selection.anchor.offset === anchorNode.getTextContentSize()) { + selection.anchor.set('root', anchorNode.getIndexWithinParent() + 1, 'element'); + selection.focus.set('root', focusNode.getIndexWithinParent() + 2, 'element'); + } else { + selection.anchor.set('root', anchorNode.getIndexWithinParent(), 'element'); + selection.focus.set('root', focusNode.getIndexWithinParent() + 1, 'element'); + } + event.preventDefault(); + return true; + } + } + + // if using the root node, simply add the card below + if ($isRootNode(anchorNode)) { + const offset = selection.focus.offset; + if (offset <= anchorNode.getLastChildOrThrow().getIndexWithinParent()) { + selection.focus.set('root', selection.focus.offset + 1, 'element'); + } + event.preventDefault(); + return true; + } + } + // use default behavior for other selection + return false; + } + + // if we're in a nested editor, we need to move selection back to the parent editor + if ((event as KeyboardEvent & {_fromCaptionEditor?: boolean})?._fromCaptionEditor) { + $selectCard(editor, selectedCardKey!); + } + + // avoid processing card behaviours when an inner element has focus (e.g. nested editors) + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + if ($isNodeSelection(selection)) { + const currentNode = selection.getNodes()[0]; + const nextSibling = currentNode.getNextSibling(); + + // create a new paragraph and select it if selected card is at end of document + if (!nextSibling) { + const paragraph = $createParagraphNode(); + currentNode.insertAfter(paragraph); + paragraph.select(); + return true; + } + + // if next sibling is a card, select it (default Lexical behaviour skips over cards) + if ($isDecoratorNode(nextSibling)) { + $selectDecoratorNode(nextSibling); + return true; + } + + // move cursor to end of previous node + event?.preventDefault(); + nextSibling.selectStart(); + return true; + } + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + const topLevelElement = selection.anchor.getNode().getTopLevelElement(); + const nativeSelection = window.getSelection(); + if (!nativeSelection) { + return false; + } + const nativeTopLevelElement = getTopLevelNativeElement(nativeSelection.anchorNode); + + // empty paragraphs are odd because the native range won't + // have a rect to compare positioning + const onEmptyNode = + topLevelElement?.getTextContent().trim() === '' && + selection.anchor.offset === 0; + + const atEndOfElement = + nativeTopLevelElement && + nativeSelection.rangeCount !== 0 && + nativeSelection.anchorNode === nativeTopLevelElement && + nativeSelection.anchorOffset === nativeTopLevelElement.children.length - 1 && + nativeSelection.focusOffset === nativeTopLevelElement.children.length - 1; + + if (onEmptyNode || atEndOfElement) { + const nextSibling = topLevelElement?.getNextSibling(); + if ($isDecoratorNode(nextSibling)) { + $selectDecoratorNode(nextSibling); + return true; + } + } else if (nativeTopLevelElement) { + const range = nativeSelection.getRangeAt(0).cloneRange(); + const rects = range.getClientRects(); + + if (rects.length > 0) { + // rects.length will be 2 if at the start/end of a line and we should default to the new/second line for + // determining if a card is below the cursor + const rangeRect = rects.length > 1 ? rects[1] : rects[0]; + const elemRect = nativeTopLevelElement.getBoundingClientRect(); + + if (Math.abs(rangeRect.bottom - elemRect.bottom) < RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX) { + const nextSibling = topLevelElement.getNextSibling(); + if ($isDecoratorNode(nextSibling)) { + $selectDecoratorNode(nextSibling); + return true; + } + } + } + } + } + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (event: KeyboardEvent) => { + // avoid processing card behaviours when an inner element has focus + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + const selection = $getSelection(); + + if (cursorDidExitAtTop) { + if ($isNodeSelection(selection)) { + const currentNode = selection.getNodes()[0]; + const previousSibling = currentNode.getPreviousSibling(); + + if (!previousSibling) { + event.preventDefault(); + selection.clear(); + cursorDidExitAtTop?.(); + return true; + } + } else if ($isRangeSelection(selection) && $isAtStartOfDocument(selection)) { + event.preventDefault(); + cursorDidExitAtTop(); + return true; + } + } + + if (!$isNodeSelection(selection)) { + return false; + } + + const firstNode = selection.getNodes()[0]; + let previousSibling; + + if (!$isKoenigCard(firstNode)) { + const topLevelElement = firstNode.getTopLevelElement(); + previousSibling = topLevelElement.getPreviousSibling(); + } else { + previousSibling = firstNode.getPreviousSibling(); + } + + if ($isDecoratorNode(previousSibling)) { + event.preventDefault(); + $selectDecoratorNode(previousSibling); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event: KeyboardEvent) => { + // avoid processing card behaviours when an inner element has focus + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + const selection = $getSelection(); + + if (!$isNodeSelection(selection)) { + return false; + } + + const selectedNodes = selection.getNodes(); + const lastNode = selectedNodes[selectedNodes.length - 1]; + + let nextSibling; + if ($isKoenigCard(lastNode)) { + nextSibling = lastNode.getNextSibling(); + } else { + const topLevelElement = lastNode.getTopLevelElement(); + nextSibling = topLevelElement.getNextSibling(); + } + + if ($isDecoratorNode(nextSibling)) { + event.preventDefault(); + $selectDecoratorNode(nextSibling); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_MODIFIER_COMMAND, + (event: KeyboardEvent) => { + const {altKey, ctrlKey, metaKey, shiftKey, code, key} = event; + const isArrowUp = key === 'ArrowUp' || event.keyCode === 38; + const isArrowDown = key === 'ArrowDown' || event.keyCode === 40; + + if (metaKey && (isArrowUp || isArrowDown)) { + const selection = $getSelection(); + const isNodeSelected = $isNodeSelection(selection); + const hasCardAtStart = $isDecoratorNode($getRoot().getFirstChild()); + const hasCardAtEnd = $isDecoratorNode($getRoot().getLastChild()); + + if (isNodeSelected || hasCardAtStart || hasCardAtEnd) { + // meta+down on macos moves cursor to end of document + if (isArrowDown) { + event.preventDefault(); + + const lastNode = $getRoot().getLastChild(); + + if ($isDecoratorNode(lastNode)) { + $selectDecoratorNode(lastNode); + return true; + } else if (lastNode) { + lastNode.selectEnd(); + return true; + } + } + + // meta+up on macos moves cursor to start of document + if (isArrowUp) { + event.preventDefault(); + + const firstNode = $getRoot().getFirstChild(); + + if ($isDecoratorNode(firstNode)) { + $selectDecoratorNode(firstNode); + return true; + } else if (firstNode) { + firstNode.selectStart(); + return true; + } + } + } + } + + if (ctrlKey && code === 'KeyQ') { + // avoid quit behaviour + event.preventDefault(); + + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const firstNode = selection.anchor.getNode().getTopLevelElement(); + + if ($isParagraphNode(firstNode)) { + $setBlocksType(selection, () => $createQuoteNode()); + } else if ($isQuoteNode(firstNode)) { + $setBlocksType(selection, () => $createAsideNode()); + } else if ($isAsideNode(firstNode)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + } + } + + // Ctrl+Option+H to toggle highlight + if ((ctrlKey || metaKey) && altKey && code === 'KeyH') { + event.preventDefault(); + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight'); + return true; + } + + // ctrl shift K should format text as code + if (ctrlKey && shiftKey && code === 'KeyK') { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); + return true; + } + + // ctrl alt U should strikethrough (cmd alt U launches the browser source view) + if (ctrlKey && altKey && code === 'KeyU') { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + return true; + } + + // ctrl alt 1-6 should create headings + if (ctrlKey && altKey && key.match(/^[1-6]$/)) { + event.preventDefault(); + + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(`h${key}` as HeadingTagType)); + } + } + + if (ctrlKey && code === 'KeyL') { + event.preventDefault(); + + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const firstNode = selection.anchor.getNode().getTopLevelElement(); + + if ($isListNode(firstNode)) { + editor.update(() => { + const pNode = $createParagraphNode(); + $setBlocksType(selection, () => pNode); + + // Lexical will automatically indent the paragraph node to the + // list item level but we don't allow indented paragraphs + pNode.setIndent(0); + }); + } else { + if (altKey) { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } + } + } + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + // backspace when card isn't selected + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + (event: KeyboardEvent) => { + // avoid processing card behaviours when an inner element has focus + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + // delete selected card if we have one + if (!isNested && selectedCardKey) { + event.preventDefault(); + editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: 'backward'}); + return true; + } + + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + const topLevelElement = anchorNode.getTopLevelElement(); + const previousSibling = topLevelElement.getPreviousSibling(); + + const atStartOfElement = + selection.anchor.offset === 0 && + selection.focus.offset === 0; + + // convert empty top level list items to paragraphs + if ( + atStartOfElement && + $isListItemNode(anchorNode) && + anchorNode.getIndent() === 0 && + anchorNode.isEmpty() + ) { + event.preventDefault(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + return true; + } + + // see https://github.com/facebook/lexical/issues/5226 + // upstream bug with firefox only + if ( + atStartOfElement && + $isLinkNode(anchorNode.getPreviousSibling()) + ) { + const linkNode = anchorNode.getPreviousSibling(); + const lastDescendent = linkNode.getLastDescendant(); + if ($isTextNode(lastDescendent)) { + lastDescendent.spliceText(lastDescendent.getTextContentSize(), 1, '', true); + return true; + } + } + + // delete empty paragraphs and select card if preceded by card + if ($isParagraphNode(anchorNode) && anchorNode.isEmpty() && $isDecoratorNode(previousSibling)) { + topLevelElement.remove(); + $selectDecoratorNode(previousSibling); + return true; + } + + // convert populated top level list items to paragraphs when cursor is at beginning + if (atStartOfElement && $isListItemNode(anchorNode.getParent())) { + const listItemNode = anchorNode.getParent(); + if (listItemNode.getIndent() === 0) { + event.preventDefault(); + const paragraphNode = $createParagraphNode(); + paragraphNode.append(...listItemNode.getChildren()); + listItemNode.replace(paragraphNode); + return true; + } + } + + const anchorNodeParent = anchorNode.getParent(); + + // convert to paragraph if backspace is at start of the quote/aside block + if ( + atStartOfElement && + ($isQuoteNode(anchorNodeParent) || $isAsideNode(anchorNodeParent)) + ) { + const paragraph = $createParagraphNode(); + anchorNodeParent.getChildren().forEach((child) => { + paragraph.append(child); + }); + anchorNodeParent.replace(paragraph); + paragraph.selectStart(); + event.preventDefault(); + return true; + } + + // delete any previous card keeping caret in place + if ( + atStartOfElement && + $isDecoratorNode(previousSibling) && + anchorNodeParent === topLevelElement && // handles lists, where the parent node is not the paragraph + anchorNodeParent.getFirstChild().is(anchorNode) // handles child nodes in paragraphs, e.g. LinkNode and HorizontalRule + ) { + event.preventDefault(); + previousSibling.remove(); + return true; + } + + const anchorNodeLength = anchorNode.getTextContentSize(); + const atEndOfElement = + selection.anchor.offset === anchorNodeLength && + selection.focus.offset === anchorNodeLength; + + // undo any markdown special formats when deleting at the end of a formatted text node + if (atEndOfElement && $isTextNode(anchorNode)) { + const textContent = anchorNode.getTextContent(); + + for (const tag of Object.keys(SPECIAL_MARKUPS) as (TextFormatType & keyof typeof SPECIAL_MARKUPS)[]) { + if (anchorNode.hasFormat(tag)) { + const markup = SPECIAL_MARKUPS[tag]; + // for replacement strings e.g. {{variable}} we shouldn't add the markup (assumes use of ReplacementStringsPlugin) + let newText = textContent; + if (tag === 'code' && textContent.match(/{.*?}(?![A-Za-z\s])/)) { + newText = newText.slice(0,-1); + } else { + newText = markup + newText + markup; + newText = newText.slice(0,-1); // remove last markup character + } + + // manually clear formatting and push offset to accommodate for the added markup + anchorNode.setFormat(0); + anchorNode.setTextContent(newText); + selection.anchor.offset = selection.anchor.offset + newText.length - textContent.length; + selection.focus.offset = selection.focus.offset + newText.length - textContent.length; + + event.preventDefault(); + return true; + } + } + } + } + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + (event: KeyboardEvent) => { + // avoid processing card behaviours when an inner element has focus + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + // delete selected card if we have one + if (!isNested && selectedCardKey) { + event.preventDefault(); + editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: 'forward'}); + return true; + } + + // handle card selection around card boundaries + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + const topLevelElement = anchorNode.getTopLevelElement(); + const nextSibling = topLevelElement.getNextSibling(); + + const onEmptyNode = + topLevelElement?.getTextContent().trim() === '' && + selection.anchor.offset === 0; + + if (onEmptyNode && $isDecoratorNode(nextSibling)) { + // delete the empty node and select the previous card + event.preventDefault(); + topLevelElement.remove(); + $selectDecoratorNode(nextSibling); + return true; + } + + const atEndOfNode = (( + anchor.type === 'element' && + $isElementNode(anchorNode) && + anchor.offset === anchorNode.getChildrenSize() + ) || ( + anchor.type === 'text' && + anchor.offset === anchorNode.getTextContentSize() && + anchor.getNode().getParent().getLastChild().is(anchor.getNode()) + )); + + if (atEndOfNode && $isDecoratorNode(nextSibling)) { + // delete the card, keeping selection in place + event.preventDefault(); + nextSibling.remove(); + return true; + } + } + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + DELETE_LINE_COMMAND, + (isBackward: boolean) => { + // delete selected card if it's not a nested editor + if (selectedCardKey && document.activeElement === editor.getRootElement() && !isNested) { + editor.dispatchCommand(DELETE_CARD_COMMAND, {cardKey: selectedCardKey, direction: isBackward ? 'backward' : 'forward'}); + return true; + } + + // Avoid deleting a card accidentally: + // If a paragraph contains only one line and is next to a card, then by default CMD + Backspace deletes the line + the sibling card + // In that case, we avoid using the default `selection.deleteLine()` from Lexical + // Instead, we remove the topLevelElement and put the selection on the sibling card + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + const topLevelElement = anchorNode.getTopLevelElement(); + const previousSibling = topLevelElement.getPreviousSibling(); + const nextSibling = topLevelElement.getNextSibling(); + const sibling = isBackward ? previousSibling : nextSibling; + + // Find out if the paragraph contains only one line + const nativeSelection = window.getSelection(); + const isFirstLine = nativeSelection && $isAtTopOfNode(nativeSelection, RANGE_TO_ELEMENT_BOUNDARY_THRESHOLD_PX); + + if ($isDecoratorNode(sibling) && isFirstLine) { + if (isBackward && $isLineBreakNode(anchorNode.getNextSibling())) { + anchorNode.remove(); + return true; + } + topLevelElement.remove(); + $selectDecoratorNode(sibling); + + return true; + } + } + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_TAB_COMMAND, + (event: KeyboardEvent) => { + // avoid processing card behaviours when an inner element has focus + if (document.activeElement !== editor.getRootElement()) { + return true; + } + + // exit the editor if we're shift tabbing on an element that isn't tabbed + if (event.shiftKey && cursorDidExitAtTop) { + const selection = $getSelection(); + + if ($isNodeSelection(selection)) { + event.preventDefault(); + selection.clear(); + cursorDidExitAtTop(); + return true; + } + + let nodes: (LexicalNode | null)[] = []; + if ($isRangeSelection(selection) && selection.isCollapsed()) { + const anchorNode = selection.anchor.getNode(); + nodes = $isTextNode(anchorNode) ? [anchorNode.getParent()] : [anchorNode]; + } else if (selection) { + nodes = selection.getNodes(); + } + + const hasIndentedNode = nodes.some((node: LexicalNode | null) => { + return node && $isElementNode(node) && node.getIndent() > 0; + }); + + if (!hasIndentedNode) { + event.preventDefault(); + cursorDidExitAtTop(); + return true; + } + } + + // code card shortcut + if (!isNested) { + const selection = $getSelection(); + if (!selection) { + return false; + } + const currentNode = selection.getNodes()[0]; + if ($isTextNode(currentNode)) { + const textContent = currentNode.getTextContent(); + if (textContent.match(/^```(\w{1,10})?/)) { + event.preventDefault(); + const language = textContent.replace(/^```/,''); + const topLevel = currentNode.getTopLevelElement(); + if (!topLevel) { + return false; + } + const replacementNode = topLevel.insertAfter($createCodeBlockNode({language, _openInEditMode: true})); + topLevel.remove(); + + // select node when replacing so it immediately renders in editing mode + const replacementSelection = $createNodeSelection(); + replacementSelection.add(replacementNode.getKey()); + $setSelection(replacementSelection); + return true; + } + } + + // handle indent behavior + if ($isListItemNode(currentNode) || ($isTextNode(currentNode) && $isListItemNode(currentNode.getParent()))) { + event.preventDefault(); + const node = $isTextNode(currentNode) ? currentNode.getParent() : currentNode; + const indent = node.getIndent(); + if (event.shiftKey) { + if (indent > 0) { + node.setIndent(indent - 1); + } + } else { + node.setIndent(indent + 1); + } + return true; + } + + // generally prevent tabs from leaving the editor/interacting with the browser + event.preventDefault(); + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event: KeyboardEvent) => { + if (selectedCardKey && isEditingCard) { + const parentEditor = getParentEditor(editor); + (parentEditor || editor).dispatchCommand(SELECT_CARD_COMMAND, {cardKey: selectedCardKey}); + } + + const parentEditor = getParentEditor(editor); + if (parentEditor) { + parentEditor.getRootElement()?.focus(); + } + + event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + PASTE_COMMAND, + (clipboardEvent: ClipboardEvent) => { + // avoid Koenig behaviours when an inner element (e.g. a card input) has focus + // and event wasn't triggered from nested editor + if (document.activeElement !== editor.getRootElement() && !isNested) { + // ignore default Lexical behaviour when inside an inner input or contenteditable, + // without this paste events inside CodeMirror for example will replace the card + if (shouldIgnoreEvent(clipboardEvent)) { + return true; + } else { + return false; + } + } + + const clipboardData = clipboardEvent.clipboardData; + if (!clipboardData) { + return false; + } + + const text = clipboardData.getData(MIME_TEXT_PLAIN); + + // TODO: replace with better regex to include more protocols like mailto, ftp, etc + const linkMatch = text?.match(/^(https?:\/\/[^\s]+)$/); + if (linkMatch) { + // avoid any conversion if we're pasting onto a card shortcut + const pasteSelection = $getSelection(); + const node = $isRangeSelection(pasteSelection) ? pasteSelection.anchor.getNode() : null; + if (node && node.getTextContent().startsWith('/')) { + return false; + } + + // we're pasting a URL, convert it to an embed/bookmark/link + clipboardEvent.preventDefault(); + editor.dispatchCommand(PASTE_LINK_COMMAND, {linkMatch}); + + return true; + } + + const html = clipboardData.getData(MIME_TEXT_HTML); + if (text && !html) { + clipboardEvent?.preventDefault(); + editor.dispatchCommand(PASTE_MARKDOWN_COMMAND, {text, allowBr: true}); + + return true; + } + + // Override Lexical's default paste behaviour when copy/pasting images: + // - By default, Lexical ignores files if there is text/html or text/plain content in the clipboard + // - This causes images copied from e.g. Slack to not paste correctly + // - With this override, we allow pasting images when there is a single image file in the clipboard and if the text/html contains a tag + // + // Lexical code: + // https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L492-L494 + // https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L1035 + const files = clipboardData.files ? Array.from(clipboardData.files) : []; + const imageFiles = files.filter((file: File) => file.type.startsWith('image/')); + const imgTagMatch = html && !!html.match(/<\s*img\b/gi); + + if (imageFiles.length === 1 && imgTagMatch) { + clipboardEvent.preventDefault(); + editor.dispatchCommand(DRAG_DROP_PASTE, files); + + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + PASTE_LINK_COMMAND, + ({linkMatch}: {linkMatch: RegExpMatchArray}) => { + const selection = $getSelection(); + if (!selection || !$isRangeSelection(selection)) { + return false; + } + const selectionContent = selection.getTextContent(); + const node = selection.anchor.getNode(); + const nodeContent = node.getTextContent(); + + if (selectionContent.length > 0) { + const url = linkMatch[1]; + if ($isRangeSelection(selection)) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, {url, rel: null}); + } + return true; + } + + // if a link is pasted in a populated text node or pasted with Shift pressed, insert a link + if (nodeContent.length > 0 || isShiftPressed.current === true) { + const link = linkMatch[1]; + const linkNode = $createLinkNode(link); + const linkTextNode = $createTextNode(link); + linkNode.append(linkTextNode); + + // add a space after to avoid the rest of the text being linked when inserting + // then immediately remove as we don't want the extra space + // TODO: raise Lexical bug? + const spaceTextNode = $createTextNode(' '); + $insertNodes([linkNode, spaceTextNode]); + spaceTextNode.remove(); + + return true; + } + + // if a link is pasted in a blank text node, insert an embed card (may turn into bookmark) + if (selectionContent.length === 0 && nodeContent.length === 0) { + const url = linkMatch[1]; + const embedNode = $createEmbedNode({url}); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: embedNode, createdWithUrl: true}); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + CLICK_COMMAND, + (event: MouseEvent) => { + if (event.target instanceof Element && event.target.matches('[data-lexical-decorator="true"]')) { + // clicked on a decorator node, select it + // - only occurs when the padding above a card is clicked as our + // cards have their own click handlers + event.preventDefault(); + const cardNode = $getNearestNodeFromDOMNode(event.target as Node); + if (!cardNode) { + return false; + } + $selectCard(editor, cardNode.getKey()); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + CUT_COMMAND, + (event: ClipboardEvent) => { + // prevent cut events inside card editors triggering lexical behaviour + if (shouldIgnoreEvent(event)) { + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + SHOW_CARD_VISIBILITY_SETTINGS_COMMAND, + ({cardKey}: {cardKey: string}) => { + editor.update(() => { + const cardNode = $getNodeByKey(cardKey); + + // If the card is an html card, we toggle the visibility settings differently + // because we want to show the visibility settings panel while in selected mode + // instead of entering edit mode + if ($isHtmlNode(cardNode)) { + setShowVisibilitySettings(true); + if (!selectedCardKey) { + editor.dispatchCommand(SELECT_CARD_COMMAND, {cardKey, focusEditor: true}); + } + } else { + if (cardNode && $isKoenigCardNode(cardNode) && cardNode.hasEditMode?.() && !isEditingCard) { + setShowVisibilitySettings(true); + editor.dispatchCommand(EDIT_CARD_COMMAND, {cardKey, focusEditor: true}); + } else if (isEditingCard) { + $deselectCard(editor, cardKey); + } + } + }); + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + HIDE_CARD_VISIBILITY_SETTINGS_COMMAND, + ({cardKey}: {cardKey: string}) => { + editor.update(() => { + setShowVisibilitySettings(false); + editor.dispatchCommand(DESELECT_CARD_COMMAND, {cardKey}); + }); + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }); + + // remove alignment formats, + // denest invalid node nesting, + // merge list nodes of same type + React.useEffect(() => { + return registerDefaultTransforms(editor); + }, [editor]); + + return null; +} + +export default function KoenigBehaviourPlugin({containerElem, cursorDidExitAtTop, isNested}: {containerElem?: React.RefObject; cursorDidExitAtTop?: () => void; isNested?: boolean}) { + const [editor] = useLexicalComposerContext(); + const defaultRef = React.useRef(document.querySelector('.koenig-editor')); + return useKoenigBehaviour({editor, containerElem: containerElem || defaultRef, cursorDidExitAtTop, isNested}); +} diff --git a/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.jsx deleted file mode 100644 index 5db6cc7c74..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import {BLUR_COMMAND, COMMAND_PRIORITY_EDITOR} from 'lexical'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const KoenigBlurPlugin = ({onBlur}) => { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - editor.registerCommand( - BLUR_COMMAND, - () => { - onBlur?.(); - }, - COMMAND_PRIORITY_EDITOR - ); - }, [editor, onBlur]); - - return null; -}; diff --git a/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.tsx new file mode 100644 index 0000000000..f9b10af95a --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigBlurPlugin.tsx @@ -0,0 +1,19 @@ +import {BLUR_COMMAND, COMMAND_PRIORITY_EDITOR} from 'lexical'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const KoenigBlurPlugin = ({onBlur}: {onBlur?: () => void}) => { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + return editor.registerCommand( + BLUR_COMMAND, + () => { + onBlur?.(); + return false; + }, + COMMAND_PRIORITY_EDITOR + ); + }, [editor, onBlur]); + + return null; +}; diff --git a/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.jsx deleted file mode 100644 index 9137e9d0db..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import {COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND} from 'lexical'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const KoenigFocusPlugin = ({onFocus}) => { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - editor.registerCommand( - FOCUS_COMMAND, - () => { - onFocus?.(); - }, - COMMAND_PRIORITY_EDITOR - ); - }, [editor, onFocus]); - - return null; -}; diff --git a/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx new file mode 100644 index 0000000000..2fefdccc8f --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx @@ -0,0 +1,19 @@ +import {COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND} from 'lexical'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const KoenigFocusPlugin = ({onFocus}: {onFocus?: () => void}) => { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + editor.registerCommand( + FOCUS_COMMAND, + () => { + onFocus?.(); + return false; + }, + COMMAND_PRIORITY_EDITOR + ); + }, [editor, onFocus]); + + return null; +}; diff --git a/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.jsx deleted file mode 100644 index 6b72b03b6d..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import CardContext from '../context/CardContext'; -import React from 'react'; -import { - $createNodeSelection, - $getSelection, - $setSelection, - BLUR_COMMAND, - COMMAND_PRIORITY_LOW, - KEY_ENTER_COMMAND -} from 'lexical'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext.js'; - -function KoenigNestedEditorPlugin({ - autoFocus, - focusNext, - hasSettingsPanel, - // Enter will focus the next card if this is true - defaultKoenigEnterBehaviour = false -}) { - const [editor] = useLexicalComposerContext(); - const {isEditing: isParentCardEditing, nodeKey: parentCardNodeKey} = React.useContext(CardContext); - - // using state here because this component can get re-rendered after the - // editor's editable state changes so we need to re-focus on re-render - const [shouldFocus, setShouldFocus] = React.useState(autoFocus); - - // Sync the nested editor's editable state with the parent card's editing - // state synchronously (before browser paint). Without this, the nested - // editor can briefly be contenteditable="true" during decorator mount - // (e.g. after undo restores a card), causing the browser to fire - // selectionchange events that interfere with the parent editor's selection. - React.useLayoutEffect(() => { - if (parentCardNodeKey !== undefined) { - editor.setEditable(!!isParentCardEditing); - } - }, [editor, isParentCardEditing, parentCardNodeKey]); - - React.useEffect(() => { - // prevent nested editor getting focus when its card isn't being edited - if (!isParentCardEditing) { - return; - } - - if (shouldFocus) { - editor.focus(() => { - editor.getRootElement().focus({preventScroll: true}); - }); - } - }, [shouldFocus, editor, isParentCardEditing]); - - React.useEffect(() => { - return mergeRegister( - // watch for editor becoming editable rather than relying on an `isEditing` prop - // because the prop will change before the contenteditable becomes editable, meaning - // we try to focus a non-editable editor which puts focus on the main editor instead - editor.registerEditableListener((isEditable) => { - if (!autoFocus) { - return; - } - - if (isEditable) { - setShouldFocus(true); - } else { - setShouldFocus(false); - } - }), - editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - // TODO: wait for new lexical version, see https://github.com/facebook/lexical/commit/df2a50bc88e0778af26e109502cfcfb9cbe245d5 - if (document.querySelector(`#typeahead-menu`)) { - return false; - } - - // let the parent editor handle the edit mode product - if (event.metaKey || event.ctrlKey) { - event._fromNested = true; - editor._parentEditor?.dispatchCommand(KEY_ENTER_COMMAND, event); - return true; - } - - // move focus to the next editor if it exists (e.g. from header to content editor) - if (focusNext && !event.shiftKey) { - event.preventDefault(); - focusNext.focus(() => { - focusNext.getRootElement().focus({preventScroll: true}); - }); - return true; - } - - if (defaultKoenigEnterBehaviour) { - // allow shift+enter to create a line break - if (event.shiftKey) { - return false; - } - - // otherwise, let the parent editor handle the enter key - // - with ctrl/cmd+enter toggles edit mode - // - or creates paragraph after card and moves cursor - event._fromNested = true; - editor._parentEditor.dispatchCommand(KEY_ENTER_COMMAND, event); - - // prevent normal/KoenigBehaviourPlugin enter key behaviour - return true; - } - return false; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - BLUR_COMMAND, - () => { - // when the nested editor is selected, the parent editor clears its selection so we need to - // return parent editor selection to the card when the nested editor loses focus - if (hasSettingsPanel && editor._parentEditor) { - editor._parentEditor.getEditorState().read(() => { - editor._parentEditor.update(() => { - if (!$getSelection()) { - const selection = $createNodeSelection(); - selection.add(parentCardNodeKey); - $setSelection(selection); - } - }, {tag: 'history-merge'}); // don't include an undo history entry for this change of selection - }); - - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor, autoFocus, focusNext, parentCardNodeKey, hasSettingsPanel, defaultKoenigEnterBehaviour]); - - return null; -} - -export default KoenigNestedEditorPlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.tsx new file mode 100644 index 0000000000..ba2738e37f --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.tsx @@ -0,0 +1,148 @@ +import CardContext from '../context/CardContext'; +import React from 'react'; +import { + $createNodeSelection, + $getSelection, + $setSelection, + BLUR_COMMAND, + COMMAND_PRIORITY_LOW, + KEY_ENTER_COMMAND +} from 'lexical'; +import {getParentEditor} from '../utils/lexical-internals'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext.js'; +import type {LexicalEditor} from 'lexical'; + +function KoenigNestedEditorPlugin({ + autoFocus, + focusNext, + hasSettingsPanel, + // Enter will focus the next card if this is true + defaultKoenigEnterBehaviour = false +}: { + autoFocus?: boolean; + focusNext?: LexicalEditor; + hasSettingsPanel?: boolean; + defaultKoenigEnterBehaviour?: boolean; +}) { + const [editor] = useLexicalComposerContext(); + const {isEditing: isParentCardEditing, nodeKey: parentCardNodeKey} = React.useContext(CardContext); + + // using state here because this component can get re-rendered after the + // editor's editable state changes so we need to re-focus on re-render + const [shouldFocus, setShouldFocus] = React.useState(autoFocus); + + // Sync the nested editor's editable state with the parent card's editing + // state synchronously (before browser paint). Without this, the nested + // editor can briefly be contenteditable="true" during decorator mount + // (e.g. after undo restores a card), causing the browser to fire + // selectionchange events that interfere with the parent editor's selection. + React.useLayoutEffect(() => { + if (parentCardNodeKey !== undefined) { + editor.setEditable(!!isParentCardEditing); + } + }, [editor, isParentCardEditing, parentCardNodeKey]); + + React.useEffect(() => { + // prevent nested editor getting focus when its card isn't being edited + if (!isParentCardEditing) { + return; + } + + if (shouldFocus) { + editor.focus(() => { + editor.getRootElement()?.focus({preventScroll: true}); + }); + } + }, [shouldFocus, editor, isParentCardEditing]); + + React.useEffect(() => { + return mergeRegister( + // watch for editor becoming editable rather than relying on an `isEditing` prop + // because the prop will change before the contenteditable becomes editable, meaning + // we try to focus a non-editable editor which puts focus on the main editor instead + editor.registerEditableListener((isEditable) => { + if (!autoFocus) { + return; + } + + if (isEditable) { + setShouldFocus(true); + } else { + setShouldFocus(false); + } + }), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + // TODO: wait for new lexical version, see https://github.com/facebook/lexical/commit/df2a50bc88e0778af26e109502cfcfb9cbe245d5 + if (document.querySelector(`#typeahead-menu`)) { + return false; + } + + // let the parent editor handle the edit mode product + if (event?.metaKey || event?.ctrlKey) { + (event as KeyboardEvent & {_fromNested?: boolean})._fromNested = true; + getParentEditor(editor)?.dispatchCommand(KEY_ENTER_COMMAND, event); + return true; + } + + // move focus to the next editor if it exists (e.g. from header to content editor) + if (focusNext && !event?.shiftKey) { + event?.preventDefault(); + focusNext.focus(() => { + focusNext.getRootElement()?.focus({preventScroll: true}); + }); + return true; + } + + if (defaultKoenigEnterBehaviour) { + // allow shift+enter to create a line break + if (event?.shiftKey) { + return false; + } + + // otherwise, let the parent editor handle the enter key + // - with ctrl/cmd+enter toggles edit mode + // - or creates paragraph after card and moves cursor + (event as KeyboardEvent & {_fromNested?: boolean})._fromNested = true; + getParentEditor(editor)?.dispatchCommand(KEY_ENTER_COMMAND, event); + + // prevent normal/KoenigBehaviourPlugin enter key behaviour + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + BLUR_COMMAND, + () => { + // when the nested editor is selected, the parent editor clears its selection so we need to + // return parent editor selection to the card when the nested editor loses focus + const parentEditor = getParentEditor(editor); + if (hasSettingsPanel && parentEditor) { + parentEditor.getEditorState().read(() => { + parentEditor.update(() => { + if (!$getSelection()) { + const selection = $createNodeSelection(); + selection.add(parentCardNodeKey); + $setSelection(selection); + } + }, {tag: 'history-merge'}); // don't include an undo history entry for this change of selection + }); + + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor, autoFocus, focusNext, parentCardNodeKey, hasSettingsPanel, defaultKoenigEnterBehaviour]); + + return null; +} + +export default KoenigNestedEditorPlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.jsx deleted file mode 100644 index 99d3e5f8f8..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import GifPlugin from '../components/ui/GifPlugin'; -import React from 'react'; -import UnsplashPlugin from '../components/ui/UnsplashPlugin'; -import {$createImageNode, ImageNode} from '../nodes/ImageNode'; -import {$getSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const OPEN_GIF_SELECTOR_COMMAND = createCommand('OPEN_GIF_SELECTOR_COMMAND'); -export const INSERT_FROM_GIF_COMMAND = createCommand('INSERT_FROM_GIF_COMMAND'); -export const OPEN_UNSPLASH_SELECTOR_COMMAND = createCommand('OPEN_UNSPLASH_SELECTOR_COMMAND'); - -export const KoenigSelectorPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([ImageNode])){ - console.error('ImagePlugin: ImageNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - OPEN_GIF_SELECTOR_COMMAND, - async (dataset) => { - const cardNode = $createImageNode({...dataset, selector: GifPlugin, isImageHidden: true}); - - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - INSERT_FROM_GIF_COMMAND, - async (dataset) => { - const imageNode = $createImageNode(dataset); - - const selection = $getSelection(); - const selectedNode = selection.getNodes()[0]; - - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: imageNode}); - selectedNode.remove(); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - OPEN_UNSPLASH_SELECTOR_COMMAND, - async (dataset) => { - const cardNode = $createImageNode({...dataset, selector: UnsplashPlugin}); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - ); - }, [editor]); - - return null; -}; - -export default KoenigSelectorPlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.tsx new file mode 100644 index 0000000000..9b4d9f2ed8 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.tsx @@ -0,0 +1,65 @@ +import GifPlugin from '../components/ui/GifPlugin'; +import React from 'react'; +import UnsplashPlugin from '../components/ui/UnsplashPlugin'; +import {$createImageNode, ImageNode} from '../nodes/ImageNode'; +import {$getSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const OPEN_GIF_SELECTOR_COMMAND = createCommand('OPEN_GIF_SELECTOR_COMMAND'); +export const INSERT_FROM_GIF_COMMAND = createCommand('INSERT_FROM_GIF_COMMAND'); +export const OPEN_UNSPLASH_SELECTOR_COMMAND = createCommand('OPEN_UNSPLASH_SELECTOR_COMMAND'); + +export const KoenigSelectorPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([ImageNode])){ + console.error('ImagePlugin: ImageNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + OPEN_GIF_SELECTOR_COMMAND, + (dataset: Record) => { + const cardNode = $createImageNode({...dataset, selector: GifPlugin, isImageHidden: true}); + + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + INSERT_FROM_GIF_COMMAND, + (dataset: Record) => { + const imageNode = $createImageNode(dataset); + + const selection = $getSelection(); + const selectedNode = selection?.getNodes()?.[0]; + + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: imageNode}); + selectedNode?.remove(); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + OPEN_UNSPLASH_SELECTOR_COMMAND, + (dataset: Record) => { + const cardNode = $createImageNode({...dataset, selector: UnsplashPlugin}); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + ); + }, [editor]); + + return null; +}; + +export default KoenigSelectorPlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.jsx deleted file mode 100644 index 33afbc94a1..0000000000 --- a/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import {$createParagraphNode, $getSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; -import {$generateNodesFromSerializedNodes, $insertGeneratedNodes} from '@lexical/clipboard'; -import {$isKoenigCard} from '@tryghost/kg-default-nodes'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const INSERT_SNIPPET_COMMAND = createCommand('INSERT_SNIPPET_COMMAND'); - -export const KoenigSnippetPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - return mergeRegister( - editor.registerCommand( - INSERT_SNIPPET_COMMAND, - async (dataset) => { - editor.update(() => { - const snippetData = JSON.parse(dataset.value); - const nodes = $generateNodesFromSerializedNodes(snippetData.nodes); - const firstNode = nodes.length === 1 && nodes[0]; - const lastNode = !!nodes.length && nodes[nodes.length - 1]; - - if (firstNode && $isKoenigCard(firstNode)) { - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: firstNode}); - - return true; - } - - const selection = $getSelection(); - $insertGeneratedNodes(editor, nodes, selection); - - if (lastNode && $isKoenigCard(lastNode) && !lastNode.getNextSibling()) { - try { - const paragraph = $createParagraphNode(); - lastNode.getTopLevelElementOrThrow().insertAfter(paragraph); - } catch (e) { - console.log(e); - } - } - }); - return true; - }, - COMMAND_PRIORITY_LOW - ), - ); - }, [editor]); - - return null; -}; - -export default KoenigSnippetPlugin; diff --git a/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.tsx b/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.tsx new file mode 100644 index 0000000000..8d5dc07190 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {$createParagraphNode, $getSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; +import {$generateNodesFromSerializedNodes, $insertGeneratedNodes} from '@lexical/clipboard'; +import {$isKoenigCard} from '@tryghost/kg-default-nodes'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const INSERT_SNIPPET_COMMAND = createCommand('INSERT_SNIPPET_COMMAND'); + +export const KoenigSnippetPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + return mergeRegister( + editor.registerCommand( + INSERT_SNIPPET_COMMAND, + (dataset: Record) => { + editor.update(() => { + const snippetData = JSON.parse(dataset.value as string); + const nodes = $generateNodesFromSerializedNodes(snippetData.nodes); + const firstNode = nodes.length === 1 && nodes[0]; + const lastNode = !!nodes.length && nodes[nodes.length - 1]; + + if (firstNode && $isKoenigCard(firstNode)) { + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode: firstNode}); + + return true; + } + + const selection = $getSelection(); + if (selection) { + $insertGeneratedNodes(editor, nodes, selection); + } + + if (lastNode && $isKoenigCard(lastNode) && !lastNode.getNextSibling()) { + try { + const paragraph = $createParagraphNode(); + lastNode.getTopLevelElementOrThrow().insertAfter(paragraph); + } catch (e) { + console.log(e); + } + } + }); + return true; + }, + COMMAND_PRIORITY_LOW + ), + ); + }, [editor]); + + return null; +}; + +export default KoenigSnippetPlugin; diff --git a/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.jsx b/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.jsx deleted file mode 100644 index a7780533ff..0000000000 --- a/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; -import {$insertDataTransferForRichText} from '@lexical/clipboard'; -import {render as markdownRender} from '@tryghost/kg-markdown-html-renderer'; -import {mergeRegister} from '@lexical/utils'; -import {sanitizeHtml} from '../utils/sanitize-html.js'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -export const PASTE_MARKDOWN_COMMAND = createCommand('PASTE_MARKDOWN_COMMAND'); -export const MIME_TEXT_PLAIN = 'text/plain'; -export const MIME_TEXT_HTML = 'text/html'; - -export const MarkdownPastePlugin = () => { - const [editor] = useLexicalComposerContext(); - const [isShiftDown, setShiftDown] = React.useState(false); - - React.useEffect(() => { - const handleKeyUp = (e) => { - if (e.key === 'Shift') { - setShiftDown(false); - } - }; - document.addEventListener('keyup', handleKeyUp); - return () => { - document.removeEventListener('keyup', handleKeyUp); - }; - }, [setShiftDown]); - - React.useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === 'Shift') { - setShiftDown(true); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [setShiftDown]); - - React.useEffect(() => { - return mergeRegister( - editor.registerCommand( - PASTE_MARKDOWN_COMMAND, - ({text, allowBr}) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } - const dataTransfer = new DataTransfer(); - if (isShiftDown) { - dataTransfer.setData(MIME_TEXT_PLAIN, text); - } else { - const markdownHtml = markdownRender(text); - // don't use cleanBasicHtml as it removes images and hr; in this case, we need to remove just br - const cleanedHtml = allowBr ? markdownHtml : markdownHtml.replace(//g, ''); - const sanitizedHtml = sanitizeHtml(cleanedHtml, {replaceJS: true}); - dataTransfer.setData(MIME_TEXT_HTML, sanitizedHtml); - } - $insertDataTransferForRichText(dataTransfer, selection, editor); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor, isShiftDown]); - - return null; -}; - -export default MarkdownPastePlugin; diff --git a/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.tsx b/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.tsx new file mode 100644 index 0000000000..fcea5d23a1 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/MarkdownPastePlugin.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand} from 'lexical'; +import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import {render as markdownRender} from '@tryghost/kg-markdown-html-renderer'; +import {mergeRegister} from '@lexical/utils'; +import {sanitizeHtml} from '../utils/sanitize-html'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +export const PASTE_MARKDOWN_COMMAND = createCommand('PASTE_MARKDOWN_COMMAND'); +export const MIME_TEXT_PLAIN = 'text/plain'; +export const MIME_TEXT_HTML = 'text/html'; + +export const MarkdownPastePlugin = () => { + const [editor] = useLexicalComposerContext(); + const [isShiftDown, setShiftDown] = React.useState(false); + + React.useEffect(() => { + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShiftDown(false); + } + }; + document.addEventListener('keyup', handleKeyUp); + return () => { + document.removeEventListener('keyup', handleKeyUp); + }; + }, [setShiftDown]); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShiftDown(true); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [setShiftDown]); + + React.useEffect(() => { + return mergeRegister( + editor.registerCommand( + PASTE_MARKDOWN_COMMAND, + ({text, allowBr}: {text: string; allowBr: boolean}) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + const dataTransfer = new DataTransfer(); + if (isShiftDown) { + dataTransfer.setData(MIME_TEXT_PLAIN, text); + } else { + const markdownHtml = markdownRender(text); + // don't use cleanBasicHtml as it removes images and hr; in this case, we need to remove just br + const cleanedHtml = allowBr ? markdownHtml : markdownHtml.replace(//g, ''); + const sanitizedHtml = sanitizeHtml(cleanedHtml, {replaceJS: true}); + dataTransfer.setData(MIME_TEXT_HTML, sanitizedHtml); + } + $insertDataTransferForRichText(dataTransfer, selection, editor); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor, isShiftDown]); + + return null; +}; + +export default MarkdownPastePlugin; diff --git a/packages/koenig-lexical/src/plugins/MarkdownPlugin.jsx b/packages/koenig-lexical/src/plugins/MarkdownPlugin.jsx deleted file mode 100644 index 896e1cb2a0..0000000000 --- a/packages/koenig-lexical/src/plugins/MarkdownPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createMarkdownNode, INSERT_MARKDOWN_COMMAND, MarkdownNode} from '../nodes/MarkdownNode'; -import {COMMAND_PRIORITY_HIGH} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const MarkdownPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([MarkdownNode])){ - console.error('MarkdownPlugin: MarkdownNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_MARKDOWN_COMMAND, - async (dataset) => { - const cardNode = $createMarkdownNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor]); - - return null; -}; - -export default MarkdownPlugin; diff --git a/packages/koenig-lexical/src/plugins/MarkdownPlugin.tsx b/packages/koenig-lexical/src/plugins/MarkdownPlugin.tsx new file mode 100644 index 0000000000..bbe2d6f5f6 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/MarkdownPlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createMarkdownNode, INSERT_MARKDOWN_COMMAND, MarkdownNode} from '../nodes/MarkdownNode'; +import {COMMAND_PRIORITY_HIGH} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const MarkdownPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([MarkdownNode])){ + console.error('MarkdownPlugin: MarkdownNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_MARKDOWN_COMMAND, + (dataset: Record) => { + const cardNode = $createMarkdownNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor]); + + return null; +}; + +export default MarkdownPlugin; diff --git a/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.jsx b/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.jsx deleted file mode 100644 index afdcba3dbc..0000000000 --- a/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import {$createCodeBlockNode, $isCodeBlockNode, CodeBlockNode} from '../nodes/CodeBlockNode'; -import {$createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode} from '../nodes/HorizontalRuleNode'; -import {$createNodeSelection, $setSelection} from 'lexical'; -import { - HEADING, - ORDERED_LIST, - QUOTE, - TEXT_FORMAT_TRANSFORMERS, - TEXT_MATCH_TRANSFORMERS, - UNORDERED_LIST -} from '@lexical/markdown'; -import {MarkdownShortcutPlugin as LexicalMarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin'; - -export const HR = { - dependencies: [HorizontalRuleNode], - export: (node) => { - return $isHorizontalRuleNode(node) ? '---' : null; - }, - regExp: /^(---|\*\*\*|___)\s?$/, - replace: (parentNode, _1, _2, isImport) => { - const line = $createHorizontalRuleNode(); - - // TODO: Get rid of isImport flag - if (isImport || parentNode.getNextSibling() != null) { - parentNode.replace(line); - } else { - parentNode.insertBefore(line); - } - - line.selectNext(); - }, - type: 'element' -}; - -export const CODE_BLOCK = { - dependencies: [CodeBlockNode], - export: (node) => { - if (!$isCodeBlockNode(node)) { - return null; - } - const textContent = node.getTextContent(); - return ( - '```' + - (node.language || '') + - (textContent ? '\n' + textContent : '') + - '\n' + - '```' - ); - }, - regExp: /^```(\w{1,10})?\s/, - replace: (textNode, match, text) => { - const language = text[1]; - const codeBlockNode = $createCodeBlockNode({language, _openInEditMode: true}); - const replacementNode = textNode.replace(codeBlockNode); - - // select node when replacing so it immediately renders in editing mode - const replacementSelection = $createNodeSelection(); - replacementSelection.add(replacementNode.getKey()); - $setSelection(replacementSelection); - }, - type: 'element' -}; - -// custom text format transformers -export const SUBSCRIPT = { - format: ['subscript'], - tag: '~', - type: 'text-format' -}; - -export const SUPERSCRIPT = { - format: ['superscript'], - tag: '^', - type: 'text-format' -}; - -export const ELEMENT_TRANSFORMERS = [ - HEADING, - QUOTE, - UNORDERED_LIST, - ORDERED_LIST, - HR, - CODE_BLOCK -]; - -export const CUSTOM_TEXT_FORMAT_TRANSFORMERS = [ - SUBSCRIPT, - SUPERSCRIPT -]; - -export const DEFAULT_TRANSFORMERS = [ - ...ELEMENT_TRANSFORMERS, - ...TEXT_FORMAT_TRANSFORMERS, - ...CUSTOM_TEXT_FORMAT_TRANSFORMERS, - ...TEXT_MATCH_TRANSFORMERS -]; - -export const MINIMAL_TRANSFORMERS = [ - ...TEXT_FORMAT_TRANSFORMERS, - ...CUSTOM_TEXT_FORMAT_TRANSFORMERS, - ...TEXT_MATCH_TRANSFORMERS -]; - -export const BASIC_TRANSFORMERS = [ - UNORDERED_LIST, - ORDERED_LIST, - ...TEXT_FORMAT_TRANSFORMERS, - ...CUSTOM_TEXT_FORMAT_TRANSFORMERS, - ...TEXT_MATCH_TRANSFORMERS -]; - -export const EMAIL_TRANSFORMERS = [ - HEADING, - QUOTE, - UNORDERED_LIST, - ORDERED_LIST, - HR, - ...TEXT_FORMAT_TRANSFORMERS, - ...CUSTOM_TEXT_FORMAT_TRANSFORMERS, - ...TEXT_MATCH_TRANSFORMERS -]; - -export default function MarkdownShortcutPlugin({transformers = DEFAULT_TRANSFORMERS} = {}) { - return LexicalMarkdownShortcutPlugin({transformers}); -} diff --git a/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.tsx b/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.tsx new file mode 100644 index 0000000000..4cfaee727f --- /dev/null +++ b/packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.tsx @@ -0,0 +1,152 @@ +import {$createCodeBlockNode, $isCodeBlockNode, CodeBlockNode} from '../nodes/CodeBlockNode'; +import {$createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode} from '../nodes/HorizontalRuleNode'; +import {$createImageNode, $isImageNode, ImageNode} from '../nodes/ImageNode'; +import {$createNodeSelection, $setSelection} from 'lexical'; +import { + HEADING, + ORDERED_LIST, + QUOTE, + TEXT_FORMAT_TRANSFORMERS, + TEXT_MATCH_TRANSFORMERS, + UNORDERED_LIST +} from '@lexical/markdown'; +import {MarkdownShortcutPlugin as LexicalMarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin'; +import type {ElementNode, LexicalNode} from 'lexical'; +import type {ElementTransformer, Transformer} from '@lexical/markdown'; + +export const HR: ElementTransformer = { + dependencies: [HorizontalRuleNode], + export: (node: LexicalNode) => { + return $isHorizontalRuleNode(node) ? '---' : null; + }, + regExp: /^(---|\*\*\*|___)\s?$/, + replace: (parentNode: ElementNode, _1: LexicalNode[], _2: string[], isImport: boolean) => { + const line = $createHorizontalRuleNode(); + + // TODO: Get rid of isImport flag + if (isImport || parentNode.getNextSibling() != null) { + parentNode.replace(line); + } else { + parentNode.insertBefore(line); + } + + line.selectNext(); + }, + type: 'element' +}; + +export const CODE_BLOCK: ElementTransformer = { + dependencies: [CodeBlockNode], + export: (node: LexicalNode) => { + if (!$isCodeBlockNode(node)) { + return null; + } + const textContent = node.getTextContent(); + return ( + '```' + + (node.language || '') + + (textContent ? '\n' + textContent : '') + + '\n' + + '```' + ); + }, + regExp: /^```(\w{1,10})?\s/, + replace: (textNode: ElementNode, _match: LexicalNode[], text: string[]) => { + const language = text[1]; + const codeBlockNode = $createCodeBlockNode({language, _openInEditMode: true}); + const replacementNode = textNode.replace(codeBlockNode); + + // select node when replacing so it immediately renders in editing mode + const replacementSelection = $createNodeSelection(); + replacementSelection.add(replacementNode.getKey()); + $setSelection(replacementSelection); + }, + type: 'element' +}; + +// render imageNode when writing image! +// regex that detects exactly the string 'image!' + +export const IMAGE: ElementTransformer = { + dependencies: [ImageNode], + export: (node: LexicalNode) => { + if (!$isImageNode(node)){ + return null; + } else { + const {src, alt} = (node as ImageNode).getDataset() as {src: string; alt: string}; + return `![${alt}](${src})`; + } + }, + regExp: /^image! $/, + replace: (parentNode: ElementNode) => { + const alt = ''; + const src = ''; + const imageNode = $createImageNode({altText: alt, src}); + parentNode.replace(imageNode); + }, + type: 'element' +}; + +// custom text format transformers +export const SUBSCRIPT = { + format: ['subscript'], + tag: '~', + type: 'text-format' +}; + +export const SUPERSCRIPT = { + format: ['superscript'], + tag: '^', + type: 'text-format' +}; + +export const ELEMENT_TRANSFORMERS = [ + HEADING, + QUOTE, + UNORDERED_LIST, + ORDERED_LIST, + HR, + CODE_BLOCK, + IMAGE +]; + +export const CUSTOM_TEXT_FORMAT_TRANSFORMERS = [ + SUBSCRIPT, + SUPERSCRIPT +]; + +export const DEFAULT_TRANSFORMERS: Transformer[] = [ + ...ELEMENT_TRANSFORMERS, + ...TEXT_FORMAT_TRANSFORMERS, + ...CUSTOM_TEXT_FORMAT_TRANSFORMERS as Transformer[], + ...TEXT_MATCH_TRANSFORMERS +]; + +export const MINIMAL_TRANSFORMERS: Transformer[] = [ + ...TEXT_FORMAT_TRANSFORMERS, + ...CUSTOM_TEXT_FORMAT_TRANSFORMERS as Transformer[], + ...TEXT_MATCH_TRANSFORMERS +]; + +export const BASIC_TRANSFORMERS: Transformer[] = [ + UNORDERED_LIST, + ORDERED_LIST, + ...TEXT_FORMAT_TRANSFORMERS, + ...CUSTOM_TEXT_FORMAT_TRANSFORMERS as Transformer[], + ...TEXT_MATCH_TRANSFORMERS +]; + +export const EMAIL_TRANSFORMERS: Transformer[] = [ + HEADING, + QUOTE, + UNORDERED_LIST, + ORDERED_LIST, + HR, + ...TEXT_FORMAT_TRANSFORMERS, + ...CUSTOM_TEXT_FORMAT_TRANSFORMERS as Transformer[], + ...TEXT_MATCH_TRANSFORMERS +]; + +export default function MarkdownShortcutPlugin({transformers = DEFAULT_TRANSFORMERS}: {transformers?: Transformer[]} = {}) { + return LexicalMarkdownShortcutPlugin({transformers}); +} diff --git a/packages/koenig-lexical/src/plugins/PaywallPlugin.jsx b/packages/koenig-lexical/src/plugins/PaywallPlugin.jsx deleted file mode 100644 index 2f6d045d6b..0000000000 --- a/packages/koenig-lexical/src/plugins/PaywallPlugin.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import {$createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection, COMMAND_PRIORITY_EDITOR} from 'lexical'; -import {$createPaywallNode, INSERT_PAYWALL_COMMAND} from '../nodes/PaywallNode'; -import {getSelectedNode} from '../utils/getSelectedNode'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const PaywallPlugin = () => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([])) { - console.error('PaywallPlugin: PaywallNode not registered'); - return; - } - return editor.registerCommand( - INSERT_PAYWALL_COMMAND, - () => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return false; - } - - const focusNode = selection.focus.getNode(); - - if (focusNode !== null) { - const paywallNode = $createPaywallNode(); - - // insert a paragraph unless we're already on a blank paragraph - const selectedNode = selection.focus.getNode(); - if ($isParagraphNode(selectedNode) && selectedNode.getTextContent() !== '') { - selection.insertParagraph(); - } - - // insert the paywall before the current/inserted paragraph - // so the cursor stays on the blank paragraph - selection.focus - .getNode() - .getTopLevelElementOrThrow() - .insertBefore(paywallNode); - } - - return true; - }, - COMMAND_PRIORITY_EDITOR - ); - }, [editor]); - - // add markdown shortcut '===' - useEffect(() => { - return editor.registerUpdateListener(() => { - editor.update(() => { - // don't do anything when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.type === 'text' || !selection.isCollapsed()) { - return; - } - - const paywallShortcutRegex = /^(===)\s?$/; - const node = getSelectedNode(selection).getTopLevelElement(); - if (!node || !$isParagraphNode(node) || !node.getTextContent().match(paywallShortcutRegex)) { - return; - } - - const nativeSelection = window.getSelection(); - const anchorNode = nativeSelection.anchorNode; - const rootElement = editor.getRootElement(); - - if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement.contains(anchorNode)) { - return; - } - - const line = $createPaywallNode(); - const parentNode = node.getTopLevelElement(); - - if (parentNode.getNextSibling()) { - parentNode.replace(line); - } else { - parentNode.insertBefore(line); - parentNode.replace($createParagraphNode()); - } - - line.selectNext(); - }); - }); - }, [editor]); - - return null; -}; - -export default PaywallPlugin; diff --git a/packages/koenig-lexical/src/plugins/PaywallPlugin.tsx b/packages/koenig-lexical/src/plugins/PaywallPlugin.tsx new file mode 100644 index 0000000000..1c6ecbbbd7 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/PaywallPlugin.tsx @@ -0,0 +1,98 @@ +import {$createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection, COMMAND_PRIORITY_EDITOR} from 'lexical'; +import {$createPaywallNode, INSERT_PAYWALL_COMMAND} from '../nodes/PaywallNode'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const PaywallPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([])) { + console.error('PaywallPlugin: PaywallNode not registered'); + return; + } + return editor.registerCommand( + INSERT_PAYWALL_COMMAND, + () => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + const focusNode = selection.focus.getNode(); + + if (focusNode !== null) { + const paywallNode = $createPaywallNode(); + + // insert a paragraph unless we're already on a blank paragraph + const selectedNode = selection.focus.getNode(); + if ($isParagraphNode(selectedNode) && selectedNode.getTextContent() !== '') { + selection.insertParagraph(); + } + + // insert the paywall before the current/inserted paragraph + // so the cursor stays on the blank paragraph + selection.focus + .getNode() + .getTopLevelElementOrThrow() + .insertBefore(paywallNode); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR + ); + }, [editor]); + + // add markdown shortcut '===' + useEffect(() => { + return editor.registerUpdateListener(() => { + editor.update(() => { + // don't do anything when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + const paywallShortcutRegex = /^(===)\s?$/; + const node = getSelectedNode(selection).getTopLevelElement(); + if (!node || !$isParagraphNode(node) || !node.getTextContent().match(paywallShortcutRegex)) { + return; + } + + const nativeSelection = window.getSelection(); + if (!nativeSelection) { + return; + } + const anchorNode = nativeSelection.anchorNode; + const rootElement = editor.getRootElement(); + + if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement?.contains(anchorNode)) { + return; + } + + const line = $createPaywallNode(); + const parentNode = node.getTopLevelElement(); + + if (parentNode.getNextSibling()) { + parentNode.replace(line); + } else { + parentNode.insertBefore(line); + parentNode.replace($createParagraphNode()); + } + + line.selectNext(); + }); + }); + }, [editor]); + + return null; +}; + +export default PaywallPlugin; diff --git a/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx b/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx deleted file mode 100644 index 88600a898c..0000000000 --- a/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx +++ /dev/null @@ -1,267 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$getSelection, $isParagraphNode, $isRangeSelection, $setSelection} from 'lexical'; -import {CardMenu} from '../components/ui/CardMenu'; -import {PlusButton, PlusMenu} from '../components/ui/PlusMenu'; -import {buildCardMenu} from '../utils/buildCardMenu'; -import {getEditorCardNodes} from '../utils/getEditorCardNodes'; -import {getSelectedNode} from '../utils/getSelectedNode'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function usePlusCardMenu(editor) { - const [isShowingButton, setIsShowingButton] = React.useState(false); - const [isShowingMenu, setIsShowingMenu] = React.useState(false); - const [topPosition, setTopPosition] = React.useState(0); - const [cachedRange, setCachedRange] = React.useState(null); - const [cardMenu, setCardMenu] = React.useState({}); - const containerRef = React.useRef(null); - const {cardConfig} = React.useContext(KoenigComposerContext); - - function getTopPosition(elem) { - const elemRect = elem.getBoundingClientRect(); - const containerRect = elem.parentNode.getBoundingClientRect(); - - return elemRect.top - containerRect.top; - } - - function getElementRange(elem) { - const range = new Range(); - range.setStart(elem, 0); - range.setEnd(elem, 0); - return range; - } - - const moveCursorToCachedRange = React.useCallback(() => { - if (!cachedRange) { - return; - } - document.getSelection().removeAllRanges(); - document.getSelection().addRange(cachedRange); - }, [cachedRange]); - - const showButton = React.useCallback((elem) => { - const range = getElementRange(elem); - setCachedRange(range); - setIsShowingButton(true); - }, [setIsShowingButton, setCachedRange]); - - const hideButton = React.useCallback(() => { - setIsShowingButton(false); - setIsShowingMenu(false); - setCachedRange(null); - }, [setIsShowingButton, setIsShowingMenu, setCachedRange]); - - const openMenu = React.useCallback((event) => { - event?.preventDefault(); - - // clear any existing selection so cards leave selected/editing mode - // uses {discrete: true} so update is synchronous and cursor movement to cached range works - editor.update(() => { - $setSelection(null); - }, {discrete: true}); - - moveCursorToCachedRange(); - setIsShowingMenu(true); - }, [editor, moveCursorToCachedRange, setIsShowingMenu]); - - const closeMenu = React.useCallback(({resetCursor = false} = {}) => { - if (resetCursor) { - moveCursorToCachedRange(); - } - setIsShowingMenu(false); - }, [moveCursorToCachedRange, setIsShowingMenu]); - - const updateButton = React.useCallback(() => { - editor.getEditorState().read(() => { - // don't do anything when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || !selection.type === 'text' || !selection.isCollapsed()) { - hideButton(); - return; - } - - const node = getSelectedNode(selection); - - if (!$isParagraphNode(node) || node.getTextContent() !== '') { - hideButton(); - return; - } - - const nativeSelection = window.getSelection(); - const p = nativeSelection.anchorNode; - const rootElement = editor.getRootElement(); - - if (p?.tagName !== 'P' || !rootElement.contains(p)) { - hideButton(); - return; - } - - setTopPosition(getTopPosition(p)); - showButton(p); - }); - }, [editor, showButton, hideButton]); - - const insert = React.useCallback((insertCommand, {insertParams = {}} = {}) => { - const commandParams = {...insertParams}; - editor.dispatchCommand(insertCommand, commandParams); - closeMenu(); - }, [editor, closeMenu]); - - React.useEffect(() => { - return editor.registerUpdateListener(() => { - updateButton(); - }, [editor, updateButton]); - }); - - // hide the button as soon as there's any selection made outside of the - // editor canvas - any click outside makes a selection so no need for - // additional mouse tracking for this - const hideButtonOnOutsideSelection = React.useCallback(() => { - if (isShowingButton) { - const nativeSelection = window.getSelection(); - - // clicking inside the menu changes native selection, we don't want - // to close the menu when that occurs - if (isShowingMenu && containerRef.current?.contains(nativeSelection.anchorNode)) { - return; - } - - const rootElement = editor.getRootElement(); - - if (!rootElement.contains(nativeSelection.anchorNode)) { - hideButton(); - } - } - }, [editor, isShowingButton, isShowingMenu, hideButton]); - - React.useEffect(() => { - document.addEventListener('selectionchange', hideButtonOnOutsideSelection); - return () => { - document.removeEventListener('selectionchange', hideButtonOnOutsideSelection); - }; - }, [hideButtonOnOutsideSelection]); - - // show or move the button when the mouse moves over a blank paragraph - const updateButtonOnMousemove = React.useCallback((event) => { - // once the menu is open moving the mouse should not have any effect on button/menu positioning - if (isShowingMenu) { - return; - } - - const rootElement = editor.getRootElement(); - let {pageX, pageY} = event; - - // add a horizontal buffer to the pointer position so that the button - // doesn't disappear when moving across the gap between button and paragraph - let containerRect = rootElement.getBoundingClientRect(); - if (pageX < containerRect.left) { - pageX = pageX + 40; - } - - // use the page coordinates to find the element under the pointer - - // TODO: this basic implementation isn't quite as forgiving as the mobiledoc implementation - // which appears to have a threshold around the point which creates a bigger hit area for - // nearby elements, whereas this removes the button immediately on margin mouseover. See: - // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/editor/editor.ts#L1306 - // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/utils/cursor/position.ts#L115 - // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/utils/selection-utils.ts#L39-L90 - const hoveredElem = document.elementFromPoint(pageX, pageY); - - if (rootElement.contains(hoveredElem) && !hoveredElem.closest('[data-kg-card]')) { - if (hoveredElem?.tagName === 'P' && hoveredElem.textContent === '') { - // place cursor next to the hovered paragraph - setTopPosition(getTopPosition(hoveredElem)); - showButton(hoveredElem); - } else { - // reset button based on cursor position - updateButton(); - } - } - }, [editor, isShowingMenu, setTopPosition, showButton, updateButton]); - - React.useEffect(() => { - window.addEventListener('mousemove', updateButtonOnMousemove); - return () => { - window.removeEventListener('mousemove', updateButtonOnMousemove); - }; - }, [updateButtonOnMousemove]); - - // when menu is open, watch the window for mousedown events so that we can - // close it when we detect a click outside - const closeMenuOnClickOutside = React.useCallback((event) => { - if (isShowingMenu) { - if (!containerRef.current?.contains(event.target)) { - return closeMenu(); - } - } - }, [isShowingMenu, closeMenu]); - - React.useEffect(() => { - window.addEventListener('mousedown', closeMenuOnClickOutside); - return () => { - window.removeEventListener('mousedown', closeMenuOnClickOutside); - }; - }, [closeMenuOnClickOutside]); - - // when menu is open, close it when Escape or arrow keys are pressed - const handleKeydown = React.useCallback((event) => { - if (isShowingMenu) { - if (event.key === 'Escape') { - closeMenu({resetCursor: true}); - return; - } - - let arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; - if (arrowKeys.includes(event.key)) { - closeMenu(); - } - } - }, [isShowingMenu, closeMenu]); - - React.useEffect(() => { - window.addEventListener('keydown', handleKeydown); - return () => { - window.removeEventListener('keydown', handleKeydown); - }; - }); - - // build up the card menu based on registered nodes and current search - React.useEffect(() => { - const cardNodes = getEditorCardNodes(editor); - setCardMenu(buildCardMenu(cardNodes, {config: cardConfig})); - }, [cardConfig, editor, setCardMenu]); - - const style = { - top: `${topPosition}px` - }; - - if (cardMenu.menu?.size === 0) { - return null; - } - - if (isShowingButton) { - return ( -
    - {isShowingButton && } - {isShowingMenu && ( - - - - )} -
    - ); - } else { - return null; - } -} - -export default function PlusCardMenuPlugin() { - const [editor] = useLexicalComposerContext(); - return usePlusCardMenu(editor); -} diff --git a/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.tsx b/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.tsx new file mode 100644 index 0000000000..974e3adc75 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.tsx @@ -0,0 +1,276 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$getSelection, $isParagraphNode, $isRangeSelection, $setSelection} from 'lexical'; +import {CardMenu} from '../components/ui/CardMenu'; +import {type CardMenuItem, buildCardMenu} from '../utils/buildCardMenu'; +import {PlusButton, PlusMenu} from '../components/ui/PlusMenu'; +import {getEditorCardNodes} from '../utils/getEditorCardNodes'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalCommand, LexicalEditor} from 'lexical'; + +interface PlusCardMenuResult { + menu?: Map; +} + +function usePlusCardMenu(editor: LexicalEditor) { + const [isShowingButton, setIsShowingButton] = React.useState(false); + const [isShowingMenu, setIsShowingMenu] = React.useState(false); + const [topPosition, setTopPosition] = React.useState(0); + const [cachedRange, setCachedRange] = React.useState(null); + const [cardMenu, setCardMenu] = React.useState({}); + const containerRef = React.useRef(null); + const {cardConfig} = React.useContext(KoenigComposerContext); + + function getTopPosition(elem: HTMLElement) { + const elemRect = elem.getBoundingClientRect(); + const containerRect = (elem.parentNode as HTMLElement)?.getBoundingClientRect(); + + return elemRect.top - containerRect.top; + } + + function getElementRange(elem: Node) { + const range = new Range(); + range.setStart(elem, 0); + range.setEnd(elem, 0); + return range; + } + + const moveCursorToCachedRange = React.useCallback(() => { + if (!cachedRange) { + return; + } + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(cachedRange); + }, [cachedRange]); + + const showButton = React.useCallback((elem: HTMLElement) => { + const range = getElementRange(elem); + setCachedRange(range); + setIsShowingButton(true); + }, [setIsShowingButton, setCachedRange]); + + const hideButton = React.useCallback(() => { + setIsShowingButton(false); + setIsShowingMenu(false); + setCachedRange(null); + }, [setIsShowingButton, setIsShowingMenu, setCachedRange]); + + const openMenu = React.useCallback((event?: React.MouseEvent) => { + event?.preventDefault(); + + // clear any existing selection so cards leave selected/editing mode + // uses {discrete: true} so update is synchronous and cursor movement to cached range works + editor.update(() => { + $setSelection(null); + }, {discrete: true}); + + moveCursorToCachedRange(); + setIsShowingMenu(true); + }, [editor, moveCursorToCachedRange, setIsShowingMenu]); + + const closeMenu = React.useCallback(({resetCursor = false} = {}) => { + if (resetCursor) { + moveCursorToCachedRange(); + } + setIsShowingMenu(false); + }, [moveCursorToCachedRange, setIsShowingMenu]); + + const updateButton = React.useCallback(() => { + editor.getEditorState().read(() => { + // don't do anything when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + hideButton(); + return; + } + + const node = getSelectedNode(selection); + + if (!$isParagraphNode(node) || node.getTextContent() !== '') { + hideButton(); + return; + } + + const nativeSelection = window.getSelection(); + const p = nativeSelection?.anchorNode as HTMLElement | null; + const rootElement = editor.getRootElement(); + + if (p?.tagName !== 'P' || !rootElement?.contains(p)) { + hideButton(); + return; + } + + setTopPosition(getTopPosition(p)); + showButton(p); + }); + }, [editor, showButton, hideButton]); + + const insert = React.useCallback((insertCommand: LexicalCommand>, {insertParams = {}}: {insertParams?: Record} = {}) => { + const commandParams = {...insertParams}; + editor.dispatchCommand(insertCommand, commandParams); + closeMenu(); + }, [editor, closeMenu]); + + React.useEffect(() => { + return editor.registerUpdateListener(() => { + updateButton(); + }); + }); + + // hide the button as soon as there's any selection made outside of the + // editor canvas - any click outside makes a selection so no need for + // additional mouse tracking for this + const hideButtonOnOutsideSelection = React.useCallback(() => { + if (isShowingButton) { + const nativeSelection = window.getSelection(); + + // clicking inside the menu changes native selection, we don't want + // to close the menu when that occurs + if (isShowingMenu && containerRef.current?.contains(nativeSelection?.anchorNode ?? null)) { + return; + } + + const rootElement = editor.getRootElement(); + + if (!rootElement?.contains(nativeSelection?.anchorNode ?? null)) { + hideButton(); + } + } + }, [editor, isShowingButton, isShowingMenu, hideButton]); + + React.useEffect(() => { + document.addEventListener('selectionchange', hideButtonOnOutsideSelection); + return () => { + document.removeEventListener('selectionchange', hideButtonOnOutsideSelection); + }; + }, [hideButtonOnOutsideSelection]); + + // show or move the button when the mouse moves over a blank paragraph + const updateButtonOnMousemove = React.useCallback((event: MouseEvent) => { + // once the menu is open moving the mouse should not have any effect on button/menu positioning + if (isShowingMenu) { + return; + } + + const rootElement = editor.getRootElement(); + const {pageY} = event; + let {pageX} = event; + + // add a horizontal buffer to the pointer position so that the button + // doesn't disappear when moving across the gap between button and paragraph + const containerRect = rootElement?.getBoundingClientRect(); + if (!containerRect) { + return; + } + if (pageX < containerRect.left) { + pageX = pageX + 40; + } + + // use the page coordinates to find the element under the pointer + + // TODO: this basic implementation isn't quite as forgiving as the mobiledoc implementation + // which appears to have a threshold around the point which creates a bigger hit area for + // nearby elements, whereas this removes the button immediately on margin mouseover. See: + // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/editor/editor.ts#L1306 + // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/utils/cursor/position.ts#L115 + // - https://github.com/bustle/mobiledoc-kit/blob/cdd126009cb809e80ff1d0c202198310aaa1ad1a/src/js/utils/selection-utils.ts#L39-L90 + const hoveredElem = document.elementFromPoint(pageX, pageY); + + if (rootElement?.contains(hoveredElem) && !(hoveredElem as Element)?.closest('[data-kg-card]')) { + if (hoveredElem?.tagName === 'P' && hoveredElem.textContent === '') { + // place cursor next to the hovered paragraph + setTopPosition(getTopPosition(hoveredElem as HTMLElement)); + showButton(hoveredElem as HTMLElement); + } else { + // reset button based on cursor position + updateButton(); + } + } + }, [editor, isShowingMenu, setTopPosition, showButton, updateButton]); + + React.useEffect(() => { + window.addEventListener('mousemove', updateButtonOnMousemove); + return () => { + window.removeEventListener('mousemove', updateButtonOnMousemove); + }; + }, [updateButtonOnMousemove]); + + // when menu is open, watch the window for mousedown events so that we can + // close it when we detect a click outside + const closeMenuOnClickOutside = React.useCallback((event: MouseEvent) => { + if (isShowingMenu) { + if (!containerRef.current?.contains(event.target as Node)) { + return closeMenu(); + } + } + }, [isShowingMenu, closeMenu]); + + React.useEffect(() => { + window.addEventListener('mousedown', closeMenuOnClickOutside); + return () => { + window.removeEventListener('mousedown', closeMenuOnClickOutside); + }; + }, [closeMenuOnClickOutside]); + + // when menu is open, close it when Escape or arrow keys are pressed + const handleKeydown = React.useCallback((event: KeyboardEvent) => { + if (isShowingMenu) { + if (event.key === 'Escape') { + closeMenu({resetCursor: true}); + return; + } + + const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + if (arrowKeys.includes(event.key)) { + closeMenu(); + } + } + }, [isShowingMenu, closeMenu]); + + React.useEffect(() => { + window.addEventListener('keydown', handleKeydown); + return () => { + window.removeEventListener('keydown', handleKeydown); + }; + }); + + // build up the card menu based on registered nodes and current search + React.useEffect(() => { + const cardNodes = getEditorCardNodes(editor); + setCardMenu(buildCardMenu(cardNodes, {config: cardConfig})); + }, [cardConfig, editor, setCardMenu]); + + const style = { + top: `${topPosition}px` + }; + + if (cardMenu.menu?.size === 0) { + return null; + } + + if (isShowingButton) { + return ( +
    + {isShowingButton && } + {isShowingMenu && ( + + void} menu={cardMenu.menu} /> + + )} +
    + ); + } else { + return null; + } +} + +export default function PlusCardMenuPlugin() { + const [editor] = useLexicalComposerContext(); + return usePlusCardMenu(editor); +} diff --git a/packages/koenig-lexical/src/plugins/ProductPlugin.jsx b/packages/koenig-lexical/src/plugins/ProductPlugin.jsx deleted file mode 100644 index 72c3297a53..0000000000 --- a/packages/koenig-lexical/src/plugins/ProductPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createProductNode, INSERT_PRODUCT_COMMAND, ProductNode} from '../nodes/ProductNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const ProductPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([ProductNode])){ - console.error('ProductPlugin: ProductNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_PRODUCT_COMMAND, - async (dataset) => { - const cardNode = $createProductNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default ProductPlugin; diff --git a/packages/koenig-lexical/src/plugins/ProductPlugin.tsx b/packages/koenig-lexical/src/plugins/ProductPlugin.tsx new file mode 100644 index 0000000000..ad43b6c626 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/ProductPlugin.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {$createProductNode, INSERT_PRODUCT_COMMAND, ProductNode} from '../nodes/ProductNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {ProductNodeData} from '../nodes/ProductNode'; + +export const ProductPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([ProductNode])){ + console.error('ProductPlugin: ProductNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_PRODUCT_COMMAND, + (dataset: ProductNodeData) => { + const cardNode = $createProductNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default ProductPlugin; diff --git a/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.jsx b/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.jsx deleted file mode 100644 index 25e8b7733b..0000000000 --- a/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import {ExtendedTextNode} from '@tryghost/kg-default-nodes'; -import {TextNode} from 'lexical'; -import {useEffect} from 'react'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function replacementStringTransform(node) { - if (node.hasFormat('code')) { // prevent infinite loop - return; - } - const textContent = node.getTextContent(); - // const replacementString = textContent.match(/{.*?}/)?.[0]; - const replacementString = textContent.match(/\{(\w*?)(?:,? *"(.*?)")?\}/)?.[0]; - - if (!replacementString) { - return; - } - // split the text content into an array including the matched string - const splitContent = textContent.split(/({.*?})/g).filter(e => e !== ''); - - // create a new text node for each string in the array - splitContent.reverse().forEach((text) => { - const newNode = new TextNode(text); - if (text === replacementString) { - newNode.setFormat('code'); - newNode.select(); - } - node.insertAfter(newNode); - }); - node.remove(); -} - -function useReplacementStrings(editor) { - useEffect(() => { - const removeTextTransform = editor.registerNodeTransform(TextNode, replacementStringTransform); - - // Only register ExtendedTextNode transform if the editor has it registered - // (nested editors may not have ExtendedTextNode in their node list) - let removeExtendedTextTransform; - if (editor.hasNode(ExtendedTextNode)) { - removeExtendedTextTransform = editor.registerNodeTransform(ExtendedTextNode, replacementStringTransform); - } - - return () => { - removeTextTransform(); - if (removeExtendedTextTransform) { - removeExtendedTextTransform(); - } - }; - }, [editor]); -} - -export default function ReplacementStringsPlugin() { - const [editor] = useLexicalComposerContext(); - return useReplacementStrings(editor); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.tsx b/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.tsx new file mode 100644 index 0000000000..0a31884a5b --- /dev/null +++ b/packages/koenig-lexical/src/plugins/ReplacementStringsPlugin.tsx @@ -0,0 +1,57 @@ +import {ExtendedTextNode} from '@tryghost/kg-default-nodes'; +import {TextNode} from 'lexical'; +import {useEffect} from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalEditor} from 'lexical'; + +function replacementStringTransform(node: TextNode) { + if (node.hasFormat('code')) { // prevent infinite loop + return; + } + const textContent = node.getTextContent(); + // const replacementString = textContent.match(/{.*?}/)?.[0]; + const replacementString = textContent.match(/\{(\w*?)(?:,? *"(.*?)")?\}/)?.[0]; + + if (!replacementString) { + return; + } + // split the text content into an array including the matched string + const splitContent = textContent.split(/({.*?})/g).filter(e => e !== ''); + + // create a new text node for each string in the array + splitContent.reverse().forEach((text: string) => { + const newNode = new TextNode(text); + if (text === replacementString) { + newNode.setFormat('code'); + newNode.select(); + } + node.insertAfter(newNode); + }); + node.remove(); +} + +function useReplacementStrings(editor: LexicalEditor) { + useEffect(() => { + const removeTextTransform = editor.registerNodeTransform(TextNode, replacementStringTransform); + + // Only register ExtendedTextNode transform if the editor has it registered + // (nested editors may not have ExtendedTextNode in their node list) + let removeExtendedTextTransform: (() => void) | undefined; + if (editor.hasNode(ExtendedTextNode)) { + removeExtendedTextTransform = editor.registerNodeTransform(ExtendedTextNode, replacementStringTransform); + } + + return () => { + removeTextTransform(); + if (removeExtendedTextTransform) { + removeExtendedTextTransform(); + } + }; + }, [editor]); +} + +export default function ReplacementStringsPlugin() { + const [editor] = useLexicalComposerContext(); + useReplacementStrings(editor); + return null; +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/RestrictContentPlugin.jsx b/packages/koenig-lexical/src/plugins/RestrictContentPlugin.jsx deleted file mode 100644 index 6df6afc786..0000000000 --- a/packages/koenig-lexical/src/plugins/RestrictContentPlugin.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import { - $createParagraphNode, - $getSelection, - $isDecoratorNode, - $isParagraphNode, - $isRangeSelection, - COMMAND_PRIORITY_LOW, - PASTE_COMMAND, - RootNode -} from 'lexical'; -import {$isListNode} from '@lexical/list'; -import {MIME_TEXT_HTML, MIME_TEXT_PLAIN, PASTE_MARKDOWN_COMMAND} from './MarkdownPastePlugin.jsx'; -import {PASTE_LINK_COMMAND} from './KoenigBehaviourPlugin.jsx'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const RestrictContentPlugin = ({paragraphs, allowBr}) => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - return mergeRegister( - editor.registerNodeTransform(RootNode, (rootNode) => { - // even if this node transform is registered on a nested editor it will - // still be triggered for root node changes in other editors so we need - // to make sure we're only operating on the root node for this editor - if (!editor._updating) { - return; - } - - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return; - } - - const incomingNodes = rootNode.getChildren(); - - const incomingIsClean = - incomingNodes.length <= paragraphs && - incomingNodes.every($isParagraphNode); - - if (!incomingIsClean) { - // strip out any decorator nodes as we can't convert them to paragraphs - let cleanedNodes = incomingNodes.filter((node) => { - return !$isDecoratorNode(node); - }); - - // truncate cleanedNodes to the specified number of paragraphs - cleanedNodes = cleanedNodes.slice(0, paragraphs); - - // for any list nodes, convert first item of list to a paragraph - // for other non-paragraph nodes, convert them to a paragraph - cleanedNodes = cleanedNodes.map((node) => { - if ($isListNode(node)) { - const firstListItem = node.getChildren()[0]; - return $createParagraphNode().append(...firstListItem.getChildren()); - } else if (!$isParagraphNode(node)) { - return $createParagraphNode().append(...node.getChildren()); - } else { - return node; - } - }); - - // remove all existing nodes from state - incomingNodes.forEach(node => node.remove()); - // add our new node to the now empty rootNode - cleanedNodes.forEach(node => rootNode.append(node)); - // move selection to end of new node - rootNode.selectEnd(); - } - }), - editor.registerCommand( - PASTE_COMMAND, - (clipboard) => { - const text = clipboard?.clipboardData?.getData(MIME_TEXT_PLAIN); - const html = clipboard?.clipboardData?.getData(MIME_TEXT_HTML); - - // TODO: replace with better regex to include more protocols like mailto, ftp, etc - const linkMatch = text?.match(/^(https?:\/\/[^\s]+)$/); - - if (linkMatch) { - // we're pasting a URL, convert it to an embed/bookmark/link - clipboard.preventDefault(); - editor.dispatchCommand(PASTE_LINK_COMMAND, {linkMatch}); - - return true; - } - - if (text && !html) { - editor.dispatchCommand(PASTE_MARKDOWN_COMMAND, {text, allowBr}); - - return true; - } - }, - COMMAND_PRIORITY_LOW - ) - - ); - }, [allowBr, editor, paragraphs]); - return null; -}; - -export default RestrictContentPlugin; diff --git a/packages/koenig-lexical/src/plugins/RestrictContentPlugin.tsx b/packages/koenig-lexical/src/plugins/RestrictContentPlugin.tsx new file mode 100644 index 0000000000..5a485c6711 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/RestrictContentPlugin.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + $createParagraphNode, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + PASTE_COMMAND, + RootNode +} from 'lexical'; +import {$isListNode} from '@lexical/list'; +import {MIME_TEXT_HTML, MIME_TEXT_PLAIN, PASTE_MARKDOWN_COMMAND} from './MarkdownPastePlugin'; +import {PASTE_LINK_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {PasteCommandType} from 'lexical'; + +export const RestrictContentPlugin = ({paragraphs, allowBr}: {paragraphs: number; allowBr?: boolean}) => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + return mergeRegister( + editor.registerNodeTransform(RootNode, (rootNode) => { + // even if this node transform is registered on a nested editor it will + // still be triggered for root node changes in other editors so we need + // to make sure we're only operating on the root node for this editor + if (!editor._updating) { + return; + } + + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + const incomingNodes = rootNode.getChildren(); + + const incomingIsClean = + incomingNodes.length <= paragraphs && + incomingNodes.every($isParagraphNode); + + if (!incomingIsClean) { + // strip out any decorator nodes as we can't convert them to paragraphs + let cleanedNodes = incomingNodes.filter((node) => { + return !$isDecoratorNode(node); + }); + + // truncate cleanedNodes to the specified number of paragraphs + cleanedNodes = cleanedNodes.slice(0, paragraphs); + + // for any list nodes, convert first item of list to a paragraph + // for other non-paragraph nodes, convert them to a paragraph + cleanedNodes = cleanedNodes.map((node) => { + if ($isListNode(node)) { + const firstListItem = node.getChildren()[0]; + return $createParagraphNode().append(...($isElementNode(firstListItem) ? firstListItem.getChildren() : [])); + } else if (!$isParagraphNode(node)) { + return $createParagraphNode().append(...($isElementNode(node) ? node.getChildren() : [])); + } else { + return node; + } + }); + + // remove all existing nodes from state + incomingNodes.forEach(node => node.remove()); + // add our new node to the now empty rootNode + cleanedNodes.forEach(node => rootNode.append(node)); + // move selection to end of new node + rootNode.selectEnd(); + } + }), + editor.registerCommand( + PASTE_COMMAND, + (clipboard: PasteCommandType) => { + const text = (clipboard as ClipboardEvent)?.clipboardData?.getData(MIME_TEXT_PLAIN); + const html = (clipboard as ClipboardEvent)?.clipboardData?.getData(MIME_TEXT_HTML); + + // TODO: replace with better regex to include more protocols like mailto, ftp, etc + const linkMatch = text?.match(/^(https?:\/\/[^\s]+)$/); + + if (linkMatch) { + // we're pasting a URL, convert it to an embed/bookmark/link + clipboard.preventDefault(); + editor.dispatchCommand(PASTE_LINK_COMMAND, {linkMatch}); + + return true; + } + + if (text && !html) { + editor.dispatchCommand(PASTE_MARKDOWN_COMMAND, {text, allowBr}); + + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW + ) + + ); + }, [allowBr, editor, paragraphs]); + return null; +}; + +export default RestrictContentPlugin; diff --git a/packages/koenig-lexical/src/plugins/SignupPlugin.jsx b/packages/koenig-lexical/src/plugins/SignupPlugin.jsx deleted file mode 100644 index a384a7a4e3..0000000000 --- a/packages/koenig-lexical/src/plugins/SignupPlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createSignupNode, INSERT_SIGNUP_COMMAND, SignupNode} from '../nodes/SignupNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const SignupPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([SignupNode])){ - console.error('SignupPlugin: SignupNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_SIGNUP_COMMAND, - async (dataset) => { - const cardNode = $createSignupNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default SignupPlugin; diff --git a/packages/koenig-lexical/src/plugins/SignupPlugin.tsx b/packages/koenig-lexical/src/plugins/SignupPlugin.tsx new file mode 100644 index 0000000000..823aca5827 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/SignupPlugin.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {$createSignupNode, INSERT_SIGNUP_COMMAND, SignupNode} from '../nodes/SignupNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {SignupNodeData} from '../nodes/SignupNode'; + +export const SignupPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([SignupNode])){ + console.error('SignupPlugin: SignupNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_SIGNUP_COMMAND, + (dataset: SignupNodeData) => { + const cardNode = $createSignupNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default SignupPlugin; diff --git a/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.jsx b/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.jsx deleted file mode 100644 index ad41593e61..0000000000 --- a/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.jsx +++ /dev/null @@ -1,380 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext.jsx'; -import React from 'react'; -import {$createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND} from 'lexical'; -import {CardMenu} from '../components/ui/CardMenu'; -import {SlashMenu} from '../components/ui/SlashMenu'; -import {buildCardMenu} from '../utils/buildCardMenu'; -import {getEditorCardNodes} from '../utils/getEditorCardNodes'; -import {getSelectedNode} from '../utils/getSelectedNode'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -function useSlashCardMenu(editor) { - const [isShowingMenu, setIsShowingMenu] = React.useState(false); - const [position, setPosition] = React.useState({}); - const [query, setQuery] = React.useState(''); - const [commandParams, setCommandParams] = React.useState([]); - const [cardMenu, setCardMenu] = React.useState({}); - const [selectedItemIndex, setSelectedItemIndex] = React.useState(0); - const [scrollToSelectedItem, setScrollToSelectedItem] = React.useState(false); - const cachedRange = React.useRef(null); - const containerRef = React.useRef(null); - const {cardConfig} = React.useContext(KoenigComposerContext); - - function setMenuPosition(elem) { - const elemRect = elem.getBoundingClientRect(); - const containerRect = elem.parentNode.getBoundingClientRect(); - const menuRect = containerRef.current.getBoundingClientRect(); - - const wouldBeOffscreenBottom = elemRect.bottom - containerRect.top + menuRect.height > window.innerHeight; - const wouldBeOffscreenTop = elemRect.top - menuRect.height < 0; - - if (wouldBeOffscreenBottom && !wouldBeOffscreenTop) { - const bottom = containerRect.height - elem.offsetTop; - setPosition({top: null, left: 0, bottom}); - } else { - const top = elem.offsetTop + elemRect.height; - setPosition({top, left: 0, bottom: null}); - } - } - - function getSelectionElement() { - const nativeSelection = window.getSelection(); - let selectionElem; - - if (nativeSelection.anchorNode.nodeType === Node.TEXT_NODE) { - selectionElem = nativeSelection.anchorNode.parentNode.closest('p'); - } else { - selectionElem = nativeSelection.anchorNode; - } - return selectionElem; - } - - function moveCursorToCachedRange() { - if (!cachedRange.current) { - return; - } - document.getSelection().removeAllRanges(); - document.getSelection().addRange(cachedRange.current); - } - - const openMenu = React.useCallback(() => { - setIsShowingMenu(true); - }, [setIsShowingMenu]); - - const closeMenu = React.useCallback(({resetCursor = false} = {}) => { - if (resetCursor) { - moveCursorToCachedRange(); - } - setIsShowingMenu(false); - setQuery(''); - if (commandParams.length > 0) { - setCommandParams([]); - } - setScrollToSelectedItem(false); - cachedRange.current = null; - }, [setIsShowingMenu, commandParams]); - - const insert = React.useCallback((insertCommand, {insertParams = {}, queryParams = {}} = {}) => { - const dataset = {...insertParams}; - - for (let i = 0; i < queryParams.length; i++) { - if (commandParams[i]) { - const key = queryParams[i]; - const value = commandParams[i]; - dataset[key] = value; - } - } - - editor.update(() => { - const selection = $getSelection(); - - const focusPNode = selection.focus.getNode().getTopLevelElement(); - - // paragraphs at the beginning of the document will delete themselves - // via .collapseAtStart() if their contents are deleted so we create - // a new paragraph and delete the old one before the insert command - // replaces the selection with the new node - const paragraph = $createParagraphNode(); - focusPNode.insertAfter(paragraph); - focusPNode.remove(); - paragraph.select(); - - editor.dispatchCommand(insertCommand, dataset); - }); - - closeMenu(); - }, [editor, commandParams, closeMenu]); - - // close menu if selection moves out of the slash command - // update the search query when typing - React.useEffect(() => { - return editor.registerUpdateListener(() => { - editor.getEditorState().read(() => { - // don't do anything when using IME input - if (editor.isComposing()) { - return; - } - - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || !selection.type === 'text' || !selection.isCollapsed()) { - const nativeSelection = window.getSelection(); - const anchorNode = nativeSelection.anchorNode; - const isMenuSection = anchorNode?.parentNode?.dataset?.cardMenuSection; - - // don't close the menu if the selection inside the card section - if (isMenuSection) { - return; - } - - closeMenu(); - return; - } - - const node = getSelectedNode(selection).getTopLevelElement(); - - if (!node || !$isParagraphNode(node) || !node.getTextContent().startsWith('/')) { - closeMenu(); - return; - } - - const nativeSelection = window.getSelection(); - const anchorNode = nativeSelection.anchorNode; - const rootElement = editor.getRootElement(); - - if (anchorNode?.nodeType !== Node.TEXT_NODE || !rootElement.contains(anchorNode)) { - closeMenu(); - return; - } - - // store the cached range so we can reset the cursor when Escape is pressed - // because that will _always_ blur the contenteditable which we don't want - cachedRange.current = nativeSelection.getRangeAt(0); - - // capture text after the / as a query for filtering cards - const command = node.getTextContent().slice(1); - const [q, ...cps] = command.split(' '); - setQuery(q); - setCommandParams(cps); - }); - }); - }, [editor, isShowingMenu, closeMenu, setQuery, setCommandParams]); - - // open the menu when / is pressed on a blank paragraph - React.useEffect(() => { - if (isShowingMenu) { - return; - } - - const triggerMenu = (event) => { - const {key, isComposing, ctrlKey, metaKey} = event; - - // we only care about / presses when not composing or pressed with modifiers - if (key !== '/' || isComposing || ctrlKey || metaKey) { - return; - } - - // ignore if editor doesn't have focus - const rootElement = editor.getRootElement(); - if (!rootElement.matches(':focus')) { - return; - } - - // potentially valid / press - editor.getEditorState().read(() => { - const selection = $getSelection(); - const node = getSelectedNode(selection).getTopLevelElement(); - - // ignore if selection is not on a top-level paragraph - if (!node || !$isParagraphNode(node)) { - return; - } - - const paragraphSize = node.getTextContentSize(); - const isEmptyParagraph = selection.isCollapsed() && node.getTextContent() === ''; - // if full paragraph is selected, pressing / will replace it so that's a valid press - const isFullParagraphSelection = !selection.isCollapsed() && ( - (selection.anchor.offset === 0 && selection.focus.offset === paragraphSize) || - (selection.anchor.offset === paragraphSize && selection.focus.offset === 0) - ); - - if (isEmptyParagraph || isFullParagraphSelection) { - openMenu(); - } - }); - }; - - window.addEventListener('keypress', triggerMenu); - return () => { - window.removeEventListener('keypress', triggerMenu); - }; - }, [editor, isShowingMenu, openMenu]); - - // close the menu when Escape is pressed - React.useEffect(() => { - if (!isShowingMenu) { - return; - } - - const handleEscape = (event) => { - if (event.key === 'Escape') { - closeMenu({resetCursor: true}); - return; - } - }; - - window.addEventListener('keydown', handleEscape); - return () => { - window.removeEventListener('keydown', handleEscape); - }; - }, [isShowingMenu, closeMenu]); - - // close the menu on clicks outside the menu - React.useEffect(() => { - if (!isShowingMenu) { - return; - } - - const handleMousedown = (event) => { - if (containerRef.current?.contains(event.target)) { - return; - } - - closeMenu(); - }; - - window.addEventListener('mousedown', handleMousedown); - return () => { - window.removeEventListener('mousedown', handleMousedown); - }; - }, [isShowingMenu, closeMenu]); - - // capture key navigation to move/insert selected card item - React.useEffect(() => { - if (!isShowingMenu) { - return; - } - - const moveUp = (event) => { - if (selectedItemIndex === 0) { - setSelectedItemIndex(cardMenu.maxItemIndex); - } else { - setSelectedItemIndex(selectedItemIndex - 1); - } - setScrollToSelectedItem(true); - - event.preventDefault(); - return true; - }; - - const moveDown = (event) => { - if (selectedItemIndex === cardMenu.maxItemIndex) { - setSelectedItemIndex(0); - } else { - setSelectedItemIndex(selectedItemIndex + 1); - } - setScrollToSelectedItem(true); - - event.preventDefault(); - return true; - }; - - const enter = (event) => { - document.querySelector(`[data-kg-slash-menu] [data-kg-cardmenu-idx="${selectedItemIndex}"]`)?.click(); - event.preventDefault(); - return true; - }; - - return mergeRegister( - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - moveDown, - COMMAND_PRIORITY_HIGH - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - moveUp, - COMMAND_PRIORITY_HIGH - ), - editor.registerCommand( - KEY_ARROW_RIGHT_COMMAND, - moveDown, - COMMAND_PRIORITY_HIGH - ), - editor.registerCommand( - KEY_ARROW_LEFT_COMMAND, - moveUp, - COMMAND_PRIORITY_HIGH - ), - editor.registerCommand( - KEY_ENTER_COMMAND, - enter, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor, isShowingMenu, cardMenu, selectedItemIndex]); - - // build up the card menu based on registered nodes and current search - React.useEffect(() => { - const cardNodes = getEditorCardNodes(editor); - setCardMenu(buildCardMenu(cardNodes, {insert, query, config: cardConfig})); - setSelectedItemIndex(0); - }, [editor, query, insert, setCardMenu, setSelectedItemIndex, cardConfig]); - - // attach a resize observer to call setMenuPosition when the window resizes - React.useEffect(() => { - if (!isShowingMenu) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - setMenuPosition(getSelectionElement()); - }); - resizeObserver.observe(window.document.body); - - return () => { - resizeObserver.disconnect(); - }; - }, [isShowingMenu]); - - // use this to position the menu based on the window size - React.useLayoutEffect(() => { - if (!isShowingMenu) { - return; - } - - if (!containerRef || !containerRef.current) { - return; - } - - setMenuPosition(getSelectionElement()); - }, [isShowingMenu]); - - if (cardMenu.menu?.size === 0) { - return null; - } - - if (isShowingMenu) { - return ( -
    - - - -
    - ); - } - - return null; -} - -export default function SlashCardMenuPlugin() { - const [editor] = useLexicalComposerContext(); - return useSlashCardMenu(editor); -} diff --git a/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.tsx b/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.tsx new file mode 100644 index 0000000000..0cc9cbcb74 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.tsx @@ -0,0 +1,400 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import {$createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND} from 'lexical'; +import {CardMenu} from '../components/ui/CardMenu'; +import {type CardMenuItem, buildCardMenu} from '../utils/buildCardMenu'; +import {SlashMenu} from '../components/ui/SlashMenu'; +import {getEditorCardNodes} from '../utils/getEditorCardNodes'; +import {getSelectedNode} from '../utils/getSelectedNode'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {LexicalCommand, LexicalEditor} from 'lexical'; + +interface CardMenuResult { + menu?: Map; + maxItemIndex?: number; +} + +function useSlashCardMenu(editor: LexicalEditor) { + const [isShowingMenu, setIsShowingMenu] = React.useState(false); + const [position, setPosition] = React.useState>({}); + const [query, setQuery] = React.useState(''); + const [commandParams, setCommandParams] = React.useState([]); + const [cardMenu, setCardMenu] = React.useState({}); + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0); + const [scrollToSelectedItem, setScrollToSelectedItem] = React.useState(false); + const cachedRange = React.useRef(null); + const containerRef = React.useRef(null); + const {cardConfig} = React.useContext(KoenigComposerContext); + + function setMenuPosition(elem: HTMLElement | Element | null) { + if (!elem || !containerRef.current) { + return; + } + const htmlElem = elem as HTMLElement; + const elemRect = elem.getBoundingClientRect(); + const containerRect = htmlElem.parentNode ? (htmlElem.parentNode as HTMLElement).getBoundingClientRect() : elemRect; + const menuRect = containerRef.current.getBoundingClientRect(); + + const wouldBeOffscreenBottom = elemRect.bottom - containerRect.top + menuRect.height > window.innerHeight; + const wouldBeOffscreenTop = elemRect.top - menuRect.height < 0; + + if (wouldBeOffscreenBottom && !wouldBeOffscreenTop) { + const bottom = containerRect.height - htmlElem.offsetTop; + setPosition({top: null, left: 0, bottom}); + } else { + const top = htmlElem.offsetTop + elemRect.height; + setPosition({top, left: 0, bottom: null}); + } + } + + function getSelectionElement(): Element | null { + const nativeSelection = window.getSelection(); + if (!nativeSelection?.anchorNode) { + return null; + } + + let selectionElem: Element | null; + + if (nativeSelection.anchorNode.nodeType === Node.TEXT_NODE) { + selectionElem = (nativeSelection.anchorNode.parentNode as Element)?.closest?.('p') ?? null; + } else { + selectionElem = nativeSelection.anchorNode as Element; + } + return selectionElem; + } + + function moveCursorToCachedRange() { + if (!cachedRange.current) { + return; + } + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(cachedRange.current); + } + + const openMenu = React.useCallback(() => { + setIsShowingMenu(true); + }, [setIsShowingMenu]); + + const closeMenu = React.useCallback(({resetCursor = false} = {}) => { + if (resetCursor) { + moveCursorToCachedRange(); + } + setIsShowingMenu(false); + setQuery(''); + if (commandParams.length > 0) { + setCommandParams([]); + } + setScrollToSelectedItem(false); + cachedRange.current = null; + }, [setIsShowingMenu, commandParams]); + + const insert = React.useCallback((insertCommand: LexicalCommand>, {insertParams = {}, queryParams = []} : {insertParams?: Record; queryParams?: string[]} = {}) => { + const dataset: Record = {...insertParams}; + + for (let i = 0; i < queryParams.length; i++) { + if (commandParams[i]) { + const key = queryParams[i]; + const value = commandParams[i]; + dataset[key] = value; + } + } + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + const focusPNode = selection.focus.getNode().getTopLevelElement(); + + // paragraphs at the beginning of the document will delete themselves + // via .collapseAtStart() if their contents are deleted so we create + // a new paragraph and delete the old one before the insert command + // replaces the selection with the new node + const paragraph = $createParagraphNode(); + focusPNode.insertAfter(paragraph); + focusPNode.remove(); + paragraph.select(); + + editor.dispatchCommand(insertCommand, dataset); + }); + + closeMenu(); + }, [editor, commandParams, closeMenu]); + + // close menu if selection moves out of the slash command + // update the search query when typing + React.useEffect(() => { + return editor.registerUpdateListener(() => { + editor.getEditorState().read(() => { + // don't do anything when using IME input + if (editor.isComposing()) { + return; + } + + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + const nativeSelection = window.getSelection(); + const anchorNode = nativeSelection?.anchorNode; + const isMenuSection = (anchorNode?.parentNode as HTMLElement | null)?.dataset?.cardMenuSection; + + // don't close the menu if the selection inside the card section + if (isMenuSection) { + return; + } + + closeMenu(); + return; + } + + const node = getSelectedNode(selection).getTopLevelElement(); + + if (!node || !$isParagraphNode(node) || !node.getTextContent().startsWith('/')) { + closeMenu(); + return; + } + + const nativeSelection = window.getSelection(); + const anchorNode = nativeSelection?.anchorNode; + const rootElement = editor.getRootElement(); + + if (!nativeSelection || nativeSelection.rangeCount === 0 || !anchorNode || anchorNode.nodeType !== Node.TEXT_NODE || !rootElement?.contains(anchorNode)) { + closeMenu(); + return; + } + + // store the cached range so we can reset the cursor when Escape is pressed + // because that will _always_ blur the contenteditable which we don't want + cachedRange.current = nativeSelection.getRangeAt(0); + + // capture text after the / as a query for filtering cards + const command = node.getTextContent().slice(1); + const [q, ...cps] = command.split(' '); + setQuery(q); + setCommandParams(cps); + }); + }); + }, [editor, isShowingMenu, closeMenu, setQuery, setCommandParams]); + + // open the menu when / is pressed on a blank paragraph + React.useEffect(() => { + if (isShowingMenu) { + return; + } + + const triggerMenu = (event: KeyboardEvent) => { + const {key, isComposing, ctrlKey, metaKey} = event; + + // we only care about / presses when not composing or pressed with modifiers + if (key !== '/' || isComposing || ctrlKey || metaKey) { + return; + } + + // ignore if editor doesn't have focus + const rootElement = editor.getRootElement(); + if (!rootElement?.matches(':focus')) { + return; + } + + // potentially valid / press + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + const node = getSelectedNode(selection).getTopLevelElement(); + + // ignore if selection is not on a top-level paragraph + if (!node || !$isParagraphNode(node)) { + return; + } + + const paragraphSize = node.getTextContentSize(); + const isEmptyParagraph = selection.isCollapsed() && node.getTextContent() === ''; + // if full paragraph is selected, pressing / will replace it so that's a valid press + const isFullParagraphSelection = !selection.isCollapsed() && ( + (selection.anchor.offset === 0 && selection.focus.offset === paragraphSize) || + (selection.anchor.offset === paragraphSize && selection.focus.offset === 0) + ); + + if (isEmptyParagraph || isFullParagraphSelection) { + openMenu(); + } + }); + }; + + window.addEventListener('keypress', triggerMenu); + return () => { + window.removeEventListener('keypress', triggerMenu); + }; + }, [editor, isShowingMenu, openMenu]); + + // close the menu when Escape is pressed + React.useEffect(() => { + if (!isShowingMenu) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeMenu({resetCursor: true}); + return; + } + }; + + window.addEventListener('keydown', handleEscape); + return () => { + window.removeEventListener('keydown', handleEscape); + }; + }, [isShowingMenu, closeMenu]); + + // close the menu on clicks outside the menu + React.useEffect(() => { + if (!isShowingMenu) { + return; + } + + const handleMousedown = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) { + return; + } + + closeMenu(); + }; + + window.addEventListener('mousedown', handleMousedown); + return () => { + window.removeEventListener('mousedown', handleMousedown); + }; + }, [isShowingMenu, closeMenu]); + + // capture key navigation to move/insert selected card item + React.useEffect(() => { + if (!isShowingMenu) { + return; + } + + const moveUp = (event: KeyboardEvent) => { + if (selectedItemIndex === 0) { + setSelectedItemIndex(cardMenu.maxItemIndex ?? 0); + } else { + setSelectedItemIndex(selectedItemIndex - 1); + } + setScrollToSelectedItem(true); + + event.preventDefault(); + return true; + }; + + const moveDown = (event: KeyboardEvent) => { + if (selectedItemIndex === (cardMenu.maxItemIndex ?? 0)) { + setSelectedItemIndex(0); + } else { + setSelectedItemIndex(selectedItemIndex + 1); + } + setScrollToSelectedItem(true); + + event.preventDefault(); + return true; + }; + + const enter = (event: KeyboardEvent) => { + (document.querySelector(`[data-kg-slash-menu] [data-kg-cardmenu-idx="${selectedItemIndex}"]`) as HTMLElement | null)?.click(); + event.preventDefault(); + return true; + }; + + return mergeRegister( + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + moveDown, + COMMAND_PRIORITY_HIGH + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + moveUp, + COMMAND_PRIORITY_HIGH + ), + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + moveDown, + COMMAND_PRIORITY_HIGH + ), + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + moveUp, + COMMAND_PRIORITY_HIGH + ), + editor.registerCommand( + KEY_ENTER_COMMAND, + enter, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor, isShowingMenu, cardMenu, selectedItemIndex]); + + // build up the card menu based on registered nodes and current search + React.useEffect(() => { + const cardNodes = getEditorCardNodes(editor); + setCardMenu(buildCardMenu(cardNodes, {query, config: cardConfig})); + setSelectedItemIndex(0); + }, [editor, query, insert, setCardMenu, setSelectedItemIndex, cardConfig]); + + // attach a resize observer to call setMenuPosition when the window resizes + React.useEffect(() => { + if (!isShowingMenu) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + setMenuPosition(getSelectionElement()); + }); + resizeObserver.observe(window.document.body); + + return () => { + resizeObserver.disconnect(); + }; + }, [isShowingMenu]); + + // use this to position the menu based on the window size + React.useLayoutEffect(() => { + if (!isShowingMenu) { + return; + } + + if (!containerRef || !containerRef.current) { + return; + } + + setMenuPosition(getSelectionElement()); + }, [isShowingMenu]); + + if (cardMenu.menu?.size === 0) { + return null; + } + + if (isShowingMenu) { + return ( +
    + + void} + menu={cardMenu.menu} + scrollToSelectedItem={scrollToSelectedItem} + selectedItemIndex={selectedItemIndex} + /> + +
    + ); + } + + return null; +} + +export default function SlashCardMenuPlugin() { + const [editor] = useLexicalComposerContext(); + return useSlashCardMenu(editor); +} diff --git a/packages/koenig-lexical/src/plugins/TKCountPlugin.jsx b/packages/koenig-lexical/src/plugins/TKCountPlugin.jsx deleted file mode 100644 index fb21ebd07a..0000000000 --- a/packages/koenig-lexical/src/plugins/TKCountPlugin.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import {useTKContext} from '../context/TKContext'; - -export default function TKCountPlugin({onChange}) { - const {tkCount} = useTKContext(); - - React.useEffect(() => { - if (!onChange) { - return; - } - - onChange(tkCount); - }, [onChange, tkCount]); -} diff --git a/packages/koenig-lexical/src/plugins/TKCountPlugin.tsx b/packages/koenig-lexical/src/plugins/TKCountPlugin.tsx new file mode 100644 index 0000000000..50cdd343e7 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/TKCountPlugin.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import {useTKContext} from '../context/TKContext'; + +export default function TKCountPlugin({onChange}: {onChange?: (count: number) => void}) { + const {tkCount} = useTKContext(); + + React.useEffect(() => { + if (!onChange) { + return; + } + + onChange(tkCount); + }, [onChange, tkCount]); + + return null; +} diff --git a/packages/koenig-lexical/src/plugins/TKPlugin.jsx b/packages/koenig-lexical/src/plugins/TKPlugin.jsx deleted file mode 100644 index 320ad7109f..0000000000 --- a/packages/koenig-lexical/src/plugins/TKPlugin.jsx +++ /dev/null @@ -1,268 +0,0 @@ -import CardContext from '../context/CardContext'; -import {$createTKNode, $isTKNode, ExtendedTextNode, TKNode} from '@tryghost/kg-default-nodes'; -import {$getNodeByKey, $getSelection, $isDecoratorNode, $isRangeSelection, TextNode} from 'lexical'; -import {SELECT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {createPortal} from 'react-dom'; -import {useCallback, useContext, useEffect, useState} from 'react'; -import {useKoenigTextEntity} from '../hooks/useKoenigTextEntity'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {useTKContext} from '../context/TKContext'; - -const REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK|Tk|tk)+[^\p{L}\p{N}\s]*)(.)?/u); -const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); - -function TKIndicator({editor, rootElement, parentKey, nodeKeys}) { - const tkClasses = editor._config.theme.tk?.split(' ') || []; - const tkHighlightClasses = editor._config.theme.tkHighlighted?.split(' ') || []; - - const containingElement = editor.getElementByKey(parentKey); - - // position element relative to the TK Node containing element - const calculatePosition = useCallback(() => { - let top = 0; - let right = -56; - - const rootElementRect = rootElement.getBoundingClientRect(); - - const positioningElement = containingElement.querySelector('[data-kg-card]') || containingElement; - const positioningElementRect = positioningElement.getBoundingClientRect(); - - top = positioningElementRect.top - rootElementRect.top + 4; - - if (positioningElementRect.right > rootElementRect.right) { - right = right - (positioningElementRect.right - rootElementRect.right); - } - - return {top, right}; - }, [rootElement, containingElement]); - - const [position, setPosition] = useState(calculatePosition()); - - // select the TK node when the indicator is clicked, - // cycle selection through associated TK nodes when clicked multiple times - // TODO: may be some competition with the listener for clicking outside the editor since clicking on the indicator sometimes focuses the document body - const onClick = (e) => { - e.preventDefault(); - e.stopPropagation(); - - editor.update(() => { - if ($isDecoratorNode($getNodeByKey(parentKey))) { - editor.dispatchCommand(SELECT_CARD_COMMAND, {cardKey: parentKey}); - return; - } - - let nodeKeyToSelect = nodeKeys[0]; - - // if there is a selection, and it is a TK node, select the next one - const selection = $getSelection(); - if ($isRangeSelection(selection) && $isTKNode(selection.getNodes()[0])) { - const selectedIndex = nodeKeys.indexOf(selection.getNodes()[0].getKey()); - if (selectedIndex === nodeKeys.length - 1) { - nodeKeyToSelect = nodeKeys[0]; - } else { - nodeKeyToSelect = nodeKeys[selectedIndex + 1]; - } - } - - const node = $getNodeByKey(nodeKeyToSelect); - node.select(0, node.getTextContentSize()); - }); - }; - - const toggleHighlightClasses = (isHighlighted) => { - let isCard; - - editor.getEditorState().read(() => { - if ($isDecoratorNode($getNodeByKey(parentKey))) { - isCard = true; - } - }); - - if (isCard) { - return; - } - - nodeKeys.forEach((key) => { - if (isHighlighted) { - editor.getElementByKey(key).classList.remove(...tkClasses); - editor.getElementByKey(key).classList.add(...tkHighlightClasses); - } else { - editor.getElementByKey(key).classList.add(...tkClasses); - editor.getElementByKey(key).classList.remove(...tkHighlightClasses); - } - }); - }; - - // highlight all associated TK nodes when the indicator is hovered - const onMouseEnter = (e) => { - toggleHighlightClasses(true); - }; - - const onMouseLeave = (e) => { - toggleHighlightClasses(false); - }; - - // set up an observer to reposition the indicator when the TK node containing - // element moves relative to the root element - useEffect(() => { - const observer = new ResizeObserver(() => (setPosition(calculatePosition()))); - - observer.observe(rootElement); - observer.observe(containingElement); - - return () => { - observer.disconnect(); - }; - }, [rootElement, containingElement, calculatePosition]); - - const style = { - top: `${position.top}px`, - right: `${position.right}px` - }; - - return ( -
    TK
    - ); -} - -export default function TKPlugin() { - const [editor] = useLexicalComposerContext(); - const {tkNodeMap, addEditorTkNode, removeEditorTkNode, removeEditor} = useTKContext(); - const {nodeKey: parentEditorNodeKey} = useContext(CardContext); - - useEffect(() => { - if (!editor.hasNodes([TKNode])) { - throw new Error('TKPlugin: TKNode not registered on editor'); - } - - // clean up editor when it is destroyed - ensures counts are up to date - // when a nested-editor-containing card is deleted - return () => { - removeEditor(editor.getKey()); - }; - }, [editor, removeEditor]); - - useEffect(() => { - return editor.registerMutationListener(TKNode, (mutatedNodes) => { - editor.getEditorState().read(() => { - // mutatedNodes is a Map where each key is the NodeKey, and the value is the state of mutation. - for (let [tkNodeKey, mutation] of mutatedNodes) { - if (mutation === 'destroyed') { - removeEditorTkNode(editor.getKey(), tkNodeKey); - } else { - const parentNodeKey = $getNodeByKey(tkNodeKey).getTopLevelElement()?.getKey(); - const topLevelNodeKey = parentEditorNodeKey || parentNodeKey; - addEditorTkNode(editor.getKey(), topLevelNodeKey, tkNodeKey); - } - } - }); - }); - }, [editor, addEditorTkNode, removeEditorTkNode, parentEditorNodeKey]); - - const createTKNode = useCallback((textNode) => { - return $createTKNode(textNode.getTextContent()); - }, []); - - const getTKMatch = useCallback((text) => { - let matchArr = REGEX.exec(text); - - if (matchArr === null) { - return null; - } - - function isValidMatch(match) { - // negative lookbehind isn't supported before Safari 16.4 - // so we capture the preceding char and test it here - if (match[1] && match[1].trim() && WORD_CHAR_REGEX.test(match[1]) && match[2].slice(0, 1) !== '—') { - return false; - } - - // we also check any following char in code to avoid an overly - // complex regex when looking for word-chars following the optional - // trailing symbol char - if (match[4] && match[4].trim() && WORD_CHAR_REGEX.test(match[4]) && match[2].slice(-1) !== '—') { - return false; - } - - return true; - } - - // our regex will match invalid TKs because we can't use negative lookbehind - // so we need to loop through the matches discarding any that are invalid - // and keeping track of the original input so we have correct offsets - // when we find a valid match - let textBeforeMatch = ''; - - while (matchArr !== null && !isValidMatch(matchArr)) { - textBeforeMatch += text.slice(0, matchArr.index + matchArr[0].length - 1); - text = text.slice(matchArr.index + matchArr[0].length - 1); - matchArr = REGEX.exec(text); - } - - if (matchArr === null) { - return null; - } - - const offsetAdjustment = textBeforeMatch.length; - - const startOffset = offsetAdjustment + matchArr.index + matchArr[1].length; - const endOffset = startOffset + matchArr[2].length; - - return { - end: endOffset, - start: startOffset - }; - }, []); - - // TODO: register ExtendedTextNode + replacement on nested editors - const nodeType = editor.hasNode(ExtendedTextNode) ? ExtendedTextNode : TextNode; - - useKoenigTextEntity( - getTKMatch, - TKNode, - createTKNode, - nodeType - ); - - // we only want to render TK indicators for the top level editor - if (parentEditorNodeKey) { - return null; - } - - const editorRoot = editor.getRootElement(); - const editorRootParent = editor.getRootElement()?.parentElement; - - if (!editorRootParent) { - return null; - } - - const TKIndicators = Object.entries(tkNodeMap).map(([parentKey, nodeKeys]) => { - const parentContainer = editor.getElementByKey(parentKey); - - if (!parentContainer) { - return false; - } - - return ( - - ); - }).filter(Boolean); - - return createPortal( - TKIndicators, - editorRootParent - ); -} diff --git a/packages/koenig-lexical/src/plugins/TKPlugin.tsx b/packages/koenig-lexical/src/plugins/TKPlugin.tsx new file mode 100644 index 0000000000..8c95e9a42f --- /dev/null +++ b/packages/koenig-lexical/src/plugins/TKPlugin.tsx @@ -0,0 +1,279 @@ +import CardContext from '../context/CardContext'; +import React from 'react'; +import {$createTKNode, $isTKNode, ExtendedTextNode, TKNode} from '@tryghost/kg-default-nodes'; +import {$getNodeByKey, $getSelection, $isDecoratorNode, $isRangeSelection, TextNode} from 'lexical'; +import {SELECT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {createPortal} from 'react-dom'; +import {useCallback, useContext, useEffect, useState} from 'react'; +import {useKoenigTextEntity} from '../hooks/useKoenigTextEntity'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useTKContext} from '../context/TKContext'; +import type {LexicalEditor} from 'lexical'; + +const REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK|Tk|tk)+[^\p{L}\p{N}\s]*)(.)?/u); +const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); + +function TKIndicator({editor, rootElement, parentKey, nodeKeys}: {editor: LexicalEditor; rootElement: HTMLElement; parentKey: string; nodeKeys: string[]}) { + const tkClasses = editor._config.theme.tk?.split(' ') || []; + const tkHighlightClasses = editor._config.theme.tkHighlighted?.split(' ') || []; + + const containingElement = editor.getElementByKey(parentKey); + + // position element relative to the TK Node containing element + const calculatePosition = useCallback(() => { + let top = 0; + let right = -56; + + const rootElementRect = rootElement.getBoundingClientRect(); + + if (!containingElement) { + return {top, right}; + } + + const positioningElement = containingElement.querySelector('[data-kg-card]') || containingElement; + const positioningElementRect = positioningElement.getBoundingClientRect(); + + top = positioningElementRect.top - rootElementRect.top + 4; + + if (positioningElementRect.right > rootElementRect.right) { + right = right - (positioningElementRect.right - rootElementRect.right); + } + + return {top, right}; + }, [rootElement, containingElement]); + + const [position, setPosition] = useState(calculatePosition()); + + // select the TK node when the indicator is clicked, + // cycle selection through associated TK nodes when clicked multiple times + // TODO: may be some competition with the listener for clicking outside the editor since clicking on the indicator sometimes focuses the document body + const onClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + editor.update(() => { + if ($isDecoratorNode($getNodeByKey(parentKey))) { + editor.dispatchCommand(SELECT_CARD_COMMAND, {cardKey: parentKey}); + return; + } + + let nodeKeyToSelect = nodeKeys[0]; + + // if there is a selection, and it is a TK node, select the next one + const selection = $getSelection(); + if ($isRangeSelection(selection) && $isTKNode(selection.getNodes()[0])) { + const selectedIndex = nodeKeys.indexOf(selection.getNodes()[0].getKey()); + if (selectedIndex === nodeKeys.length - 1) { + nodeKeyToSelect = nodeKeys[0]; + } else { + nodeKeyToSelect = nodeKeys[selectedIndex + 1]; + } + } + + const node = $getNodeByKey(nodeKeyToSelect); + if (!node) {return;} + (node as TextNode).select(0, node.getTextContentSize()); + }); + }; + + const toggleHighlightClasses = (isHighlighted: boolean) => { + let isCard; + + editor.getEditorState().read(() => { + if ($isDecoratorNode($getNodeByKey(parentKey))) { + isCard = true; + } + }); + + if (isCard) { + return; + } + + nodeKeys.forEach((key: string) => { + const element = editor.getElementByKey(key); + if (!element) {return;} + if (isHighlighted) { + element.classList.remove(...tkClasses); + element.classList.add(...tkHighlightClasses); + } else { + element.classList.add(...tkClasses); + element.classList.remove(...tkHighlightClasses); + } + }); + }; + + // highlight all associated TK nodes when the indicator is hovered + const onMouseEnter = (_e: React.MouseEvent) => { + toggleHighlightClasses(true); + }; + + const onMouseLeave = (_e: React.MouseEvent) => { + toggleHighlightClasses(false); + }; + + // set up an observer to reposition the indicator when the TK node containing + // element moves relative to the root element + useEffect(() => { + const observer = new ResizeObserver(() => (setPosition(calculatePosition()))); + + observer.observe(rootElement); + if (containingElement) { + observer.observe(containingElement); + } + + return () => { + observer.disconnect(); + }; + }, [rootElement, containingElement, calculatePosition]); + + const style = { + top: `${position.top}px`, + right: `${position.right}px` + }; + + return ( +
    TK
    + ); +} + +export default function TKPlugin() { + const [editor] = useLexicalComposerContext(); + const {tkNodeMap, addEditorTkNode, removeEditorTkNode, removeEditor} = useTKContext(); + const {nodeKey: parentEditorNodeKey} = useContext(CardContext); + + useEffect(() => { + if (!editor.hasNodes([TKNode])) { + throw new Error('TKPlugin: TKNode not registered on editor'); + } + + // clean up editor when it is destroyed - ensures counts are up to date + // when a nested-editor-containing card is deleted + return () => { + removeEditor(editor.getKey()); + }; + }, [editor, removeEditor]); + + useEffect(() => { + return editor.registerMutationListener(TKNode, (mutatedNodes) => { + editor.getEditorState().read(() => { + // mutatedNodes is a Map where each key is the NodeKey, and the value is the state of mutation. + for (const [tkNodeKey, mutation] of mutatedNodes) { + if (mutation === 'destroyed') { + removeEditorTkNode(editor.getKey(), tkNodeKey); + } else { + const parentNodeKey = $getNodeByKey(tkNodeKey)?.getTopLevelElement()?.getKey(); + const topLevelNodeKey = parentEditorNodeKey || parentNodeKey; + addEditorTkNode(editor.getKey(), topLevelNodeKey, tkNodeKey); + } + } + }); + }); + }, [editor, addEditorTkNode, removeEditorTkNode, parentEditorNodeKey]); + + const createTKNode = useCallback((textNode: TextNode) => { + return $createTKNode(textNode.getTextContent()); + }, []); + + const getTKMatch = useCallback((text: string) => { + let matchArr = REGEX.exec(text); + + if (matchArr === null) { + return null; + } + + function isValidMatch(match: RegExpExecArray) { + // negative lookbehind isn't supported before Safari 16.4 + // so we capture the preceding char and test it here + if (match[1] && match[1].trim() && WORD_CHAR_REGEX.test(match[1]) && match[2].slice(0, 1) !== '—') { + return false; + } + + // we also check any following char in code to avoid an overly + // complex regex when looking for word-chars following the optional + // trailing symbol char + if (match[4] && match[4].trim() && WORD_CHAR_REGEX.test(match[4]) && match[2].slice(-1) !== '—') { + return false; + } + + return true; + } + + // our regex will match invalid TKs because we can't use negative lookbehind + // so we need to loop through the matches discarding any that are invalid + // and keeping track of the original input so we have correct offsets + // when we find a valid match + let textBeforeMatch = ''; + + while (matchArr !== null && !isValidMatch(matchArr)) { + textBeforeMatch += text.slice(0, matchArr.index + matchArr[0].length - 1); + text = text.slice(matchArr.index + matchArr[0].length - 1); + matchArr = REGEX.exec(text); + } + + if (matchArr === null) { + return null; + } + + const offsetAdjustment = textBeforeMatch.length; + + const startOffset = offsetAdjustment + matchArr.index + matchArr[1].length; + const endOffset = startOffset + matchArr[2].length; + + return { + end: endOffset, + start: startOffset + }; + }, []); + + // TODO: register ExtendedTextNode + replacement on nested editors + const nodeType = editor.hasNode(ExtendedTextNode) ? ExtendedTextNode : TextNode; + + useKoenigTextEntity( + getTKMatch, + TKNode, + createTKNode, + nodeType + ); + + // we only want to render TK indicators for the top level editor + if (parentEditorNodeKey) { + return null; + } + + const editorRoot = editor.getRootElement(); + const editorRootParent = editor.getRootElement()?.parentElement; + + if (!editorRootParent) { + return null; + } + + const TKIndicators = (Object.entries(tkNodeMap) as [string, string[]][]).map(([parentKey, nodeKeys]) => { + const parentContainer = editor.getElementByKey(parentKey); + + if (!parentContainer) { + return false; + } + + return ( + + ); + }).filter(Boolean); + + return createPortal( + TKIndicators, + editorRootParent + ); +} diff --git a/packages/koenig-lexical/src/plugins/TogglePlugin.jsx b/packages/koenig-lexical/src/plugins/TogglePlugin.jsx deleted file mode 100644 index 203fd9acd4..0000000000 --- a/packages/koenig-lexical/src/plugins/TogglePlugin.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {$createToggleNode, INSERT_TOGGLE_COMMAND, ToggleNode} from '../nodes/ToggleNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const TogglePlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([ToggleNode])){ - console.error('TogglePlugin: ToggleNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_TOGGLE_COMMAND, - async (dataset) => { - const cardNode = $createToggleNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default TogglePlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/TogglePlugin.tsx b/packages/koenig-lexical/src/plugins/TogglePlugin.tsx new file mode 100644 index 0000000000..9f7b42f99b --- /dev/null +++ b/packages/koenig-lexical/src/plugins/TogglePlugin.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {$createToggleNode, INSERT_TOGGLE_COMMAND, ToggleNode} from '../nodes/ToggleNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const TogglePlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([ToggleNode])){ + console.error('TogglePlugin: ToggleNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_TOGGLE_COMMAND, + (dataset: Record) => { + const cardNode = $createToggleNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default TogglePlugin; \ No newline at end of file diff --git a/packages/koenig-lexical/src/plugins/TransistorPlugin.jsx b/packages/koenig-lexical/src/plugins/TransistorPlugin.jsx deleted file mode 100644 index 665238afba..0000000000 --- a/packages/koenig-lexical/src/plugins/TransistorPlugin.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import {$createTransistorNode, INSERT_TRANSISTOR_COMMAND, TransistorNode} from '../nodes/TransistorNode'; -import {COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const TransistorPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([TransistorNode])) { - throw new Error('TransistorPlugin: TransistorNode not registered'); - } - return mergeRegister( - editor.registerCommand( - INSERT_TRANSISTOR_COMMAND, - async (dataset) => { - const cardNode = $createTransistorNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); - - return true; - }, - COMMAND_PRIORITY_LOW - ) - ); - }, [editor]); - - return null; -}; - -export default TransistorPlugin; diff --git a/packages/koenig-lexical/src/plugins/TransistorPlugin.tsx b/packages/koenig-lexical/src/plugins/TransistorPlugin.tsx new file mode 100644 index 0000000000..276d26fc12 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/TransistorPlugin.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {$createTransistorNode, INSERT_TRANSISTOR_COMMAND, TransistorNode} from '../nodes/TransistorNode'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; + +export const TransistorPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([TransistorNode])) { + throw new Error('TransistorPlugin: TransistorNode not registered'); + } + return mergeRegister( + editor.registerCommand( + INSERT_TRANSISTOR_COMMAND, + (dataset: Record) => { + const cardNode = $createTransistorNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode, openInEditMode: true}); + + return true; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +}; + +export default TransistorPlugin; diff --git a/packages/koenig-lexical/src/plugins/VideoPlugin.jsx b/packages/koenig-lexical/src/plugins/VideoPlugin.jsx deleted file mode 100644 index e0d6ad0939..0000000000 --- a/packages/koenig-lexical/src/plugins/VideoPlugin.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import {$createVideoNode, INSERT_VIDEO_COMMAND, VideoNode} from '../nodes/VideoNode'; -import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; -import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; -import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; - -export const VideoPlugin = () => { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - if (!editor.hasNodes([VideoNode])){ - console.error('VideoPlugin: VideoNode not registered'); - return; - } - return mergeRegister( - editor.registerCommand( - INSERT_VIDEO_COMMAND, - async (dataset) => { - const cardNode = $createVideoNode(dataset); - editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); - - return true; - }, - COMMAND_PRIORITY_LOW - ), - editor.registerCommand( - INSERT_MEDIA_COMMAND, - async (dataset) => { - if (dataset.type === 'video') { - editor.dispatchCommand(INSERT_VIDEO_COMMAND, {initialFile: dataset.file}); - return true; - } - return false; - }, - COMMAND_PRIORITY_HIGH - ) - ); - }, [editor]); - - return null; -}; - -export default VideoPlugin; diff --git a/packages/koenig-lexical/src/plugins/VideoPlugin.tsx b/packages/koenig-lexical/src/plugins/VideoPlugin.tsx new file mode 100644 index 0000000000..07d3219b3e --- /dev/null +++ b/packages/koenig-lexical/src/plugins/VideoPlugin.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import {$createVideoNode, INSERT_VIDEO_COMMAND, VideoNode} from '../nodes/VideoNode'; +import {COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW} from 'lexical'; +import {INSERT_CARD_COMMAND} from './KoenigBehaviourPlugin'; +import {INSERT_MEDIA_COMMAND} from './DragDropPastePlugin'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import type {ProcessedMedia} from './DragDropPastePlugin'; +import type {VideoNodeData} from '../nodes/VideoNode'; + +export const VideoPlugin = () => { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + if (!editor.hasNodes([VideoNode])){ + console.error('VideoPlugin: VideoNode not registered'); + return; + } + return mergeRegister( + editor.registerCommand( + INSERT_VIDEO_COMMAND, + (dataset: VideoNodeData) => { + const cardNode = $createVideoNode(dataset); + editor.dispatchCommand(INSERT_CARD_COMMAND, {cardNode}); + + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + INSERT_MEDIA_COMMAND, + (dataset: ProcessedMedia) => { + if (dataset.type === 'video') { + editor.dispatchCommand(INSERT_VIDEO_COMMAND, {initialFile: dataset.file}); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [editor]); + + return null; +}; + +export default VideoPlugin; diff --git a/packages/koenig-lexical/src/plugins/WordCountPlugin.jsx b/packages/koenig-lexical/src/plugins/WordCountPlugin.jsx deleted file mode 100644 index 8d2d80c8cd..0000000000 --- a/packages/koenig-lexical/src/plugins/WordCountPlugin.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import KoenigComposerContext from '../context/KoenigComposerContext'; -import React from 'react'; -import throttle from 'lodash/throttle'; -import {$getRoot, $isElementNode} from 'lexical'; -import {mergeRegister} from '@lexical/utils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {utils} from '@tryghost/helpers'; - -const {countWords} = utils; - -// TODO: language is not currently used but in future we should switch to using -// Intl.Segmenter to get more accurate word counts for non-latin languages. For -// now we're using Ghost's existing countWords util which is regex based -export const WordCountPlugin = ({onChange, language = 'en'} = {}) => { - const [editor] = useLexicalComposerContext(); - const {onWordCountChangeRef} = React.useContext(KoenigComposerContext); - - React.useLayoutEffect(() => { - if (!onChange) { - return; - } - - // store onChange in context so that we can use it in the KoenigNestedComposer - // to render nested without needing to pass onChange down - if (!editor._parentEditor) { - onWordCountChangeRef.current = onChange; - } - - let lastWordCount = 0; - - const countEditorWords = () => { - let wordCount = 0; - let topLevelEditor = editor; - - while (topLevelEditor._parentEditor) { - topLevelEditor = topLevelEditor._parentEditor; - } - - topLevelEditor.getEditorState().read(() => { - // NOTE: we can't use RootNode.getTextContent() here because it will - // return cached text content when there are no dirty nodes which is - // the case for changes in nested editors - - const rootNode = $getRoot(); - - // Borrowing code from ElementNode.getTextContent() to bypass the cache - let textContent = ''; - const children = rootNode.getChildren(); - const childrenLength = children.length; - for (let i = 0; i < childrenLength; i++) { - const child = children[i]; - textContent += child.getTextContent(); - if ( - $isElementNode(child) && - i !== childrenLength - 1 && - !child.isInline() - ) { - textContent += `\n\n`; - } - } - - wordCount = countWords(textContent); - }); - - if (wordCount !== lastWordCount) { - lastWordCount = wordCount; - onChange(wordCount); - } - - // start with zero word count if editor is empty - if (wordCount === 0 && lastWordCount === 0) { - onChange(0); - } - }; - - countEditorWords(); - - const throttledCount = throttle(countEditorWords, 200); - - const cleanupRegister = mergeRegister( - editor.registerUpdateListener(({ - dirtyElements, - dirtyLeaves, - prevEditorState, - tags - }) => { - if ((dirtyElements.size === 0 && dirtyLeaves.size === 0) || tags.has('history-merge') || prevEditorState.isEmpty()) { - return; - } - - throttledCount(); - }) - ); - - return () => { - throttledCount.cancel(); - cleanupRegister(); - - if (!editor._parentEditor) { - onWordCountChangeRef.current = null; - } - }; - }, [editor, onChange, onWordCountChangeRef]); -}; - -export default WordCountPlugin; diff --git a/packages/koenig-lexical/src/plugins/WordCountPlugin.tsx b/packages/koenig-lexical/src/plugins/WordCountPlugin.tsx new file mode 100644 index 0000000000..1c0f0d9e53 --- /dev/null +++ b/packages/koenig-lexical/src/plugins/WordCountPlugin.tsx @@ -0,0 +1,108 @@ +import KoenigComposerContext from '../context/KoenigComposerContext'; +import React from 'react'; +import throttle from 'lodash/throttle'; +import {$getRoot, $isElementNode} from 'lexical'; +import {mergeRegister} from '@lexical/utils'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {utils} from '@tryghost/helpers'; + +const {countWords} = utils; + +// TODO: language is not currently used but in future we should switch to using +// Intl.Segmenter to get more accurate word counts for non-latin languages. For +// now we're using Ghost's existing countWords util which is regex based +export const WordCountPlugin = ({onChange, language: _language = 'en'}: {onChange?: (count: number) => void; language?: string} = {}) => { + const [editor] = useLexicalComposerContext(); + const {onWordCountChangeRef} = React.useContext(KoenigComposerContext); + + React.useLayoutEffect(() => { + if (!onChange) { + return; + } + + // store onChange in context so that we can use it in the KoenigNestedComposer + // to render nested without needing to pass onChange down + if (!editor._parentEditor) { + (onWordCountChangeRef as React.MutableRefObject<((counts: unknown) => void) | null>).current = onChange as (counts: unknown) => void; + } + + let lastWordCount = 0; + + const countEditorWords = () => { + let wordCount = 0; + let topLevelEditor = editor; + + while (topLevelEditor._parentEditor) { + topLevelEditor = topLevelEditor._parentEditor; + } + + topLevelEditor.getEditorState().read(() => { + // NOTE: we can't use RootNode.getTextContent() here because it will + // return cached text content when there are no dirty nodes which is + // the case for changes in nested editors + + const rootNode = $getRoot(); + + // Borrowing code from ElementNode.getTextContent() to bypass the cache + let textContent = ''; + const children = rootNode.getChildren(); + const childrenLength = children.length; + for (let i = 0; i < childrenLength; i++) { + const child = children[i]; + textContent += child.getTextContent(); + if ( + $isElementNode(child) && + i !== childrenLength - 1 && + !child.isInline() + ) { + textContent += `\n\n`; + } + } + + wordCount = countWords(textContent); + }); + + if (wordCount !== lastWordCount) { + lastWordCount = wordCount; + onChange(wordCount); + } + + // start with zero word count if editor is empty + if (wordCount === 0 && lastWordCount === 0) { + onChange(0); + } + }; + + countEditorWords(); + + const throttledCount = throttle(countEditorWords, 200); + + const cleanupRegister = mergeRegister( + editor.registerUpdateListener(({ + dirtyElements, + dirtyLeaves, + prevEditorState, + tags + }) => { + if ((dirtyElements.size === 0 && dirtyLeaves.size === 0) || tags.has('history-merge') || prevEditorState.isEmpty()) { + return; + } + + throttledCount(); + }) + ); + + return () => { + throttledCount.cancel(); + cleanupRegister(); + + if (!editor._parentEditor) { + (onWordCountChangeRef as React.MutableRefObject<((counts: unknown) => void) | null>).current = null; + } + }; + }, [editor, onChange, onWordCountChangeRef]); + + return null; +}; + +export default WordCountPlugin; diff --git a/packages/koenig-lexical/src/themes/default.js b/packages/koenig-lexical/src/themes/default.ts similarity index 100% rename from packages/koenig-lexical/src/themes/default.js rename to packages/koenig-lexical/src/themes/default.ts diff --git a/packages/koenig-lexical/src/types/GalleryImage.ts b/packages/koenig-lexical/src/types/GalleryImage.ts new file mode 100644 index 0000000000..2b44448c64 --- /dev/null +++ b/packages/koenig-lexical/src/types/GalleryImage.ts @@ -0,0 +1,10 @@ +export interface GalleryImage { + src?: string; + previewSrc?: string; + fileName?: string; + width?: number; + height?: number; + caption?: string; + row?: number; + alt?: string; +} diff --git a/packages/koenig-lexical/src/utils/$getSelectionRangeRect.js b/packages/koenig-lexical/src/utils/$getSelectionRangeRect.js deleted file mode 100644 index 0f6a235077..0000000000 --- a/packages/koenig-lexical/src/utils/$getSelectionRangeRect.js +++ /dev/null @@ -1,32 +0,0 @@ -import {$isRangeSelection} from 'lexical'; -import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; - -export function $getSelectionRangeRect({selection, editor}) { - if (!selection || !$isRangeSelection(selection)) { - return null; - } - - const anchor = selection.anchor; - const focus = selection.focus; - const selectionRange = createDOMRange(editor, anchor.getNode(), selection.anchor.offset, focus.getNode(), selection.focus.offset); - - if (!selectionRange) { - return null; - } - - const selectionRects = createRectsFromDOMRange(editor, selectionRange); - const returnRect = selectionRects[0]; - - // we can get multiple rects if the selection spans multiple lines or has inline nodes like links - if (selectionRects.length > 1) { - // add up the widths of all rects using the first top position - for (let i = 1; i < selectionRects.length; i++) { - const rect = selectionRects[i]; - if (rect.top === returnRect.top) { - returnRect.width += rect.width; - } - } - } - - return returnRect; -} diff --git a/packages/koenig-lexical/src/utils/$getSelectionRangeRect.ts b/packages/koenig-lexical/src/utils/$getSelectionRangeRect.ts new file mode 100644 index 0000000000..449a142d71 --- /dev/null +++ b/packages/koenig-lexical/src/utils/$getSelectionRangeRect.ts @@ -0,0 +1,33 @@ +import {$isRangeSelection} from 'lexical'; +import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; +import type {BaseSelection, LexicalEditor} from 'lexical'; + +export function $getSelectionRangeRect({selection, editor}: {selection: BaseSelection | null; editor: LexicalEditor}): DOMRect | null { + if (!selection || !$isRangeSelection(selection)) { + return null; + } + + const anchor = selection.anchor; + const focus = selection.focus; + const selectionRange = createDOMRange(editor, anchor.getNode(), selection.anchor.offset, focus.getNode(), selection.focus.offset); + + if (!selectionRange) { + return null; + } + + const selectionRects = createRectsFromDOMRange(editor, selectionRange); + const returnRect = selectionRects[0]; + + // we can get multiple rects if the selection spans multiple lines or has inline nodes like links + if (selectionRects.length > 1) { + // add up the widths of all rects using the first top position + for (let i = 1; i < selectionRects.length; i++) { + const rect = selectionRects[i]; + if (rect.top === returnRect.top) { + returnRect.width += rect.width; + } + } + } + + return returnRect; +} diff --git a/packages/koenig-lexical/src/utils/$insertAndSelectNode.js b/packages/koenig-lexical/src/utils/$insertAndSelectNode.js deleted file mode 100644 index 50f3bc5672..0000000000 --- a/packages/koenig-lexical/src/utils/$insertAndSelectNode.js +++ /dev/null @@ -1,28 +0,0 @@ -import { - $createNodeSelection, - $createParagraphNode, - $isParagraphNode, - $setSelection -} from 'lexical'; - -export const $insertAndSelectNode = ({selectedNode, newNode}) => { - const selectedIsParagraph = $isParagraphNode(selectedNode); - const selectedIsEmpty = selectedNode.getTextContent() === ''; - - selectedNode - .insertAfter(newNode); - - if (selectedIsParagraph && selectedIsEmpty) { - selectedNode.remove(); - } - - const nodeSelection = $createNodeSelection(); - nodeSelection.add(newNode.getKey()); - $setSelection(nodeSelection); - - // always follow the inserted card with a blank paragraph when inserting at end of document - if (!newNode.getNextSibling()) { - const paragraph = $createParagraphNode(); - newNode.insertAfter(paragraph); - } -}; diff --git a/packages/koenig-lexical/src/utils/$insertAndSelectNode.ts b/packages/koenig-lexical/src/utils/$insertAndSelectNode.ts new file mode 100644 index 0000000000..65e26a07b6 --- /dev/null +++ b/packages/koenig-lexical/src/utils/$insertAndSelectNode.ts @@ -0,0 +1,29 @@ +import { + $createNodeSelection, + $createParagraphNode, + $isParagraphNode, + $setSelection +} from 'lexical'; +import type {LexicalNode} from 'lexical'; + +export const $insertAndSelectNode = ({selectedNode, newNode}: {selectedNode: LexicalNode; newNode: LexicalNode}) => { + const selectedIsParagraph = $isParagraphNode(selectedNode); + const selectedIsEmpty = selectedNode.getTextContent() === ''; + + selectedNode + .insertAfter(newNode); + + if (selectedIsParagraph && selectedIsEmpty) { + selectedNode.remove(); + } + + const nodeSelection = $createNodeSelection(); + nodeSelection.add(newNode.getKey()); + $setSelection(nodeSelection); + + // always follow the inserted card with a blank paragraph when inserting at end of document + if (!newNode.getNextSibling()) { + const paragraph = $createParagraphNode(); + newNode.insertAfter(paragraph); + } +}; diff --git a/packages/koenig-lexical/src/utils/$isAtStartOfDocument.js b/packages/koenig-lexical/src/utils/$isAtStartOfDocument.js deleted file mode 100644 index a0ca6fb90c..0000000000 --- a/packages/koenig-lexical/src/utils/$isAtStartOfDocument.js +++ /dev/null @@ -1,25 +0,0 @@ -import {$isListItemNode} from '@lexical/list'; -import {$isTextNode} from 'lexical'; - -export function $isAtStartOfDocument(selection) { - let [selectedNode] = selection.getNodes(); - - if ($isTextNode(selectedNode)) { - selectedNode = selectedNode.getParent(); - } - - let selectedTopLevelElement = selectedNode.getTopLevelElement(); - - // handle nested lists, where parent for a text node is not enough - if ($isListItemNode(selectedNode) && selectedTopLevelElement !== selectedNode.getParent()) { - return false; - } - - const selectedIndex = selectedNode.getIndexWithinParent(); - const selectedTopLevelIndex = selectedTopLevelElement ? selectedTopLevelElement.getIndexWithinParent() : undefined; - - return selectedIndex === 0 - && selectedTopLevelIndex === 0 - && selection.anchor.offset === 0 - && selection.focus.offset === 0; -} diff --git a/packages/koenig-lexical/src/utils/$isAtStartOfDocument.ts b/packages/koenig-lexical/src/utils/$isAtStartOfDocument.ts new file mode 100644 index 0000000000..7fcb619d13 --- /dev/null +++ b/packages/koenig-lexical/src/utils/$isAtStartOfDocument.ts @@ -0,0 +1,26 @@ +import {$isListItemNode} from '@lexical/list'; +import {$isTextNode} from 'lexical'; +import type {RangeSelection} from 'lexical'; + +export function $isAtStartOfDocument(selection: RangeSelection): boolean { + let [selectedNode] = selection.getNodes(); + + if ($isTextNode(selectedNode)) { + selectedNode = selectedNode.getParent()!; + } + + const selectedTopLevelElement = selectedNode.getTopLevelElement(); + + // handle nested lists, where parent for a text node is not enough + if ($isListItemNode(selectedNode) && selectedTopLevelElement !== selectedNode.getParent()) { + return false; + } + + const selectedIndex = selectedNode.getIndexWithinParent(); + const selectedTopLevelIndex = selectedTopLevelElement ? selectedTopLevelElement.getIndexWithinParent() : undefined; + + return selectedIndex === 0 + && selectedTopLevelIndex === 0 + && selection.anchor.offset === 0 + && selection.focus.offset === 0; +} diff --git a/packages/koenig-lexical/src/utils/$isAtTopOfNode.js b/packages/koenig-lexical/src/utils/$isAtTopOfNode.js deleted file mode 100644 index d9c18942ba..0000000000 --- a/packages/koenig-lexical/src/utils/$isAtTopOfNode.js +++ /dev/null @@ -1,23 +0,0 @@ -import {getTopLevelNativeElement} from './getTopLevelNativeElement'; - -/** - * - * @param {Selection} nativeSelection – Window selection (window.getSelection()) - * @param {number} [threshold=10] – Estimated height of one line, in pixels - * @returns {boolean} - */ -export function $isAtTopOfNode(nativeSelection, threshold = 10) { - const range = nativeSelection.getRangeAt(0).cloneRange(); - const rects = range.getClientRects(); - - if (rects.length > 0) { - // try second rect first because when the caret is at the beginning - // of a line the first rect will be positioned on line above breaking - // the top position check - const rangeRect = rects[1] || rects[0]; - const nativeTopLevelElement = getTopLevelNativeElement(nativeSelection.anchorNode); - const elemRect = nativeTopLevelElement.getBoundingClientRect(); - - return Math.abs(rangeRect.top - elemRect.top) <= threshold; - } -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/utils/$isAtTopOfNode.ts b/packages/koenig-lexical/src/utils/$isAtTopOfNode.ts new file mode 100644 index 0000000000..113996bce4 --- /dev/null +++ b/packages/koenig-lexical/src/utils/$isAtTopOfNode.ts @@ -0,0 +1,23 @@ +import {getTopLevelNativeElement} from './getTopLevelNativeElement'; + +/** + * + * @param nativeSelection - Window selection (window.getSelection()) + * @param threshold - Estimated height of one line, in pixels + * @returns boolean + */ +export function $isAtTopOfNode(nativeSelection: Selection, threshold = 10): boolean | undefined { + const range = nativeSelection.getRangeAt(0).cloneRange(); + const rects = range.getClientRects(); + + if (rects.length > 0) { + // try second rect first because when the caret is at the beginning + // of a line the first rect will be positioned on line above breaking + // the top position check + const rangeRect = rects[1] || rects[0]; + const nativeTopLevelElement = getTopLevelNativeElement(nativeSelection.anchorNode!); + const elemRect = nativeTopLevelElement!.getBoundingClientRect(); + + return Math.abs(rangeRect.top - elemRect.top) <= threshold; + } +} diff --git a/packages/koenig-lexical/src/utils/$selectDecoratorNode.js b/packages/koenig-lexical/src/utils/$selectDecoratorNode.js deleted file mode 100644 index 1185d6bf85..0000000000 --- a/packages/koenig-lexical/src/utils/$selectDecoratorNode.js +++ /dev/null @@ -1,10 +0,0 @@ -import { - $createNodeSelection, - $setSelection -} from 'lexical'; - -export function $selectDecoratorNode(node) { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(node.getKey()); - $setSelection(nodeSelection); -} diff --git a/packages/koenig-lexical/src/utils/$selectDecoratorNode.ts b/packages/koenig-lexical/src/utils/$selectDecoratorNode.ts new file mode 100644 index 0000000000..1ad454d368 --- /dev/null +++ b/packages/koenig-lexical/src/utils/$selectDecoratorNode.ts @@ -0,0 +1,11 @@ +import { + $createNodeSelection, + $setSelection +} from 'lexical'; +import type {LexicalNode} from 'lexical'; + +export function $selectDecoratorNode(node: LexicalNode): void { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} diff --git a/packages/koenig-lexical/src/utils/analytics.js b/packages/koenig-lexical/src/utils/analytics.js deleted file mode 100644 index 0bda6f17de..0000000000 --- a/packages/koenig-lexical/src/utils/analytics.js +++ /dev/null @@ -1,11 +0,0 @@ -// Wrapper function for Plausible event - -export default function trackEvent(eventName, props = {}) { - window.plausible = window.plausible || function () { - (window.plausible.q = window.plausible.q || []).push(arguments); - }; - window.plausible(eventName, {props: props}); - if (window.posthog) { - window.posthog.capture(eventName, props); - } -} diff --git a/packages/koenig-lexical/src/utils/analytics.ts b/packages/koenig-lexical/src/utils/analytics.ts new file mode 100644 index 0000000000..4e9cf0a862 --- /dev/null +++ b/packages/koenig-lexical/src/utils/analytics.ts @@ -0,0 +1,23 @@ +// Wrapper function for Plausible event + +declare global { + interface Window { + plausible?: { + (eventName: string, options: { props: Record }): void; + q?: unknown[][]; + }; + posthog?: { + capture: (eventName: string, props: Record) => void; + }; + } +} + +export default function trackEvent(eventName: string, props: Record = {}) { + window.plausible = window.plausible || function (...args: unknown[]) { + (window.plausible!.q = window.plausible!.q || []).push(args); + }; + window.plausible(eventName, {props: props}); + if (window.posthog) { + window.posthog.capture(eventName, props); + } +} diff --git a/packages/koenig-lexical/src/utils/audioUploadHandler.js b/packages/koenig-lexical/src/utils/audioUploadHandler.js deleted file mode 100644 index 2df85c420d..0000000000 --- a/packages/koenig-lexical/src/utils/audioUploadHandler.js +++ /dev/null @@ -1,36 +0,0 @@ -import prettifyFileName from './prettifyFileName'; -import {$getNodeByKey} from 'lexical'; -import {getAudioMetadata} from './getAudioMetadata'; - -export const audioUploadHandler = async (files, nodeKey, editor, upload) => { - if (!files) { - return; - } - - // perform the actual upload - const result = await upload(files); - const fileSrc = result?.[0].url; - - if (!fileSrc) { - return; - } - - // grab basic metadata from the file directly - const filename = files[0].name; - const title = prettifyFileName(filename); - - // read file into an object URL so we can grab extra metadata - const objectURL = URL.createObjectURL(files[0]); - const mimeType = files[0].type; - const {duration} = await getAudioMetadata(objectURL); - - await editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.duration = duration; - node.src = fileSrc; - node.mimeType = mimeType; - node.title = title; - }); - - return; -}; diff --git a/packages/koenig-lexical/src/utils/audioUploadHandler.ts b/packages/koenig-lexical/src/utils/audioUploadHandler.ts new file mode 100644 index 0000000000..3241200a74 --- /dev/null +++ b/packages/koenig-lexical/src/utils/audioUploadHandler.ts @@ -0,0 +1,39 @@ +import prettifyFileName from './prettifyFileName'; +import {$getNodeByKey, LexicalEditor} from 'lexical'; +import {getAudioMetadata} from './getAudioMetadata'; +import type {GeneratedDecoratorNodeBase} from '@tryghost/kg-default-nodes'; + +export const audioUploadHandler = async (files: File[], nodeKey: string, editor: LexicalEditor, upload: (files: File[]) => Promise<{url: string}[] | null>) => { + if (!files) { + return; + } + + // perform the actual upload + const result = await upload(files); + const fileSrc = result?.[0].url; + + if (!fileSrc) { + return; + } + + // grab basic metadata from the file directly + const filename = files[0].name; + const title = prettifyFileName(filename); + + // read file into an object URL so we can grab extra metadata + const objectURL = URL.createObjectURL(files[0]); + const mimeType = files[0].type; + const {duration} = await getAudioMetadata(objectURL); + + await editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + node.duration = duration; + node.src = fileSrc; + node.mimeType = mimeType; + node.title = title; + } + }); + + return; +}; diff --git a/packages/koenig-lexical/src/utils/autoExpandTextArea.js b/packages/koenig-lexical/src/utils/autoExpandTextArea.js deleted file mode 100644 index 5872a6c70a..0000000000 --- a/packages/koenig-lexical/src/utils/autoExpandTextArea.js +++ /dev/null @@ -1,14 +0,0 @@ -import {useEffect} from 'react'; - -const useAutoExpandTextArea = ({el, value}) => { - useEffect(() => { - const element = el.current; - if (element) { - element.style.height = '0px'; - const height = element.scrollHeight; - element.style.height = `${height}px`; - } - }, [el, value]); -}; - -export default useAutoExpandTextArea; diff --git a/packages/koenig-lexical/src/utils/autoExpandTextArea.ts b/packages/koenig-lexical/src/utils/autoExpandTextArea.ts new file mode 100644 index 0000000000..b2363b7b92 --- /dev/null +++ b/packages/koenig-lexical/src/utils/autoExpandTextArea.ts @@ -0,0 +1,14 @@ +import {useEffect} from 'react'; + +const useAutoExpandTextArea = ({el, value}: {el: React.RefObject; value: string}): void => { + useEffect(() => { + const element = el.current; + if (element) { + element.style.height = '0px'; + const height = element.scrollHeight; + element.style.height = `${height}px`; + } + }, [el, value]); +}; + +export default useAutoExpandTextArea; diff --git a/packages/koenig-lexical/src/utils/buildCardMenu.js b/packages/koenig-lexical/src/utils/buildCardMenu.js deleted file mode 100644 index be1d99c55d..0000000000 --- a/packages/koenig-lexical/src/utils/buildCardMenu.js +++ /dev/null @@ -1,99 +0,0 @@ -import SnippetCardIcon from '../assets/icons/kg-card-type-snippet.svg?react'; -import {INSERT_SNIPPET_COMMAND} from '../plugins/KoenigSnippetPlugin'; - -export function buildCardMenu(nodes, {query, config} = {}) { - let menu = new Map(); - - query = query?.toLowerCase(); - - let maxItemIndex = -1; - - function addMenuItem(item) { - // items hidden based on missing config (e.g. GIF provider API key) - if (!!item.isHidden && item.isHidden?.({config})) { - return; - } - - // items restricted for posts vs. pages (e.g. email CTA card) - if (item.postType && config?.post?.displayName && item.postType !== config?.post?.displayName) { - return; - } - - const matches = typeof item?.matches === 'function' - ? item?.matches?.(query, item.label) - : item?.matches?.find?.(m => m.startsWith(query)); - - if (query && !matches) { - return; - } - - if (typeof item.insertParams === 'function') { - item.insertParams = item.insertParams({config}); - } - - const section = item.section || 'Primary'; - - if (!menu.has(section)) { - menu.set(section, [item]); - } else { - menu.get(section).push(item); - } - - maxItemIndex = maxItemIndex + 1; - } - - for (const [nodeType, node] of nodes) { - if (Array.isArray(node.kgMenu)) { - node.kgMenu.forEach(item => addMenuItem({nodeType, ...item})); - } else { - addMenuItem({nodeType, ...node.kgMenu}); - } - } - - config?.snippets?.forEach((item) => { - const snippetMenuItem = buildSnippetMenuItem(item, config); - addMenuItem(snippetMenuItem); - }); - - // sort each menu section by priority - menu = new Map([...menu.entries()].map(([section, items]) => { - return [section, items.sort((a, b) => { - if (a.priority === b.priority) { - return 0; - } else if (a.priority === undefined) { - return 1; - } else if (b.priority === undefined) { - return -1; - } else { - return a.priority - b.priority; - } - })]; - })); - - // sort primary section to always display first - menu = new Map([...menu.entries()].sort((a, b) => { - if (a[0] === 'Primary') { - return -1; - } else { - return 1; - } - })); - - return {menu, maxItemIndex}; -} - -function buildSnippetMenuItem(data, config) { - const name = data.name.toLowerCase(); - const snippet = { - type: 'snippet', - label: data.name, - Icon: SnippetCardIcon, - section: 'Snippets', - matches: query => name.indexOf(query) > -1 || 'snippets'.indexOf(query) > -1, - insertCommand: INSERT_SNIPPET_COMMAND, - insertParams: data, - ...(config.deleteSnippet && {onRemove: () => config.deleteSnippet(data)}) - }; - - return snippet; -} diff --git a/packages/koenig-lexical/src/utils/buildCardMenu.ts b/packages/koenig-lexical/src/utils/buildCardMenu.ts new file mode 100644 index 0000000000..6b29f25f56 --- /dev/null +++ b/packages/koenig-lexical/src/utils/buildCardMenu.ts @@ -0,0 +1,135 @@ +import SnippetCardIcon from '../assets/icons/kg-card-type-snippet.svg?react'; +import {INSERT_SNIPPET_COMMAND} from '../plugins/KoenigSnippetPlugin'; +import type React from 'react'; + +export interface CardMenuItem { + nodeType?: string; + type?: string; + label: string; + Icon: React.ComponentType>; + desc?: string; + shortcut?: string; + section?: string; + matches?: ((query: string, label?: string) => boolean) | string[]; + isHidden?: (context: {config: unknown}) => boolean; + postType?: string; + insertParams?: unknown; + insertCommand?: unknown; + queryParams?: unknown; + priority?: number; + onRemove?: () => void; + [key: string]: unknown; +} + +interface CardMenuSnippet { + name: string; + value?: string; +} + +interface CardMenuConfig { + post?: {displayName?: string}; + snippets?: CardMenuSnippet[]; + deleteSnippet?: (data: {name: string}) => void; + [key: string]: unknown; +} + +interface CardMenuNode { + kgMenu: CardMenuItem | CardMenuItem[]; +} + +export function buildCardMenu(nodes: Array<[string, CardMenuNode]>, {query, config}: {query?: string; config?: CardMenuConfig} = {}) { + let menu = new Map(); + + query = query?.toLowerCase(); + + let maxItemIndex = -1; + + function addMenuItem(item: CardMenuItem) { + // items hidden based on missing config (e.g. GIF provider API key) + if (!!item.isHidden && item.isHidden?.({config})) { + return; + } + + // items restricted for posts vs. pages (e.g. email CTA card) + if (item.postType && config?.post?.displayName && item.postType !== config?.post?.displayName) { + return; + } + + const matches = typeof item?.matches === 'function' + ? item?.matches?.(query!, item.label) + : (item?.matches as string[] | undefined)?.find?.((m: string) => m.startsWith(query!)); + + if (query && !matches) { + return; + } + + if (typeof item.insertParams === 'function') { + item.insertParams = (item.insertParams as (ctx: {config: unknown}) => unknown)({config}); + } + + const section = item.section || 'Primary'; + + if (!menu.has(section)) { + menu.set(section, [item]); + } else { + menu.get(section)!.push(item); + } + + maxItemIndex = maxItemIndex + 1; + } + + for (const [nodeType, node] of nodes) { + if (Array.isArray(node.kgMenu)) { + node.kgMenu.forEach((item: CardMenuItem) => addMenuItem({nodeType, ...item})); + } else { + addMenuItem({nodeType, ...node.kgMenu}); + } + } + + config?.snippets?.forEach((item) => { + const snippetMenuItem = buildSnippetMenuItem(item, config); + addMenuItem(snippetMenuItem); + }); + + // sort each menu section by priority + menu = new Map([...menu.entries()].map(([section, items]) => { + return [section, items.sort((a: CardMenuItem, b: CardMenuItem) => { + if (a.priority === b.priority) { + return 0; + } else if (a.priority === undefined) { + return 1; + } else if (b.priority === undefined) { + return -1; + } else { + return a.priority - b.priority; + } + })]; + })); + + // sort primary section to always display first + menu = new Map([...menu.entries()].sort((a, _b) => { + if (a[0] === 'Primary') { + return -1; + } else { + return 1; + } + })); + + return {menu, maxItemIndex}; +} + +function buildSnippetMenuItem(data: CardMenuSnippet, config: CardMenuConfig): CardMenuItem { + const name = data.name.toLowerCase(); + const snippet: CardMenuItem = { + type: 'snippet', + label: data.name, + Icon: SnippetCardIcon, + section: 'Snippets', + matches: (query: string) => name.indexOf(query) > -1 || 'snippets'.indexOf(query) > -1, + insertCommand: INSERT_SNIPPET_COMMAND, + insertParams: data, + ...(config.deleteSnippet && {onRemove: () => config.deleteSnippet!(data)}) + }; + + return snippet; +} diff --git a/packages/koenig-lexical/src/utils/callToActionColors.js b/packages/koenig-lexical/src/utils/callToActionColors.ts similarity index 100% rename from packages/koenig-lexical/src/utils/callToActionColors.js rename to packages/koenig-lexical/src/utils/callToActionColors.ts diff --git a/packages/koenig-lexical/src/utils/codemirror-config.js b/packages/koenig-lexical/src/utils/codemirror-config.ts similarity index 100% rename from packages/koenig-lexical/src/utils/codemirror-config.js rename to packages/koenig-lexical/src/utils/codemirror-config.ts diff --git a/packages/koenig-lexical/src/utils/constants.js b/packages/koenig-lexical/src/utils/constants.ts similarity index 100% rename from packages/koenig-lexical/src/utils/constants.js rename to packages/koenig-lexical/src/utils/constants.ts diff --git a/packages/koenig-lexical/src/utils/createFileInputChangeEvent.ts b/packages/koenig-lexical/src/utils/createFileInputChangeEvent.ts new file mode 100644 index 0000000000..d07db9a3b0 --- /dev/null +++ b/packages/koenig-lexical/src/utils/createFileInputChangeEvent.ts @@ -0,0 +1,30 @@ +import type {ChangeEvent} from 'react'; + +function getFileName(blob: Blob, fileName: string) { + const extension = blob.type.split('/')[1]?.split('+')[0]; + + if (!extension || fileName.includes('.')) { + return fileName; + } + + return `${fileName}.${extension === 'jpeg' ? 'jpg' : extension}`; +} + +function createFileList(files: File[]) { + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + return dataTransfer.files; +} + +export function createFileInputChangeEventFromBlob(blob: Blob, fileName = 'edited-image'): ChangeEvent { + const input = document.createElement('input'); + input.type = 'file'; + input.files = createFileList([ + blob instanceof File ? blob : new File([blob], getFileName(blob, fileName), {type: blob.type}) + ]); + + return { + target: input, + currentTarget: input + } as ChangeEvent; +} diff --git a/packages/koenig-lexical/src/utils/ctrlOrCmd.js b/packages/koenig-lexical/src/utils/ctrlOrCmd.ts similarity index 100% rename from packages/koenig-lexical/src/utils/ctrlOrCmd.js rename to packages/koenig-lexical/src/utils/ctrlOrCmd.ts diff --git a/packages/koenig-lexical/src/utils/dataSrcToFile.js b/packages/koenig-lexical/src/utils/dataSrcToFile.js deleted file mode 100644 index 351b6bed51..0000000000 --- a/packages/koenig-lexical/src/utils/dataSrcToFile.js +++ /dev/null @@ -1,23 +0,0 @@ -export async function dataSrcToFile(src, fileName) { - if (!src.startsWith('data:')) { - return; - } - - const mimeType = src.split(',')[0].split(':')[1].split(';')[0]; - - if (!fileName) { - let uuid; - try { - uuid = window.crypto.randomUUID(); - } catch (e) { - uuid = Math.random().toString(36).substring(2, 15); - } - const extension = mimeType.split('/')[1]; - fileName = `data-src-image-${uuid}.${extension}`; - } - - const blob = await fetch(src).then(it => it.blob()); - const file = new File([blob], fileName, {type: mimeType, lastModified: new Date()}); - - return file; -} diff --git a/packages/koenig-lexical/src/utils/dataSrcToFile.ts b/packages/koenig-lexical/src/utils/dataSrcToFile.ts new file mode 100644 index 0000000000..3b387a3da5 --- /dev/null +++ b/packages/koenig-lexical/src/utils/dataSrcToFile.ts @@ -0,0 +1,31 @@ +export async function dataSrcToFile(src: string, fileName?: string): Promise { + if (!src.startsWith('data:')) { + return; + } + + const mimeType = src.split(',')[0].split(':')[1].split(';')[0]; + + if (!fileName) { + const uuid = getRandomFileId(); + const extension = mimeType.split('/')[1]; + fileName = `data-src-image-${uuid}.${extension}`; + } + + const blob = await fetch(src).then(it => it.blob()); + const file = new File([blob], fileName, {type: mimeType, lastModified: Date.now()}); + + return file; +} + +function getRandomFileId() { + if (window.crypto.randomUUID) { + return window.crypto.randomUUID(); + } + + const randomBytes = new Uint8Array(16); + window.crypto.getRandomValues(randomBytes); + + return [...randomBytes] + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/packages/koenig-lexical/src/utils/draggable/DragDropContainer.js b/packages/koenig-lexical/src/utils/draggable/DragDropContainer.js deleted file mode 100644 index dec4a782c9..0000000000 --- a/packages/koenig-lexical/src/utils/draggable/DragDropContainer.js +++ /dev/null @@ -1,153 +0,0 @@ -import * as constants from './draggable-constants'; - -// Container represents an element, inside which are draggables and/or droppables. -// -// Containers handle events triggered by the DragDropReorderPlugin. -// Containers can be nested, the DragDropReorderPlugin will select the closest -// parent container in the DOM heirarchy when triggering events. -// -// Containers accept options which are mostly configuration for how to determine -// contained draggable/droppable elements and functions to call when events are -// processed. - -export class DragDropContainer { - element = null; - draggables = []; - droppables = []; - isDragEnabled = true; - - constructor(element, options) { - if (options.createGhostElement) { - this._createGhostElement = options.createGhostElement; - delete options.createGhostElement; - } - - Object.assign(this, { - element, - draggables: [], - droppables: [], - isDragEnabled: true - }, options); - - element.dataset[constants.CONTAINER_DATA_ATTR] = 'true'; - - this.refresh(); - } - - // get the draggable type and any dataset. Types: - // - image - // - card - // - file - // TODO: review types - // TODO: get proper dataset from the gallery component - // should be overridden by passed in option - getDraggableInfo(/*draggableElement*/) { - return false; - } - - // should be overridden by passed in option - getIndicatorPosition(/*draggableInfo, droppableElem, position*/) { - return false; - } - - // override these via constructor options - onDragStart() { } - onDragEnterContainer() { } - onDragEnterDroppable() { } - onDragOverDroppable() { } - onDragLeaveDroppable() { } - onDragLeaveContainer() { } - onDragEnd() { } - onDrop() { } - onDropEnd() { } - - // TODO: allow configuration for ghost element creation - // builds an element that is attached to the mouse pointer when dragging. - // currently grabs the first and uses that but should be configurable: - // - a selector for which element in the draggable to copy - // - a function to hand off element creation to the consumer - createGhostElement(draggableInfo) { - let ghostElement; - - if (typeof this._createGhostElement === 'function') { - ghostElement = this._createGhostElement(draggableInfo); - } - - if (!ghostElement && (draggableInfo.type === 'image' || draggableInfo.cardName === 'image')) { - let image = draggableInfo.element.querySelector('img'); - if (image) { - let aspectRatio = image.width / image.height; - let width, height; - - // max ghost image size is 200px in either dimension - if (image.width > image.height) { - width = 200; - height = 200 / aspectRatio; - } else { - width = 200 * aspectRatio; - height = 200; - } - - ghostElement = document.createElement('img'); - ghostElement.width = width; - ghostElement.height = height; - ghostElement.id = 'koenig-drag-drop-ghost'; - ghostElement.src = image.src; - ghostElement.style.position = 'absolute'; - ghostElement.style.top = '0'; - ghostElement.style.left = `-${width}px`; - ghostElement.style.zIndex = constants.GHOST_ZINDEX; - ghostElement.style.willChange = 'transform'; - } else { - - console.warn('No element found in draggable'); - return; - } - } - - if (ghostElement) { - return ghostElement; - } - - - console.warn(`No default createGhostElement handler for type "${draggableInfo.type}"`); - } - - enableDrag() { - this.isDragEnabled = true; - this.element.dataset[constants.CONTAINER_DATA_ATTR] = 'true'; - this.refresh(); - } - - disableDrag() { - this.isDragEnabled = false; - delete this.element.dataset[constants.CONTAINER_DATA_ATTR]; - this.refresh(); - } - - // used to add data attributes to any draggable/droppable elements. This is - // for more efficient lookup through DOM by the drag-drop-handler service - refresh() { - // remove all data attributes for currently held draggable/droppable elements - this.draggables.forEach((draggable) => { - delete draggable.dataset[constants.DRAGGABLE_DATA_ATTR]; - }); - this.droppables.forEach((droppable) => { - delete droppable.dataset[constants.DROPPABLE_DATA_ATTR]; - }); - - // re-populate draggable/droppable arrays - this.draggables = []; - this.droppables = []; - if (this.isDragEnabled) { - this.element.querySelectorAll(this.draggableSelector).forEach((draggable) => { - draggable.dataset[constants.DRAGGABLE_DATA_ATTR] = 'true'; - this.draggables.push(draggable); - }); - this.element.querySelectorAll(this.droppableSelector).forEach((droppable) => { - droppable.dataset[constants.DROPPABLE_DATA_ATTR] = 'true'; - this.droppables.push(droppable); - }); - } - } -} diff --git a/packages/koenig-lexical/src/utils/draggable/DragDropContainer.ts b/packages/koenig-lexical/src/utils/draggable/DragDropContainer.ts new file mode 100644 index 0000000000..6cbb3dd545 --- /dev/null +++ b/packages/koenig-lexical/src/utils/draggable/DragDropContainer.ts @@ -0,0 +1,186 @@ +import * as constants from './draggable-constants'; +import type {DraggableInfo, DraggableInfoSeed} from './ScrollHandler'; + +// Container represents an element, inside which are draggables and/or droppables. +// +// Containers handle events triggered by the DragDropReorderPlugin. +// Containers can be nested, the DragDropReorderPlugin will select the closest +// parent container in the DOM heirarchy when triggering events. +// +// Containers accept options which are mostly configuration for how to determine +// contained draggable/droppable elements and functions to call when events are +// processed. + +export interface IndicatorPosition { + direction: 'horizontal' | 'vertical'; + position: string; + beforeElems: HTMLElement[]; + afterElems: HTMLElement[]; + insertIndex: number; +} + +export interface DragDropContainerOptions { + draggableSelector?: string; + droppableSelector?: string; + isDragEnabled?: boolean; + createGhostElement?: (draggableInfo: DraggableInfo) => Node | undefined; + getDraggableInfo?: (draggableElement: HTMLElement) => DraggableInfoSeed | false; + getIndicatorPosition?: (draggableInfo: DraggableInfo, droppableElem: Element, position: string) => IndicatorPosition | false; + onDragStart?: (draggableInfo: DraggableInfo) => void; + onDragEnterContainer?: (draggableInfo: DraggableInfo) => void; + onDragEnterDroppable?: (droppableElem: Element, position: string) => void; + onDragOverDroppable?: (droppableElem: Element, position: string) => void; + onDragLeaveDroppable?: (droppableElem: Element) => void; + onDragLeaveContainer?: (draggableInfo: DraggableInfo) => void; + onDragEnd?: () => void; + onDrop?: (draggableInfo: DraggableInfo, droppableElem: Element | null, position: string | null) => boolean | void; + onDropEnd?: (draggableInfo: DraggableInfo, success: boolean) => void; + [key: string]: unknown; +} + +export class DragDropContainer { + element: HTMLElement; + draggables: HTMLElement[] = []; + droppables: HTMLElement[] = []; + isDragEnabled = true; + draggableSelector!: string; + droppableSelector!: string; + private _createGhostElement?: (draggableInfo: DraggableInfo) => Node | undefined; + + constructor(element: HTMLElement, options: DragDropContainerOptions) { + if (options.createGhostElement) { + this._createGhostElement = options.createGhostElement; + delete options.createGhostElement; + } + + Object.assign(this, { + element, + draggables: [], + droppables: [], + isDragEnabled: true + }, options); + + this.element = element; + element.dataset[constants.CONTAINER_DATA_ATTR] = 'true'; + + this.refresh(); + } + + // get the draggable type and any dataset. Types: + // - image + // - card + // - file + // TODO: review types + // TODO: get proper dataset from the gallery component + // should be overridden by passed in option + getDraggableInfo(_draggableElement?: HTMLElement): DraggableInfoSeed | false { + return false; + } + + // should be overridden by passed in option + getIndicatorPosition(_draggableInfo?: DraggableInfo, _droppableElem?: Element, _position?: string): IndicatorPosition | false { + return false; + } + + // override these via constructor options + onDragStart(_draggableInfo?: DraggableInfo) { } + onDragEnterContainer(_draggableInfo?: DraggableInfo) { } + onDragEnterDroppable(_droppableElem?: Element, _position?: string) { } + onDragOverDroppable(_droppableElem?: Element, _position?: string) { } + onDragLeaveDroppable(_droppableElem?: Element) { } + onDragLeaveContainer(_draggableInfo?: DraggableInfo) { } + onDragEnd() { } + onDrop(_draggableInfo?: DraggableInfo, _droppableElem?: Element | null, _position?: string | null): boolean { return false; } + onDropEnd(_draggableInfo?: DraggableInfo, _success?: boolean) { } + + // TODO: allow configuration for ghost element creation + // builds an element that is attached to the mouse pointer when dragging. + // currently grabs the first and uses that but should be configurable: + // - a selector for which element in the draggable to copy + // - a function to hand off element creation to the consumer + createGhostElement(draggableInfo: DraggableInfo): Node | undefined { + let ghostElement: Node | undefined; + + if (typeof this._createGhostElement === 'function') { + ghostElement = this._createGhostElement(draggableInfo); + } + + if (!ghostElement && (draggableInfo.type === 'image' || draggableInfo.cardName === 'image')) { + const image = draggableInfo.element.querySelector('img'); + if (image) { + const aspectRatio = image.width / image.height; + let width: number, height: number; + + // max ghost image size is 200px in either dimension + if (image.width > image.height) { + width = 200; + height = 200 / aspectRatio; + } else { + width = 200 * aspectRatio; + height = 200; + } + + const imgEl = document.createElement('img'); + imgEl.width = width; + imgEl.height = height; + imgEl.id = 'koenig-drag-drop-ghost'; + imgEl.src = image.src; + imgEl.style.position = 'absolute'; + imgEl.style.top = '0'; + imgEl.style.left = `-${width}px`; + imgEl.style.zIndex = String(constants.GHOST_ZINDEX); + imgEl.style.willChange = 'transform'; + ghostElement = imgEl; + } else { + + console.warn('No element found in draggable'); + return; + } + } + + if (ghostElement) { + return ghostElement; + } + + + console.warn(`No default createGhostElement handler for type "${draggableInfo.type}"`); + } + + enableDrag(): void { + this.isDragEnabled = true; + this.element.dataset[constants.CONTAINER_DATA_ATTR] = 'true'; + this.refresh(); + } + + disableDrag(): void { + this.isDragEnabled = false; + delete this.element.dataset[constants.CONTAINER_DATA_ATTR]; + this.refresh(); + } + + // used to add data attributes to any draggable/droppable elements. This is + // for more efficient lookup through DOM by the drag-drop-handler service + refresh(): void { + // remove all data attributes for currently held draggable/droppable elements + this.draggables.forEach((draggable) => { + delete draggable.dataset[constants.DRAGGABLE_DATA_ATTR]; + }); + this.droppables.forEach((droppable) => { + delete droppable.dataset[constants.DROPPABLE_DATA_ATTR]; + }); + + // re-populate draggable/droppable arrays + this.draggables = []; + this.droppables = []; + if (this.isDragEnabled) { + this.element.querySelectorAll(this.draggableSelector).forEach((draggable) => { + draggable.dataset[constants.DRAGGABLE_DATA_ATTR] = 'true'; + this.draggables.push(draggable); + }); + this.element.querySelectorAll(this.droppableSelector).forEach((droppable) => { + droppable.dataset[constants.DROPPABLE_DATA_ATTR] = 'true'; + this.droppables.push(droppable); + }); + } + } +} diff --git a/packages/koenig-lexical/src/utils/draggable/DragDropHandler.jsx b/packages/koenig-lexical/src/utils/draggable/DragDropHandler.jsx deleted file mode 100644 index f4ee60b4c7..0000000000 --- a/packages/koenig-lexical/src/utils/draggable/DragDropHandler.jsx +++ /dev/null @@ -1,749 +0,0 @@ -import * as constants from './draggable-constants'; -import * as utils from './draggable-utils'; -import EventEmitter from 'eventemitter3'; -import {DragDropContainer} from './DragDropContainer'; -import {ScrollHandler} from './ScrollHandler'; - -export class DragDropHandler { - EE = null; - editorContainerElement = null; - containers = null; - draggableInfo = null; - ghostInfo = null; - grabbedElement = null; - scrollHandler = null; - sourceContainer = null; - - _currentOverContainer = null; - _currentOverContainerElem = null; - _currentOverDroppableElem = null; - _currentOverDroppablePosition = null; - _dropIndicator = null; - _elementsWithHoverRemoved = null; - _eventHandlers = null; - _ghostContainerElement = null; - _rafUpdateGhostElementPosition = null; - _transformedDroppables = null; - _waitForDragStartPromise = null; - - // lifecycle --------------------------------------------------------------- - - constructor({editorContainerElement}) { - this.editorContainerElement = editorContainerElement || document.querySelector('[data-kg-editor] [data-lexical-editor]'); - this.containers = []; - this.scrollHandler = new ScrollHandler(); - this._eventHandlers = []; - this._transformedDroppables = []; - - // bind any raf handler functions - this._rafUpdateGhostElementPosition = this._updateGhostElementPosition.bind(this); - - // set up document event listeners - this._addGrabListeners(); - - // append body elements - this._appendGhostContainerElement(); - - this.EE = new EventEmitter(); - } - - destroy() { - // reset any on-going drag and remove any temporary listeners - this.cleanup(); - - // clean up document event listeners - this._removeGrabListeners(); - - // remove body elements - this._removeDropIndicator(); - this._removeGhostContainerElement(); - } - - // interface --------------------------------------------------------------- - - registerContainer(element, options) { - const container = new DragDropContainer(element, options); - this.containers.push(container); - - // return a minimal interface to the container because this class - // should be used for management rather than the container class instance - return { - enableDrag: () => { - container.enableDrag(); - }, - - disableDrag: () => { - container.disableDrag(); - }, - - refresh: () => { - // re-calculate draggables/droppables - container.refresh(); - }, - - destroy: () => { - // unregister container - container.disableDrag(); - this.containers = this.containers.filter(c => c !== container); - } - }; - } - - // remove all containers and event handlers, useful when leaving an editor route - cleanup() { - this.containers.forEach(container => container.disableDrag()); - this.containers = []; - // cancel any tasks and remove intermittent event handlers - this._resetDrag(); - } - - // event handlers ---------------------------------------------------------- - - // we use a custom "drag" detection rather than native drag events because it - // allows better tracking across multiple containers and gives more flexibility - // for handling touch events later if required - _onMouseDown(event) { - if (!this.isDragging && (event.button === undefined || event.button === 0)) { - this.grabbedElement = utils.getParent(event.target, constants.DRAGGABLE_SELECTOR); - - if (this.grabbedElement) { - // some elements may have explicitly disabled dragging such as - // captions where we want to allow text selection instead - const dragDisabledElement = utils.getParent(event.target, constants.DRAG_DISABLED_SELECTOR); - if (dragDisabledElement && this.grabbedElement.contains(dragDisabledElement)) { - return; - } - - const containerElement = utils.getParent(this.grabbedElement, constants.CONTAINER_SELECTOR); - const container = this.containers.find(c => c.element === containerElement); - this.sourceContainer = container; - - if (container?.isDragEnabled) { - this._waitForDragStart(event).then(() => { - // stop the drag creating a selection - window.getSelection().removeAllRanges(); - // set up the drag details - this._initiateDrag(event); - }).catch((reason) => { - if (!reason.isCanceled) { - throw reason; - } - }); - } - } - } - } - - _onMouseMove(event) { - event.preventDefault(); - - if (this.draggableInfo) { - this.draggableInfo.mousePosition.x = event.clientX; - this.draggableInfo.mousePosition.y = event.clientY; - - this._handleDrag(event); - } - } - - _onMouseUp() { - if (this.draggableInfo) { - let success = false; - - // TODO: accept object rather than positioned args? OR, should the - // droppable data be stored on draggableInfo? - if (this._currentOverContainer) { - success = this._currentOverContainer.onDrop( - this.draggableInfo, - this._currentOverDroppableElem, - this._currentOverDroppablePosition - ); - } - - this.containers.forEach((container) => { - container.onDropEnd(this.draggableInfo, success); - }); - } - - // remove drag info and any ghost element - this._resetDrag(); - } - - // cancel drag on escape - _onKeyDown(event) { - if (this.isDragging && event.key === 'Escape') { - this._resetDrag(); - } - } - - // private ----------------------------------------------------------------- - - // called when we detect a mousedown event on a draggable element. Sets - // up temporary event handlers for mousemove, mouseup, and drag. If - // sufficient movement is detected before the mouse is released and we don't - // detect a native drag event then the promise will resolve. Mouseup or drag - // events will cancel the promise which will result in a rejection with {isCanceled: true} - async _waitForDragStart(startEvent) { - const moveThreshold = 1; - - // if we somehow already have a waiting promise, cancel it and keep the new one - if (this._waitForDragStartPromise) { - this.EE.emit('drag-start-canceled'); - this._waitForDragStartPromise = null; - } - - const onMove = (event) => { - let {clientX: currentX, clientY: currentY} = event; - - if ( - Math.abs(startEvent.clientX - currentX) > moveThreshold || - Math.abs(startEvent.clientY - currentY) > moveThreshold - ) { - this.EE.emit('drag-start-conditions-met'); - } - }; - - const onUp = () => { - this.EE.emit('drag-start-canceled'); - }; - - const onHtmlDrag = () => { - this.EE.emit('drag-start-canceled'); - }; - - const waitForDragStart = () => { - document.addEventListener('mousemove', onMove, {passive: false}); - document.addEventListener('mouseup', onUp, {passive: false}); - document.addEventListener('drag', onHtmlDrag, {passive: false}); - - return new Promise((resolve, reject) => { - const conditionsMet = () => { - this.EE.removeListener('drag-start-canceled', canceled); - resolve(); - }; - - const canceled = () => { - this.EE.removeListener('drag-start-conditions-met', conditionsMet); - reject({isCanceled: true}); - }; - - this.EE.once('drag-start-conditions-met', conditionsMet); - this.EE.once('drag-start-canceled', canceled); - }); - }; - - this._waitForDragStartPromise = waitForDragStart() - .finally(() => { - this._waitForDragStartPromise = null; - - document.removeEventListener('mousemove', onMove, {passive: false}); - document.removeEventListener('mouseup', onUp, {passive: false}); - document.removeEventListener('drag', onHtmlDrag, {passive: false}); - }); - - return this._waitForDragStartPromise; - } - - // called once drag start conditions have been met, `startEvent` is the initial mousedown event - _initiateDrag(startEvent) { - this.isDragging = true; - utils.applyUserSelect(document.body, 'none'); - - let draggableInfo = this.sourceContainer.getDraggableInfo(this.grabbedElement); - - if (!draggableInfo) { - this._resetDrag(); - return; - } - - // append the drop indicator if it doesn't already exist - we append to - // the editor's element rather than body so it needs to be re-appended - // each time a drag is initiated in a new editor instance - this._appendDropIndicator(); - - draggableInfo = Object.assign({}, draggableInfo, { - element: this.grabbedElement, - mousePosition: { - x: startEvent.clientX, - y: startEvent.clientY - } - }); - this.draggableInfo = draggableInfo; - - this.containers.forEach((container) => { - container.onDragStart(draggableInfo); - }); - - // style the dragged element - this.draggableInfo.element.style.opacity = 0.5; - - // create the ghost element and cache its position to avoid costly - // getBoundingClientRect calls in the mousemove handler - const ghostElement = this.sourceContainer.createGhostElement(this.draggableInfo); - if (ghostElement && ghostElement instanceof HTMLElement) { - this._ghostContainerElement.appendChild(ghostElement); - const ghostElementRect = ghostElement.getBoundingClientRect(); - const ghostInfo = { - element: ghostElement, - positionX: ghostElementRect.x, - positionY: ghostElementRect.y - }; - this.ghostInfo = ghostInfo; - } else { - - console.warn('container.createGhostElement did not return an element', this.draggableInfo, { ghostElement }); - this._resetDrag(); - return; - } - - // add watches to follow the drag/drop - this._addMoveListeners(); - this._addReleaseListeners(); - this._addKeyDownListeners(); - - // start ghost element following the mouse - requestAnimationFrame(this._rafUpdateGhostElementPosition); - - // let the scroll handler select the scrollable element - this.scrollHandler.dragStart(this.draggableInfo); - - // prevent the pointer showing the text caret over text content whilst dragging - document.querySelectorAll('[data-kg="editor"] [data-lexical-editor]').forEach((el) => { - el.style.setProperty('cursor', 'default', 'important'); - }); - - // prevent hover effects showing whilst dragging - this._removeHoverClasses(); - - this._handleDrag(); - } - - _removeHoverClasses() { - this._restoreHoverClasses(); - - this._elementsWithHoverRemoved = new Map(); - - const elementsWithHover = document.querySelectorAll('[class*="hover:"]'); - - elementsWithHover.forEach((element) => { - const hoverClasses = Array.from(element.classList.values()).filter(cls => cls.startsWith('hover:')); - - this._elementsWithHoverRemoved.set(element, hoverClasses); - - element.classList.remove(...hoverClasses); - }); - } - - _restoreHoverClasses() { - if (!this._elementsWithHoverRemoved) { - return; - } - - this._elementsWithHoverRemoved.forEach((hoverClasses, element) => { - element.classList.add(...hoverClasses); - }); - - this._elementsWithHoverRemoved = new Map(); - } - - // called when mouse moves whilst a drag is in progress - _handleDrag(event) { - // hide the ghost element so that it's not picked up by elementFromPoint - // when determining the target element under the mouse - this._ghostContainerElement.hidden = true; - const target = document.elementFromPoint( - this.draggableInfo.mousePosition.x, - this.draggableInfo.mousePosition.y - ); - this.draggableInfo.target = target; - this._ghostContainerElement.hidden = false; - - this.scrollHandler.dragMove(this.draggableInfo); - - const overContainerElem = utils.getParent(target, constants.CONTAINER_SELECTOR); - let overDroppableElem = utils.getParent(target, constants.DROPPABLE_SELECTOR); - - // it's possible for the mouse to be over a "dead" area when dragging over - // the position indicator, in this case we want to prevent a parent - // container's droppable from being picked up - if (!overContainerElem || !overContainerElem.contains(overDroppableElem)) { - overDroppableElem = null; - } - - const isLeavingContainer = this._currentOverContainerElem && overContainerElem !== this._currentOverContainerElem; - const isLeavingDroppable = this._currentOverDroppableElem && overDroppableElem !== this._currentOverDroppableElem; - const isOverContainer = overContainerElem && overContainerElem !== this._currentOverContainer; - const isOverDroppable = overDroppableElem; - - if (isLeavingContainer) { - this._currentOverContainer.onDragLeaveContainer(this.draggableInfo); - this._currentOverContainer = null; - this._currentOverContainerElem = null; - this._hideDropIndicator(); - } - - if (isOverContainer) { - const container = this.containers.find(c => c.element === overContainerElem); - if (!this._currentOverContainer) { - container.onDragEnterContainer(this.draggableInfo); - } - - this._currentOverContainer = container; - this._currentOverContainerElem = overContainerElem; - } - - if (isLeavingDroppable) { - if (this._currentOverContainer) { - this._currentOverContainer.onDragLeaveDroppable(overDroppableElem); - } - this._currentOverDroppableElem = null; - } - - if (isOverDroppable) { - // get position within the droppable - const rect = overDroppableElem.getBoundingClientRect(); - const inTop = this.draggableInfo.mousePosition.y < (rect.y + rect.height / 2); - const inLeft = this.draggableInfo.mousePosition.x < (rect.x + rect.width / 2); - const position = `${inTop ? 'top' : 'bottom'}-${inLeft ? 'left' : 'right'}`; - - if (!this._currentOverDroppableElem) { - this._currentOverContainer.onDragEnterDroppable(overDroppableElem, position); - } - - if (overDroppableElem !== this._currentOverDroppableElem || position !== this._currentOverDroppablePosition) { - this._currentOverDroppableElem = overDroppableElem; - this._currentOverDroppablePosition = position; - this._currentOverContainer.onDragOverDroppable(overDroppableElem, position); - - // container.getIndicatorPosition returns false if the drop is not allowed - const indicatorPosition = this._currentOverContainer.getIndicatorPosition(this.draggableInfo, overDroppableElem, position); - if (indicatorPosition) { - this.draggableInfo.insertIndex = indicatorPosition.insertIndex; - this._showDropIndicator(indicatorPosition); - } else { - this._hideDropIndicator(); - } - } - } - } - - _updateGhostElementPosition() { - if (this.isDragging) { - requestAnimationFrame(this._rafUpdateGhostElementPosition); - } - - const {ghostInfo, draggableInfo} = this; - if (draggableInfo && ghostInfo) { - const left = (ghostInfo.positionX * -1) + draggableInfo.mousePosition.x; - const top = (ghostInfo.positionY * -1) + draggableInfo.mousePosition.y; - ghostInfo.element.style.transform = `translate3d(${left}px, ${top}px, 0)`; - } - } - - // direction = horizontal/vertical - // horizontal = beforeElems shift left, afterElems shift right - // vertical = afterElems shift down - // position = above/below/left/right, used to place the indicator - _showDropIndicator({direction, position, beforeElems, afterElems}) { - const dropIndicator = this._dropIndicator; - - // reset everything except insertIndex before re-displaying indicator - this._hideDropIndicator({clearInsertIndex: false}); - - if (direction === 'horizontal') { - beforeElems.forEach((elem) => { - elem.style.transform = 'translate3d(-30px, 0, 0)'; - elem.style.transitionDuration = '250ms'; - this._transformedDroppables.push(elem); - }); - - afterElems.forEach((elem) => { - elem.style.transform = 'translate3d(30px, 0, 0)'; - elem.style.transitionDuration = '250ms'; - this._transformedDroppables.push(elem); - }); - - let leftAdjustment = 0; - const droppable = this._currentOverDroppableElem; - const droppableStyles = getComputedStyle(droppable); - // calculate position based on offset parent to avoid the transform - // being accounted for - const parentRect = droppable.offsetParent.getBoundingClientRect(); - const offsetLeft = parentRect.left + droppable.offsetLeft; - const offsetTop = parentRect.top + droppable.offsetTop; - - if (position === 'left') { - leftAdjustment -= parseInt(droppableStyles.marginLeft); - } else { - leftAdjustment += parseInt(droppable.offsetWidth) + parseInt(droppableStyles.marginRight); - } - - // account for indicator width - leftAdjustment -= 2; - - const dropIndicatorParentRect = dropIndicator.parentNode.getBoundingClientRect(); - const lastLeft = parseInt(dropIndicator.style.left); - const lastTop = parseInt(dropIndicator.style.top); - const newLeft = offsetLeft + leftAdjustment - dropIndicatorParentRect.left; - const newTop = offsetTop - dropIndicatorParentRect.top; - const newHeight = droppable.offsetHeight; - - // if indicator hasn't moved, keep it showing, otherwise wait for - // the transform transitions to almost finish before re-positioning - // and showing - // NOTE: +- 1px is due to sub-pixel positioning of droppables - if ( - newTop >= lastTop - 1 && newTop <= lastTop + 1 && - newLeft >= lastLeft - 1 && newLeft <= lastLeft + 1 - ) { - dropIndicator.style.opacity = 1; - } else { - dropIndicator.style.opacity = 0; - - this._dropIndicatorTimeout = setTimeout(function () { - dropIndicator.style.width = '4px'; - dropIndicator.style.height = `${newHeight}px`; - dropIndicator.style.left = `${newLeft}px`; - dropIndicator.style.top = `${newTop}px`; - dropIndicator.style.opacity = 1; - }, 150); - } - } - - if (direction === 'vertical') { - let transformSize = 60; - const droppable = this._currentOverDroppableElem; - let topElement, bottomElement; - - if (position === 'top') { - topElement = utils.getPreviousSibling(droppable, constants.DROPPABLE_SELECTOR); - bottomElement = droppable; - } else if (position === 'bottom') { - topElement = droppable; - bottomElement = utils.getNextSibling(droppable, constants.DROPPABLE_SELECTOR); - } - - // marginTop of the first element affects the offset of the - // children so it needs to be taken into account - const firstElement = (topElement || bottomElement).parentElement.children[0]; - const firstElementStyles = getComputedStyle(firstElement); - const firstTopMargin = parseInt(firstElementStyles.marginTop); - - const newWidth = droppable.offsetWidth; - const newLeft = droppable.offsetLeft; - let newTop; - - if (topElement && bottomElement) { - const topElementStyles = getComputedStyle(topElement); - const bottomElementStyles = getComputedStyle(bottomElement); - - const offsetTop = bottomElement.offsetTop; - - const topMargin = parseInt(topElementStyles.marginBottom); - const bottomMargin = parseInt(bottomElementStyles.marginTop); - const marginHeight = topMargin + bottomMargin; - - newTop = offsetTop - (marginHeight / 2) + firstTopMargin; - } else if (topElement) { - // at the bottom of the container - newTop = topElement.offsetTop + topElement.offsetHeight + firstTopMargin; - } else if (bottomElement) { - // at the top of the container, place the indicator 0px from the top - newTop = -26; // account for later adjustments and indicator height - transformSize = 30; // halve normal adjustment because there's no gap needed between top element - } - - // account for indicator height - newTop -= 2; - - // vertical always pushes elements down - newTop += 30; - - // if indicator hasn't moved, keep it showing, otherwise wait for - // the transform transitions to almost finish before re-positioning - // and showing - // NOTE: +- 1px is due to sub-pixel positioning of droppables - let lastLeft = parseInt(dropIndicator.style.left); - let lastTop = parseInt(dropIndicator.style.top); - - if ( - newTop >= lastTop - 1 && newTop <= lastTop + 1 && - newLeft >= lastLeft - 1 && newLeft <= lastLeft + 1 - ) { - dropIndicator.style.opacity = 1; - } else { - dropIndicator.style.opacity = 0; - - this._dropIndicatorTimeout = setTimeout(() => { - dropIndicator.style.height = '4px'; - dropIndicator.style.width = `${newWidth}px`; - dropIndicator.style.left = `${newLeft}px`; - dropIndicator.style.top = `${newTop}px`; - dropIndicator.style.opacity = 1; - }, 150); - } - - // always update the droppable transforms so that re-positining in - // the same place still moves the elements. Effectively a no-op if - // the styles already exist - beforeElems.forEach((elem) => { - elem.style.transform = 'translate3d(0, 0, 0)'; - elem.style.transitionDuration = '250ms'; - this._transformedDroppables.push(elem); - }); - - afterElems.forEach((elem) => { - elem.style.transform = `translate3d(0, ${transformSize}px, 0)`; - elem.style.transitionDuration = '250ms'; - this._transformedDroppables.push(elem); - }); - } - } - - _hideDropIndicator({clearInsertIndex = true} = {}) { - // make sure the indicator isn't shown due to a running timeout - clearTimeout(this._dropIndicatorTimeout); - - // clear droppable insert index unless instructed not to (eg, when - // resetting the display before re-positioning the indicator) - if (clearInsertIndex && this.draggableInfo) { - delete this.draggableInfo.insertIndex; - } - - // reset all transforms - this._transformedDroppables.forEach((elem) => { - elem.style.transform = ''; - }); - this.transformedDroppables = []; - - // hide drop indicator - if (this._dropIndicator) { - this._dropIndicator.style.opacity = 0; - } - } - - _resetDrag() { - this.EE.emit('drag-start-canceled'); - this._hideDropIndicator(); - this._removeMoveListeners(); - this._removeReleaseListeners(); - - this.scrollHandler.dragStop(); - - if (this.grabbedElement) { - this.grabbedElement.style.opacity = ''; - } - - this.isDragging = false; - this.grabbedElement = null; - this.sourceContainer = null; - - if (this.ghostInfo) { - this.ghostInfo.element.__reactRoot?.unmount(); - this.ghostInfo.element.remove(); - this.ghostInfo = null; - } - - this.containers.forEach((container) => { - container.onDragEnd(); - }); - - this._restoreHoverClasses(); - - utils.applyUserSelect(document.body, ''); - document.querySelectorAll('[data-kg="editor"] [data-lexical-editor]').forEach((el) => { - el.style.cursor = ''; - }); - } - - _appendDropIndicator() { - let dropIndicator = document.querySelector(`#${constants.DROP_INDICATOR_ID}`); - if (!dropIndicator) { - dropIndicator = document.createElement('div'); - dropIndicator.id = constants.DROP_INDICATOR_ID; - // "rounded-full bg-green" kept as classes so Tailwind picks up usage - dropIndicator.className = 'rounded-full bg-green'; - Object.assign(dropIndicator.style, { - position: 'absolute', - opacity: 0, - width: '4px', - height: '0', - zIndex: constants.DROP_INDICATOR_ZINDEX, - pointerEvents: 'none' - }); - - this.editorContainerElement.appendChild(dropIndicator); - } - - this._dropIndicator = dropIndicator; - } - - _removeDropIndicator() { - this._dropIndicator?.remove(); - } - - _appendGhostContainerElement() { - if (!this._ghostContainerElement) { - const ghostContainerElement = document.createElement('div'); - ghostContainerElement.id = constants.GHOST_CONTAINER_ID; - ghostContainerElement.style.position = 'fixed'; - ghostContainerElement.style.width = '100%'; - ghostContainerElement.style.zIndex = constants.DROP_INDICATOR_ZINDEX + 1; - - this.editorContainerElement.appendChild(ghostContainerElement); - - this._ghostContainerElement = ghostContainerElement; - } - } - - _removeGhostContainerElement() { - this._ghostContainerElement?.remove(); - } - - _addGrabListeners() { - this._addEventListener('mousedown', this._onMouseDown, {passive: false}); - } - - _removeGrabListeners() { - this._removeEventListener('mousedown'); - } - - _addMoveListeners() { - this._addEventListener('mousemove', this._onMouseMove, {passive: false}); - } - - _removeMoveListeners() { - this._removeEventListener('mousemove'); - } - - _addReleaseListeners() { - this._addEventListener('mouseup', this._onMouseUp, {passive: false}); - } - - _removeReleaseListeners() { - this._removeEventListener('mouseup'); - } - - _addKeyDownListeners() { - this._addEventListener('keydown', this._onKeyDown); - } - - _removeKeyDownListeners() { - this._removeEventListener('keydown'); - } - - _addEventListener(e, method, options) { - if (!this._eventHandlers[e]) { - let handler = method.bind(this); - this._eventHandlers[e] = {handler, options}; - document.addEventListener(e, handler, options); - } - } - - _removeEventListener(e) { - let event = this._eventHandlers[e]; - if (event) { - document.removeEventListener(e, event.handler, event.options); - delete this._eventHandlers[e]; - } - } -} diff --git a/packages/koenig-lexical/src/utils/draggable/DragDropHandler.tsx b/packages/koenig-lexical/src/utils/draggable/DragDropHandler.tsx new file mode 100644 index 0000000000..ed60d5a722 --- /dev/null +++ b/packages/koenig-lexical/src/utils/draggable/DragDropHandler.tsx @@ -0,0 +1,771 @@ +import * as constants from './draggable-constants'; +import * as utils from './draggable-utils'; +import EventEmitter from 'eventemitter3'; +import {DragDropContainer} from './DragDropContainer'; +import {ScrollHandler} from './ScrollHandler'; +import type {DragDropContainerOptions, IndicatorPosition} from './DragDropContainer'; +import type {DraggableInfo} from './ScrollHandler'; + +interface GhostInfo { + element: HTMLElement; + positionX: number; + positionY: number; +} + +interface EventHandlerEntry { + handler: EventListener; + options?: AddEventListenerOptions; +} + +export class DragDropHandler { + EE: EventEmitter | null = null; + editorContainerElement: HTMLElement | null = null; + containers: DragDropContainer[] = []; + draggableInfo: DraggableInfo | null = null; + ghostInfo: GhostInfo | null = null; + grabbedElement: HTMLElement | null = null; + scrollHandler: ScrollHandler | null = null; + sourceContainer: DragDropContainer | null = null; + isDragging = false; + + _currentOverContainer: DragDropContainer | null = null; + _currentOverContainerElem: Element | null = null; + _currentOverDroppableElem: Element | null = null; + _currentOverDroppablePosition: string | null = null; + _dropIndicator: HTMLElement | null = null; + _dropIndicatorTimeout?: ReturnType; + _elementsWithHoverRemoved: Map | null = null; + _eventHandlers: Record = {}; + _ghostContainerElement: HTMLElement | null = null; + _rafUpdateGhostElementPosition: FrameRequestCallback; + _transformedDroppables: HTMLElement[] = []; + _waitForDragStartPromise: Promise | null = null; + + // lifecycle --------------------------------------------------------------- + + constructor({editorContainerElement}: {editorContainerElement?: HTMLElement | null}) { + this.editorContainerElement = editorContainerElement || document.querySelector('[data-kg-editor] [data-lexical-editor]'); + this.containers = []; + this.scrollHandler = new ScrollHandler(); + this._eventHandlers = {}; + this._transformedDroppables = []; + + // bind any raf handler functions + this._rafUpdateGhostElementPosition = this._updateGhostElementPosition.bind(this); + + // set up document event listeners + this._addGrabListeners(); + + // append body elements + this._appendGhostContainerElement(); + + this.EE = new EventEmitter(); + } + + destroy(): void { + // reset any on-going drag and remove any temporary listeners + this.cleanup(); + + // clean up document event listeners + this._removeGrabListeners(); + + // remove body elements + this._removeDropIndicator(); + this._removeGhostContainerElement(); + } + + // interface --------------------------------------------------------------- + + registerContainer(element: HTMLElement, options: DragDropContainerOptions) { + const container = new DragDropContainer(element, options); + this.containers.push(container); + + // return a minimal interface to the container because this class + // should be used for management rather than the container class instance + return { + enableDrag: () => { + container.enableDrag(); + }, + + disableDrag: () => { + container.disableDrag(); + }, + + refresh: () => { + // re-calculate draggables/droppables + container.refresh(); + }, + + destroy: () => { + // unregister container + container.disableDrag(); + this.containers = this.containers.filter(c => c !== container); + } + }; + } + + // remove all containers and event handlers, useful when leaving an editor route + cleanup(): void { + this.containers.forEach(container => container.disableDrag()); + this.containers = []; + // cancel any tasks and remove intermittent event handlers + this._resetDrag(); + } + + // event handlers ---------------------------------------------------------- + + // we use a custom "drag" detection rather than native drag events because it + // allows better tracking across multiple containers and gives more flexibility + // for handling touch events later if required + _onMouseDown(event: MouseEvent): void { + if (!this.isDragging && (event.button === undefined || event.button === 0)) { + this.grabbedElement = utils.getParent(event.target as Element, constants.DRAGGABLE_SELECTOR) as HTMLElement | null; + + if (this.grabbedElement) { + // some elements may have explicitly disabled dragging such as + // captions where we want to allow text selection instead + const dragDisabledElement = utils.getParent(event.target as Element, constants.DRAG_DISABLED_SELECTOR); + if (dragDisabledElement && this.grabbedElement.contains(dragDisabledElement)) { + return; + } + + const containerElement = utils.getParent(this.grabbedElement, constants.CONTAINER_SELECTOR); + const container = this.containers.find(c => c.element === containerElement); + this.sourceContainer = container || null; + + if (container?.isDragEnabled) { + this._waitForDragStart(event).then(() => { + // stop the drag creating a selection + window.getSelection()?.removeAllRanges(); + // set up the drag details + this._initiateDrag(event); + }).catch((reason) => { + if (!(reason as {isCanceled?: boolean}).isCanceled) { + throw reason; + } + }); + } + } + } + } + + _onMouseMove(event: MouseEvent): void { + event.preventDefault(); + + if (this.draggableInfo) { + this.draggableInfo.mousePosition.x = event.clientX; + this.draggableInfo.mousePosition.y = event.clientY; + + this._handleDrag(); + } + } + + _onMouseUp(): void { + if (this.draggableInfo) { + let success = false; + + // TODO: accept object rather than positioned args? OR, should the + // droppable data be stored on draggableInfo? + if (this._currentOverContainer) { + success = this._currentOverContainer.onDrop( + this.draggableInfo, + this._currentOverDroppableElem, + this._currentOverDroppablePosition + ); + } + + this.containers.forEach((container) => { + container.onDropEnd(this.draggableInfo!, success); + }); + } + + // remove drag info and any ghost element + this._resetDrag(); + } + + // cancel drag on escape + _onKeyDown(event: KeyboardEvent): void { + if (this.isDragging && event.key === 'Escape') { + this._resetDrag(); + } + } + + // private ----------------------------------------------------------------- + + // called when we detect a mousedown event on a draggable element. Sets + // up temporary event handlers for mousemove, mouseup, and drag. If + // sufficient movement is detected before the mouse is released and we don't + // detect a native drag event then the promise will resolve. Mouseup or drag + // events will cancel the promise which will result in a rejection with {isCanceled: true} + async _waitForDragStart(startEvent: MouseEvent): Promise { + const moveThreshold = 1; + + // if we somehow already have a waiting promise, cancel it and keep the new one + if (this._waitForDragStartPromise) { + this.EE!.emit('drag-start-canceled'); + this._waitForDragStartPromise = null; + } + + const onMove = (event: Event) => { + const {clientX: currentX, clientY: currentY} = event as MouseEvent; + + if ( + Math.abs(startEvent.clientX - currentX) > moveThreshold || + Math.abs(startEvent.clientY - currentY) > moveThreshold + ) { + this.EE!.emit('drag-start-conditions-met'); + } + }; + + const onUp = () => { + this.EE!.emit('drag-start-canceled'); + }; + + const onHtmlDrag = () => { + this.EE!.emit('drag-start-canceled'); + }; + + const waitForDragStart = (): Promise => { + document.addEventListener('mousemove', onMove, {passive: false}); + document.addEventListener('mouseup', onUp, {passive: false}); + document.addEventListener('drag', onHtmlDrag, {passive: false}); + + return new Promise((resolve, reject) => { + const conditionsMet = () => { + this.EE!.removeListener('drag-start-canceled', canceled); + resolve(); + }; + + const canceled = () => { + this.EE!.removeListener('drag-start-conditions-met', conditionsMet); + reject({isCanceled: true}); + }; + + this.EE!.once('drag-start-conditions-met', conditionsMet); + this.EE!.once('drag-start-canceled', canceled); + }); + }; + + this._waitForDragStartPromise = waitForDragStart() + .finally(() => { + this._waitForDragStartPromise = null; + + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.removeEventListener('drag', onHtmlDrag); + }); + + return this._waitForDragStartPromise; + } + + // called once drag start conditions have been met, `startEvent` is the initial mousedown event + _initiateDrag(startEvent: MouseEvent): void { + this.isDragging = true; + utils.applyUserSelect(document.body, 'none'); + + const draggableInfoSeed = this.sourceContainer!.getDraggableInfo(this.grabbedElement!); + + if (!draggableInfoSeed) { + this._resetDrag(); + return; + } + + // append the drop indicator if it doesn't already exist - we append to + // the editor's element rather than body so it needs to be re-appended + // each time a drag is initiated in a new editor instance + this._appendDropIndicator(); + + // merge the live element/mouse position onto the seed to form the full DraggableInfo + const draggableInfo: DraggableInfo = { + ...draggableInfoSeed, + element: this.grabbedElement!, + mousePosition: { + x: startEvent.clientX, + y: startEvent.clientY + } + }; + this.draggableInfo = draggableInfo; + + this.containers.forEach((container) => { + container.onDragStart(draggableInfo); + }); + + // style the dragged element + this.draggableInfo.element.style.opacity = '0.5'; + + // create the ghost element and cache its position to avoid costly + // getBoundingClientRect calls in the mousemove handler + const ghostElement = this.sourceContainer!.createGhostElement(this.draggableInfo); + if (ghostElement && ghostElement instanceof HTMLElement) { + this._ghostContainerElement!.appendChild(ghostElement); + const ghostElementRect = ghostElement.getBoundingClientRect(); + const ghostInfo: GhostInfo = { + element: ghostElement, + positionX: ghostElementRect.x, + positionY: ghostElementRect.y + }; + this.ghostInfo = ghostInfo; + } else { + + console.warn('container.createGhostElement did not return an element', this.draggableInfo, { ghostElement }); + this._resetDrag(); + return; + } + + // add watches to follow the drag/drop + this._addMoveListeners(); + this._addReleaseListeners(); + this._addKeyDownListeners(); + + // start ghost element following the mouse + requestAnimationFrame(this._rafUpdateGhostElementPosition); + + // let the scroll handler select the scrollable element + this.scrollHandler!.dragStart(this.draggableInfo); + + // prevent the pointer showing the text caret over text content whilst dragging + document.querySelectorAll('[data-kg="editor"] [data-lexical-editor]').forEach((el) => { + el.style.setProperty('cursor', 'default', 'important'); + }); + + // prevent hover effects showing whilst dragging + this._removeHoverClasses(); + + this._handleDrag(); + } + + _removeHoverClasses(): void { + this._restoreHoverClasses(); + + this._elementsWithHoverRemoved = new Map(); + + const elementsWithHover = document.querySelectorAll('[class*="hover:"]'); + + elementsWithHover.forEach((element) => { + const hoverClasses = Array.from(element.classList.values()).filter(cls => cls.startsWith('hover:')); + + this._elementsWithHoverRemoved!.set(element, hoverClasses); + + element.classList.remove(...hoverClasses); + }); + } + + _restoreHoverClasses(): void { + if (!this._elementsWithHoverRemoved) { + return; + } + + this._elementsWithHoverRemoved.forEach((hoverClasses, element) => { + element.classList.add(...hoverClasses); + }); + + this._elementsWithHoverRemoved = new Map(); + } + + // called when mouse moves whilst a drag is in progress + _handleDrag(): void { + // hide the ghost element so that it's not picked up by elementFromPoint + // when determining the target element under the mouse + this._ghostContainerElement!.hidden = true; + const target = document.elementFromPoint( + this.draggableInfo!.mousePosition.x, + this.draggableInfo!.mousePosition.y + ); + this.draggableInfo!.target = target; + this._ghostContainerElement!.hidden = false; + + this.scrollHandler!.dragMove(this.draggableInfo!); + + const overContainerElem = utils.getParent(target, constants.CONTAINER_SELECTOR); + let overDroppableElem = utils.getParent(target, constants.DROPPABLE_SELECTOR); + + // it's possible for the mouse to be over a "dead" area when dragging over + // the position indicator, in this case we want to prevent a parent + // container's droppable from being picked up + if (!overContainerElem || !overContainerElem.contains(overDroppableElem)) { + overDroppableElem = null; + } + + const isLeavingContainer = this._currentOverContainerElem && overContainerElem !== this._currentOverContainerElem; + const isLeavingDroppable = this._currentOverDroppableElem && overDroppableElem !== this._currentOverDroppableElem; + const isOverContainer = overContainerElem && overContainerElem !== this._currentOverContainer?.element; + const isOverDroppable = overDroppableElem; + + if (isLeavingContainer) { + this._currentOverContainer!.onDragLeaveContainer(this.draggableInfo!); + this._currentOverContainer = null; + this._currentOverContainerElem = null; + this._hideDropIndicator(); + } + + if (isOverContainer) { + const container = this.containers.find(c => c.element === overContainerElem); + if (!this._currentOverContainer && container) { + container.onDragEnterContainer(this.draggableInfo!); + } + + this._currentOverContainer = container || null; + this._currentOverContainerElem = overContainerElem; + } + + if (isLeavingDroppable) { + if (this._currentOverContainer) { + this._currentOverContainer.onDragLeaveDroppable(overDroppableElem!); + } + this._currentOverDroppableElem = null; + } + + if (isOverDroppable) { + // get position within the droppable + const rect = overDroppableElem!.getBoundingClientRect(); + const inTop = this.draggableInfo!.mousePosition.y < (rect.y + rect.height / 2); + const inLeft = this.draggableInfo!.mousePosition.x < (rect.x + rect.width / 2); + const position = `${inTop ? 'top' : 'bottom'}-${inLeft ? 'left' : 'right'}`; + + if (!this._currentOverDroppableElem) { + this._currentOverContainer!.onDragEnterDroppable(overDroppableElem!, position); + } + + if (overDroppableElem !== this._currentOverDroppableElem || position !== this._currentOverDroppablePosition) { + this._currentOverDroppableElem = overDroppableElem; + this._currentOverDroppablePosition = position; + this._currentOverContainer!.onDragOverDroppable(overDroppableElem!, position); + + // container.getIndicatorPosition returns false if the drop is not allowed + const indicatorPosition = this._currentOverContainer!.getIndicatorPosition(this.draggableInfo!, overDroppableElem!, position); + if (indicatorPosition) { + this.draggableInfo!.insertIndex = indicatorPosition.insertIndex; + this._showDropIndicator(indicatorPosition); + } else { + this._hideDropIndicator(); + } + } + } + } + + _updateGhostElementPosition(): void { + if (this.isDragging) { + requestAnimationFrame(this._rafUpdateGhostElementPosition); + } + + const {ghostInfo, draggableInfo} = this; + if (draggableInfo && ghostInfo) { + const left = (ghostInfo.positionX * -1) + draggableInfo.mousePosition.x; + const top = (ghostInfo.positionY * -1) + draggableInfo.mousePosition.y; + ghostInfo.element.style.transform = `translate3d(${left}px, ${top}px, 0)`; + } + } + + // direction = horizontal/vertical + // horizontal = beforeElems shift left, afterElems shift right + // vertical = afterElems shift down + // position = above/below/left/right, used to place the indicator + _showDropIndicator({direction, position, beforeElems, afterElems}: IndicatorPosition): void { + const dropIndicator = this._dropIndicator!; + + // reset everything except insertIndex before re-displaying indicator + this._hideDropIndicator({clearInsertIndex: false}); + + if (direction === 'horizontal') { + beforeElems.forEach((elem) => { + elem.style.transform = 'translate3d(-30px, 0, 0)'; + elem.style.transitionDuration = '250ms'; + this._transformedDroppables.push(elem); + }); + + afterElems.forEach((elem) => { + elem.style.transform = 'translate3d(30px, 0, 0)'; + elem.style.transitionDuration = '250ms'; + this._transformedDroppables.push(elem); + }); + + let leftAdjustment = 0; + const droppable = this._currentOverDroppableElem as HTMLElement; + const droppableStyles = getComputedStyle(droppable); + // calculate position based on offset parent to avoid the transform + // being accounted for + const parentRect = (droppable.offsetParent as HTMLElement).getBoundingClientRect(); + const offsetLeft = parentRect.left + droppable.offsetLeft; + const offsetTop = parentRect.top + droppable.offsetTop; + + if (position === 'left') { + leftAdjustment -= parseInt(droppableStyles.marginLeft); + } else { + leftAdjustment += droppable.offsetWidth + parseInt(droppableStyles.marginRight); + } + + // account for indicator width + leftAdjustment -= 2; + + const dropIndicatorParentRect = (dropIndicator.parentNode as HTMLElement).getBoundingClientRect(); + const lastLeft = parseInt(dropIndicator.style.left); + const lastTop = parseInt(dropIndicator.style.top); + const newLeft = offsetLeft + leftAdjustment - (dropIndicatorParentRect as DOMRect).left; + const newTop = offsetTop - (dropIndicatorParentRect as DOMRect).top; + const newHeight = droppable.offsetHeight; + + // if indicator hasn't moved, keep it showing, otherwise wait for + // the transform transitions to almost finish before re-positioning + // and showing + // NOTE: +- 1px is due to sub-pixel positioning of droppables + if ( + newTop >= lastTop - 1 && newTop <= lastTop + 1 && + newLeft >= lastLeft - 1 && newLeft <= lastLeft + 1 + ) { + dropIndicator.style.opacity = '1'; + } else { + dropIndicator.style.opacity = '0'; + + this._dropIndicatorTimeout = setTimeout(function () { + dropIndicator.style.width = '4px'; + dropIndicator.style.height = `${newHeight}px`; + dropIndicator.style.left = `${newLeft}px`; + dropIndicator.style.top = `${newTop}px`; + dropIndicator.style.opacity = '1'; + }, 150); + } + } + + if (direction === 'vertical') { + let transformSize = 60; + const droppable = this._currentOverDroppableElem as HTMLElement; + let topElement: Element | null, bottomElement: Element | null; + + if (position === 'top') { + topElement = utils.getPreviousSibling(droppable, constants.DROPPABLE_SELECTOR); + bottomElement = droppable; + } else if (position === 'bottom') { + topElement = droppable; + bottomElement = utils.getNextSibling(droppable, constants.DROPPABLE_SELECTOR); + } else { + topElement = null; + bottomElement = null; + } + + // marginTop of the first element affects the offset of the + // children so it needs to be taken into account + const firstElement = (topElement || bottomElement)!.parentElement!.children[0] as HTMLElement; + const firstElementStyles = getComputedStyle(firstElement); + const firstTopMargin = parseInt(firstElementStyles.marginTop); + + const newWidth = droppable.offsetWidth; + const newLeft = droppable.offsetLeft; + let newTop: number; + + if (topElement && bottomElement) { + const topElementStyles = getComputedStyle(topElement); + const bottomElementStyles = getComputedStyle(bottomElement); + + const offsetTop = (bottomElement as HTMLElement).offsetTop; + + const topMargin = parseInt(topElementStyles.marginBottom); + const bottomMargin = parseInt(bottomElementStyles.marginTop); + const marginHeight = topMargin + bottomMargin; + + newTop = offsetTop - (marginHeight / 2) + firstTopMargin; + } else if (topElement) { + // at the bottom of the container + newTop = (topElement as HTMLElement).offsetTop + (topElement as HTMLElement).offsetHeight + firstTopMargin; + } else if (bottomElement) { + // at the top of the container, place the indicator 0px from the top + newTop = -26; // account for later adjustments and indicator height + transformSize = 30; // halve normal adjustment because there's no gap needed between top element + } else { + newTop = 0; + } + + // account for indicator height + newTop -= 2; + + // vertical always pushes elements down + newTop += 30; + + // if indicator hasn't moved, keep it showing, otherwise wait for + // the transform transitions to almost finish before re-positioning + // and showing + // NOTE: +- 1px is due to sub-pixel positioning of droppables + const lastLeft = parseInt(dropIndicator.style.left); + const lastTop = parseInt(dropIndicator.style.top); + + if ( + newTop >= lastTop - 1 && newTop <= lastTop + 1 && + newLeft >= lastLeft - 1 && newLeft <= lastLeft + 1 + ) { + dropIndicator.style.opacity = '1'; + } else { + dropIndicator.style.opacity = '0'; + + this._dropIndicatorTimeout = setTimeout(() => { + dropIndicator.style.height = '4px'; + dropIndicator.style.width = `${newWidth}px`; + dropIndicator.style.left = `${newLeft}px`; + dropIndicator.style.top = `${newTop}px`; + dropIndicator.style.opacity = '1'; + }, 150); + } + + // always update the droppable transforms so that re-positining in + // the same place still moves the elements. Effectively a no-op if + // the styles already exist + beforeElems.forEach((elem) => { + elem.style.transform = 'translate3d(0, 0, 0)'; + elem.style.transitionDuration = '250ms'; + this._transformedDroppables.push(elem); + }); + + afterElems.forEach((elem) => { + elem.style.transform = `translate3d(0, ${transformSize}px, 0)`; + elem.style.transitionDuration = '250ms'; + this._transformedDroppables.push(elem); + }); + } + } + + _hideDropIndicator({clearInsertIndex = true} = {}): void { + // make sure the indicator isn't shown due to a running timeout + clearTimeout(this._dropIndicatorTimeout); + + // clear droppable insert index unless instructed not to (eg, when + // resetting the display before re-positioning the indicator) + if (clearInsertIndex && this.draggableInfo) { + delete this.draggableInfo.insertIndex; + } + + // reset all transforms + this._transformedDroppables.forEach((elem) => { + elem.style.transform = ''; + }); + this._transformedDroppables = []; + + // hide drop indicator + if (this._dropIndicator) { + this._dropIndicator.style.opacity = '0'; + } + } + + _resetDrag(): void { + this.EE!.emit('drag-start-canceled'); + this._hideDropIndicator(); + this._removeMoveListeners(); + this._removeReleaseListeners(); + + this.scrollHandler!.dragStop(); + + if (this.grabbedElement) { + this.grabbedElement.style.opacity = ''; + } + + this.isDragging = false; + this.grabbedElement = null; + this.sourceContainer = null; + + if (this.ghostInfo) { + (this.ghostInfo.element as HTMLElement & {__reactRoot?: {unmount(): void}}).__reactRoot?.unmount(); + this.ghostInfo.element.remove(); + this.ghostInfo = null; + } + + this.containers.forEach((container) => { + container.onDragEnd(); + }); + + this._restoreHoverClasses(); + + utils.applyUserSelect(document.body, ''); + document.querySelectorAll('[data-kg="editor"] [data-lexical-editor]').forEach((el) => { + el.style.cursor = ''; + }); + } + + _appendDropIndicator(): void { + let dropIndicator = document.querySelector(`#${constants.DROP_INDICATOR_ID}`); + if (!dropIndicator) { + dropIndicator = document.createElement('div'); + dropIndicator.id = constants.DROP_INDICATOR_ID; + // "rounded-full bg-green" kept as classes so Tailwind picks up usage + dropIndicator.className = 'rounded-full bg-green'; + Object.assign(dropIndicator.style, { + position: 'absolute', + opacity: 0, + width: '4px', + height: '0', + zIndex: constants.DROP_INDICATOR_ZINDEX, + pointerEvents: 'none' + }); + + this.editorContainerElement!.appendChild(dropIndicator); + } + + this._dropIndicator = dropIndicator as HTMLElement; + } + + _removeDropIndicator(): void { + this._dropIndicator?.remove(); + } + + _appendGhostContainerElement(): void { + if (!this._ghostContainerElement) { + const ghostContainerElement = document.createElement('div'); + ghostContainerElement.id = constants.GHOST_CONTAINER_ID; + ghostContainerElement.style.position = 'fixed'; + ghostContainerElement.style.width = '100%'; + ghostContainerElement.style.zIndex = String(constants.DROP_INDICATOR_ZINDEX + 1); + + this.editorContainerElement!.appendChild(ghostContainerElement); + + this._ghostContainerElement = ghostContainerElement; + } + } + + _removeGhostContainerElement(): void { + this._ghostContainerElement?.remove(); + } + + _addGrabListeners(): void { + this._addEventListener('mousedown', this._onMouseDown, {passive: false}); + } + + _removeGrabListeners(): void { + this._removeEventListener('mousedown'); + } + + _addMoveListeners(): void { + this._addEventListener('mousemove', this._onMouseMove, {passive: false}); + } + + _removeMoveListeners(): void { + this._removeEventListener('mousemove'); + } + + _addReleaseListeners(): void { + this._addEventListener('mouseup', this._onMouseUp, {passive: false}); + } + + _removeReleaseListeners(): void { + this._removeEventListener('mouseup'); + } + + _addKeyDownListeners(): void { + this._addEventListener('keydown', this._onKeyDown); + } + + _removeKeyDownListeners(): void { + this._removeEventListener('keydown'); + } + + _addEventListener(e: string, method: (event: E) => void, options?: AddEventListenerOptions): void { + if (!this._eventHandlers[e]) { + const handler = method.bind(this) as EventListener; + this._eventHandlers[e] = {handler, options}; + document.addEventListener(e, handler, options); + } + } + + _removeEventListener(e: string): void { + const event = this._eventHandlers[e]; + if (event) { + document.removeEventListener(e, event.handler, event.options); + delete this._eventHandlers[e]; + } + } +} diff --git a/packages/koenig-lexical/src/utils/draggable/ScrollHandler.js b/packages/koenig-lexical/src/utils/draggable/ScrollHandler.js deleted file mode 100644 index c0c8962339..0000000000 --- a/packages/koenig-lexical/src/utils/draggable/ScrollHandler.js +++ /dev/null @@ -1,111 +0,0 @@ -// adapted from draggable.js Scrollable plugin (MIT) -// https://github.com/Shopify/draggable/blob/master/src/Draggable/Plugins/Scrollable/Scrollable.js -import { - getDocumentScrollingElement, - getParentScrollableElement -} from './draggable-utils'; - -export const defaultOptions = { - speed: 8, - sensitivity: 50 -}; - -export class ScrollHandler { - constructor() { - this.options = Object.assign({}, defaultOptions); - - this.currentMousePosition = null; - this.findScrollableElementFrame = null; - this.scrollableElement = null; - this.scrollAnimationFrame = null; - - // bind `this` so methods can be passed to requestAnimationFrame - this._scroll = this._scroll.bind(this); - - // cache browser info to avoid parsing on every animation frame - this._isSafari = navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1; - } - - dragStart(draggableInfo) { - this.findScrollableElementFrame = requestAnimationFrame(() => { - this.scrollableElement = this.getScrollableElement(draggableInfo.element); - }); - } - - dragMove(draggableInfo) { - this.findScrollableElementFrame = requestAnimationFrame(() => { - this.scrollableElement = this.getScrollableElement(draggableInfo.target); - }); - - if (!this.scrollableElement) { - return; - } - - this.currentMousePosition = { - clientX: draggableInfo.mousePosition.x, - clientY: draggableInfo.mousePosition.y - }; - - this.scrollAnimationFrame = requestAnimationFrame(this._scroll); - } - - dragStop() { - cancelAnimationFrame(this.scrollAnimationFrame); - cancelAnimationFrame(this.findScrollableElementFrame); - - this.currentMousePosition = null; - this.findScrollableElementFrame = null; - this.scrollableElement = null; - this.scrollAnimationFrame = null; - } - - getScrollableElement(target) { - let scrollableElement = getParentScrollableElement(target); - - // workaround for our particular scrolling setup - // TODO: find a way to make this configurable - if (scrollableElement === getDocumentScrollingElement()) { - // TODO: will only work inside Admin - scrollableElement = document.querySelector('.gh-koenig-editor'); - } - - return scrollableElement; - } - - _scroll() { - if (!this.scrollableElement || !this.currentMousePosition) { - return; - } - - cancelAnimationFrame(this.scrollAnimationFrame); - - let {speed, sensitivity} = this.options; - - let rect = this.scrollableElement.getBoundingClientRect(); - - let scrollableElement = this.scrollableElement; - let clientX = this.currentMousePosition.clientX; - let clientY = this.currentMousePosition.clientY; - - let {offsetHeight, offsetWidth} = scrollableElement; - - let topPosition = rect.top + offsetHeight - clientY; - let bottomPosition = clientY - rect.top; - - // Safari will automatically scroll when the mouse is outside of the window - // so we want to avoid our own scrolling in that situation to avoid jank - if (topPosition < sensitivity && !(this._isSafari && topPosition < 0)) { - scrollableElement.scrollTop += speed; - } else if (bottomPosition < sensitivity && !(this._isSafari && bottomPosition < 0)) { - scrollableElement.scrollTop -= speed; - } - - if (rect.left + offsetWidth - clientX < sensitivity) { - scrollableElement.scrollLeft += speed; - } else if (clientX - rect.left < sensitivity) { - scrollableElement.scrollLeft -= speed; - } - - this.scrollAnimationFrame = requestAnimationFrame(this._scroll); - } -} diff --git a/packages/koenig-lexical/src/utils/draggable/ScrollHandler.ts b/packages/koenig-lexical/src/utils/draggable/ScrollHandler.ts new file mode 100644 index 0000000000..ba94fb8345 --- /dev/null +++ b/packages/koenig-lexical/src/utils/draggable/ScrollHandler.ts @@ -0,0 +1,136 @@ +// adapted from draggable.js Scrollable plugin (MIT) +// https://github.com/Shopify/draggable/blob/master/src/Draggable/Plugins/Scrollable/Scrollable.js +import { + getDocumentScrollingElement, + getParentScrollableElement +} from './draggable-utils'; +import type {ComponentType} from 'react'; + +// the partial info produced by a container's `getDraggableInfo` callback before +// the handler merges in the live `element`/`mousePosition` to form a DraggableInfo +export interface DraggableInfoSeed { + type?: string; + cardName?: string; + nodeKey?: string; + dataset?: Record; + Icon?: ComponentType<{className?: string}>; +} + +export interface DraggableInfo extends DraggableInfoSeed { + element: HTMLElement; + mousePosition: {x: number; y: number}; + target?: Element | null; + insertIndex?: number; +} + +export const defaultOptions = { + speed: 8, + sensitivity: 50 +}; + +export class ScrollHandler { + options: {speed: number; sensitivity: number}; + currentMousePosition: {clientX: number; clientY: number} | null; + findScrollableElementFrame: number | null; + scrollableElement: Element | null; + scrollAnimationFrame: number | null; + private _isSafari: boolean; + + constructor() { + this.options = Object.assign({}, defaultOptions); + + this.currentMousePosition = null; + this.findScrollableElementFrame = null; + this.scrollableElement = null; + this.scrollAnimationFrame = null; + + // bind `this` so methods can be passed to requestAnimationFrame + this._scroll = this._scroll.bind(this); + + // cache browser info to avoid parsing on every animation frame + this._isSafari = navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1; + } + + dragStart(draggableInfo: DraggableInfo): void { + this.findScrollableElementFrame = requestAnimationFrame(() => { + this.scrollableElement = getParentScrollableElement(draggableInfo.element); + }); + } + + dragMove(draggableInfo: DraggableInfo): void { + this.findScrollableElementFrame = requestAnimationFrame(() => { + this.scrollableElement = getParentScrollableElement(draggableInfo.target as Element | null); + }); + + if (!this.scrollableElement) { + return; + } + + this.currentMousePosition = { + clientX: draggableInfo.mousePosition.x, + clientY: draggableInfo.mousePosition.y + }; + + this.scrollAnimationFrame = requestAnimationFrame(this._scroll); + } + + dragStop(): void { + cancelAnimationFrame(this.scrollAnimationFrame!); + cancelAnimationFrame(this.findScrollableElementFrame!); + + this.currentMousePosition = null; + this.findScrollableElementFrame = null; + this.scrollableElement = null; + this.scrollAnimationFrame = null; + } + + getScrollableElement(target: Element | null): Element | null { + let scrollableElement = getParentScrollableElement(target); + + // workaround for our particular scrolling setup + // TODO: find a way to make this configurable + if (scrollableElement === getDocumentScrollingElement()) { + // TODO: will only work inside Admin + scrollableElement = document.querySelector('.gh-koenig-editor'); + } + + return scrollableElement; + } + + _scroll(): void { + if (!this.scrollableElement || !this.currentMousePosition) { + return; + } + + cancelAnimationFrame(this.scrollAnimationFrame!); + + const {speed, sensitivity} = this.options; + + const rect = this.scrollableElement.getBoundingClientRect(); + + const scrollableElement = this.scrollableElement; + const clientX = this.currentMousePosition.clientX; + const clientY = this.currentMousePosition.clientY; + + const {offsetHeight, offsetWidth} = scrollableElement as HTMLElement; + + const topPosition = rect.top + offsetHeight - clientY; + const bottomPosition = clientY - rect.top; + + // Safari will automatically scroll when the mouse is outside of the window + // so we want to avoid our own scrolling in that situation to avoid jank + if (topPosition < sensitivity && !(this._isSafari && topPosition < 0)) { + scrollableElement.scrollTop += speed; + } else if (bottomPosition < sensitivity && !(this._isSafari && bottomPosition < 0)) { + scrollableElement.scrollTop -= speed; + } + + if (rect.left + offsetWidth - clientX < sensitivity) { + scrollableElement.scrollLeft += speed; + } else if (clientX - rect.left < sensitivity) { + scrollableElement.scrollLeft -= speed; + } + + this.scrollAnimationFrame = requestAnimationFrame(this._scroll); + } +} diff --git a/packages/koenig-lexical/src/utils/draggable/draggable-constants.js b/packages/koenig-lexical/src/utils/draggable/draggable-constants.ts similarity index 100% rename from packages/koenig-lexical/src/utils/draggable/draggable-constants.js rename to packages/koenig-lexical/src/utils/draggable/draggable-constants.ts diff --git a/packages/koenig-lexical/src/utils/draggable/draggable-utils.js b/packages/koenig-lexical/src/utils/draggable/draggable-utils.js deleted file mode 100644 index 0c8622d69a..0000000000 --- a/packages/koenig-lexical/src/utils/draggable/draggable-utils.js +++ /dev/null @@ -1,125 +0,0 @@ -// TODO: more or less duplicated in koenig-card-gallery other than direction -export function isCardDropAllowed(draggableIndex, droppableIndex, position = '') { - // images can be dragged out of a gallery to any position - if (draggableIndex === -1) { - return true; - } - - // can't drop on itself or when droppableIndex doesn't exist - if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { - return false; - } - - // account for dropping at beginning or end of a row - if (position.match(/top/)) { - droppableIndex -= 1; - } - - if (position.match(/bottom/)) { - droppableIndex += 1; - } - - return droppableIndex !== draggableIndex; -} - -// TODO: rename to closest? getParent can actually match passed in element -export function getParent(element, value) { - return getWithMatch(element, value, current => current.parentNode); -} - -export function getNextSibling(element, value) { - // don't match the passed in element - element = element.nextElementSibling; - return getWithMatch(element, value, current => current.nextElementSibling); -} - -export function getPreviousSibling(element, value) { - // don't match the passed in element - element = element.previousElementSibling; - return getWithMatch(element, value, current => current.previousElementSibling); -} - -export function getParentScrollableElement(element) { - if (!element) { - return getDocumentScrollingElement(); - } - - let position = getComputedStyle(element).getPropertyValue('position'); - let excludeStaticParents = position === 'absolute'; - - let scrollableElement = getParent(element, (parent) => { - if (excludeStaticParents && isStaticallyPositioned(parent)) { - return false; - } - return hasOverflow(parent); - }); - - if (position === 'fixed' && !scrollableElement) { - return getDocumentScrollingElement(); - } else { - return scrollableElement; - } -} - -export function getDocumentScrollingElement() { - return document.scrollingElement?.body || document.scrollingElement || document.element; -} - -export function applyUserSelect(element, value) { - element.style.webkitUserSelect = value; - element.style.mozUserSelect = value; - element.style.msUserSelect = value; - element.style.oUserSelect = value; - element.style.userSelect = value; -} - -/* Not exported --------------------------------------------------------------*/ - -function getWithMatch(element, value, next) { - if (!element) { - return null; - } - - let selector = value; - let callback = value; - - let isSelector = typeof value === 'string'; - let isFunction = typeof value === 'function'; - - function matches(currentElement) { - if (!currentElement) { - return currentElement; - } else if (isSelector) { - return currentElement.matches(selector); - } else if (isFunction) { - return callback(currentElement); - } - } - - let current = element; - - do { - if (matches(current)) { - return current; - } - - current = next(current); - } while (current && current !== document.body && current !== document); -} - -function isStaticallyPositioned(element) { - let position = getComputedStyle(element).getPropertyValue('position'); - return position === 'static'; -} - -function hasOverflow(element) { - let overflowRegex = /(auto|scroll)/; - let computedStyles = getComputedStyle(element, null); - - let overflow = - computedStyles.getPropertyValue('overflow') + - computedStyles.getPropertyValue('overflow-y') + - computedStyles.getPropertyValue('overflow-x'); - - return overflowRegex.test(overflow); -} diff --git a/packages/koenig-lexical/src/utils/draggable/draggable-utils.ts b/packages/koenig-lexical/src/utils/draggable/draggable-utils.ts new file mode 100644 index 0000000000..f5b5a882d3 --- /dev/null +++ b/packages/koenig-lexical/src/utils/draggable/draggable-utils.ts @@ -0,0 +1,123 @@ +// TODO: more or less duplicated in koenig-card-gallery other than direction +export function isCardDropAllowed(draggableIndex: number, droppableIndex: number, position = ''): boolean { + // images can be dragged out of a gallery to any position + if (draggableIndex === -1) { + return true; + } + + // can't drop on itself or when droppableIndex doesn't exist + if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') { + return false; + } + + // account for dropping at beginning or end of a row + if (position.match(/top/)) { + droppableIndex -= 1; + } + + if (position.match(/bottom/)) { + droppableIndex += 1; + } + + return droppableIndex !== draggableIndex; +} + +// TODO: rename to closest? getParent can actually match passed in element +export function getParent(element: Element | null, value: string | ((el: Element) => boolean)): Element | null { + return getWithMatch(element, value, current => current.parentNode as Element | null); +} + +export function getNextSibling(element: Element | null, value: string | ((el: Element) => boolean)): Element | null { + // don't match the passed in element + element = element?.nextElementSibling ?? null; + return getWithMatch(element, value, current => current.nextElementSibling); +} + +export function getPreviousSibling(element: Element | null, value: string | ((el: Element) => boolean)): Element | null { + // don't match the passed in element + element = element?.previousElementSibling ?? null; + return getWithMatch(element, value, current => current.previousElementSibling); +} + +export function getParentScrollableElement(element: Element | null): Element | null { + if (!element) { + return getDocumentScrollingElement(); + } + + const position = getComputedStyle(element).getPropertyValue('position'); + const excludeStaticParents = position === 'absolute'; + + const scrollableElement = getParent(element, (parent) => { + if (excludeStaticParents && isStaticallyPositioned(parent)) { + return false; + } + return hasOverflow(parent); + }); + + if (position === 'fixed' && !scrollableElement) { + return getDocumentScrollingElement(); + } else { + return scrollableElement; + } +} + +export function getDocumentScrollingElement(): Element | null { + return (document.scrollingElement as HTMLElement)?.querySelector('body') || document.scrollingElement || document.documentElement; +} + +export function applyUserSelect(element: HTMLElement, value: string): void { + element.style.webkitUserSelect = value; + (element.style as unknown as Record).mozUserSelect = value; + (element.style as unknown as Record).msUserSelect = value; + (element.style as unknown as Record).oUserSelect = value; + element.style.userSelect = value; +} + +/* Not exported --------------------------------------------------------------*/ + +function getWithMatch(element: Element | null, value: string | ((el: Element) => boolean), next: (el: Element) => Element | null): Element | null { + if (!element) { + return null; + } + + const isSelector = typeof value === 'string'; + + function matches(currentElement: Element | null): boolean { + if (!currentElement) { + return false; + } else if (isSelector) { + return currentElement.matches(value as string); + } else { + return (value as (el: Element) => boolean)(currentElement); + } + } + + let current: Element | null = element; + + do { + if (matches(current)) { + return current; + } + + current = next(current); + } while (current && current !== document.body && current !== document.documentElement); + + return null; +} + +function isStaticallyPositioned(element: Element): boolean { + const position = getComputedStyle(element).getPropertyValue('position'); + return position === 'static'; +} + +function hasOverflow(element: Element): boolean { + const overflowRegex = /(auto|scroll)/; + const computedStyles = getComputedStyle(element, null); + + const overflow = + computedStyles.getPropertyValue('overflow') + + computedStyles.getPropertyValue('overflow-y') + + computedStyles.getPropertyValue('overflow-x'); + + return overflowRegex.test(overflow); +} diff --git a/packages/koenig-lexical/src/utils/extractVideoMetadata.js b/packages/koenig-lexical/src/utils/extractVideoMetadata.js deleted file mode 100644 index 1e36eb1a0e..0000000000 --- a/packages/koenig-lexical/src/utils/extractVideoMetadata.js +++ /dev/null @@ -1,49 +0,0 @@ -// taken from https://github.com/TryGhost/Ghost/blob/main/ghost/admin/lib/koenig-editor/addon/utils/extract-video-metadata.js -export default function extractVideoMetadata(file) { - return new Promise((resolve, reject) => { - const mimeType = file.type; - let duration, width, height; - - const video = document.createElement('video'); - video.muted = true; - video.playsInline = true; - - video.onerror = reject; - - video.onloadedmetadata = function () { - duration = video.duration; - width = video.videoWidth; - height = video.videoHeight; - }; - - video.oncanplay = function () { - video.currentTime = 0.5; - video.oncanplay = null; - }; - - video.onseeked = function () { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext('2d'); - ctx.drawImage(video, 0, 0, width, height); - - window.URL.revokeObjectURL(video.src); - - ctx.canvas.toBlob((thumbnailBlob) => { - resolve({ - duration, - width, - height, - mimeType, - thumbnailBlob - }); - }, 'image/jpeg', 0.75); - }; - - video.src = URL.createObjectURL(file); - // required for iPhone Safari to load the video contents for the thumbnail - video.load(); - }); -} diff --git a/packages/koenig-lexical/src/utils/extractVideoMetadata.ts b/packages/koenig-lexical/src/utils/extractVideoMetadata.ts new file mode 100644 index 0000000000..1a5200d8ed --- /dev/null +++ b/packages/koenig-lexical/src/utils/extractVideoMetadata.ts @@ -0,0 +1,58 @@ +// taken from https://github.com/TryGhost/Ghost/blob/main/ghost/admin/lib/koenig-editor/addon/utils/extract-video-metadata.js + +interface VideoMetadata { + duration: number; + width: number; + height: number; + mimeType: string; + thumbnailBlob: Blob | null; +} + +export default function extractVideoMetadata(file: File): Promise { + return new Promise((resolve, reject) => { + const mimeType = file.type; + let duration: number, width: number, height: number; + + const video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + + video.onerror = reject; + + video.onloadedmetadata = function () { + duration = video.duration; + width = video.videoWidth; + height = video.videoHeight; + }; + + video.oncanplay = function () { + video.currentTime = 0.5; + video.oncanplay = null; + }; + + video.onseeked = function () { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d')!; + ctx.drawImage(video, 0, 0, width, height); + + window.URL.revokeObjectURL(video.src); + + ctx.canvas.toBlob((thumbnailBlob) => { + resolve({ + duration, + width, + height, + mimeType, + thumbnailBlob + }); + }, 'image/jpeg', 0.75); + }; + + video.src = URL.createObjectURL(file); + // required for iPhone Safari to load the video contents for the thumbnail + video.load(); + }); +} diff --git a/packages/koenig-lexical/src/utils/fileUploadHandler.js b/packages/koenig-lexical/src/utils/fileUploadHandler.js deleted file mode 100644 index 442ac5268e..0000000000 --- a/packages/koenig-lexical/src/utils/fileUploadHandler.js +++ /dev/null @@ -1,33 +0,0 @@ -import {$getNodeByKey} from 'lexical'; - -export const stripFileExtension = (fileName) => { - const fileExtension = fileName.split('.').pop(); - const fileNameWithoutExtension = fileName.replace(`.${fileExtension}`, ''); - return fileNameWithoutExtension; -}; - -export const fileUploadHandler = async (files, nodeKey, editor, upload) => { - if (!files) { - return; - } - const result = await upload(files); - const meta = files; - const fileName = meta?.[0].name; - const fileSize = meta?.[0].size; - const src = result?.[0].url; - - let dataset = { - fileName: fileName, - fileSize: fileSize, - src: src - }; - await editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.fileTitle = stripFileExtension(dataset.fileName); // initially sets the title to the file name - node.fileName = dataset.fileName; - node.fileSize = dataset.fileSize; - node.src = dataset.src; - }); - - return; -}; diff --git a/packages/koenig-lexical/src/utils/fileUploadHandler.ts b/packages/koenig-lexical/src/utils/fileUploadHandler.ts new file mode 100644 index 0000000000..6a3b879ebb --- /dev/null +++ b/packages/koenig-lexical/src/utils/fileUploadHandler.ts @@ -0,0 +1,36 @@ +import {$getNodeByKey, LexicalEditor} from 'lexical'; +import type {GeneratedDecoratorNodeBase} from '@tryghost/kg-default-nodes'; + +export const stripFileExtension = (fileName: string) => { + const fileExtension = fileName.split('.').pop(); + const fileNameWithoutExtension = fileName.replace(`.${fileExtension}`, ''); + return fileNameWithoutExtension; +}; + +export const fileUploadHandler = async (files: File[], nodeKey: string, editor: LexicalEditor, upload: (files: File[]) => Promise<{url: string}[] | null>) => { + if (!files) { + return; + } + const result = await upload(files); + const meta = files; + const fileName = meta?.[0].name; + const fileSize = meta?.[0].size; + const src = result?.[0].url; + + const dataset = { + fileName: fileName, + fileSize: fileSize, + src: src + }; + await editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + node.fileTitle = stripFileExtension(dataset.fileName); // initially sets the title to the file name + node.fileName = dataset.fileName; + node.fileSize = dataset.fileSize; + node.src = dataset.src; + } + }); + + return; +}; diff --git a/packages/koenig-lexical/src/utils/generateEditorState.js b/packages/koenig-lexical/src/utils/generateEditorState.js deleted file mode 100644 index dc50802b2a..0000000000 --- a/packages/koenig-lexical/src/utils/generateEditorState.js +++ /dev/null @@ -1,44 +0,0 @@ -import {$createParagraphNode, $setSelection} from 'lexical'; -import {$generateNodesFromDOM} from '@lexical/html'; -import {$getRoot, $insertNodes} from 'lexical'; - -// exported for testing -export function _$generateNodesFromHTML(editor, html) { - const parser = new DOMParser(); - const dom = parser.parseFromString(html, 'text/html'); - const nodes = $generateNodesFromDOM(editor, dom); - return nodes; -} - -export default function generateEditorState({editor, initialHtml}) { - if (initialHtml) { - // convert html in `text` to Lexical nodes and populate the editor - editor.update(() => { - const nodes = _$generateNodesFromHTML(editor, initialHtml); - - // Select the root - $getRoot().select(); - // Clear existing content (we initialize an editor with an empty p node so it is focusable if there's no content) - $getRoot().clear(); - - // Insert them at a selection. - $insertNodes(nodes); - - // $insertNodes is focusing an editor (https://github.com/facebook/lexical/issues/4546) - // This behaviour can break the ability to autofocus the editor because - // initial state filling can happen after the component is already mounted. - // Reset selection to make it easier to manage editor focus in components instead of editor state generation - if (nodes.length) { - $setSelection(null); - } - }, {discrete: true, tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state - } else { - // for empty initial values, create a paragraph because a completely empty - // root won't accept focus - editor.update(() => { - $getRoot().append($createParagraphNode()); - }, {discrete: true, tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state - } - - return editor.getEditorState(); -} diff --git a/packages/koenig-lexical/src/utils/generateEditorState.ts b/packages/koenig-lexical/src/utils/generateEditorState.ts new file mode 100644 index 0000000000..9568a5cde6 --- /dev/null +++ b/packages/koenig-lexical/src/utils/generateEditorState.ts @@ -0,0 +1,45 @@ +import {$createParagraphNode, $setSelection} from 'lexical'; +import {$generateNodesFromDOM} from '@lexical/html'; +import {$getRoot, $insertNodes} from 'lexical'; +import type {EditorState, LexicalEditor} from 'lexical'; + +// exported for testing +export function _$generateNodesFromHTML(editor: LexicalEditor, html: string) { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + const nodes = $generateNodesFromDOM(editor, dom); + return nodes; +} + +export default function generateEditorState({editor, initialHtml}: {editor: LexicalEditor; initialHtml?: string}): EditorState { + if (initialHtml) { + // convert html in `text` to Lexical nodes and populate the editor + editor.update(() => { + const nodes = _$generateNodesFromHTML(editor, initialHtml); + + // Select the root + $getRoot().select(); + // Clear existing content (we initialize an editor with an empty p node so it is focusable if there's no content) + $getRoot().clear(); + + // Insert them at a selection. + $insertNodes(nodes); + + // $insertNodes is focusing an editor (https://github.com/facebook/lexical/issues/4546) + // This behaviour can break the ability to autofocus the editor because + // initial state filling can happen after the component is already mounted. + // Reset selection to make it easier to manage editor focus in components instead of editor state generation + if (nodes.length) { + $setSelection(null); + } + }, {discrete: true, tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state + } else { + // for empty initial values, create a paragraph because a completely empty + // root won't accept focus + editor.update(() => { + $getRoot().append($createParagraphNode()); + }, {discrete: true, tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state + } + + return editor.getEditorState(); +} diff --git a/packages/koenig-lexical/src/utils/getAccentColor.js b/packages/koenig-lexical/src/utils/getAccentColor.ts similarity index 100% rename from packages/koenig-lexical/src/utils/getAccentColor.js rename to packages/koenig-lexical/src/utils/getAccentColor.ts diff --git a/packages/koenig-lexical/src/utils/getAudioMetadata.js b/packages/koenig-lexical/src/utils/getAudioMetadata.js deleted file mode 100644 index 3ff0e871e2..0000000000 --- a/packages/koenig-lexical/src/utils/getAudioMetadata.js +++ /dev/null @@ -1,16 +0,0 @@ -// gets image dimensions from a given Url - -export async function getAudioMetadata(url) { - let audio = new Audio(); - let duration; - - return new Promise((resolve) => { - audio.onloadedmetadata = function () { - duration = audio.duration; - resolve({ - duration - }); - }; - audio.src = url; - }); -} diff --git a/packages/koenig-lexical/src/utils/getAudioMetadata.ts b/packages/koenig-lexical/src/utils/getAudioMetadata.ts new file mode 100644 index 0000000000..bdfc0deff9 --- /dev/null +++ b/packages/koenig-lexical/src/utils/getAudioMetadata.ts @@ -0,0 +1,16 @@ +// gets image dimensions from a given Url + +export async function getAudioMetadata(url: string): Promise<{duration: number}> { + const audio = new Audio(); + let duration: number; + + return new Promise((resolve) => { + audio.onloadedmetadata = function () { + duration = audio.duration; + resolve({ + duration + }); + }; + audio.src = url; + }); +} diff --git a/packages/koenig-lexical/src/utils/getDOMRangeRect.js b/packages/koenig-lexical/src/utils/getDOMRangeRect.js deleted file mode 100644 index a5c421c870..0000000000 --- a/packages/koenig-lexical/src/utils/getDOMRangeRect.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -export function getDOMRangeRect( - nativeSelection, - rootElement, -) { - const domRange = nativeSelection.getRangeAt(0); - - let rect; - - if (nativeSelection.anchorNode === rootElement) { - let inner = rootElement; - while (inner.firstElementChild != null) { - inner = inner.firstElementChild; - } - rect = inner.getBoundingClientRect(); - } else { - rect = domRange.getBoundingClientRect(); - } - - return rect; -} diff --git a/packages/koenig-lexical/src/utils/getDOMRangeRect.ts b/packages/koenig-lexical/src/utils/getDOMRangeRect.ts new file mode 100644 index 0000000000..7fad8c67db --- /dev/null +++ b/packages/koenig-lexical/src/utils/getDOMRangeRect.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +export function getDOMRangeRect( + nativeSelection: Selection, + rootElement: HTMLElement, +): DOMRect { + const domRange = nativeSelection.getRangeAt(0); + + let rect; + + if (nativeSelection.anchorNode === rootElement) { + let inner: Element = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + return rect; +} diff --git a/packages/koenig-lexical/src/utils/getEditorCardNodes.js b/packages/koenig-lexical/src/utils/getEditorCardNodes.js deleted file mode 100644 index 0be4ef35c8..0000000000 --- a/packages/koenig-lexical/src/utils/getEditorCardNodes.js +++ /dev/null @@ -1,15 +0,0 @@ -export function getEditorCardNodes(editor) { - // TODO: open upstream PR to add public method of getting nodes - const allNodes = editor._nodes; - const cardNodes = []; - - for (const [nodeType, {klass}] of allNodes) { - if (!klass.kgMenu) { - continue; - } - - cardNodes.push([nodeType, klass]); - } - - return cardNodes; -} diff --git a/packages/koenig-lexical/src/utils/getEditorCardNodes.ts b/packages/koenig-lexical/src/utils/getEditorCardNodes.ts new file mode 100644 index 0000000000..6314f7ede3 --- /dev/null +++ b/packages/koenig-lexical/src/utils/getEditorCardNodes.ts @@ -0,0 +1,21 @@ +import {getKoenigCardNodeClass, hasKoenigCardMenu} from './koenig-node-class'; +import {getRegisteredNodeClasses} from './lexical-internals'; +import type {KoenigCardMenuNodeClass} from './koenig-node-class'; +import type {LexicalEditor} from 'lexical'; + +export function getEditorCardNodes(editor: LexicalEditor): Array<[string, KoenigCardMenuNodeClass]> { + // TODO: open upstream PR to add public method of getting nodes + const allNodes = getRegisteredNodeClasses(editor); + const cardNodes: Array<[string, KoenigCardMenuNodeClass]> = []; + + for (const [nodeType, {klass}] of allNodes) { + const nodeClass = getKoenigCardNodeClass(klass); + if (!hasKoenigCardMenu(nodeClass)) { + continue; + } + + cardNodes.push([nodeType, nodeClass]); + } + + return cardNodes; +} diff --git a/packages/koenig-lexical/src/utils/getImageDimensions.js b/packages/koenig-lexical/src/utils/getImageDimensions.js deleted file mode 100644 index fb097563d8..0000000000 --- a/packages/koenig-lexical/src/utils/getImageDimensions.js +++ /dev/null @@ -1,13 +0,0 @@ -// gets image dimensions from a given Url - -export async function getImageDimensions(url) { - const img = new Image(); - return new Promise((resolve, reject) => { - img.onload = () => { - resolve({width: img.naturalWidth, height: img.naturalHeight}); - }; - img.onerror = reject; - // Set image src after listeners to avoid the image loading before the listener is set - img.src = url; - }); -} diff --git a/packages/koenig-lexical/src/utils/getImageDimensions.ts b/packages/koenig-lexical/src/utils/getImageDimensions.ts new file mode 100644 index 0000000000..1e598ad4eb --- /dev/null +++ b/packages/koenig-lexical/src/utils/getImageDimensions.ts @@ -0,0 +1,13 @@ +// gets image dimensions from a given Url + +export async function getImageDimensions(url: string): Promise<{width: number; height: number}> { + const img = new Image(); + return new Promise((resolve, reject) => { + img.onload = () => { + resolve({width: img.naturalWidth, height: img.naturalHeight}); + }; + img.onerror = reject; + // Set image src after listeners to avoid the image loading before the listener is set + img.src = url; + }); +} diff --git a/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.js b/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.js deleted file mode 100644 index 27abf123b2..0000000000 --- a/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.js +++ /dev/null @@ -1,5 +0,0 @@ -export function getImageFilenameFromSrc(src) { - const url = new URL(src); - const fileName = url.pathname.match(/\/([^/]*)$/)[1]; - return fileName; -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.ts b/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.ts new file mode 100644 index 0000000000..f3a4793cf0 --- /dev/null +++ b/packages/koenig-lexical/src/utils/getImageFilenameFromSrc.ts @@ -0,0 +1,5 @@ +export function getImageFilenameFromSrc(src: string): string { + const url = new URL(src); + const fileName = url.pathname.match(/\/([^/]*)$/)![1]; + return fileName; +} \ No newline at end of file diff --git a/packages/koenig-lexical/src/utils/getScrollParent.js b/packages/koenig-lexical/src/utils/getScrollParent.js deleted file mode 100644 index 04b2af281c..0000000000 --- a/packages/koenig-lexical/src/utils/getScrollParent.js +++ /dev/null @@ -1,13 +0,0 @@ -export function getScrollParent(node) { - const isElement = node instanceof HTMLElement; - const overflowY = isElement && window.getComputedStyle(node).overflowY; - const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; - - if (!node) { - return null; - } else if (isScrollable && node.scrollHeight >= node.clientHeight) { - return node; - } - - return getScrollParent(node.parentNode) || document.body; -} diff --git a/packages/koenig-lexical/src/utils/getScrollParent.ts b/packages/koenig-lexical/src/utils/getScrollParent.ts new file mode 100644 index 0000000000..c266fb493c --- /dev/null +++ b/packages/koenig-lexical/src/utils/getScrollParent.ts @@ -0,0 +1,15 @@ +export function getScrollParent(node: Node | null): HTMLElement | null { + if (!node) { + return null; + } + + const isElement = node instanceof HTMLElement; + const overflowY = isElement && window.getComputedStyle(node).overflowY; + const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; + + if (isScrollable && isElement && node.scrollHeight >= node.clientHeight) { + return node; + } + + return getScrollParent(node.parentNode) || document.body; +} diff --git a/packages/koenig-lexical/src/utils/getSelectedNode.js b/packages/koenig-lexical/src/utils/getSelectedNode.js deleted file mode 100644 index 86af1ea8f5..0000000000 --- a/packages/koenig-lexical/src/utils/getSelectedNode.js +++ /dev/null @@ -1,17 +0,0 @@ -import {$isAtNodeEnd} from '@lexical/selection'; - -export function getSelectedNode(selection) { - const anchor = selection.anchor; - const focus = selection.focus; - const anchorNode = selection.anchor.getNode(); - const focusNode = selection.focus.getNode(); - if (anchorNode === focusNode) { - return anchorNode; - } - const isBackward = selection.isBackward(); - if (isBackward) { - return $isAtNodeEnd(focus) ? anchorNode : focusNode; - } else { - return $isAtNodeEnd(anchor) ? focusNode : anchorNode; - } -} diff --git a/packages/koenig-lexical/src/utils/getSelectedNode.ts b/packages/koenig-lexical/src/utils/getSelectedNode.ts new file mode 100644 index 0000000000..f649e0adcd --- /dev/null +++ b/packages/koenig-lexical/src/utils/getSelectedNode.ts @@ -0,0 +1,18 @@ +import {$isAtNodeEnd} from '@lexical/selection'; +import type {LexicalNode, RangeSelection} from 'lexical'; + +export function getSelectedNode(selection: RangeSelection): LexicalNode { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} diff --git a/packages/koenig-lexical/src/utils/getTopLevelNativeElement.js b/packages/koenig-lexical/src/utils/getTopLevelNativeElement.js deleted file mode 100644 index dac95f4170..0000000000 --- a/packages/koenig-lexical/src/utils/getTopLevelNativeElement.js +++ /dev/null @@ -1,8 +0,0 @@ -export function getTopLevelNativeElement(node) { - if (node.nodeType === Node.TEXT_NODE) { - node = node.parentNode; - } - - const selector = '[data-lexical-editor] > *'; - return node.closest(selector); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/utils/getTopLevelNativeElement.ts b/packages/koenig-lexical/src/utils/getTopLevelNativeElement.ts new file mode 100644 index 0000000000..604729f174 --- /dev/null +++ b/packages/koenig-lexical/src/utils/getTopLevelNativeElement.ts @@ -0,0 +1,15 @@ +export function getTopLevelNativeElement(node: Node | null): Element | null { + if (!node) { + return null; + } + let element: Element | null; + + if (node.nodeType === Node.TEXT_NODE) { + element = node.parentElement; + } else { + element = node as Element; + } + + const selector = '[data-lexical-editor] > *'; + return element?.closest(selector) ?? null; +} diff --git a/packages/koenig-lexical/src/utils/image-card-widths.js b/packages/koenig-lexical/src/utils/image-card-widths.js deleted file mode 100644 index 2330df622c..0000000000 --- a/packages/koenig-lexical/src/utils/image-card-widths.js +++ /dev/null @@ -1,23 +0,0 @@ -const VALID_IMAGE_CARD_WIDTHS = ['regular', 'wide', 'full']; - -export function getAllowedImageCardWidths(configuredWidths) { - if (!Array.isArray(configuredWidths)) { - return VALID_IMAGE_CARD_WIDTHS; - } - - const filteredWidths = [...new Set(configuredWidths.filter(width => VALID_IMAGE_CARD_WIDTHS.includes(width)))]; - - if (filteredWidths.length === 0) { - return VALID_IMAGE_CARD_WIDTHS; - } - - return filteredWidths; -} - -export function getDefaultImageCardWidth(allowedWidths) { - if (allowedWidths.includes('regular')) { - return 'regular'; - } - - return allowedWidths[0]; -} diff --git a/packages/koenig-lexical/src/utils/image-card-widths.ts b/packages/koenig-lexical/src/utils/image-card-widths.ts new file mode 100644 index 0000000000..fd89dc9a7d --- /dev/null +++ b/packages/koenig-lexical/src/utils/image-card-widths.ts @@ -0,0 +1,26 @@ +import {CARD_WIDTHS, isCardWidth, type CardWidth} from '@tryghost/kg-default-nodes'; + +const VALID_IMAGE_CARD_WIDTHS = CARD_WIDTHS; +export type ImageCardWidth = CardWidth; + +export function getAllowedImageCardWidths(configuredWidths: unknown): readonly ImageCardWidth[] { + if (!Array.isArray(configuredWidths)) { + return VALID_IMAGE_CARD_WIDTHS; + } + + const filteredWidths = [...new Set(configuredWidths.filter(isCardWidth))]; + + if (filteredWidths.length === 0) { + return VALID_IMAGE_CARD_WIDTHS; + } + + return filteredWidths; +} + +export function getDefaultImageCardWidth(allowedWidths: readonly ImageCardWidth[]): ImageCardWidth { + if (allowedWidths.includes('regular')) { + return 'regular'; + } + + return allowedWidths[0]; +} diff --git a/packages/koenig-lexical/src/utils/imageUploadHandler.js b/packages/koenig-lexical/src/utils/imageUploadHandler.js deleted file mode 100644 index 4c95031301..0000000000 --- a/packages/koenig-lexical/src/utils/imageUploadHandler.js +++ /dev/null @@ -1,51 +0,0 @@ -import {$getNodeByKey} from 'lexical'; -import {getImageDimensions} from './getImageDimensions'; - -export const imageUploadHandler = async (files, nodeKey, editor, upload) => { - if (!files) { - return; - } - - // show preview via an object URL whilst upload is in progress - let previewUrl = URL.createObjectURL(files[0]); - if (previewUrl) { - await editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.previewSrc = previewUrl; - }); - } - - // use the local object URL to grab metadata - const {width, height} = await getImageDimensions(previewUrl); - - // perform the actual upload - const result = await upload(files); - const imageSrc = result?.[0].url; - - // replace preview URL with real URL and set image metadata - await editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.width = width; - node.height = height; - node.src = imageSrc; - node.previewSrc = null; - }); - - return; -}; - -export const backgroundImageUploadHandler = async (files, upload) => { - if (!files) { - return; - } - const result = await upload(files); - const imageSrc = result?.[0].url; - - const {width, height} = await getImageDimensions(imageSrc); - - return { - imageSrc, - width, - height - }; -}; diff --git a/packages/koenig-lexical/src/utils/imageUploadHandler.ts b/packages/koenig-lexical/src/utils/imageUploadHandler.ts new file mode 100644 index 0000000000..1320049a5a --- /dev/null +++ b/packages/koenig-lexical/src/utils/imageUploadHandler.ts @@ -0,0 +1,56 @@ +import {$getNodeByKey, LexicalEditor} from 'lexical'; +import {getImageDimensions} from './getImageDimensions'; +import type {GeneratedDecoratorNodeBase} from '@tryghost/kg-default-nodes'; + +export const imageUploadHandler = async (files: File[] | FileList, nodeKey: string, editor: LexicalEditor, upload: (files: File[] | FileList) => Promise<{url: string}[] | null>) => { + if (!files) { + return; + } + + // show preview via an object URL whilst upload is in progress + const previewUrl = URL.createObjectURL(files[0]); + if (previewUrl) { + await editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + node.previewSrc = previewUrl; + } + }); + } + + // use the local object URL to grab metadata + const {width, height} = await getImageDimensions(previewUrl); + + // perform the actual upload + const result = await upload(files); + const imageSrc = result?.[0].url; + + // replace preview URL with real URL and set image metadata + await editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + node.width = width; + node.height = height; + node.src = imageSrc; + node.previewSrc = null; + } + }); + + return; +}; + +export const backgroundImageUploadHandler = async (files: File[], upload: (files: File[]) => Promise<{url: string}[] | null>) => { + if (!files) { + return; + } + const result = await upload(files); + const imageSrc = result?.[0].url; + + const {width, height} = await getImageDimensions(imageSrc!); + + return { + imageSrc, + width, + height + }; +}; diff --git a/packages/koenig-lexical/src/utils/index.js b/packages/koenig-lexical/src/utils/index.ts similarity index 100% rename from packages/koenig-lexical/src/utils/index.js rename to packages/koenig-lexical/src/utils/index.ts diff --git a/packages/koenig-lexical/src/utils/isEditorEmpty.js b/packages/koenig-lexical/src/utils/isEditorEmpty.js deleted file mode 100644 index ebfc16838f..0000000000 --- a/packages/koenig-lexical/src/utils/isEditorEmpty.js +++ /dev/null @@ -1,9 +0,0 @@ -import {$canShowPlaceholderCurry} from '@lexical/text'; - -export function isEditorEmpty(editor) { - // NOTE: This feels hacky but was required because we check editor empty state - // when rendering cards to determine whether to show nested editors. But - // _after an undo_ at the point we check the nested editor state is not yet fully committed - const editorState = editor._pendingEditorState || editor.getEditorState(); - return editorState.read($canShowPlaceholderCurry(false)); -} diff --git a/packages/koenig-lexical/src/utils/isEditorEmpty.ts b/packages/koenig-lexical/src/utils/isEditorEmpty.ts new file mode 100644 index 0000000000..0ec8510919 --- /dev/null +++ b/packages/koenig-lexical/src/utils/isEditorEmpty.ts @@ -0,0 +1,11 @@ +import {$canShowPlaceholderCurry} from '@lexical/text'; +import {getPendingEditorState} from './lexical-internals'; +import type {LexicalEditor} from 'lexical'; + +export function isEditorEmpty(editor: LexicalEditor): boolean { + // NOTE: This feels hacky but was required because we check editor empty state + // when rendering cards to determine whether to show nested editors. But + // _after an undo_ at the point we check the nested editor state is not yet fully committed + const editorState = getPendingEditorState(editor) || editor.getEditorState(); + return editorState.read($canShowPlaceholderCurry(false)); +} diff --git a/packages/koenig-lexical/src/utils/isGif.js b/packages/koenig-lexical/src/utils/isGif.js deleted file mode 100644 index 722c2d22be..0000000000 --- a/packages/koenig-lexical/src/utils/isGif.js +++ /dev/null @@ -1,3 +0,0 @@ -export function isGif(url) { - return /\.(gif)$/.test(url); -} diff --git a/packages/koenig-lexical/src/utils/isGif.ts b/packages/koenig-lexical/src/utils/isGif.ts new file mode 100644 index 0000000000..f59197e74d --- /dev/null +++ b/packages/koenig-lexical/src/utils/isGif.ts @@ -0,0 +1,3 @@ +export function isGif(url: string): boolean { + return /\.(gif)$/.test(url); +} diff --git a/packages/koenig-lexical/src/utils/isInternalUrl.js b/packages/koenig-lexical/src/utils/isInternalUrl.js deleted file mode 100644 index 647b4229b8..0000000000 --- a/packages/koenig-lexical/src/utils/isInternalUrl.js +++ /dev/null @@ -1,14 +0,0 @@ -export function isInternalUrl(url, siteUrl) { - if (!url || !siteUrl) { - return false; - } - - try { - const urlObj = new URL(url); - const subdir = `/${new URL(siteUrl).pathname.split('/')[1]}`; - return urlObj.hostname === new URL(siteUrl).hostname - && urlObj.pathname.startsWith(subdir); - } catch (e) { - return false; - } -} diff --git a/packages/koenig-lexical/src/utils/isInternalUrl.ts b/packages/koenig-lexical/src/utils/isInternalUrl.ts new file mode 100644 index 0000000000..40dcc5a6a2 --- /dev/null +++ b/packages/koenig-lexical/src/utils/isInternalUrl.ts @@ -0,0 +1,14 @@ +export function isInternalUrl(url: string, siteUrl: string): boolean { + if (!url || !siteUrl) { + return false; + } + + try { + const urlObj = new URL(url); + const subdir = `/${new URL(siteUrl).pathname.split('/')[1]}`; + return urlObj.hostname === new URL(siteUrl).hostname + && urlObj.pathname.startsWith(subdir); + } catch { + return false; + } +} diff --git a/packages/koenig-lexical/src/utils/koenig-node-class.ts b/packages/koenig-lexical/src/utils/koenig-node-class.ts new file mode 100644 index 0000000000..d4e2e825cb --- /dev/null +++ b/packages/koenig-lexical/src/utils/koenig-node-class.ts @@ -0,0 +1,20 @@ +import type {CardMenuItem} from './buildCardMenu'; + +type KoenigCardMenu = CardMenuItem | CardMenuItem[]; + +export interface KoenigCardNodeClass { + kgMenu?: KoenigCardMenu; + uploadType?: string; +} + +export interface KoenigCardMenuNodeClass extends KoenigCardNodeClass { + kgMenu: KoenigCardMenu; +} + +export function getKoenigCardNodeClass(nodeClass: unknown): KoenigCardNodeClass { + return nodeClass as KoenigCardNodeClass; +} + +export function hasKoenigCardMenu(nodeClass: KoenigCardNodeClass): nodeClass is KoenigCardMenuNodeClass { + return Boolean(nodeClass.kgMenu); +} diff --git a/packages/koenig-lexical/src/utils/lexical-internals.ts b/packages/koenig-lexical/src/utils/lexical-internals.ts new file mode 100644 index 0000000000..d707c0e0c7 --- /dev/null +++ b/packages/koenig-lexical/src/utils/lexical-internals.ts @@ -0,0 +1,18 @@ +import type {EditorState, LexicalEditor} from 'lexical'; + +// Lexical does not expose public APIs for a few editor relationships and node +// registry details that Koenig needs. Keep those private-field reads isolated +// here so version-specific assumptions are named and easy to audit when +// upgrading Lexical. + +export function getParentEditor(editor: LexicalEditor): LexicalEditor | null { + return editor._parentEditor ?? null; +} + +export function getPendingEditorState(editor: LexicalEditor): EditorState | null { + return editor._pendingEditorState ?? null; +} + +export function getRegisteredNodeClasses(editor: LexicalEditor): Iterable<[string, {klass: unknown}]> { + return editor._nodes; +} diff --git a/packages/koenig-lexical/src/utils/nested-editors.js b/packages/koenig-lexical/src/utils/nested-editors.js deleted file mode 100644 index a90935d3c5..0000000000 --- a/packages/koenig-lexical/src/utils/nested-editors.js +++ /dev/null @@ -1,53 +0,0 @@ -import generateEditorState from './generateEditorState'; -import {MINIMAL_NODES} from '../index.js'; -import {createEditor} from 'lexical'; - -const BLANK_EDITOR_STATE = JSON.stringify({ - root: { - children: [ - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } -}); - -export function setupNestedEditor(node, editorProperty, {editor, initialEditorState = BLANK_EDITOR_STATE, nodes = MINIMAL_NODES} = {}) { - if (editor) { - node[editorProperty] = editor; - } else { - node[editorProperty] = createEditor({nodes}); - - // set up a blank document with a paragraph otherwise the editor won't receive focus - const editorState = node[editorProperty].parseEditorState(initialEditorState); - node[editorProperty].setEditorState(editorState, {tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state - } -} - -export function populateNestedEditor(node, editorProperty, html) { - if (!html) { - return; - } - - const nestedEditor = node[editorProperty]; - const editorState = generateEditorState({ - editor: nestedEditor, - initialHtml: html - }); - - nestedEditor.setEditorState(editorState, {tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state - - // store the initial state separately as it's passed in to `` - // for use when there is no YJS document already stored - node[`${editorProperty}InitialState`] = editorState; -} diff --git a/packages/koenig-lexical/src/utils/nested-editors.ts b/packages/koenig-lexical/src/utils/nested-editors.ts new file mode 100644 index 0000000000..12600c347e --- /dev/null +++ b/packages/koenig-lexical/src/utils/nested-editors.ts @@ -0,0 +1,72 @@ +import generateEditorState from './generateEditorState'; +import {MINIMAL_NODES} from '../index'; +import {createEditor} from 'lexical'; +import type {Klass, LexicalEditor, LexicalNode, LexicalNodeReplacement} from 'lexical'; + +const BLANK_EDITOR_STATE = JSON.stringify({ + root: { + children: [ + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } +}); + +interface SetupNestedEditorOptions { + editor?: unknown; + initialEditorState?: string; + nodes?: ReadonlyArray | LexicalNodeReplacement>; +} + +// `LexicalEditor` is not exported as a value (so no `instanceof`) and the class +// name is minified in production, so we duck-type against the editor API instead +function isLexicalEditor(value: unknown): value is LexicalEditor { + return ( + typeof value === 'object' && + value !== null && + typeof (value as LexicalEditor).getEditorState === 'function' && + typeof (value as LexicalEditor).parseEditorState === 'function' && + typeof (value as LexicalEditor).setEditorState === 'function' + ); +} + +export function setupNestedEditor(node: Record, editorProperty: string, {editor, initialEditorState = BLANK_EDITOR_STATE, nodes = MINIMAL_NODES}: SetupNestedEditorOptions = {}) { + if (isLexicalEditor(editor)) { + node[editorProperty] = editor; + } else { + node[editorProperty] = createEditor({nodes}); + + // set up a blank document with a paragraph otherwise the editor won't receive focus + const editorState = (node[editorProperty] as LexicalEditor).parseEditorState(initialEditorState); + (node[editorProperty] as LexicalEditor).setEditorState(editorState, {tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state + } +} + +export function populateNestedEditor(node: Record, editorProperty: string, html: string | undefined) { + if (!html) { + return; + } + + const nestedEditor = node[editorProperty] as LexicalEditor; + const editorState = generateEditorState({ + editor: nestedEditor, + initialHtml: html + }); + + nestedEditor.setEditorState(editorState, {tag: 'history-merge'}); // use history merge to prevent undo clearing the initial state + + // store the initial state separately as it's passed in to `` + // for use when there is no YJS document already stored + node[`${editorProperty}InitialState`] = editorState; +} diff --git a/packages/koenig-lexical/src/utils/openFileSelection.js b/packages/koenig-lexical/src/utils/openFileSelection.js deleted file mode 100644 index cd3991c04a..0000000000 --- a/packages/koenig-lexical/src/utils/openFileSelection.js +++ /dev/null @@ -1,5 +0,0 @@ -// Triggers the file selection dialog from a given referenced element - -export function openFileSelection({fileInputRef}) { - fileInputRef.current?.click(); -} diff --git a/packages/koenig-lexical/src/utils/openFileSelection.ts b/packages/koenig-lexical/src/utils/openFileSelection.ts new file mode 100644 index 0000000000..94aac183c4 --- /dev/null +++ b/packages/koenig-lexical/src/utils/openFileSelection.ts @@ -0,0 +1,5 @@ +// Triggers the file selection dialog from a given referenced element + +export function openFileSelection({fileInputRef}: {fileInputRef: React.RefObject}): void { + fileInputRef.current?.click(); +} diff --git a/packages/koenig-lexical/src/utils/prettifyFileName.js b/packages/koenig-lexical/src/utils/prettifyFileName.js deleted file mode 100644 index bf9656e46c..0000000000 --- a/packages/koenig-lexical/src/utils/prettifyFileName.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function prettifyFileName(filename) { - if (!filename || typeof filename !== 'string') { - return ''; - } - let updatedName = filename.split('.').slice(0, -1).join('.').replace(/[-_]/g,' ').replace(/[^\w\s]+/g,'').replace(/\s\s+/g, ' '); - return updatedName.charAt(0).toUpperCase() + updatedName.slice(1); -} diff --git a/packages/koenig-lexical/src/utils/prettifyFileName.ts b/packages/koenig-lexical/src/utils/prettifyFileName.ts new file mode 100644 index 0000000000..86778369df --- /dev/null +++ b/packages/koenig-lexical/src/utils/prettifyFileName.ts @@ -0,0 +1,7 @@ +export default function prettifyFileName(filename: string): string { + if (!filename || typeof filename !== 'string') { + return ''; + } + const updatedName = filename.split('.').slice(0, -1).join('.').replace(/[-_]/g,' ').replace(/[^\w\s]+/g,'').replace(/\s\s+/g, ' '); + return updatedName.charAt(0).toUpperCase() + updatedName.slice(1); +} diff --git a/packages/koenig-lexical/src/utils/sanitize-html.js b/packages/koenig-lexical/src/utils/sanitize-html.js deleted file mode 100644 index 801298c9f6..0000000000 --- a/packages/koenig-lexical/src/utils/sanitize-html.js +++ /dev/null @@ -1,23 +0,0 @@ -import DOMPurify from 'dompurify'; - -export function sanitizeHtml(html = '', options = {}) { - options = { - ...{replaceJS: true}, - ...options - }; - - // replace script and iFrame - if (options.replaceJS) { - html = html.replace(/)<[^<]*)*<\/script>/gi, - '
    Embedded JavaScript
    '); - html = html.replace(/)<[^<]*)*<\/iframe>/gi, - '
    Embedded iFrame
    '); - } - - // sanitize html - return DOMPurify.sanitize(html, { - ALLOWED_URI_REGEXP: /^(?:https?:|\/|blob:)/, - ADD_ATTR: ['id'], - FORBID_TAGS: ['style'] - }); -} diff --git a/packages/koenig-lexical/src/utils/sanitize-html.ts b/packages/koenig-lexical/src/utils/sanitize-html.ts new file mode 100644 index 0000000000..17338ecf33 --- /dev/null +++ b/packages/koenig-lexical/src/utils/sanitize-html.ts @@ -0,0 +1,40 @@ +import DOMPurify from 'dompurify'; + +export function sanitizeHtml(html = '', options: {replaceJS?: boolean} = {}) { + options = { + ...{replaceJS: true}, + ...options + }; + + // replace script and iFrame + if (options.replaceJS) { + html = replaceEmbedsWithPlaceholders(html); + } + + // sanitize html + return DOMPurify.sanitize(html, { + ALLOWED_URI_REGEXP: /^(?:https?:|\/|blob:)/, + ADD_ATTR: ['id'], + FORBID_TAGS: ['style'] + }); +} + +function replaceEmbedsWithPlaceholders(html: string) { + const template = document.createElement('template'); + template.innerHTML = html; + + replaceElements(template, 'script', 'js-embed-placeholder', 'Embedded JavaScript'); + replaceElements(template, 'iframe', 'iframe-embed-placeholder', 'Embedded iFrame'); + + return template.innerHTML; +} + +function replaceElements(template: HTMLTemplateElement, selector: string, className: string, textContent: string) { + template.content.querySelectorAll(selector).forEach((element) => { + const placeholder = document.createElement('pre'); + placeholder.className = className; + placeholder.textContent = textContent; + + element.replaceWith(placeholder); + }); +} diff --git a/packages/koenig-lexical/src/utils/services/gif.js b/packages/koenig-lexical/src/utils/services/gif.js deleted file mode 100644 index 8d91d53ae5..0000000000 --- a/packages/koenig-lexical/src/utils/services/gif.js +++ /dev/null @@ -1,285 +0,0 @@ -import debounce from 'lodash/debounce'; -import {useRef, useState} from 'react'; - -const API_VERSION = 'v2'; -const DEBOUNCE_MS = 600; - -// Both Tenor and Klipy expose a Tenor-compatible v2 API; only the base URL and -// API key differ, so one client serves either provider. -const PROVIDER_API_URLS = { - klipy: 'https://api.klipy.com', - tenor: 'https://tenor.googleapis.com' -}; - -export const ERROR_TYPE = { - COMMON: 'common', - INVALID_API_KEY: 'invalid_key' -}; - -// Resolve which GIF provider to use from the editor's cardConfig. Klipy takes -// precedence when both are configured; returns null when neither is set. -export function getGifProviderConfig(cardConfig) { - if (cardConfig?.klipy?.apiKey) { - return { - provider: 'klipy', - apiUrl: PROVIDER_API_URLS.klipy, - apiKey: cardConfig.klipy.apiKey, - contentFilter: cardConfig.klipy.contentFilter || 'off' - }; - } - if (cardConfig?.tenor?.googleApiKey) { - return { - provider: 'tenor', - apiUrl: PROVIDER_API_URLS.tenor, - apiKey: cardConfig.tenor.googleApiKey, - contentFilter: cardConfig.tenor.contentFilter || 'off' - }; - } - return null; -} - -// Tenor returns {error: {message}}; Klipy returns {result: false, errors: {message: [...]}}. -export function extractErrorMessage(json) { - const klipyMessage = json?.errors?.message; - return json?.error?.message - || json?.error - || (Array.isArray(klipyMessage) ? klipyMessage[0] : klipyMessage) - || 'Unknown error'; -} - -// Detect an invalid-API-key error from the provider's message. Tenor and Klipy -// word these differently (Tenor: "API key not valid"; Klipy: "The provided API -// key is invalid"), so match the phrasing they share. -export function isInvalidKeyError(message) { - const text = message || ''; - return /api key/i.test(text) && /(invalid|not valid)/i.test(text); -} - -export function useGif({config}) { - const [columns, setColumns] = useState([]); - const [error, setError] = useState(null); - const [isLoading, setLoading] = useState(false); - const [isLazyLoading, setLazyLoading] = useState(false); - const [gifs, setGifs] = useState([]); - - // useRef const for internal calculations - const nextPos = useRef(null); - const loadedType = useRef(''); - const columnHeights = useRef([]); - const lastRequestArgs = useRef(null); - const searchTerm = useRef(''); - const columnCount = useRef(4); - // There are a lot of calculations for columns/gifs, and there is no need to update the state every time. - // Use this const for computations; once everything is ready, update columns/gifs state for external usage. - const internalStateColumns = useRef([]); - const internalStateGifs = useRef([]); - - function search(term) { - searchTerm.current = term; - reset(); - - if (term) { - return searchTask(term); - } else { - return loadTrendingGifs(term); - } - } - - const updateSearch = debounce((term = '') => search(term), DEBOUNCE_MS); - - async function searchTask(term) { - loadedType.current = 'search'; - - await makeRequest(loadedType.current, {params: { - q: term, - media_filter: 'tinygif,gif' - }}); - } - - async function loadTrendingGifs() { - loadedType.current = 'featured'; - - await makeRequest(loadedType.current, {params: { - q: 'excited', - media_filter: 'tinygif,gif' - }}); - } - - function reset() { - internalStateGifs.current = []; - nextPos.current = null; - resetColumns(); - } - - function resetColumns() { - let newColumns = []; - let newColumnHeights = []; - - // pre-fill column arrays based on columnCount - for (let i = 0; i < columnCount.current; i += 1) { - newColumns[i] = []; - newColumnHeights[i] = 0; - } - - internalStateColumns.current = newColumns; - columnHeights.current = newColumnHeights; - - if (internalStateGifs.current.length) { - adjustToNewColumnCount(); - } - } - - function adjustToNewColumnCount() { - internalStateGifs.current.forEach((gif) => { - addGifToColumns(gif); - }); - } - - function addGifToColumns(gif) { - const min = Math.min(...columnHeights.current); - const columnIndex = columnHeights.current.indexOf(min); - - // use a fixed width when calculating height to compensate for different overall sizes - columnHeights.current[columnIndex] += 300 * gif.ratio; - internalStateColumns.current[columnIndex].push(gif); - - // store the column indexes on the gif for use in keyboard nav - gif.columnIndex = columnIndex; - gif.columnRowIndex = internalStateColumns.current[columnIndex].length - 1; - } - - function addGif(gif, gifIndex) { - // re-calculate ratio for later use - const [width, height] = gif.media_formats.tinygif.dims; - gif.ratio = height / width; - - // add to general gifs list - internalStateGifs.current.push(gif); - - // store index for use in templates and keyboard nav - gif.index = gifIndex; - - // add to least populated column - addGifToColumns(gif); - } - - async function makeRequest(path, options) { - const versionedPath = `${API_VERSION}/${path}`.replace(/\/+/, '/'); - const url = new URL(versionedPath, config.apiUrl); - - const params = new URLSearchParams(options.params); - params.set('key', config.apiKey); - params.set('client_key', 'ghost-editor'); - params.set('contentfilter', getContentFilter()); - - url.search = params.toString(); - - // store the url so it can be retried if needed - lastRequestArgs.current = arguments; - - setError(null); - setLoading(true); - - return fetch(url) - .then(response => checkStatus(response)) - .then(response => response.json()) - .then(response => extractPagination(response)) - .then(response => addGifsFromResponse(response)) - .then(() => { - setColumns(internalStateColumns.current); - setGifs(internalStateGifs.current); - }) - .catch((e) => { - // e.message is the provider's error text (from checkStatus) or a - // fetch connection error. - if (!options.ignoreErrors) { - setError(isInvalidKeyError(e?.message) ? ERROR_TYPE.INVALID_API_KEY : ERROR_TYPE.COMMON); - } - console.error(e); - }) - .finally(() => { - setLoading(false); - setLazyLoading(false); - }); - } - - async function checkStatus(response) { - // successful request - if (response.status >= 200 && response.status < 300) { - return response; - } - - let responseText; - - if (response.headers.map['content-type'].startsWith('application/json')) { - responseText = await response.json().then(json => extractErrorMessage(json)); - } else if (response.headers.map['content-type'] === 'text/xml') { - responseText = await response.text(); - } - - setError(responseText); - - const responseError = new Error(responseText); - responseError.response = response; - throw responseError; - } - - async function extractPagination(response) { - nextPos.current = response.next; - return response; - } - - async function addGifsFromResponse(response) { - const newGifs = response.results; - newGifs.forEach((gif, index) => addGif(gif, index)); - - return response; - } - - function loadNextPage() { - // protect against scroll trigger firing when the gifs are reset - if (isLoading) { - return; - } - - if (!internalStateGifs.current.length) { - return loadTrendingGifs(); - } - - if (nextPos.current !== null) { - const params = { - pos: nextPos.current, - media_filter: 'tinygif,gif' - }; - - if (loadedType.current === 'search') { - params.q = searchTerm.current; - } - - setLazyLoading(true); - - return makeRequest(loadedType.current, {params}); - } - } - - function getContentFilter() { - return config.contentFilter || 'off'; - } - - function changeColumnCount(count) { - columnCount.current = count; - resetColumns(); - setColumns(internalStateColumns.current); - } - - return { - updateSearch, - isLoading, - isLazyLoading, - error, - loadNextPage, - columns, - changeColumnCount, - gifs - }; -} diff --git a/packages/koenig-lexical/src/utils/services/gif.ts b/packages/koenig-lexical/src/utils/services/gif.ts new file mode 100644 index 0000000000..b1999a7951 --- /dev/null +++ b/packages/koenig-lexical/src/utils/services/gif.ts @@ -0,0 +1,308 @@ +import debounce from 'lodash/debounce'; +import {useRef, useState} from 'react'; + +const API_VERSION = 'v2'; +const DEBOUNCE_MS = 600; + +// Both Tenor and Klipy expose a Tenor-compatible v2 API; only the base URL and +// API key differ, so one client serves either provider. +const PROVIDER_API_URLS = { + klipy: 'https://api.klipy.com', + tenor: 'https://tenor.googleapis.com' +}; + +export const ERROR_TYPE = { + COMMON: 'common', + INVALID_API_KEY: 'invalid_key' +}; + +interface GifItem { + media_formats: {tinygif: {dims: [number, number]}}; + ratio: number; + columnIndex: number; + columnRowIndex: number; + index: number; + [key: string]: unknown; +} + +interface GifCardConfig { + klipy?: {apiKey?: string; contentFilter?: string} | null; + tenor?: {googleApiKey?: string; contentFilter?: string} | null; + [key: string]: unknown; +} + +interface GifProviderConfig { + provider: string; + apiUrl: string; + apiKey: string; + contentFilter: string; +} + +interface MakeRequestOptions { + params: Record; + ignoreErrors?: boolean; +} + +// Resolve which GIF provider to use from the editor's cardConfig. Klipy takes +// precedence when both are configured; returns null when neither is set. +export function getGifProviderConfig(cardConfig: GifCardConfig | null | undefined): GifProviderConfig | null { + if (cardConfig?.klipy?.apiKey) { + return { + provider: 'klipy', + apiUrl: PROVIDER_API_URLS.klipy, + apiKey: cardConfig.klipy.apiKey, + contentFilter: cardConfig.klipy.contentFilter || 'off' + }; + } + if (cardConfig?.tenor?.googleApiKey) { + return { + provider: 'tenor', + apiUrl: PROVIDER_API_URLS.tenor, + apiKey: cardConfig.tenor.googleApiKey, + contentFilter: cardConfig.tenor.contentFilter || 'off' + }; + } + return null; +} + +// Tenor returns {error: {message}}; Klipy returns {result: false, errors: {message: [...]}}. +export function extractErrorMessage(json: {error?: {message?: string} | string; errors?: {message?: string | string[]}; [key: string]: unknown}): string { + const klipyMessage = json?.errors?.message; + return (typeof json?.error === 'object' ? json?.error?.message : json?.error) + || (Array.isArray(klipyMessage) ? klipyMessage[0] : klipyMessage) + || 'Unknown error'; +} + +// Detect an invalid-API-key error from the provider's message. Tenor and Klipy +// word these differently (Tenor: "API key not valid"; Klipy: "The provided API +// key is invalid"), so match the phrasing they share. +export function isInvalidKeyError(message?: string): boolean { + const text = message || ''; + return /api key/i.test(text) && /(invalid|not valid)/i.test(text); +} + +export function useGif({config}: {config: GifProviderConfig}) { + const [columns, setColumns] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setLoading] = useState(false); + const [isLazyLoading, setLazyLoading] = useState(false); + const [gifs, setGifs] = useState([]); + + // useRef const for internal calculations + const nextPos = useRef(null); + const loadedType = useRef(''); + const columnHeights = useRef([]); + const searchTerm = useRef(''); + const columnCount = useRef(4); + // There are a lot of calculations for columns/gifs, and there is no need to update the state every time. + // Use this const for computations; once everything is ready, update columns/gifs state for external usage. + const internalStateColumns = useRef([]); + const internalStateGifs = useRef([]); + + function search(term: string) { + searchTerm.current = term; + reset(); + + if (term) { + return searchTask(term); + } else { + return loadTrendingGifs(); + } + } + + const updateSearch = debounce((term = '') => search(term), DEBOUNCE_MS); + + async function searchTask(term: string) { + loadedType.current = 'search'; + + await makeRequest(loadedType.current, {params: { + q: term, + media_filter: 'tinygif,gif' + }}); + } + + async function loadTrendingGifs() { + loadedType.current = 'featured'; + + await makeRequest(loadedType.current, {params: { + q: 'excited', + media_filter: 'tinygif,gif' + }}); + } + + function reset() { + internalStateGifs.current = []; + nextPos.current = null; + resetColumns(); + } + + function resetColumns() { + const newColumns: GifItem[][] = []; + const newColumnHeights: number[] = []; + + // pre-fill column arrays based on columnCount + for (let i = 0; i < columnCount.current; i += 1) { + newColumns[i] = []; + newColumnHeights[i] = 0; + } + + internalStateColumns.current = newColumns; + columnHeights.current = newColumnHeights; + + if (internalStateGifs.current.length) { + adjustToNewColumnCount(); + } + } + + function adjustToNewColumnCount() { + internalStateGifs.current.forEach((gif) => { + addGifToColumns(gif); + }); + } + + function addGifToColumns(gif: GifItem) { + const min = Math.min(...columnHeights.current); + const columnIndex = columnHeights.current.indexOf(min); + + // use a fixed width when calculating height to compensate for different overall sizes + columnHeights.current[columnIndex] += 300 * gif.ratio; + internalStateColumns.current[columnIndex].push(gif); + + // store the column indexes on the gif for use in keyboard nav + gif.columnIndex = columnIndex; + gif.columnRowIndex = internalStateColumns.current[columnIndex].length - 1; + } + + function addGif(gif: GifItem, gifIndex: number) { + // re-calculate ratio for later use + const [width, height] = gif.media_formats.tinygif.dims; + gif.ratio = height / width; + + // add to general gifs list + internalStateGifs.current.push(gif); + + // store index for use in templates and keyboard nav + gif.index = gifIndex; + + // add to least populated column + addGifToColumns(gif); + } + + async function makeRequest(path: string, options: MakeRequestOptions) { + const versionedPath = `${API_VERSION}/${path}`.replace(/\/+/, '/'); + const url = new URL(versionedPath, config.apiUrl); + + const params = new URLSearchParams(options.params); + params.set('key', config.apiKey); + params.set('client_key', 'ghost-editor'); + params.set('contentfilter', getContentFilter()); + + url.search = params.toString(); + + setError(null); + setLoading(true); + + return fetch(url) + .then(response => checkStatus(response)) + .then(response => response.json()) + .then(response => extractPagination(response)) + .then(response => addGifsFromResponse(response)) + .then(() => { + setColumns(internalStateColumns.current); + setGifs(internalStateGifs.current); + }) + .catch((e) => { + // e.message is the provider's error text (from checkStatus) or a + // fetch connection error. + if (!options.ignoreErrors) { + setError(isInvalidKeyError(e?.message) ? ERROR_TYPE.INVALID_API_KEY : ERROR_TYPE.COMMON); + } + console.error(e); + }) + .finally(() => { + setLoading(false); + setLazyLoading(false); + }); + } + + async function checkStatus(response: Response): Promise { + // successful request + if (response.status >= 200 && response.status < 300) { + return response; + } + + let responseText: string | undefined; + + const contentType = response.headers.get('content-type'); + if (contentType?.startsWith('application/json')) { + responseText = await response.json().then(json => extractErrorMessage(json)); + } else if (contentType === 'text/xml') { + responseText = await response.text(); + } + + setError(responseText || null); + + const responseError = new Error(responseText) as Error & {response: Response}; + responseError.response = response; + throw responseError; + } + + async function extractPagination(response: {next?: string; results: GifItem[]}) { + nextPos.current = response.next || null; + return response; + } + + async function addGifsFromResponse(response: {results: GifItem[]}) { + const newGifs = response.results; + newGifs.forEach((gif, index) => addGif(gif, index)); + + return response; + } + + function loadNextPage() { + // protect against scroll trigger firing when the gifs are reset + if (isLoading) { + return; + } + + if (!internalStateGifs.current.length) { + return loadTrendingGifs(); + } + + if (nextPos.current !== null) { + const params: Record = { + pos: nextPos.current, + media_filter: 'tinygif,gif' + }; + + if (loadedType.current === 'search') { + params.q = searchTerm.current; + } + + setLazyLoading(true); + + return makeRequest(loadedType.current, {params}); + } + } + + function getContentFilter() { + return config.contentFilter || 'off'; + } + + function changeColumnCount(count: number) { + columnCount.current = count; + resetColumns(); + setColumns(internalStateColumns.current); + } + + return { + updateSearch, + isLoading, + isLazyLoading, + error, + loadNextPage, + columns, + changeColumnCount, + gifs + }; +} diff --git a/packages/koenig-lexical/src/utils/setFloatingElemPosition.js b/packages/koenig-lexical/src/utils/setFloatingElemPosition.js deleted file mode 100644 index ad55be936f..0000000000 --- a/packages/koenig-lexical/src/utils/setFloatingElemPosition.js +++ /dev/null @@ -1,39 +0,0 @@ -const VERTICAL_GAP = 10; - -export function setFloatingElemPosition( - targetRect, - floatingElem, - anchorElem, - options = {} -) { - options = Object.assign({ - verticalGap: VERTICAL_GAP, - controlOpacity: false - }, options); - - const scrollerElem = anchorElem.parentElement; - - if (!targetRect || !scrollerElem || !floatingElem) { - return; - } - - const floatingElemRect = floatingElem.getBoundingClientRect(); - const editorScrollerRect = scrollerElem.getBoundingClientRect(); - - let top = targetRect.top - floatingElemRect.height - options.verticalGap; - let left = targetRect.left + targetRect.width / 2 - floatingElemRect.width / 2; - - if (left < editorScrollerRect.left) { - left = editorScrollerRect.left; - } - - if (left + floatingElemRect.width > editorScrollerRect.right) { - left = editorScrollerRect.right - floatingElemRect.width; - } - - if (options.controlOpacity) { - floatingElem.style.opacity = '1'; - } - floatingElem.style.top = `${top}px`; - floatingElem.style.left = `${left}px`; -} diff --git a/packages/koenig-lexical/src/utils/setFloatingElemPosition.ts b/packages/koenig-lexical/src/utils/setFloatingElemPosition.ts new file mode 100644 index 0000000000..d37395513d --- /dev/null +++ b/packages/koenig-lexical/src/utils/setFloatingElemPosition.ts @@ -0,0 +1,44 @@ +const VERTICAL_GAP = 10; + +interface FloatingElemOptions { + verticalGap?: number; + controlOpacity?: boolean; +} + +export function setFloatingElemPosition( + targetRect: DOMRect | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, + options: FloatingElemOptions = {} +) { + const resolvedOptions = Object.assign({ + verticalGap: VERTICAL_GAP, + controlOpacity: false + }, options); + + const scrollerElem = anchorElem.parentElement; + + if (!targetRect || !scrollerElem || !floatingElem) { + return; + } + + const floatingElemRect = floatingElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); + + const top = targetRect.top - floatingElemRect.height - resolvedOptions.verticalGap; + let left = targetRect.left + targetRect.width / 2 - floatingElemRect.width / 2; + + if (left < editorScrollerRect.left) { + left = editorScrollerRect.left; + } + + if (left + floatingElemRect.width > editorScrollerRect.right) { + left = editorScrollerRect.right - floatingElemRect.width; + } + + if (resolvedOptions.controlOpacity) { + floatingElem.style.opacity = '1'; + } + floatingElem.style.top = `${top}px`; + floatingElem.style.left = `${left}px`; +} diff --git a/packages/koenig-lexical/src/utils/shortcutSymbols.js b/packages/koenig-lexical/src/utils/shortcutSymbols.ts similarity index 100% rename from packages/koenig-lexical/src/utils/shortcutSymbols.js rename to packages/koenig-lexical/src/utils/shortcutSymbols.ts diff --git a/packages/koenig-lexical/src/utils/shouldIgnoreEvent.js b/packages/koenig-lexical/src/utils/shouldIgnoreEvent.js deleted file mode 100644 index c35c729a2a..0000000000 --- a/packages/koenig-lexical/src/utils/shouldIgnoreEvent.js +++ /dev/null @@ -1,28 +0,0 @@ -// util to avoid processing events in Koenig when they originate from an editor -// element inside a card -export const shouldIgnoreEvent = (event) => { - if (!event) { - return false; - } - - const {metaKey, key, target} = event; - const isEscape = key === 'Escape'; - const isMetaEnter = metaKey && key === 'Enter'; - - // we want to allow some keys presses to pass through as we - // always override them to toggle card editing mode - if (isEscape || isMetaEnter) { - return false; - } - - // Check for standard form inputs and CodeMirror editors. - // For cut events, CodeMirror may process the event first and remove the - // target element from the DOM before the event bubbles to Lexical, so - // target.closest('.cm-editor') would return null. Fall back to checking - // document.activeElement when the target is disconnected. - const isFromCardEditor = target.matches('input, textarea') - || !!target.closest('.cm-editor') - || (!target.isConnected && !!document.activeElement?.closest('.cm-editor')); - - return isFromCardEditor; -}; diff --git a/packages/koenig-lexical/src/utils/shouldIgnoreEvent.ts b/packages/koenig-lexical/src/utils/shouldIgnoreEvent.ts new file mode 100644 index 0000000000..819fd43109 --- /dev/null +++ b/packages/koenig-lexical/src/utils/shouldIgnoreEvent.ts @@ -0,0 +1,30 @@ +// util to avoid processing events in Koenig when they originate from an editor +// element inside a card +export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { + if (!event) { + return false; + } + + const metaKey = 'metaKey' in event ? event.metaKey : false; + const key = 'key' in event ? event.key : undefined; + const target = event.target as HTMLElement; + const isEscape = key === 'Escape'; + const isMetaEnter = metaKey && key === 'Enter'; + + // we want to allow some keys presses to pass through as we + // always override them to toggle card editing mode + if (isEscape || isMetaEnter) { + return false; + } + + // Check for standard form inputs and CodeMirror editors. + // For cut events, CodeMirror may process the event first and remove the + // target element from the DOM before the event bubbles to Lexical, so + // target.closest('.cm-editor') would return null. Fall back to checking + // document.activeElement when the target is disconnected. + const isFromCardEditor = target.matches('input, textarea') + || !!target.closest('.cm-editor') + || (!target.isConnected && !!document.activeElement?.closest('.cm-editor')); + + return isFromCardEditor; +}; diff --git a/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.js b/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.js deleted file mode 100644 index 527e23c16b..0000000000 --- a/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.js +++ /dev/null @@ -1,5 +0,0 @@ -import generateEditorState from '../generateEditorState'; - -export default function populateEditor({editor, initialHtml}) { - generateEditorState({editor, initialHtml}); -} \ No newline at end of file diff --git a/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.ts b/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.ts new file mode 100644 index 0000000000..3c366b871c --- /dev/null +++ b/packages/koenig-lexical/src/utils/storybook/populate-storybook-editor.ts @@ -0,0 +1,6 @@ +import generateEditorState from '../generateEditorState'; +import type {LexicalEditor} from 'lexical'; + +export default function populateEditor({editor, initialHtml}: {editor: LexicalEditor; initialHtml?: string}) { + generateEditorState({editor, initialHtml}); +} diff --git a/packages/koenig-lexical/src/utils/thumbnailUploadHandler.js b/packages/koenig-lexical/src/utils/thumbnailUploadHandler.js deleted file mode 100644 index fd60f68029..0000000000 --- a/packages/koenig-lexical/src/utils/thumbnailUploadHandler.js +++ /dev/null @@ -1,23 +0,0 @@ -import {$getNodeByKey} from 'lexical'; - -export const thumbnailUploadHandler = async (files, nodeKey, editor, upload) => { - if (!files) { - return; - } - - let mediaSrc = ''; - - editor.getEditorState().read(() => { - const node = $getNodeByKey(nodeKey); - mediaSrc = node.src; - }); - - const uploadResult = await upload(files, {formData: {url: mediaSrc}}); - - await editor.update(() => { - const node = $getNodeByKey(nodeKey); - node.thumbnailSrc = uploadResult[0].url; - }); - - return; -}; diff --git a/packages/koenig-lexical/src/utils/thumbnailUploadHandler.ts b/packages/koenig-lexical/src/utils/thumbnailUploadHandler.ts new file mode 100644 index 0000000000..db97da5bb9 --- /dev/null +++ b/packages/koenig-lexical/src/utils/thumbnailUploadHandler.ts @@ -0,0 +1,28 @@ +import {$getNodeByKey, LexicalEditor} from 'lexical'; +import type {GeneratedDecoratorNodeBase} from '@tryghost/kg-default-nodes'; + +export const thumbnailUploadHandler = async (files: File[], nodeKey: string, editor: LexicalEditor, upload: (files: File[], options?: {formData: {url: string}}) => Promise<{url: string}[]>) => { + if (!files) { + return; + } + + let mediaSrc = ''; + + editor.getEditorState().read(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + mediaSrc = node.src as string; + } + }); + + const uploadResult = await upload(files, {formData: {url: mediaSrc}}); + + await editor.update(() => { + const node = $getNodeByKey(nodeKey) as GeneratedDecoratorNodeBase | null; + if (node) { + node.thumbnailSrc = uploadResult[0].url; + } + }); + + return; +}; diff --git a/packages/koenig-lexical/src/utils/visibility.js b/packages/koenig-lexical/src/utils/visibility.js deleted file mode 100644 index 4155fa0b44..0000000000 --- a/packages/koenig-lexical/src/utils/visibility.js +++ /dev/null @@ -1,103 +0,0 @@ -import {buildDefaultVisibility} from '@tryghost/kg-default-nodes/visibility'; - -export const VISIBILITY_SETTINGS = { - WEB_AND_EMAIL: 'web and email', - WEB_ONLY: 'web only', - EMAIL_ONLY: 'email only', - NONE: 'none' -}; - -export function parseVisibilityToToggles(visibility) { - return { - web: { - nonMembers: visibility.web.nonMember, - freeMembers: visibility.web.memberSegment.indexOf('status:free') !== -1, - paidMembers: visibility.web.memberSegment.indexOf('status:-free') !== -1 - }, - email: { - freeMembers: visibility.email.memberSegment.indexOf('status:free') !== -1, - paidMembers: visibility.email.memberSegment.indexOf('status:-free') !== -1 - } - }; -} - -function isToggleChecked(toggles, key, fallback) { - return toggles.find(t => t.key === key)?.checked ?? fallback; -} - -// used for building UI -export function getVisibilityOptions(visibility, {isStripeEnabled = true, showWeb = true, showEmail = true} = {}) { - visibility = visibility || buildDefaultVisibility(); - const toggles = parseVisibilityToToggles(visibility); - - // use arrays to ensure consistent order when using to build UI - const options = [ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: toggles.web.nonMembers}, - {key: 'freeMembers', label: 'Free members', checked: toggles.web.freeMembers}, - {key: 'paidMembers', label: 'Paid members', checked: toggles.web.paidMembers} - ] - }, - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: toggles.email.freeMembers}, - {key: 'paidMembers', label: 'Paid members', checked: toggles.email.paidMembers} - ] - } - ]; - - if (!isStripeEnabled) { - options[0].toggles = options[0].toggles.filter(t => t.key !== 'paidMembers'); - options[1].toggles = options[1].toggles.filter(t => t.key !== 'paidMembers'); - } - - return options.filter((option) => { - if (option.key === 'web') { - return showWeb; - } - - if (option.key === 'email') { - return showEmail; - } - - return true; - }); -} - -export function serializeOptionsToVisibility(options, existingVisibility) { - existingVisibility = existingVisibility || buildDefaultVisibility(); - const existingToggles = parseVisibilityToToggles(existingVisibility); - const webToggles = options.find(group => group.key === 'web')?.toggles ?? []; - const emailToggles = options.find(group => group.key === 'email')?.toggles ?? []; - - const webSegments = []; - if (isToggleChecked(webToggles, 'freeMembers', existingToggles.web.freeMembers)) { - webSegments.push('status:free'); - } - if (isToggleChecked(webToggles, 'paidMembers', existingToggles.web.paidMembers)) { - webSegments.push('status:-free'); - } - - const emailSegments = []; - if (isToggleChecked(emailToggles, 'freeMembers', existingToggles.email.freeMembers)) { - emailSegments.push('status:free'); - } - if (isToggleChecked(emailToggles, 'paidMembers', existingToggles.email.paidMembers)) { - emailSegments.push('status:-free'); - } - - return { - web: { - nonMember: isToggleChecked(webToggles, 'nonMembers', existingToggles.web.nonMembers), - memberSegment: webSegments.join(',') - }, - email: { - memberSegment: emailSegments.join(',') - } - }; -} diff --git a/packages/koenig-lexical/src/utils/visibility.ts b/packages/koenig-lexical/src/utils/visibility.ts new file mode 100644 index 0000000000..36786ab45a --- /dev/null +++ b/packages/koenig-lexical/src/utils/visibility.ts @@ -0,0 +1,137 @@ +import {buildDefaultVisibility} from '@tryghost/kg-default-nodes/visibility'; + +export const VISIBILITY_SETTINGS = { + WEB_AND_EMAIL: 'web and email', + WEB_ONLY: 'web only', + EMAIL_ONLY: 'email only', + NONE: 'none' +}; + +export interface Visibility { + web: { + nonMember: boolean; + memberSegment: string; + }; + email: { + memberSegment: string; + }; +} + +export interface VisibilityToggles { + web: { + nonMembers: boolean; + freeMembers: boolean; + paidMembers: boolean; + }; + email: { + freeMembers: boolean; + paidMembers: boolean; + }; +} + +interface Toggle { + key: string; + label: string; + checked: boolean; +} + +export interface VisibilityOption { + label: string; + key: string; + toggles: Toggle[]; +} + +export function parseVisibilityToToggles(visibility: Visibility): VisibilityToggles { + return { + web: { + nonMembers: visibility.web.nonMember, + freeMembers: visibility.web.memberSegment.indexOf('status:free') !== -1, + paidMembers: visibility.web.memberSegment.indexOf('status:-free') !== -1 + }, + email: { + freeMembers: visibility.email.memberSegment.indexOf('status:free') !== -1, + paidMembers: visibility.email.memberSegment.indexOf('status:-free') !== -1 + } + }; +} + +function isToggleChecked(toggles: Toggle[], key: string, fallback: boolean): boolean { + return toggles.find(t => t.key === key)?.checked ?? fallback; +} + +// used for building UI +export function getVisibilityOptions(visibility: Visibility | undefined, {isStripeEnabled = true, showWeb = true, showEmail = true} = {}): VisibilityOption[] { + visibility = visibility || buildDefaultVisibility(); + const toggles = parseVisibilityToToggles(visibility); + + // use arrays to ensure consistent order when using to build UI + const options: VisibilityOption[] = [ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: toggles.web.nonMembers}, + {key: 'freeMembers', label: 'Free members', checked: toggles.web.freeMembers}, + {key: 'paidMembers', label: 'Paid members', checked: toggles.web.paidMembers} + ] + }, + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: toggles.email.freeMembers}, + {key: 'paidMembers', label: 'Paid members', checked: toggles.email.paidMembers} + ] + } + ]; + + if (!isStripeEnabled) { + options[0].toggles = options[0].toggles.filter(t => t.key !== 'paidMembers'); + options[1].toggles = options[1].toggles.filter(t => t.key !== 'paidMembers'); + } + + return options.filter((option) => { + if (option.key === 'web') { + return showWeb; + } + + if (option.key === 'email') { + return showEmail; + } + + return true; + }); +} + +export function serializeOptionsToVisibility(options: VisibilityOption[], existingVisibility?: Visibility) { + existingVisibility = existingVisibility || buildDefaultVisibility(); + const existingToggles = parseVisibilityToToggles(existingVisibility); + const webToggles = options.find(group => group.key === 'web')?.toggles ?? []; + const emailToggles = options.find(group => group.key === 'email')?.toggles ?? []; + + const webSegments: string[] = []; + if (isToggleChecked(webToggles, 'freeMembers', existingToggles.web.freeMembers)) { + webSegments.push('status:free'); + } + if (isToggleChecked(webToggles, 'paidMembers', existingToggles.web.paidMembers)) { + webSegments.push('status:-free'); + } + + const emailSegments: string[] = []; + if (isToggleChecked(emailToggles, 'freeMembers', existingToggles.email.freeMembers)) { + emailSegments.push('status:free'); + } + if (isToggleChecked(emailToggles, 'paidMembers', existingToggles.email.paidMembers)) { + emailSegments.push('status:-free'); + } + + return { + web: { + nonMember: isToggleChecked(webToggles, 'nonMembers', existingToggles.web.nonMembers), + memberSegment: webSegments.join(',') + }, + email: { + memberSegment: emailSegments.join(',') + } + }; +} diff --git a/packages/koenig-lexical/src/vite-env.d.ts b/packages/koenig-lexical/src/vite-env.d.ts new file mode 100644 index 0000000000..70e1683c35 --- /dev/null +++ b/packages/koenig-lexical/src/vite-env.d.ts @@ -0,0 +1,24 @@ +/// +/// + +declare const process: { + cwd: () => string; + env: Record; + platform: string; +}; + +interface ImportMetaEnv { + readonly VITE_TEST?: string; +} + +declare const __APP_VERSION__: string; + +declare module '@tryghost/kg-simplemde'; +declare module '@tryghost/helpers' { + export const utils: { + countWords(text: string): number; + }; +} +declare module 'pluralize'; +declare module 'react-slider'; +declare module 'react-highlight'; diff --git a/packages/koenig-lexical/svgo.config.js b/packages/koenig-lexical/svgo.config.cjs similarity index 100% rename from packages/koenig-lexical/svgo.config.js rename to packages/koenig-lexical/svgo.config.cjs diff --git a/packages/koenig-lexical/test/e2e/card-behaviour.test.js b/packages/koenig-lexical/test/e2e/card-behaviour.test.js deleted file mode 100644 index ab575d963a..0000000000 --- a/packages/koenig-lexical/test/e2e/card-behaviour.test.js +++ /dev/null @@ -1,2013 +0,0 @@ -import {assertHTML, assertSelection, ctrlOrCmd, focusEditor, getScrollPosition, html, initialize, insertCard, pasteText} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Card behaviour', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async ({context}) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('CLICKS', function () { - test('click selects card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - - // clicking first HR card makes it selected - await page.click('hr'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - // clicking second HR card deselects the first and selects the second - await page.click('[data-lexical-decorator]:nth-of-type(2) hr'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('click keeps selection', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - await page.click('hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('click off deselects', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - await page.click('p'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('click outside editor deselects', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - await page.click('body'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('double-click on an unselected card puts it into edit mode', async function () { - await focusEditor(page); - // TODO: Update this after setting to isEditing on creation - await page.keyboard.type('```javascript '); - - await page.click('div[data-kg-card="codeblock"]'); - await page.click('div[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - }); - - test('single clicking on a selected card puts it into edit mode', async function () { - await focusEditor(page); - // TODO: Update this after setting to isEditing on creation - await page.keyboard.type('```javascript '); - // Click to select - await page.click('div[data-kg-card="codeblock"]'); - // Click to edit - await page.click('div[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - }); - - test('clicking outside the edit mode card switches back to display mode', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.type('```javascript '); - - await page.click('div[data-kg-card="codeblock"]'); - await page.click('div[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.click('p'); - expect(await page.locator('[data-kg-card-editing="false"]')); - }); - - test('clicking outside the editor and then on a card focuses the editor', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.keyboard.type('import React from "react"'); - - const title = page.getByTestId('post-title'); - await title.click(); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - - await page.click('div[data-kg-card="codeblock"]'); - const editor = await page.locator('div.kg-prose').first(); - let editorHasFocus = await editor.evaluate(node => document.activeElement === node); - expect(editorHasFocus).toEqual(true); - }); - - test('clicking outside the empty edit mode card removes the card', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.click('.koenig-lexical'); - await assertHTML(page, html` -


    - `); - }); - - test('clicking on another card when a card is in edit mode selected new card and switches old card to display mode', async function () { - await focusEditor(page); - await page.keyboard.type('```python '); - await page.waitForSelector('[data-kg-card="codeblock"] [contenteditable="true"]'); - await page.keyboard.type('import pandas as pd'); - await page.keyboard.press('Meta+Enter'); - await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); - await page.keyboard.press('Enter'); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] [contenteditable="true"]'); - await page.keyboard.type('import React from "react"'); - await page.keyboard.press('Meta+Enter'); - await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); - - // Neither card should be in editing mode right now - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: true, ignoreCardToolbarContents: true}); - - // Select the python card - await page.click('div[data-kg-card="codeblock"]'); - // Click the selected card again to enter editing mode - await page.click('div[data-kg-card-selected="true"]'); - - // Now the first card should be editing and the second card should not be - await expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - await expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); - - // Click the card that's not currently editing (second card) - await page.click('div[data-kg-card-editing="false"]'); - // Now neither card should be editing - await expect(await page.locator('[data-kg-card-editing="true"]')).toHaveCount(0); - - await assertHTML(page, html` -
    -
    -
    -
    import pandas as pd
    -
    python
    -
    -
    -
    -
    -
    -
    -
    import React from "react"
    -
    javascript
    -
    -
    -
    -
    -
    - - `, {ignoreCardToolbarContents: true, ignoreCardCaptionContents: true}); - }); - - test('clicking below the editor focuses the editor if last node is a paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('Here is some text'); - - await page.mouse.click(100, 900); - await assertSelection(page, { - anchorOffset: 1, - anchorPath: [0], - focusOffset: 1, - focusPath: [0] - }); - }); - - test('clicking below the editor focuses the editor if last node is a card', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - await page.keyboard.type('import React from "react"'); - await page.keyboard.press('Meta+Enter'); - await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); - - await page.mouse.click(100, 900); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - - //test.fixme('lazy click puts card in edit mode'); - test('clicking in the space between cards selects the card under it', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - await page.keyboard.type('import React from "react"'); - await page.keyboard.press('Meta+Enter'); - await page.keyboard.press('ArrowUp'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: true}); - - await page.mouse.click(275, 275); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - }); - - test.describe('LEFT', function () { - // deselects card and moves cursor onto paragraph - test('with selected card after paragraph', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.click('hr'); - - await assertHTML(page, html` -


    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('ArrowLeft'); - - await assertHTML(page, html` -


    -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - }); - - // moves selection to previous card - test('when selected card is after card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - - await page.keyboard.press('ArrowLeft'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('ArrowLeft'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - }); - - // triggers "caret left at top" prop fn - //test.fixme('when selected card is first section'); - }); - - test.describe('RIGHT', function () { - test('with selected card before paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('ArrowRight'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - - // moves selection to previous card - test('when selected card is before card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('ArrowRight'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('ArrowRight'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [2], - focusOffset: 0, - focusPath: [2] - }); - }); - }); - - test.describe('UP', function () { - // moves caret to end of paragraph - test('with selected card after paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('First line'); - await page.keyboard.down('Shift'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Shift'); - await page.keyboard.type('Second line'); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'divider'}); - - // sanity check - await assertHTML(page, html` -

    - First line -
    - Second line -

    -
    -

    -
    -


    - `); - - await page.click('[data-kg-card="horizontalrule"]'); - await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - - await page.keyboard.press('ArrowUp'); - - // caret is at end of second line of paragraph - await assertSelection(page, { - anchorOffset: 11, - anchorPath: [0, 2, 0], - focusOffset: 11, - focusPath: [0, 2, 0] - }); - - // card is no longer selected - await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); - }); - - // selects the previous card - test('with selected card after card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('[data-lexical-decorator]:nth-of-type(2)'); - - // sanity check, second card is selected - await assertHTML(page, html` -
    -

    -
    -
    -

    -
    -


    - `); - - await page.keyboard.press('ArrowUp'); - - // first card is now selected - await assertHTML(page, html` -
    -

    -
    -
    -

    -
    -


    - `); - }); - - // selects the card once caret reaches top of paragraph - test('moving through paragraph to card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await expect(await page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - // three lines of text - paste it because keyboard.type is slow for long text - const text = 'Chislic bacon flank andouille picanha turkey porchetta chuck venison shank. Beef sirloin bresaola, meatball hamburger pork belly shankle. Frankfurter brisket t-bone alcatra porchetta tongue flank pork chop kevin picanha prosciutto meatball.'; - await pasteText(page, text); - - await expect(await page.getByText(text)).toBeVisible(); - - // place cursor at beginning of third line - const textLocator = await page.locator('[data-lexical-editor] > p'); - const pRect = await textLocator.boundingBox(); - await page.mouse.click(pRect.x + 1, pRect.y + pRect.height - 5); - - await assertSelection(page, { - anchorOffset: 220, - anchorPath: [1, 0, 0], - focusOffset: 220, - focusPath: [1, 0, 0] - }); - - await page.keyboard.press('ArrowUp'); - - await assertSelection(page, { - anchorOffset: 150, - anchorPath: [1, 0, 0], - focusOffset: 150, - focusPath: [1, 0, 0] - }); - - await page.keyboard.press('ArrowUp'); - - await assertSelection(page, { - anchorOffset: 76, - anchorPath: [1, 0, 0], - focusOffset: 76, - focusPath: [1, 0, 0] - }); - - await page.keyboard.press('ArrowUp'); - - await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, 0, 0], - focusOffset: 0, - focusPath: [1, 0, 0] - }); - - await page.keyboard.press('ArrowUp'); - - // card is selected - await assertHTML(page, html` -
    -

    -
    -

    - - Chislic bacon flank andouille picanha turkey porchetta chuck venison shank. Beef - sirloin bresaola, meatball hamburger pork belly shankle. Frankfurter brisket t-bone - alcatra porchetta tongue flank pork chop kevin picanha prosciutto meatball. - -

    - `); - }); - - test('moving through paragraph with breaks to card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('First line'); - await page.keyboard.down('Shift'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Shift'); - await page.keyboard.type('Second line after break'); - - // sanity check, caret is at end of second line after break - await assertSelection(page, { - anchorOffset: 23, - anchorPath: [1, 3, 0], - focusOffset: 23, - focusPath: [1, 3, 0] - }); - - await page.keyboard.press('ArrowUp'); - - // caret moved to empty line - await assertSelection(page, { - anchorOffset: 2, - anchorPath: [1], - focusOffset: 2, - focusPath: [1] - }); - - await page.keyboard.press('ArrowUp'); - - // caret moved to end of first line - await assertSelection(page, { - anchorOffset: 10, - anchorPath: [1, 0, 0], - focusOffset: 10, - focusPath: [1, 0, 0] - }); - - await page.keyboard.press('ArrowUp'); - - // card is selected - await assertHTML(page, html` -
    -

    -
    -

    - First line -
    -
    - Second line after break -

    - `); - }); - }); - - test.describe('DOWN', function () { - // moves caret to beginning of paragraph - test('with selected card before paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('First line'); - await page.keyboard.down('Shift'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Shift'); - await page.keyboard.type('Second line'); - - await page.click('[data-lexical-decorator]'); - - // sanity check - await assertHTML(page, html` -
    -

    -
    -

    - First line -
    - Second line -

    - `); - - await page.keyboard.press('ArrowDown'); - - // caret is at beginning of paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, 0, 0], - focusOffset: 0, - focusPath: [1, 0, 0] - }); - - // card is no longer selected - await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); - }); - - // selects the next card - test('with selected card before card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('[data-lexical-decorator]'); - - // sanity check, first card is selected - await assertHTML(page, html` -
    -

    -
    -
    -

    -
    -


    - `); - - await page.keyboard.press('ArrowDown'); - - // first card is now selected - await assertHTML(page, html` -
    -

    -
    -
    -

    -
    -


    - `); - }); - - // selects the card once caret reaches bottom of paragraph - test('moving through paragraph to card', async function () { - await focusEditor(page); - await page.keyboard.type('First line'); - await page.keyboard.down('Shift'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Shift'); - await page.keyboard.type('Second line after break'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - - // place cursor at beginning of first line - const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); - const pRect = await pHandle.boundingBox(); - await page.mouse.click(pRect.x + 5, pRect.y + 5); - - // sanity check - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 0, - focusPath: [0, 0, 0] - }); - - await page.keyboard.press('ArrowDown'); - - // caret on blank break line - await assertSelection(page, { - anchorOffset: 2, - anchorPath: [0], - focusOffset: 2, - focusPath: [0] - }); - - await page.keyboard.press('ArrowDown'); - - // caret on second line after break - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 3, 0], - focusOffset: 0, - focusPath: [0, 3, 0] - }); - - // wait for cursor position to be painted so getClientRects() is accurate - // when the ArrowDown handler checks if cursor is at bottom of element - await page.waitForTimeout(50); - await page.keyboard.press('ArrowDown'); - - // card is selected - await expect(page.locator('[data-kg-card="horizontalrule"][data-kg-card-selected="true"]')).toBeVisible(); - }); - - test('with selected card at end of document', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('Backspace'); - - // sanity check - await assertHTML(page, html` -
    -
    -
    -
    -
    - `); - - await page.keyboard.press('ArrowDown'); - - // should create a new paragraph and move cursor to it - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorPath: [1], - anchorOffset: 0, - focusPath: [1], - focusOffset: 0 - }); - }); - }); - - test.describe('ENTER', function () { - test('with selected card creates paragraph after and moves selection', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - }); - - test.describe('BACKSPACE', function () { - // deletes card and puts cursor at end of previous paragraph - test('with selected card after paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('Testing'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.click('hr'); - - await assertHTML(page, html` -

    Testing

    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    Testing

    -


    - `); - - await assertSelection(page, { - anchorOffset: 7, - anchorPath: [0, 0, 0], - focusOffset: 7, - focusPath: [0, 0, 0] - }); - }); - - test('with selected card after card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('[data-lexical-decorator]:nth-of-type(2) hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('with selected card as first section followed by paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('Testing'); - await page.click('hr'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    Testing

    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 0, - focusPath: [0, 0, 0] - }); - }); - - test('with selected card as first section followed by card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('hr'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('with selected card as only node', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -


    - `); - }); - - // deletes empty paragraph, selects card - test('on empty paragraph after card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - await page.keyboard.press('Enter'); - await page.keyboard.type('Populated paragraph after empty paragraph'); - await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(50); - - // sanity check - cursor is on empty paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - - await page.keyboard.press('Backspace'); - - // wait for the card to be selected after backspace removes the empty paragraph - await expect(page.locator('[data-kg-card="horizontalrule"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Populated paragraph after empty paragraph

    - `); - }); - - // deletes card, keeps selection at beginning of paragraph - test('at beginning of paragraph after card', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - // Wait for HR card to be created before typing - await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - await page.keyboard.type('Second paragraph'); - for (let i = 0; i < 'Second paragraph'.length; i++) { - await page.keyboard.press('ArrowLeft'); - } - // Wait for selection to settle after arrow key navigation - await page.waitForTimeout(50); - - await assertHTML(page, html` -

    First paragraph

    -
    -
    -
    -
    -
    -

    Second paragraph

    - `); - - // sanity check - cursor is at beginning of second paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [2, 0, 0], - focusOffset: 0, - focusPath: [2, 0, 0] - }); - - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    First paragraph

    -

    Second paragraph

    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, 0, 0], - focusOffset: 0, - focusPath: [1, 0, 0] - }); - }); - - test('at start of list after a card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('* Test'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
      -
    • Test
    • -
    - `); - - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Test

    - `); - }); - - test('at start of a quote block after a card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('> Test'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Test
    - `); - - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Test

    - `); - }); - - test('at start of an aside after a card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('> Test'); - await page.keyboard.press('Control+q'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - - `); - - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Test

    - `); - }); - }); - - test.describe('DELETE', function () { - test('with selected card before paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('Testing'); - await page.click('hr'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Testing

    - `); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -

    Testing

    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 0, - focusPath: [0, 0, 0] - }); - }); - - test('with selected card before card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.click('hr'); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('with selected card as only node', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -


    - `); - }); - - // deletes paragraph and selects card - test('on empty paragraph before card', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - - await assertHTML(page, html` -


    -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - }); - - // deletes card, keeping caret at end of paragraph - test('at end of paragraph before card', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - await page.keyboard.type('Second paragraph'); - await page.click('[data-lexical-editor] > p:first-of-type'); - await page.keyboard.press('End'); - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - await assertSelection(page, { - anchorOffset: 15, - anchorPath: [0, 0, 0], - focusOffset: 15, - focusPath: [0, 0, 0] - }); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -

    First paragraph

    -

    Second paragraph

    - `); - - await assertSelection(page, { - anchorOffset: 15, - anchorPath: [0, 0, 0], - focusOffset: 15, - focusPath: [0, 0, 0] - }); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -

    First paragraphSecond paragraph

    - `); - }); - - test('at start of formatted text in paragraph before card', async function () { - await focusEditor(page); - await page.keyboard.type('Before '); - await page.keyboard.press(`${ctrlOrCmd(page)}+i`); - await page.keyboard.type('italic'); - await page.keyboard.press(`${ctrlOrCmd(page)}+i`); - await page.keyboard.type(' after'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - - await assertHTML(page, html` -

    - Before - italic - after -

    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - await page.locator('em').click({position: {x: 0, y: 0}}); - - await assertSelection(page, { - anchorOffset: 7, - anchorPath: [0, 0, 0], - focusOffset: 7, - focusPath: [0, 0, 0] - }); - - await page.keyboard.press('Delete'); - - await assertHTML(page, html` -

    - Before - talic - after -

    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - }); - - // this behaviour changes between mac and windows - test.describe.skip('CMD+BACKSPACE', function () { - test('on an populated paragraph after a card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('Some content'); - - await page.keyboard.press(`${ctrlOrCmd(page)}+Backspace`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - `); - }); - - test('on the first line of a multi-line paragraph after a card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('Some content'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('Some more content'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press(`${ctrlOrCmd(page)}+Backspace`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    -
    -
    - Some more content -

    - `); - }); - }); - - test.describe('CMD+ENTER', function () { - test('with a non-edit-mode card selected', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('hr'); - - await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - - await page.keyboard.press('Meta+Enter'); - - // card does not enter edit mode - await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - await expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); - }); - - test('with an edit-mode card selected', async function () { - await focusEditor(page); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - await page.keyboard.type('import React from "react"'); - await page.click('[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.keyboard.press('Meta+Enter'); - - // card exits edit mode - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); - - await page.keyboard.press('Meta+Enter'); - - // card enters edit mode - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - }); - - test('cursor position when deselecting empty card with nested editor', async function () { - // Focus/cursor position was not correct when a card with a nested editor was deselected+removed, - // an extra reset was occurring putting the cursor at the start of the document. - // See https://github.com/TryGhost/Product/issues/3430 - await focusEditor(page); - await page.keyboard.type('Testing'); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'product'}); - await page.keyboard.press('Meta+Enter'); - - // focus is on blank paragraph that's left after empty card is removed - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - }); - - test.describe('ESCAPE', function () { - test('with an edit mode card that is not empty', async function () { - await focusEditor(page); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"]'); - await page.keyboard.type('import React from "react"'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.keyboard.press('Escape'); - - // card exits edit mode - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); - - // card is still able to re-enter edit mode with CMD+ENTER - await page.keyboard.press('Meta+Enter'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - }); - - test('with an edit mode card that is empty', async function () { - await focusEditor(page); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.keyboard.press('Escape'); - - // card is removed leaving the empty paragraph - await assertHTML(page, html` -


    - `); - - // paragraph is selected - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - }); - - test('with an edit mode card that is empty before existing content', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.keyboard.press('Escape'); - - // card is removed leaving the existing paragraph - await assertHTML(page, html` -

    Testing

    - `); - - // cursor is at beginning of trailing paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 0, - focusPath: [0, 0, 0] - }); - }); - - test('with an edit mode card that is empty before another card', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"]'); - - expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); - expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); - - await page.keyboard.press('Escape'); - - // card is removed leaving the existing card - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - // test editor does actually have focus by trying to move the caret - await page.keyboard.press('ArrowDown'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - }); - - test.describe('SELECTION', function () { - test('shift+down does not put card in selected state', async function () { - await focusEditor(page); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - await page.keyboard.type('Second'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Home'); - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - // sanity check - await assertSelection(page, { - anchorPath: [0, 0, 0], - anchorOffset: 0, - focusPath: [0, 0, 0], - focusOffset: 0 - }); - - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Shift'); - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - // offsets are based on the root node offset - // anchorOffset can be 0 or 1 depending on how Chrome resolves - // the root-level selection (paragraph vs decorator boundary) - await assertSelection(page, { - anchorPath: [], - anchorOffset: [0, 1], - focusPath: [], - focusOffset: 2 - }); - // this is a range selection, so the card is not explicitly selected - await expect(page.locator('[data-kg-card-selected="true"]')).not.toBeVisible(); - }); - - test('shift+up does not put card in selected state', async function () { - await focusEditor(page); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second'); - - // sanity check - await assertSelection(page, { - anchorPath: [2, 0, 0], - anchorOffset: 6, - focusPath: [2, 0, 0], - focusOffset: 6 - }); - - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.up('Shift'); - - // offsets are based on the root node offset - await assertSelection(page, { - anchorPath: [], - anchorOffset: 3, - focusPath: [], - focusOffset: 1 - }); - // this is a range selection, so the card is not explicitly selected - await expect(page.locator('[data-kg-card-selected="true"]')).not.toBeVisible(); - }); - }); - - test.describe('CMD+UP', function () { - test('with selected card and plain text at top', async function () { - await focusEditor(page); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press('Meta+ArrowUp'); - - await assertSelection(page, { - anchorPath: [0, 0, 0], - anchorOffset: 0, - focusPath: [0, 0, 0], - focusOffset: 0 - }); - }); - - test('with selected card and card at top', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press('Meta+ArrowUp'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('with caret in text and card at top', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Second'); - - await page.keyboard.press('Meta+ArrowUp'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    First

    -

    Second

    - `); - }); - }); - - test.describe('CMD+DOWN', function () { - test('with selected card and plain text at bottom', async function () { - await focusEditor(page); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press('Meta+ArrowDown'); - - await assertSelection(page, { - anchorPath: [2, 0, 0], - anchorOffset: 6, - focusPath: [2, 0, 0], - focusOffset: 6 - }); - }); - - test('with selected card and card at bottom', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.type('---'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press('Meta+ArrowDown'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - `); - }); - - test('with caret in text and card at bottom', async function () { - await focusEditor(page); - await page.keyboard.type('First'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Second'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - - await page.keyboard.press('Meta+ArrowDown'); - - await assertHTML(page, html` -

    First

    -

    Second

    -
    -
    -
    -
    -
    - `); - }); - }); - - test.describe('captions', function () { - // we had a bug where the caption would steal focus when typing in any - // other card, resulting in the typed text being inserted into the caption - test('do not steal focus when not selected', async function () { - await focusEditor(page); - await page.keyboard.type('/image https://example.com/image.jpg'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="image"]'); - await page.keyboard.type('Caption value'); - - await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption value'); - - await page.keyboard.press('Meta+Enter'); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"]'); - await expect(page.locator('[data-kg-card="codeblock"] .cm-editor')).toBeVisible(); - await page.locator('[data-kg-card="codeblock"] .cm-content').click(); - await page.keyboard.type('Code content'); - - await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption value'); - await expect(page.locator('[data-kg-card="codeblock"] .cm-line')).toHaveText('Code content'); - }); - }); - - test.describe('inner editors', function () { - test('can use the delete key to remove text', async function () { - await focusEditor(page); - await page.keyboard.type('/image https://example.com/image.jpg'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="image"]'); - await page.keyboard.type('Caption value'); - await page.keyboard.press('ArrowLeft'); - // await page.keyboard.press('Fn+Backspace'); // note: this is the delete key for macs, but playwright doesn't recognize "Fn" even when running on a mac :( - await page.keyboard.press('Delete'); - - await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption valu'); - }); - - test.describe('codemirror', function () { - // Skipped because CodeMirror does not pick up the copy/paste properly inside Playwright - manual testing is working - test.skip('can copy/paste', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'html'}); - - // waiting for html editor - await expect(await page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - - await page.keyboard.type('Testing', {delay: 10}); - await page.waitForTimeout(100); - await page.keyboard.press(`${ctrlOrCmd(page)}+KeyA`); - await page.waitForTimeout(100); - await page.keyboard.press(`${ctrlOrCmd(page)}+KeyC`); - await page.waitForTimeout(100); - await page.keyboard.press(`${ctrlOrCmd(page)}+KeyV`); - await page.waitForTimeout(100); - await page.keyboard.press(`${ctrlOrCmd(page)}+KeyV`); - await page.waitForTimeout(100); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    Testing
    -
    - - -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: false}); - }); - }); - - test('entering edit mode on a card does not scroll when other cards have nested editors', async function () { - // Build content with a CTA card at the top, many paragraphs to - // create scroll distance, and another CTA card at the bottom. - // Cards are pre-loaded so they haven't been through an edit cycle - // (their nested editor autoFocus initial state stays true). - // Before the fix, the global isEditingCard flag caused ALL nested - // editors with shouldFocus=true to fire their focus effect when any - // card entered edit mode, causing a scroll jump as the nested editor - // further down the page transiently grabbed focus. - const ctaCard = { - type: 'call-to-action', - backgroundColor: 'green', - buttonColor: '#F0F0F0', - buttonText: 'Click me', - buttonTextColor: '#000000', - buttonUrl: '', - hasImage: false, - hasSponsorLabel: true, - sponsorLabel: '

    SPONSORED

    ', - layout: 'minimal', - showButton: true, - showDividers: true, - textValue: '

    CTA content

    ' - }; - - const children = [{...ctaCard}]; - - for (let i = 0; i < 30; i++) { - children.push({ - children: [{ - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: `Line ${i + 1} of filler content to create scroll distance`, - type: 'text', - version: 1 - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }); - } - - children.push({...ctaCard}); - - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children, - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - // Select the first card (already in viewport at the top) - const firstCard = page.locator('[data-kg-card="call-to-action"]').first(); - await firstCard.click(); - await expect(firstCard).toHaveAttribute('data-kg-card-selected', 'true'); - - // Scroll position should be at the top - const scrollBefore = await getScrollPosition(page); - expect(scrollBefore).toBe(0); - - // Start monitoring for any scroll movement - await page.evaluate(() => { - const container = document.querySelector('.h-full.overflow-auto'); - window._scrollStartPosition = container.scrollTop; - window._maxScrollDeviation = 0; - window._scrollHandler = () => { - const deviation = Math.abs(container.scrollTop - window._scrollStartPosition); - if (deviation > window._maxScrollDeviation) { - window._maxScrollDeviation = deviation; - } - }; - container.addEventListener('scroll', window._scrollHandler); - }); - - // Enter edit mode on the first card — the second card's nested - // editor is far below the viewport so any transient focus grab - // would cause a large downward scroll jump - await firstCard.click(); - await expect(firstCard).toHaveAttribute('data-kg-card-editing', 'true'); - - // Check no scroll movement occurred (even transiently) - const maxDeviation = await page.evaluate(() => { - const container = document.querySelector('.h-full.overflow-auto'); - container.removeEventListener('scroll', window._scrollHandler); - return window._maxScrollDeviation; - }); - - expect(maxDeviation).toBe(0); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/card-behaviour.test.ts b/packages/koenig-lexical/test/e2e/card-behaviour.test.ts new file mode 100644 index 0000000000..7485522470 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/card-behaviour.test.ts @@ -0,0 +1,2016 @@ +import {assertHTML, assertSelection, ctrlOrCmd, focusEditor, getScrollPosition, html, initialize, insertCard, pasteText} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Card behaviour', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async ({context}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('CLICKS', function () { + test('click selects card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + + // clicking first HR card makes it selected + await page.click('hr'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + // clicking second HR card deselects the first and selects the second + await page.click('[data-lexical-decorator]:nth-of-type(2) hr'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('click keeps selection', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + await page.click('hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('click off deselects', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + await page.click('p'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('click outside editor deselects', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + await page.click('body'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('double-click on an unselected card puts it into edit mode', async function () { + await focusEditor(page); + // TODO: Update this after setting to isEditing on creation + await page.keyboard.type('```javascript '); + + await page.click('div[data-kg-card="codeblock"]'); + await page.click('div[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + }); + + test('single clicking on a selected card puts it into edit mode', async function () { + await focusEditor(page); + // TODO: Update this after setting to isEditing on creation + await page.keyboard.type('```javascript '); + // Click to select + await page.click('div[data-kg-card="codeblock"]'); + // Click to edit + await page.click('div[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + }); + + test('clicking outside the edit mode card switches back to display mode', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type('```javascript '); + + await page.click('div[data-kg-card="codeblock"]'); + await page.click('div[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.click('p'); + expect(await page.locator('[data-kg-card-editing="false"]')); + }); + + test('clicking outside the editor and then on a card focuses the editor', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.keyboard.type('import React from "react"'); + + const title = page.getByTestId('post-title'); + await title.click(); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + + await page.click('div[data-kg-card="codeblock"]'); + const editor = await page.locator('div.kg-prose').first(); + const editorHasFocus = await editor.evaluate(node => document.activeElement === node); + expect(editorHasFocus).toEqual(true); + }); + + test('clicking outside the empty edit mode card removes the card', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.click('.koenig-lexical'); + await assertHTML(page, html` +


    + `); + }); + + test('clicking on another card when a card is in edit mode selected new card and switches old card to display mode', async function () { + await focusEditor(page); + await page.keyboard.type('```python '); + await page.waitForSelector('[data-kg-card="codeblock"] [contenteditable="true"]'); + await page.keyboard.type('import pandas as pd'); + await page.keyboard.press('Meta+Enter'); + await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); + await page.keyboard.press('Enter'); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] [contenteditable="true"]'); + await page.keyboard.type('import React from "react"'); + await page.keyboard.press('Meta+Enter'); + await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); + + // Neither card should be in editing mode right now + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: true, ignoreCardToolbarContents: true}); + + // Select the python card + await page.click('div[data-kg-card="codeblock"]'); + // Click the selected card again to enter editing mode + await page.click('div[data-kg-card-selected="true"]'); + + // Now the first card should be editing and the second card should not be + await expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + await expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); + + // Click the card that's not currently editing (second card) + await page.click('div[data-kg-card-editing="false"]'); + // Now neither card should be editing + await expect(await page.locator('[data-kg-card-editing="true"]')).toHaveCount(0); + + await assertHTML(page, html` +
    +
    +
    +
    import pandas as pd
    +
    python
    +
    +
    +
    +
    +
    +
    +
    import React from "react"
    +
    javascript
    +
    +
    +
    +
    +
    + + `, {ignoreCardToolbarContents: true, ignoreCardCaptionContents: true}); + }); + + test('clicking below the editor focuses the editor if last node is a paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('Here is some text'); + + await page.mouse.click(100, 900); + await assertSelection(page, { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0] + }); + }); + + test('clicking below the editor focuses the editor if last node is a card', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + await page.keyboard.type('import React from "react"'); + await page.keyboard.press('Meta+Enter'); + await page.waitForSelector('[data-kg-card="codeblock"][data-kg-card-selected="true"][data-kg-card-editing="false"]'); + + await page.mouse.click(100, 900); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + + //test.fixme('lazy click puts card in edit mode'); + test('clicking in the space between cards selects the card under it', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + await page.keyboard.type('import React from "react"'); + await page.keyboard.press('Meta+Enter'); + await page.keyboard.press('ArrowUp'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: true}); + + await page.mouse.click(275, 275); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + }); + + test.describe('LEFT', function () { + // deselects card and moves cursor onto paragraph + test('with selected card after paragraph', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.click('hr'); + + await assertHTML(page, html` +


    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('ArrowLeft'); + + await assertHTML(page, html` +


    +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + }); + + // moves selection to previous card + test('when selected card is after card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + + await page.keyboard.press('ArrowLeft'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('ArrowLeft'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + }); + + // triggers "caret left at top" prop fn + //test.fixme('when selected card is first section'); + }); + + test.describe('RIGHT', function () { + test('with selected card before paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('ArrowRight'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + + // moves selection to previous card + test('when selected card is before card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('ArrowRight'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('ArrowRight'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [2], + focusOffset: 0, + focusPath: [2] + }); + }); + }); + + test.describe('UP', function () { + // moves caret to end of paragraph + test('with selected card after paragraph moves caret up', async function () { + await focusEditor(page); + await page.keyboard.type('First line'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await page.keyboard.type('Second line'); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'divider'}); + + // sanity check + await assertHTML(page, html` +

    + First line +
    + Second line +

    +
    +

    +
    +


    + `); + + await page.click('[data-kg-card="horizontalrule"]'); + await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + + await page.keyboard.press('ArrowUp'); + + // caret is at end of second line of paragraph + await assertSelection(page, { + anchorOffset: 11, + anchorPath: [0, 2, 0], + focusOffset: 11, + focusPath: [0, 2, 0] + }); + + // card is no longer selected + await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); + }); + + // selects the previous card + test('with selected card after card moves caret up', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('[data-lexical-decorator]:nth-of-type(2)'); + + // sanity check, second card is selected + await assertHTML(page, html` +
    +

    +
    +
    +

    +
    +


    + `); + + await page.keyboard.press('ArrowUp'); + + // first card is now selected + await assertHTML(page, html` +
    +

    +
    +
    +

    +
    +


    + `); + }); + + // selects the card once caret reaches top of paragraph + test('moving through paragraph to card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await expect(await page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + // three lines of text - paste it because keyboard.type is slow for long text + const text = 'Chislic bacon flank andouille picanha turkey porchetta chuck venison shank. Beef sirloin bresaola, meatball hamburger pork belly shankle. Frankfurter brisket t-bone alcatra porchetta tongue flank pork chop kevin picanha prosciutto meatball.'; + await pasteText(page, text); + + await expect(await page.getByText(text)).toBeVisible(); + + // place cursor at beginning of third line + const textLocator = await page.locator('[data-lexical-editor] > p'); + const pRect = await textLocator.boundingBox(); + await page.mouse.click(pRect!.x + 1, pRect!.y + pRect!.height - 5); + + await assertSelection(page, { + anchorOffset: 220, + anchorPath: [1, 0, 0], + focusOffset: 220, + focusPath: [1, 0, 0] + }); + + await page.keyboard.press('ArrowUp'); + + await assertSelection(page, { + anchorOffset: 150, + anchorPath: [1, 0, 0], + focusOffset: 150, + focusPath: [1, 0, 0] + }); + + await page.keyboard.press('ArrowUp'); + + await assertSelection(page, { + anchorOffset: 76, + anchorPath: [1, 0, 0], + focusOffset: 76, + focusPath: [1, 0, 0] + }); + + await page.keyboard.press('ArrowUp'); + + await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1, 0, 0], + focusOffset: 0, + focusPath: [1, 0, 0] + }); + + await page.keyboard.press('ArrowUp'); + + // card is selected + await assertHTML(page, html` +
    +

    +
    +

    + + Chislic bacon flank andouille picanha turkey porchetta chuck venison shank. Beef + sirloin bresaola, meatball hamburger pork belly shankle. Frankfurter brisket t-bone + alcatra porchetta tongue flank pork chop kevin picanha prosciutto meatball. + +

    + `); + }); + + test('moving through paragraph with breaks to card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('First line'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await page.keyboard.type('Second line after break'); + + // sanity check, caret is at end of second line after break + await assertSelection(page, { + anchorOffset: 23, + anchorPath: [1, 3, 0], + focusOffset: 23, + focusPath: [1, 3, 0] + }); + + await page.keyboard.press('ArrowUp'); + + // caret moved to empty line + await assertSelection(page, { + anchorOffset: 2, + anchorPath: [1], + focusOffset: 2, + focusPath: [1] + }); + + await page.keyboard.press('ArrowUp'); + + // caret moved to end of first line + await assertSelection(page, { + anchorOffset: 10, + anchorPath: [1, 0, 0], + focusOffset: 10, + focusPath: [1, 0, 0] + }); + + await page.keyboard.press('ArrowUp'); + + // card is selected + await assertHTML(page, html` +
    +

    +
    +

    + First line +
    +
    + Second line after break +

    + `); + }); + }); + + test.describe('DOWN', function () { + // moves caret to beginning of paragraph + test('with selected card before paragraph moves caret down', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('First line'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await page.keyboard.type('Second line'); + + await page.click('[data-lexical-decorator]'); + + // sanity check + await assertHTML(page, html` +
    +

    +
    +

    + First line +
    + Second line +

    + `); + + await page.keyboard.press('ArrowDown'); + + // caret is at beginning of paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1, 0, 0], + focusOffset: 0, + focusPath: [1, 0, 0] + }); + + // card is no longer selected + await expect(await page.locator('[data-kg-card-selected="true"]')).toHaveCount(0); + }); + + // selects the next card + test('with selected card before card moves caret down', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('[data-lexical-decorator]'); + + // sanity check, first card is selected + await assertHTML(page, html` +
    +

    +
    +
    +

    +
    +


    + `); + + await page.keyboard.press('ArrowDown'); + + // first card is now selected + await assertHTML(page, html` +
    +

    +
    +
    +

    +
    +


    + `); + }); + + // selects the card once caret reaches bottom of paragraph + test('moving down through paragraph to card', async function () { + await focusEditor(page); + await page.keyboard.type('First line'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.up('Shift'); + await page.keyboard.type('Second line after break'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + + // place cursor at beginning of first line + const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); + const pRect = await pHandle.boundingBox(); + await page.mouse.click(pRect!.x + 5, pRect!.y + 5); + + // sanity check + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0] + }); + + await page.keyboard.press('ArrowDown'); + + // caret on blank break line + await assertSelection(page, { + anchorOffset: 2, + anchorPath: [0], + focusOffset: 2, + focusPath: [0] + }); + + await page.keyboard.press('ArrowDown'); + + // caret on second line after break + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 3, 0], + focusOffset: 0, + focusPath: [0, 3, 0] + }); + + // wait for cursor position to be painted so getClientRects() is accurate + // when the ArrowDown handler checks if cursor is at bottom of element + await page.waitForTimeout(50); + await page.keyboard.press('ArrowDown'); + + // card is selected + await expect(page.locator('[data-kg-card="horizontalrule"][data-kg-card-selected="true"]')).toBeVisible(); + }); + + test('with selected card at end of document', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('Backspace'); + + // sanity check + await assertHTML(page, html` +
    +
    +
    +
    +
    + `); + + await page.keyboard.press('ArrowDown'); + + // should create a new paragraph and move cursor to it + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorPath: [1], + anchorOffset: 0, + focusPath: [1], + focusOffset: 0 + }); + }); + }); + + test.describe('ENTER', function () { + test('with selected card creates paragraph after and moves selection', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + }); + + test.describe('BACKSPACE', function () { + // deletes card and puts cursor at end of previous paragraph + test('with selected card after paragraph deletes card', async function () { + await focusEditor(page); + await page.keyboard.type('Testing'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.click('hr'); + + await assertHTML(page, html` +

    Testing

    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    Testing

    +


    + `); + + await assertSelection(page, { + anchorOffset: 7, + anchorPath: [0, 0, 0], + focusOffset: 7, + focusPath: [0, 0, 0] + }); + }); + + test('with selected card after card deletes card via backspace', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('[data-lexical-decorator]:nth-of-type(2) hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('with selected card as first section followed by paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('Testing'); + await page.click('hr'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    Testing

    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0] + }); + }); + + test('with selected card as first section followed by card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('hr'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('with selected card as only node', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +


    + `); + }); + + // deletes empty paragraph, selects card + test('on empty paragraph after card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + await page.keyboard.press('Enter'); + await page.keyboard.type('Populated paragraph after empty paragraph'); + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(50); + + // sanity check - cursor is on empty paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + + await page.keyboard.press('Backspace'); + + // wait for the card to be selected after backspace removes the empty paragraph + await expect(page.locator('[data-kg-card="horizontalrule"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Populated paragraph after empty paragraph

    + `); + }); + + // deletes card, keeps selection at beginning of paragraph + test('at beginning of paragraph after card', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + // Wait for HR card to be created before typing + await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + await page.keyboard.type('Second paragraph'); + for (let i = 0; i < 'Second paragraph'.length; i++) { + await page.keyboard.press('ArrowLeft'); + } + // Wait for selection to settle after arrow key navigation + await page.waitForTimeout(50); + + await assertHTML(page, html` +

    First paragraph

    +
    +
    +
    +
    +
    +

    Second paragraph

    + `); + + // sanity check - cursor is at beginning of second paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [2, 0, 0], + focusOffset: 0, + focusPath: [2, 0, 0] + }); + + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    First paragraph

    +

    Second paragraph

    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1, 0, 0], + focusOffset: 0, + focusPath: [1, 0, 0] + }); + }); + + test('at start of list after a card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('* Test'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
      +
    • Test
    • +
    + `); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Test

    + `); + }); + + test('at start of a quote block after a card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('> Test'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Test
    + `); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Test

    + `); + }); + + test('at start of an aside after a card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('> Test'); + await page.keyboard.press('Control+q'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + + `); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Test

    + `); + }); + }); + + test.describe('DELETE', function () { + test('with selected card before paragraph deletes card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('Testing'); + await page.click('hr'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Testing

    + `); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +

    Testing

    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0] + }); + }); + + test('with selected card before card deletes card via delete', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.click('hr'); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('with selected card as only node deletes card via delete', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +


    + `); + }); + + // deletes paragraph and selects card + test('on empty paragraph before card', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + await assertHTML(page, html` +


    +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + }); + + // deletes card, keeping caret at end of paragraph + test('at end of paragraph before card', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + await page.keyboard.type('Second paragraph'); + await page.click('[data-lexical-editor] > p:first-of-type'); + await page.keyboard.press('End'); + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + await assertSelection(page, { + anchorOffset: 15, + anchorPath: [0, 0, 0], + focusOffset: 15, + focusPath: [0, 0, 0] + }); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +

    First paragraph

    +

    Second paragraph

    + `); + + await assertSelection(page, { + anchorOffset: 15, + anchorPath: [0, 0, 0], + focusOffset: 15, + focusPath: [0, 0, 0] + }); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +

    First paragraphSecond paragraph

    + `); + }); + + test('at start of formatted text in paragraph before card', async function () { + await focusEditor(page); + await page.keyboard.type('Before '); + await page.keyboard.press(`${ctrlOrCmd(page)}+i`); + await page.keyboard.type('italic'); + await page.keyboard.press(`${ctrlOrCmd(page)}+i`); + await page.keyboard.type(' after'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + + await assertHTML(page, html` +

    + Before + italic + after +

    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + await page.locator('em').click({position: {x: 0, y: 0}}); + + await assertSelection(page, { + anchorOffset: 7, + anchorPath: [0, 0, 0], + focusOffset: 7, + focusPath: [0, 0, 0] + }); + + await page.keyboard.press('Delete'); + + await assertHTML(page, html` +

    + Before + talic + after +

    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + }); + + // this behaviour changes between mac and windows + test.describe.skip('CMD+BACKSPACE', function () { + test('on an populated paragraph after a card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('Some content'); + + await page.keyboard.press(`${ctrlOrCmd(page)}+Backspace`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + `); + }); + + test('on the first line of a multi-line paragraph after a card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('Some content'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('Some more content'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press(`${ctrlOrCmd(page)}+Backspace`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    +
    +
    + Some more content +

    + `); + }); + }); + + test.describe('CMD+ENTER', function () { + test('with a non-edit-mode card selected', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('hr'); + + await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + + await page.keyboard.press('Meta+Enter'); + + // card does not enter edit mode + await expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + await expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); + }); + + test('with an edit-mode card selected', async function () { + await focusEditor(page); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + await page.keyboard.type('import React from "react"'); + await page.click('[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.keyboard.press('Meta+Enter'); + + // card exits edit mode + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); + + await page.keyboard.press('Meta+Enter'); + + // card enters edit mode + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + }); + + test('cursor position when deselecting empty card with nested editor', async function () { + // Focus/cursor position was not correct when a card with a nested editor was deselected+removed, + // an extra reset was occurring putting the cursor at the start of the document. + // See https://github.com/TryGhost/Product/issues/3430 + await focusEditor(page); + await page.keyboard.type('Testing'); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'product'}); + await page.keyboard.press('Meta+Enter'); + + // focus is on blank paragraph that's left after empty card is removed + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + }); + + test.describe('ESCAPE', function () { + test('with an edit mode card that is not empty', async function () { + await focusEditor(page); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"]'); + await page.keyboard.type('import React from "react"'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.keyboard.press('Escape'); + + // card exits edit mode + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="false"]')).not.toBeNull(); + + // card is still able to re-enter edit mode with CMD+ENTER + await page.keyboard.press('Meta+Enter'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + }); + + test('with an edit mode card that is empty', async function () { + await focusEditor(page); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.keyboard.press('Escape'); + + // card is removed leaving the empty paragraph + await assertHTML(page, html` +


    + `); + + // paragraph is selected + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + }); + + test('with an edit mode card that is empty before existing content', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.keyboard.press('Escape'); + + // card is removed leaving the existing paragraph + await assertHTML(page, html` +

    Testing

    + `); + + // cursor is at beginning of trailing paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0] + }); + }); + + test('with an edit mode card that is empty before another card', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"]'); + + expect(await page.locator('[data-kg-card-selected="true"]')).not.toBeNull(); + expect(await page.locator('[data-kg-card-editing="true"]')).not.toBeNull(); + + await page.keyboard.press('Escape'); + + // card is removed leaving the existing card + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + // test editor does actually have focus by trying to move the caret + await page.keyboard.press('ArrowDown'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + }); + + test.describe('SELECTION', function () { + test('shift+down does not put card in selected state', async function () { + await focusEditor(page); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + await page.keyboard.type('Second'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Home'); + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + // sanity check + await assertSelection(page, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [0, 0, 0], + focusOffset: 0 + }); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + // offsets are based on the root node offset + // anchorOffset can be 0 or 1 depending on how Chrome resolves + // the root-level selection (paragraph vs decorator boundary) + await assertSelection(page, { + anchorPath: [], + anchorOffset: [0, 1], + focusPath: [], + focusOffset: 2 + }); + // this is a range selection, so the card is not explicitly selected + await expect(page.locator('[data-kg-card-selected="true"]')).not.toBeVisible(); + }); + + test('shift+up does not put card in selected state', async function () { + await focusEditor(page); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second'); + + // sanity check + await assertSelection(page, { + anchorPath: [2, 0, 0], + anchorOffset: 6, + focusPath: [2, 0, 0], + focusOffset: 6 + }); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.up('Shift'); + + // offsets are based on the root node offset + await assertSelection(page, { + anchorPath: [], + anchorOffset: 3, + focusPath: [], + focusOffset: 1 + }); + // this is a range selection, so the card is not explicitly selected + await expect(page.locator('[data-kg-card-selected="true"]')).not.toBeVisible(); + }); + }); + + test.describe('CMD+UP', function () { + test('with selected card and plain text at top', async function () { + await focusEditor(page); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press('Meta+ArrowUp'); + + await assertSelection(page, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [0, 0, 0], + focusOffset: 0 + }); + }); + + test('with selected card and card at top', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press('Meta+ArrowUp'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('with caret in text and card at top', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second'); + + await page.keyboard.press('Meta+ArrowUp'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    First

    +

    Second

    + `); + }); + }); + + test.describe('CMD+DOWN', function () { + test('with selected card and plain text at bottom', async function () { + await focusEditor(page); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press('Meta+ArrowDown'); + + await assertSelection(page, { + anchorPath: [2, 0, 0], + anchorOffset: 6, + focusPath: [2, 0, 0], + focusOffset: 6 + }); + }); + + test('with selected card and card at bottom', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.type('---'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press('Meta+ArrowDown'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `); + }); + + test('with caret in text and card at bottom', async function () { + await focusEditor(page); + await page.keyboard.type('First'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + await page.keyboard.press('Meta+ArrowDown'); + + await assertHTML(page, html` +

    First

    +

    Second

    +
    +
    +
    +
    +
    + `); + }); + }); + + test.describe('captions', function () { + // we had a bug where the caption would steal focus when typing in any + // other card, resulting in the typed text being inserted into the caption + test('do not steal focus when not selected', async function () { + await focusEditor(page); + await page.keyboard.type('/image https://example.com/image.jpg'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="image"]'); + await page.keyboard.type('Caption value'); + + await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption value'); + + await page.keyboard.press('Meta+Enter'); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"]'); + await expect(page.locator('[data-kg-card="codeblock"] .cm-editor')).toBeVisible(); + await page.locator('[data-kg-card="codeblock"] .cm-content').click(); + await page.keyboard.type('Code content'); + + await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption value'); + await expect(page.locator('[data-kg-card="codeblock"] .cm-line')).toHaveText('Code content'); + }); + }); + + test.describe('inner editors', function () { + test('can use the delete key to remove text', async function () { + await focusEditor(page); + await page.keyboard.type('/image https://example.com/image.jpg'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="image"]'); + await page.keyboard.type('Caption value'); + await page.keyboard.press('ArrowLeft'); + // await page.keyboard.press('Fn+Backspace'); // note: this is the delete key for macs, but playwright doesn't recognize "Fn" even when running on a mac :( + await page.keyboard.press('Delete'); + + await expect(page.locator('[data-kg-card="image"] figcaption [data-kg="editor"]')).toHaveText('Caption valu'); + }); + + test.describe('codemirror', function () { + // Skipped because CodeMirror does not pick up the copy/paste properly inside Playwright - manual testing is working + test.skip('can copy/paste', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'html'}); + + // waiting for html editor + await expect(await page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + + await page.keyboard.type('Testing', {delay: 10}); + await page.waitForTimeout(100); + await page.keyboard.press(`${ctrlOrCmd(page)}+KeyA`); + await page.waitForTimeout(100); + await page.keyboard.press(`${ctrlOrCmd(page)}+KeyC`); + await page.waitForTimeout(100); + await page.keyboard.press(`${ctrlOrCmd(page)}+KeyV`); + await page.waitForTimeout(100); + await page.keyboard.press(`${ctrlOrCmd(page)}+KeyV`); + await page.waitForTimeout(100); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Testing
    +
    + + +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: false}); + }); + }); + + test('entering edit mode on a card does not scroll when other cards have nested editors', async function () { + // Build content with a CTA card at the top, many paragraphs to + // create scroll distance, and another CTA card at the bottom. + // Cards are pre-loaded so they haven't been through an edit cycle + // (their nested editor autoFocus initial state stays true). + // Before the fix, the global isEditingCard flag caused ALL nested + // editors with shouldFocus=true to fire their focus effect when any + // card entered edit mode, causing a scroll jump as the nested editor + // further down the page transiently grabbed focus. + const ctaCard = { + type: 'call-to-action', + backgroundColor: 'green', + buttonColor: '#F0F0F0', + buttonText: 'Click me', + buttonTextColor: '#000000', + buttonUrl: '', + hasImage: false, + hasSponsorLabel: true, + sponsorLabel: '

    SPONSORED

    ', + layout: 'minimal', + showButton: true, + showDividers: true, + textValue: '

    CTA content

    ' + }; + + const children: Record[] = [{...ctaCard}]; + + for (let i = 0; i < 30; i++) { + children.push({ + children: [{ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: `Line ${i + 1} of filler content to create scroll distance`, + type: 'text', + version: 1 + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }); + } + + children.push({...ctaCard}); + + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children, + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + // Select the first card (already in viewport at the top) + const firstCard = page.locator('[data-kg-card="call-to-action"]').first(); + await firstCard.click(); + await expect(firstCard).toHaveAttribute('data-kg-card-selected', 'true'); + + // Scroll position should be at the top + const scrollBefore = await getScrollPosition(page); + expect(scrollBefore).toBe(0); + + // Start monitoring for any scroll movement + await page.evaluate(() => { + const container = document.querySelector('.h-full.overflow-auto')!; + const w = window as unknown as Record; + w._scrollStartPosition = container.scrollTop; + w._maxScrollDeviation = 0; + w._scrollHandler = () => { + const deviation = Math.abs(container.scrollTop - (w._scrollStartPosition as number)); + if (deviation > (w._maxScrollDeviation as number)) { + w._maxScrollDeviation = deviation; + } + }; + container.addEventListener('scroll', w._scrollHandler as EventListener); + }); + + // Enter edit mode on the first card — the second card's nested + // editor is far below the viewport so any transient focus grab + // would cause a large downward scroll jump + await firstCard.click(); + await expect(firstCard).toHaveAttribute('data-kg-card-editing', 'true'); + + // Check no scroll movement occurred (even transiently) + const maxDeviation = await page.evaluate(() => { + const container = document.querySelector('.h-full.overflow-auto')!; + const w = window as unknown as Record; + container.removeEventListener('scroll', w._scrollHandler as EventListener); + return w._maxScrollDeviation; + }); + + expect(maxDeviation).toBe(0); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/audio-card.test.js b/packages/koenig-lexical/test/e2e/cards/audio-card.test.js deleted file mode 100644 index f98c85eb67..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/audio-card.test.js +++ /dev/null @@ -1,374 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Audio card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized audio card nodes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'audio', - src: '/content/images/2022/11/koenig-lexical.jpg', - title: 'This is a title', - duration: '', - mimeType: 'audio/mp3', - thumbnailSrc: '/content/images/2022/12/koenig-lexical.png' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders audio card node', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - - await focusEditor(page); - const fileChooserPromise = page.waitForEvent('filechooser'); - await insertCard(page, {cardName: 'audio'}); - const fileChooser = await fileChooserPromise; - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - // Close the fileChooser by selecting a file - // Without this line, fileChooser stays open for subsequent tests - await fileChooser.setFiles([filePath]); - }); - - test('can upload an audio file', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Check that audio file was uploaded - await expect(await page.getByTestId('audio-title')).toBeVisible(); - expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample'); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); // TODO: assert on HTML of inner card (not working due to error in prettier) - }); - - test('can upload dropped audio', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - - // Open audio card and dismiss files chooser to prepare card for audio dropping - await insertCard(page, {cardName: 'audio'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([]); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Check that audio file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:19'); - }); - - test('shows errors on failed audio upload', async function () { - await focusEditor(page); - await uploadAudio(page, 'audio-sample-fail.mp3'); - - // Check that errors are displayed - await page.waitForSelector('[data-testid="audio-upload-errors"]'); - await expect(await page.getByTestId('audio-upload-errors')).toBeVisible(); - }); - - test('can show errors if was dropped a file with wrong extension to audio placeholder', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - - // Open audio card and dismiss files chooser to prepare card for audio dropping - await insertCard(page, {cardName: 'audio'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([]); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Errors should be visible - await expect(await page.getByTestId('audio-upload-errors')).toBeVisible(); - }); - - test('file input opens immediately when added via card menu', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Audio"]') - ]); - - expect(fileChooser).not.toBeNull(); - }); - - test('file input opens immediately when added via slash menu', async function () { - await focusEditor(page); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'audio'}) - ]); - - expect(fileChooser).not.toBeNull(); - }); - - test('can change the title of the audio card', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Change title - await expect(await page.getByTestId('audio-title')).toBeVisible(); - await page.getByTestId('audio-title').click(); - await page.keyboard.type(' 1'); - - // Check that title updated - expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample 1'); - }); - - test('can upload and remove a thumbnail image', async function () { - const thumbnailFilePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - - await focusEditor(page); - await uploadAudio(page); - - // Upload thumbnail - const thumbnailFileChooserPromise = page.waitForEvent('filechooser'); - await page.getByTestId('upload-thumbnail').click(); - const thumbnailFileChooser = await thumbnailFileChooserPromise; - await thumbnailFileChooser.setFiles([thumbnailFilePath]); - - expect (await page.getByTestId('audio-thumbnail')).not.toBeNull(); - - // Remove thumbnail - await page.getByTestId('remove-thumbnail').click(); - expect (await page.getByTestId('upload-thumbnail')).not.toBeNull(); - }); - - test('can upload dropped thumbnail', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - await focusEditor(page); - await uploadAudio(page); - - // Check that audio file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:19'); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('audio-card-populated').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.getByTestId('audio-thumbnail-dragover')).toBeVisible(); - - // Drop file - await page.getByTestId('audio-card-populated').dispatchEvent('drop', {dataTransfer}); - - // Check that audio file was uploaded - await expect (await page.getByTestId('audio-thumbnail')).toBeVisible(); - }); - - test('can show errors if was dropped a file with wrong extension to thumbnail', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - await focusEditor(page); - await uploadAudio(page); - - // Check that audio file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:19'); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); - await page.getByTestId('audio-card-populated').dispatchEvent('drop', {dataTransfer}); - - // Errors should be visible - await expect(await page.getByTestId('thumbnail-errors')).toBeVisible(); - }); - - test('shows errors on a failed thumbnail upload', async function () { - const thumbnailFilePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); - - await focusEditor(page); - await uploadAudio(page); - - // Upload thumbnail - const thumbnailFileChooserPromise = page.waitForEvent('filechooser'); - await page.getByTestId('upload-thumbnail').click(); - const thumbnailFileChooser = await thumbnailFileChooserPromise; - await thumbnailFileChooser.setFiles([thumbnailFilePath]); - - await page.waitForSelector('[data-testid="thumbnail-errors"]'); - expect (await page.getByTestId('thumbnail-errors').textContent()).toEqual('Upload failed'); - }); - - test('renders audio card toolbar', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Leave editing mode to display the toolbar - await expect(await page.getByTestId('audio-title')).toBeVisible(); - await page.keyboard.press('Escape'); - - // Check that the toolbar is displayed - expect(await page.locator('[data-kg-card-toolbar="audio"]')).not.toBeNull(); - }); - - test('audio card toolbar has Edit button', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Leave editing mode to display the toolbar - await expect(await page.getByTestId('audio-title')).toBeVisible(); - await page.keyboard.press('Escape'); - - // Check that the toolbar is displayed - expect(await page.locator('[data-kg-card-toolbar="audio"]')).not.toBeNull(); - - await page.waitForSelector('[data-kg-card-toolbar="audio"] button[aria-label="Edit"]'); - await page.locator('[data-kg-card-toolbar="audio"] button[aria-label="Edit"]').click(); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('should not be available for editing in preview mode', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Check that audio file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:19'); - await page.keyboard.press('Escape'); - - // Title input should be read only - await expect(await page.getByTestId('audio-title')).toHaveAttribute('readOnly', ''); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('audio-card-populated').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text shouldn't be visible - await expect(await page.getByTestId('audio-thumbnail-dragover')).toBeHidden(); - }); - - test('does not add extra paragraph when audio is inserted mid-document', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('ArrowUp'); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Audio"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -

    Testing

    - `, {ignoreCardContents: true}); - }); - - test('adds extra paragraph when audio is inserted at end of document', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Audio"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await uploadAudio(page); - - // Check that audio file was uploaded - await expect(await page.getByTestId('audio-title')).toBeVisible(); - expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="audio"]')).toHaveCount(2); - }); -}); - -async function uploadAudio(page, fileName = 'audio-sample.mp3') { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); - - const fileChooserPromise = page.waitForEvent('filechooser'); - await insertCard(page, {cardName: 'audio'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); -} diff --git a/packages/koenig-lexical/test/e2e/cards/audio-card.test.ts b/packages/koenig-lexical/test/e2e/cards/audio-card.test.ts new file mode 100644 index 0000000000..e959ffa3ce --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/audio-card.test.ts @@ -0,0 +1,375 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Audio card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized audio card nodes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'audio', + src: '/content/images/2022/11/koenig-lexical.jpg', + title: 'This is a title', + duration: '', + mimeType: 'audio/mp3', + thumbnailSrc: '/content/images/2022/12/koenig-lexical.png' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders audio card node', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + + await focusEditor(page); + const fileChooserPromise = page.waitForEvent('filechooser'); + await insertCard(page, {cardName: 'audio'}); + const fileChooser = await fileChooserPromise; + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + // Close the fileChooser by selecting a file + // Without this line, fileChooser stays open for subsequent tests + await fileChooser.setFiles([filePath]); + }); + + test('can upload an audio file', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Check that audio file was uploaded + await expect(await page.getByTestId('audio-title')).toBeVisible(); + expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample'); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); // TODO: assert on HTML of inner card (not working due to error in prettier) + }); + + test('can upload dropped audio', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + + // Open audio card and dismiss files chooser to prepare card for audio dropping + await insertCard(page, {cardName: 'audio'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([]); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Check that audio file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:19'); + }); + + test('shows errors on failed audio upload', async function () { + await focusEditor(page); + await uploadAudio(page, 'audio-sample-fail.mp3'); + + // Check that errors are displayed + await page.waitForSelector('[data-testid="audio-upload-errors"]'); + await expect(await page.getByTestId('audio-upload-errors')).toBeVisible(); + }); + + test('can show errors if was dropped a file with wrong extension to audio placeholder', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + + // Open audio card and dismiss files chooser to prepare card for audio dropping + await insertCard(page, {cardName: 'audio'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([]); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Errors should be visible + await expect(await page.getByTestId('audio-upload-errors')).toBeVisible(); + }); + + test('file input opens immediately when added via card menu', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Audio"]') + ]); + + expect(fileChooser).not.toBeNull(); + }); + + test('file input opens immediately when added via slash menu', async function () { + await focusEditor(page); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'audio'}) + ]); + + expect(fileChooser).not.toBeNull(); + }); + + test('can change the title of the audio card', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Change title + await expect(await page.getByTestId('audio-title')).toBeVisible(); + await page.getByTestId('audio-title').click(); + await page.keyboard.type(' 1'); + + // Check that title updated + expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample 1'); + }); + + test('can upload and remove a thumbnail image', async function () { + const thumbnailFilePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + + await focusEditor(page); + await uploadAudio(page); + + // Upload thumbnail + const thumbnailFileChooserPromise = page.waitForEvent('filechooser'); + await page.getByTestId('upload-thumbnail').click(); + const thumbnailFileChooser = await thumbnailFileChooserPromise; + await thumbnailFileChooser.setFiles([thumbnailFilePath]); + + expect (await page.getByTestId('audio-thumbnail')).not.toBeNull(); + + // Remove thumbnail + await page.getByTestId('remove-thumbnail').click(); + expect (await page.getByTestId('upload-thumbnail')).not.toBeNull(); + }); + + test('can upload dropped thumbnail', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + await focusEditor(page); + await uploadAudio(page); + + // Check that audio file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:19'); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('audio-card-populated').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.getByTestId('audio-thumbnail-dragover')).toBeVisible(); + + // Drop file + await page.getByTestId('audio-card-populated').dispatchEvent('drop', {dataTransfer}); + + // Check that audio file was uploaded + await expect (await page.getByTestId('audio-thumbnail')).toBeVisible(); + }); + + test('can show errors if was dropped a file with wrong extension to thumbnail', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + await focusEditor(page); + await uploadAudio(page); + + // Check that audio file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:19'); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); + await page.getByTestId('audio-card-populated').dispatchEvent('drop', {dataTransfer}); + + // Errors should be visible + await expect(await page.getByTestId('thumbnail-errors')).toBeVisible(); + }); + + test('shows errors on a failed thumbnail upload', async function () { + const thumbnailFilePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); + + await focusEditor(page); + await uploadAudio(page); + + // Upload thumbnail + const thumbnailFileChooserPromise = page.waitForEvent('filechooser'); + await page.getByTestId('upload-thumbnail').click(); + const thumbnailFileChooser = await thumbnailFileChooserPromise; + await thumbnailFileChooser.setFiles([thumbnailFilePath]); + + await page.waitForSelector('[data-testid="thumbnail-errors"]'); + expect (await page.getByTestId('thumbnail-errors').textContent()).toEqual('Upload failed'); + }); + + test('renders audio card toolbar', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Leave editing mode to display the toolbar + await expect(await page.getByTestId('audio-title')).toBeVisible(); + await page.keyboard.press('Escape'); + + // Check that the toolbar is displayed + expect(await page.locator('[data-kg-card-toolbar="audio"]')).not.toBeNull(); + }); + + test('audio card toolbar has Edit button', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Leave editing mode to display the toolbar + await expect(await page.getByTestId('audio-title')).toBeVisible(); + await page.keyboard.press('Escape'); + + // Check that the toolbar is displayed + expect(await page.locator('[data-kg-card-toolbar="audio"]')).not.toBeNull(); + + await page.waitForSelector('[data-kg-card-toolbar="audio"] button[aria-label="Edit"]'); + await page.locator('[data-kg-card-toolbar="audio"] button[aria-label="Edit"]').click(); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('should not be available for editing in preview mode', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Check that audio file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:19'); + await page.keyboard.press('Escape'); + + // Title input should be read only + await expect(await page.getByTestId('audio-title')).toHaveAttribute('readOnly', ''); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('audio-card-populated').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text shouldn't be visible + await expect(await page.getByTestId('audio-thumbnail-dragover')).toBeHidden(); + }); + + test('does not add extra paragraph when audio is inserted mid-document', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('ArrowUp'); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Audio"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +

    Testing

    + `, {ignoreCardContents: true}); + }); + + test('adds extra paragraph when audio is inserted at end of document', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Audio"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await uploadAudio(page); + + // Check that audio file was uploaded + await expect(await page.getByTestId('audio-title')).toBeVisible(); + expect(await page.getByTestId('audio-title').inputValue()).toEqual('Audio sample'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="audio"]')).toHaveCount(2); + }); +}); + +async function uploadAudio(page: Page, fileName = 'audio-sample.mp3') { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await insertCard(page, {cardName: 'audio'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); +} diff --git a/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.js b/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.js deleted file mode 100644 index decd9ac275..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.js +++ /dev/null @@ -1,544 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Bookmark card (with searchLinks)', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - let errors; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - - page.on('pageerror', (err) => { - errors.push(err.message); - }); - }); - - test.beforeEach(async () => { - errors = []; - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized bookmark card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'bookmark', - url: 'https://www.ghost.org/', - caption: 'caption here', - metadata: { - icon: 'https://www.ghost.org/favicon.ico', - title: 'Ghost: The Creator Economy Platform', - description: 'lorem ipsum dolor amet lorem ipsum dolor amet', - author: 'ghost', - publisher: 'Ghost - The Professional Publishing Platform', - thumbnail: 'https://ghost.org/images/meta/ghost.png' - } - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Ghost: The Creator Economy Platform
    -
    lorem ipsum dolor amet lorem ipsum dolor amet
    -
    - - Ghost - The Professional Publishing Platform - ghost -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - caption here -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('renders bookmark card node', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can interact with url input after inserting', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); - - await urlInput.fill('test'); - await expect(urlInput).toHaveValue('test'); - }); - - test.describe('Valid URL handling', async () => { - test('shows loading wheel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(page.getByTestId('bookmark-url-loading-container')).toBeVisible(); - await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); - }); - - test('displays expected metadata', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(page.getByTestId('bookmark-title')).toHaveText('Ghost: The Creator Economy Platform'); - await expect(page.getByTestId('bookmark-description')).toContainText('The former of the two songs addresses the issue of negative rumors in a relationship, while the latter, with a more upbeat pulse, is a classic club track; the single is highlighted by a hyped bridge.'); - await expect(page.getByTestId('bookmark-publisher')).toContainText('Ghost - The Professional Publishing Platform'); - }); - - // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it - test('caption displays on insert', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - const captionInput = await page.getByTestId('bookmark-caption'); - await expect(captionInput).toContainText('Type caption for bookmark (optional)'); - }); - }); - - test.describe('Error Handling', async () => { - test('bad url entry shows error message', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - await expect(page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); - }); - - test('retry button bring back url input', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-retry'); - await retryButton.click(); - - const urlInputRetry = await page.getByTestId('bookmark-url'); - await expect(urlInputRetry).toHaveValue('badurl'); - await expect(retryButton).not.toBeVisible(); - }); - - // todo: test is failing, need to figure if the error in test logic or on code - test.skip('paste as link button removes card and inserts text node link', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-pasteAsLink'); - await retryButton.click(); - - await assertHTML(page, html` -

    - badurl -

    -


    - `); - }); - - test('close button removes card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-close'); - await retryButton.click(); - - await assertHTML(page, html`


    `); - }); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - await expect(page.getByTestId('bookmark-description')).toBeVisible(); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="bookmark"]')).toHaveCount(2); - }); - - test('can undo/redo without losing caption', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - await expect(page.getByTestId('bookmark-description')).toBeVisible(); - - await page.click('[data-testid="bookmark-caption"]'); - await page.keyboard.type('My test caption'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Ghost: The Creator Economy Platform
    -
    - The former of the two songs addresses the issue of negative rumors - in a relationship, while the latter, with a more upbeat pulse, is a - classic club track; the single is highlighted by a hyped bridge. -
    -
    - - Ghost - The Professional Publishing Platform - Author McAuthory -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - My test caption -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('escape removes url input component', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); - - test('escape removes url error component', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await page.keyboard.type('badurl'); - await page.keyboard.press('Enter'); - - await expect(page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); - - // AtLinkPlugin added a PASTE_COMMAND handler which didn't account for - // pastes occurring in input fields inside the main editor resulting in a TypeError - test('can paste into URL input', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toBeFocused(); - - await pasteText(page, 'https://ghost.org/'); - - expect(errors).toEqual([]); - }); - - // Searchable URL input ---------------------------------------------------- - - test.describe('Search', function () { - test('shows default options when opening', async function () { - await page.mouse.move(0,0); // was triggering hover state on option after the first - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
      -
    • Latest posts
    • -
    • - Remote Work's Impact on Job Markets and Employment - - - 8 May 2024 - -
    • -
    • - Robotics Renaissance: How Automation is Transforming Industries -
    • -
    • - Biodiversity Conservation in Fragile Ecosystems -
    • -
    • - Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment -
    • -
    -
    -
    -
    -
    -


    - `); - }); - - test('shows metadata on selected items', async function () { - await page.mouse.move(0, 0); // avoid hover state interfering with keyboard selection - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await assertHTML(page, html` - Remote Work's Impact on Job Markets and Employment - - - 8 May 2024 - - `, {selector: '[data-testid="bookmark-url-listOption"][aria-selected="true"]'}); - - // wait for dropdown to fully settle before navigating - await page.waitForTimeout(100); - - await page.keyboard.press('ArrowDown'); - - // wait for selection to move to the second item - await expect(page.locator('[data-testid="bookmark-url-listOption"]').nth(1)).toHaveAttribute('aria-selected', 'true'); - - // check all conditions atomically because the dropdown selection can be unstable - await expect(async () => { - const firstItemText = await page.locator('[data-testid="bookmark-url-listOption"]').nth(0).textContent(); - const selectedItemText = await page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]').textContent(); - - // first item no longer shows metadata - expect(firstItemText).not.toContain('May 2024'); - - // second now-selected item shows metadata - expect(selectedItemText).toContain('Robotics Renaissance'); - expect(selectedItemText).toContain('2 May 2024'); - }).toPass(); - }); - - test('highlights matches in results', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await page.keyboard.type('Emoji'); - - await expect(page.locator('[data-testid="bookmark-url-listOption"]')).toBeVisible(); - await expect(page.locator('span.font-bold').first()).toHaveText('Emoji'); - }); - - test('does not crash with regexp chars in search', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await page.keyboard.type('['); - - await expect(page.locator('[data-testid="bookmark-url-dropdown"]')).toBeVisible(); - }); - - test('filters options whilst typing', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await page.keyboard.type('e'); - - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('TK Reminders'); - - await page.keyboard.type('mo'); - - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('✨ Emoji autocomplete ✨'); - }); - - test('can change selected item with arrow keys', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Remote Work\'s Impact on Job Markets and Employment'); - await page.keyboard.press('ArrowDown'); - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Robotics Renaissance: How Automation is Transforming Industries'); - await page.keyboard.press('ArrowUp'); - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Remote Work\'s Impact on Job Markets and Employment'); - }); - - test('inserts selected item on enter', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - await page.keyboard.type('Emoji'); - await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('✨ Emoji autocomplete ✨'); - await page.keyboard.press('Enter'); - - // NOTE: this doesn't test for the right item being inserted because - // the demo app always inserts a mocked oembed response - await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); - await expect(page.getByTestId('bookmark-container')).toBeVisible(); - }); - - test('inserts item on click', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await page.click('[data-testid="bookmark-url-listOption"]:nth-child(2)'); - - // NOTE: this doesn't test for the right item being inserted because - // the demo app always inserts a mocked oembed response - await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); - await expect(page.getByTestId('bookmark-container')).toBeVisible(); - }); - - test('handles Enter with no matching result', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await page.keyboard.type('Not a valid match'); - - await expect(page.getByText('Enter URL to create link')).toBeVisible(); - - await page.keyboard.press('Enter'); - - await expect(page.getByText('Enter URL to create link')).toBeVisible(); - - expect(errors).toEqual([]); - }); - - [ - 'http', - '#test', - '/test', - 'mailto:' - ].forEach((expected) => { - test(`handles URL-like inputs (${expected})`, async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); - - await page.keyboard.type(expected, {delay: 10}); - await expect(page.getByTestId('input-list-spinner')).not.toBeVisible(); - - await assertHTML(page, html` -
  • Link to web page
  • -
  • - - - ${expected} - -
  • - `, {selector: '[data-testid="bookmark-url-dropdown"]'}); - }); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.ts b/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.ts new file mode 100644 index 0000000000..c56b6b2953 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/bookmark-card-with-search.test.ts @@ -0,0 +1,545 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac, pasteText} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Bookmark card (with searchLinks)', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + let errors: string[]; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + + page.on('pageerror', (err) => { + errors.push(err.message); + }); + }); + + test.beforeEach(async () => { + errors = []; + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized bookmark card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'bookmark', + url: 'https://www.ghost.org/', + caption: 'caption here', + metadata: { + icon: 'https://www.ghost.org/favicon.ico', + title: 'Ghost: The Creator Economy Platform', + description: 'lorem ipsum dolor amet lorem ipsum dolor amet', + author: 'ghost', + publisher: 'Ghost - The Professional Publishing Platform', + thumbnail: 'https://ghost.org/images/meta/ghost.png' + } + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Ghost: The Creator Economy Platform
    +
    lorem ipsum dolor amet lorem ipsum dolor amet
    +
    + + Ghost - The Professional Publishing Platform + ghost +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + caption here +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('renders bookmark card node', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can interact with url input after inserting', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); + + await urlInput.fill('test'); + await expect(urlInput).toHaveValue('test'); + }); + + test.describe('Valid URL handling', async () => { + test('shows loading wheel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(page.getByTestId('bookmark-url-loading-container')).toBeVisible(); + await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); + }); + + test('displays expected metadata', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(page.getByTestId('bookmark-title')).toHaveText('Ghost: The Creator Economy Platform'); + await expect(page.getByTestId('bookmark-description')).toContainText('The former of the two songs addresses the issue of negative rumors in a relationship, while the latter, with a more upbeat pulse, is a classic club track; the single is highlighted by a hyped bridge.'); + await expect(page.getByTestId('bookmark-publisher')).toContainText('Ghost - The Professional Publishing Platform'); + }); + + // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it + test('caption displays on insert', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + const captionInput = await page.getByTestId('bookmark-caption'); + await expect(captionInput).toContainText('Type caption for bookmark (optional)'); + }); + }); + + test.describe('Error Handling', async () => { + test('bad url entry shows error message', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + await expect(page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); + }); + + test('retry button bring back url input', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-retry'); + await retryButton.click(); + + const urlInputRetry = await page.getByTestId('bookmark-url'); + await expect(urlInputRetry).toHaveValue('badurl'); + await expect(retryButton).not.toBeVisible(); + }); + + // todo: test is failing, need to figure if the error in test logic or on code + test.skip('paste as link button removes card and inserts text node link', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-pasteAsLink'); + await retryButton.click(); + + await assertHTML(page, html` +

    + badurl +

    +


    + `); + }); + + test('close button removes card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL or search posts and pages...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-close'); + await retryButton.click(); + + await assertHTML(page, html`


    `); + }); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + await expect(page.getByTestId('bookmark-description')).toBeVisible(); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="bookmark"]')).toHaveCount(2); + }); + + test('can undo/redo without losing caption', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + await expect(page.getByTestId('bookmark-description')).toBeVisible(); + + await page.click('[data-testid="bookmark-caption"]'); + await page.keyboard.type('My test caption'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Ghost: The Creator Economy Platform
    +
    + The former of the two songs addresses the issue of negative rumors + in a relationship, while the latter, with a more upbeat pulse, is a + classic club track; the single is highlighted by a hyped bridge. +
    +
    + + Ghost - The Professional Publishing Platform + Author McAuthory +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + My test caption +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('escape removes url input component', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); + + test('escape removes url error component', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await page.keyboard.type('badurl'); + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); + + // AtLinkPlugin added a PASTE_COMMAND handler which didn't account for + // pastes occurring in input fields inside the main editor resulting in a TypeError + test('can paste into URL input', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toBeFocused(); + + await pasteText(page, 'https://ghost.org/'); + + expect(errors).toEqual([]); + }); + + // Searchable URL input ---------------------------------------------------- + + test.describe('Search', function () { + test('shows default options when opening', async function () { + await page.mouse.move(0,0); // was triggering hover state on option after the first + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
      +
    • Latest posts
    • +
    • + Remote Work's Impact on Job Markets and Employment + + + 8 May 2024 + +
    • +
    • + Robotics Renaissance: How Automation is Transforming Industries +
    • +
    • + Biodiversity Conservation in Fragile Ecosystems +
    • +
    • + Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment +
    • +
    +
    +
    +
    +
    +


    + `); + }); + + test('shows metadata on selected items', async function () { + await page.mouse.move(0, 0); // avoid hover state interfering with keyboard selection + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await assertHTML(page, html` + Remote Work's Impact on Job Markets and Employment + + + 8 May 2024 + + `, {selector: '[data-testid="bookmark-url-listOption"][aria-selected="true"]'}); + + // wait for dropdown to fully settle before navigating + await page.waitForTimeout(100); + + await page.keyboard.press('ArrowDown'); + + // wait for selection to move to the second item + await expect(page.locator('[data-testid="bookmark-url-listOption"]').nth(1)).toHaveAttribute('aria-selected', 'true'); + + // check all conditions atomically because the dropdown selection can be unstable + await expect(async () => { + const firstItemText = await page.locator('[data-testid="bookmark-url-listOption"]').nth(0).textContent(); + const selectedItemText = await page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]').textContent(); + + // first item no longer shows metadata + expect(firstItemText).not.toContain('May 2024'); + + // second now-selected item shows metadata + expect(selectedItemText).toContain('Robotics Renaissance'); + expect(selectedItemText).toContain('2 May 2024'); + }).toPass(); + }); + + test('highlights matches in results', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await page.keyboard.type('Emoji'); + + await expect(page.locator('[data-testid="bookmark-url-listOption"]')).toBeVisible(); + await expect(page.locator('span.font-bold').first()).toHaveText('Emoji'); + }); + + test('does not crash with regexp chars in search', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await page.keyboard.type('['); + + await expect(page.locator('[data-testid="bookmark-url-dropdown"]')).toBeVisible(); + }); + + test('filters options whilst typing', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await page.keyboard.type('e'); + + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('TK Reminders'); + + await page.keyboard.type('mo'); + + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('✨ Emoji autocomplete ✨'); + }); + + test('can change selected item with arrow keys', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Remote Work\'s Impact on Job Markets and Employment'); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Robotics Renaissance: How Automation is Transforming Industries'); + await page.keyboard.press('ArrowUp'); + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('Remote Work\'s Impact on Job Markets and Employment'); + }); + + test('inserts selected item on enter', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + await page.keyboard.type('Emoji'); + await expect(page.locator('[data-testid="bookmark-url-listOption"][aria-selected="true"]')).toContainText('✨ Emoji autocomplete ✨'); + await page.keyboard.press('Enter'); + + // NOTE: this doesn't test for the right item being inserted because + // the demo app always inserts a mocked oembed response + await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); + await expect(page.getByTestId('bookmark-container')).toBeVisible(); + }); + + test('inserts item on click', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await page.click('[data-testid="bookmark-url-listOption"]:nth-child(2)'); + + // NOTE: this doesn't test for the right item being inserted because + // the demo app always inserts a mocked oembed response + await expect(page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); + await expect(page.getByTestId('bookmark-container')).toBeVisible(); + }); + + test('handles Enter with no matching result', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await page.keyboard.type('Not a valid match'); + + await expect(page.getByText('Enter URL to create link')).toBeVisible(); + + await page.keyboard.press('Enter'); + + await expect(page.getByText('Enter URL to create link')).toBeVisible(); + + expect(errors).toEqual([]); + }); + + [ + 'http', + '#test', + '/test', + 'mailto:' + ].forEach((expected) => { + test(`handles URL-like inputs (${expected})`, async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + + await page.keyboard.type(expected, {delay: 10}); + await expect(page.getByTestId('input-list-spinner')).not.toBeVisible(); + + await assertHTML(page, html` +
  • Link to web page
  • +
  • + + + ${expected} + +
  • + `, {selector: '[data-testid="bookmark-url-dropdown"]'}); + }); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.js b/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.js deleted file mode 100644 index e7e6ac5310..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.js +++ /dev/null @@ -1,331 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Bookmark card', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/?content=false&searchLinks=false'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized bookmark card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'bookmark', - url: 'https://www.ghost.org/', - caption: 'caption here', - metadata: { - icon: 'https://www.ghost.org/favicon.ico', - title: 'Ghost: The Creator Economy Platform', - description: 'lorem ipsum dolor amet lorem ipsum dolor amet', - author: 'ghost', - publisher: 'Ghost - The Professional Publishing Platform', - thumbnail: 'https://ghost.org/images/meta/ghost.png' - } - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Ghost: The Creator Economy Platform
    -
    lorem ipsum dolor amet lorem ipsum dolor amet
    -
    - - Ghost - The Professional Publishing Platform - ghost -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - caption here -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('renders bookmark card node', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can interact with url input after inserting', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); - - await urlInput.fill('test'); - await expect(urlInput).toHaveValue('test'); - }); - - test.describe('Valid URL handling', async () => { - test('shows loading wheel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('bookmark-url-loading-container')).toBeVisible(); - await expect(await page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); - }); - - test('displays expected metadata', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('bookmark-title')).toHaveText('Ghost: The Creator Economy Platform'); - await expect(await page.getByTestId('bookmark-description')).toContainText('The former of the two songs addresses the issue of negative rumors in a relationship, while the latter, with a more upbeat pulse, is a classic club track; the single is highlighted by a hyped bridge.'); - await expect(await page.getByTestId('bookmark-publisher')).toContainText('Ghost - The Professional Publishing Platform'); - }); - - // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it - test('caption displays on insert', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - const captionInput = await page.getByTestId('bookmark-caption'); - await expect(captionInput).toContainText('Type caption for bookmark (optional)'); - }); - }); - - test.describe('Error Handling', async () => { - test('bad url entry shows error message', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); - }); - - test('retry button bring back url input', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-retry'); - await retryButton.click(); - - const urlInputRetry = await page.getByTestId('bookmark-url'); - await expect(urlInputRetry).toHaveValue('badurl'); - await expect(retryButton).not.toBeVisible(); - }); - - // todo: test is failing, need to figure if the error in test logic or on code - test.skip('paste as link button removes card and inserts text node link', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-pasteAsLink'); - await retryButton.click(); - - await assertHTML(page, html` -

    - badurl -

    -


    - `); - }); - - test('close button removes card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('bookmark-url-error-close'); - await retryButton.click(); - - await assertHTML(page, html`


    `); - }); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - await expect(await page.getByTestId('bookmark-description')).toBeVisible(); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="bookmark"]')).toHaveCount(2); - }); - - test('can undo/redo without losing caption', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = await page.getByTestId('bookmark-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - await expect(await page.getByTestId('bookmark-description')).toBeVisible(); - - await page.click('[data-testid="bookmark-caption"]'); - await page.keyboard.type('My test caption'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Ghost: The Creator Economy Platform
    -
    - The former of the two songs addresses the issue of negative rumors - in a relationship, while the latter, with a more upbeat pulse, is a - classic club track; the single is highlighted by a hyped bridge. -
    -
    - - Ghost - The Professional Publishing Platform - Author McAuthory -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - My test caption -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('escape removes url input component', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); - - test('escape removes url error component', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - await page.keyboard.type('badurl'); - await page.keyboard.press('Enter'); - - await expect(await page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.ts b/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.ts new file mode 100644 index 0000000000..966424d557 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/bookmark-card-without-search.test.ts @@ -0,0 +1,332 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Bookmark card', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false&searchLinks=false'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized bookmark card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'bookmark', + url: 'https://www.ghost.org/', + caption: 'caption here', + metadata: { + icon: 'https://www.ghost.org/favicon.ico', + title: 'Ghost: The Creator Economy Platform', + description: 'lorem ipsum dolor amet lorem ipsum dolor amet', + author: 'ghost', + publisher: 'Ghost - The Professional Publishing Platform', + thumbnail: 'https://ghost.org/images/meta/ghost.png' + } + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Ghost: The Creator Economy Platform
    +
    lorem ipsum dolor amet lorem ipsum dolor amet
    +
    + + Ghost - The Professional Publishing Platform + ghost +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + caption here +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('renders bookmark card node', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can interact with url input after inserting', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); + + await urlInput.fill('test'); + await expect(urlInput).toHaveValue('test'); + }); + + test.describe('Valid URL handling', async () => { + test('shows loading wheel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('bookmark-url-loading-container')).toBeVisible(); + await expect(await page.getByTestId('bookmark-url-loading-spinner')).toBeVisible(); + }); + + test('displays expected metadata', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('bookmark-title')).toHaveText('Ghost: The Creator Economy Platform'); + await expect(await page.getByTestId('bookmark-description')).toContainText('The former of the two songs addresses the issue of negative rumors in a relationship, while the latter, with a more upbeat pulse, is a classic club track; the single is highlighted by a hyped bridge.'); + await expect(await page.getByTestId('bookmark-publisher')).toContainText('Ghost - The Professional Publishing Platform'); + }); + + // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it + test('caption displays on insert', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + const captionInput = await page.getByTestId('bookmark-caption'); + await expect(captionInput).toContainText('Type caption for bookmark (optional)'); + }); + }); + + test.describe('Error Handling', async () => { + test('bad url entry shows error message', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); + }); + + test('retry button bring back url input', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-retry'); + await retryButton.click(); + + const urlInputRetry = await page.getByTestId('bookmark-url'); + await expect(urlInputRetry).toHaveValue('badurl'); + await expect(retryButton).not.toBeVisible(); + }); + + // todo: test is failing, need to figure if the error in test logic or on code + test.skip('paste as link button removes card and inserts text node link', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-pasteAsLink'); + await retryButton.click(); + + await assertHTML(page, html` +

    + badurl +

    +


    + `); + }); + + test('close button removes card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add bookmark content...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('bookmark-url-error-close'); + await retryButton.click(); + + await assertHTML(page, html`


    `); + }); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + await expect(await page.getByTestId('bookmark-description')).toBeVisible(); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="bookmark"]')).toHaveCount(2); + }); + + test('can undo/redo without losing caption', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = await page.getByTestId('bookmark-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + await expect(await page.getByTestId('bookmark-description')).toBeVisible(); + + await page.click('[data-testid="bookmark-caption"]'); + await page.keyboard.type('My test caption'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Ghost: The Creator Economy Platform
    +
    + The former of the two songs addresses the issue of negative rumors + in a relationship, while the latter, with a more upbeat pulse, is a + classic club track; the single is highlighted by a hyped bridge. +
    +
    + + Ghost - The Professional Publishing Platform + Author McAuthory +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + My test caption +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('escape removes url input component', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); + + test('escape removes url error component', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + await page.keyboard.type('badurl'); + await page.keyboard.press('Enter'); + + await expect(await page.getByTestId('bookmark-url-error-message')).toContainText('Oops, that link didn\'t work.'); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/button-card.test.js b/packages/koenig-lexical/test/e2e/cards/button-card.test.js deleted file mode 100644 index 1415ae82f1..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/button-card.test.js +++ /dev/null @@ -1,166 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Button Card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized button card nodes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'button', - buttonUrl: 'http://someblog.com/somepost', - buttonText: 'button text', - alignment: 'center' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders button card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('has settings panel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - await expect(await page.getByTestId('settings-panel')).toBeVisible(); - await expect(await page.getByTestId('button-align-left')).toBeVisible(); - await expect(await page.getByTestId('button-align-center')).toBeVisible(); - await expect(await page.getByTestId('button-input-text')).toBeVisible(); - await expect(await page.getByTestId('button-input-url')).toBeVisible(); - }); - - test('alignment buttons work', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - // align center by default - const buttonCard = await page.getByTestId('button-card'); - await expect(buttonCard).toHaveClass(/justify-center/); - - const leftAlignButton = await page.getByTestId('button-align-left'); - leftAlignButton.click(); - await expect(buttonCard).toHaveClass(/justify-start/); - - const centerAlignButton = await page.getByTestId('button-align-center'); - centerAlignButton.click(); - await expect(buttonCard).toHaveClass(/justify-center/); - }); - - test('default settings are appropriate', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('Add button text'); - const buttonTextInput = await page.getByTestId('button-input-text'); - await expect(buttonTextInput).toHaveAttribute('placeholder','Add button text'); - const buttonUrlInput = await page.getByTestId('button-input-url'); - await expect(buttonUrlInput).toHaveAttribute('placeholder','https://yoursite.com/#/portal/signup/'); - }); - - test('text input field works', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - // verify default values - await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('Add button text'); - - const buttonTextInput = await page.getByTestId('button-input-text'); - await expect(buttonTextInput).toHaveValue(''); - - await page.getByTestId('button-input-text').fill('test'); - await expect(buttonTextInput).toHaveValue('test'); - await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('test'); - }); - - test('url input field works', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - const buttonTextInput = await page.getByTestId('button-input-url'); - await expect(buttonTextInput).toHaveValue(''); - - await page.getByTestId('button-input-url').fill('https://someblog.com/somepost'); - await expect(buttonTextInput).toHaveValue('https://someblog.com/somepost'); - const buttonLink = await page.getByTestId('button-card-btn'); - await expect(buttonLink).toHaveAttribute('href','https://someblog.com/somepost'); - }); - - // NOTE: an improvement would be to pass in suggested url options, but the construction now doesn't make that straightforward - test('suggested urls display', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - const buttonTextInput = await page.getByTestId('button-input-url'); - await expect(buttonTextInput).toHaveValue(''); - - await page.getByTestId('button-input-url').fill('Home'); - await page.waitForSelector('[data-testid="button-input-url-listOption"]'); - await expect(await page.getByTestId('button-input-url-listOption-Homepage')).toHaveText('Homepage'); - await page.getByTestId('button-input-url-listOption').click(); - - // need to make this any string value because we don't want to hardcode the window.location value - const anyString = new RegExp(`.*`); - await expect(buttonTextInput).toHaveValue(anyString); - const buttonLink = await page.getByTestId('button-card-btn'); - await expect(buttonLink).toHaveAttribute('href',anyString); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'button'}); - - await page.getByTestId('button-input-text').fill('test'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="button"]')).toHaveCount(2); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/button-card.test.ts b/packages/koenig-lexical/test/e2e/cards/button-card.test.ts new file mode 100644 index 0000000000..c7d1703dbf --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/button-card.test.ts @@ -0,0 +1,167 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Button Card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized button card nodes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'button', + buttonUrl: 'http://someblog.com/somepost', + buttonText: 'button text', + alignment: 'center' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders button card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('has settings panel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + await expect(await page.getByTestId('settings-panel')).toBeVisible(); + await expect(await page.getByTestId('button-align-left')).toBeVisible(); + await expect(await page.getByTestId('button-align-center')).toBeVisible(); + await expect(await page.getByTestId('button-input-text')).toBeVisible(); + await expect(await page.getByTestId('button-input-url')).toBeVisible(); + }); + + test('alignment buttons work', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + // align center by default + const buttonCard = await page.getByTestId('button-card'); + await expect(buttonCard).toHaveClass(/justify-center/); + + const leftAlignButton = await page.getByTestId('button-align-left'); + leftAlignButton.click(); + await expect(buttonCard).toHaveClass(/justify-start/); + + const centerAlignButton = await page.getByTestId('button-align-center'); + centerAlignButton.click(); + await expect(buttonCard).toHaveClass(/justify-center/); + }); + + test('default settings are appropriate', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('Add button text'); + const buttonTextInput = await page.getByTestId('button-input-text'); + await expect(buttonTextInput).toHaveAttribute('placeholder','Add button text'); + const buttonUrlInput = await page.getByTestId('button-input-url'); + await expect(buttonUrlInput).toHaveAttribute('placeholder','https://yoursite.com/#/portal/signup/'); + }); + + test('text input field works', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + // verify default values + await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('Add button text'); + + const buttonTextInput = await page.getByTestId('button-input-text'); + await expect(buttonTextInput).toHaveValue(''); + + await page.getByTestId('button-input-text').fill('test'); + await expect(buttonTextInput).toHaveValue('test'); + await expect(await page.getByTestId('button-card-btn-span').textContent()).toEqual('test'); + }); + + test('url input field works', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + const buttonTextInput = await page.getByTestId('button-input-url'); + await expect(buttonTextInput).toHaveValue(''); + + await page.getByTestId('button-input-url').fill('https://someblog.com/somepost'); + await expect(buttonTextInput).toHaveValue('https://someblog.com/somepost'); + const buttonLink = await page.getByTestId('button-card-btn'); + await expect(buttonLink).toHaveAttribute('href','https://someblog.com/somepost'); + }); + + // NOTE: an improvement would be to pass in suggested url options, but the construction now doesn't make that straightforward + test('suggested urls display', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + const buttonTextInput = await page.getByTestId('button-input-url'); + await expect(buttonTextInput).toHaveValue(''); + + await page.getByTestId('button-input-url').fill('Home'); + await page.waitForSelector('[data-testid="button-input-url-listOption"]'); + await expect(await page.getByTestId('button-input-url-listOption-Homepage')).toHaveText('Homepage'); + await page.getByTestId('button-input-url-listOption').click(); + + // need to make this any string value because we don't want to hardcode the window.location value + const anyString = new RegExp(`.*`); + await expect(buttonTextInput).toHaveValue(anyString); + const buttonLink = await page.getByTestId('button-card-btn'); + await expect(buttonLink).toHaveAttribute('href',anyString); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'button'}); + + await page.getByTestId('button-input-text').fill('test'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="button"]')).toHaveCount(2); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.js b/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.js deleted file mode 100644 index ff2df770d1..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.js +++ /dev/null @@ -1,650 +0,0 @@ -import path from 'path'; -import {CALLTOACTION_COLORS} from '../../../src/utils/callToActionColors.js'; -import {assertHTML, createDataTransfer, focusEditor, getEditorStateJSON, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -import {selectCustomColor, selectNamedColor, selectTitledColor} from '../../utils/color-select-helper'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Call To Action Card', async () => { - let page; - let serializedTestCard; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/?content=false'}); - - serializedTestCard = { - type: 'call-to-action', - backgroundColor: 'green', - buttonColor: '#F0F0F0', - buttonText: 'Get access now', - buttonTextColor: '#000000', - buttonUrl: 'http://someblog.com/somepost', - hasImage: true, - hasSponsorLabel: true, - sponsorLabel: '

    SPONSORED

    ', - imageUrl: '/content/images/2022/11/koenig-lexical.jpg', - layout: 'minimal', - showButton: true, - showDividers: true, - textValue: '

    This is a new CTA Card.

    ' - }; - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized CTA card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [serializedTestCard], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - const ctaCardHtml = html` -
    -
    -
    -
    -

    Sponsored

    -
    -
    -
    - Placeholder -
    -
    -
    -
    -
    -

    - This is a new CTA Card. -

    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
      -
    • - -
    • -
    • -
    • - -
    • -
    -
    -
    -
    -`; - await assertHTML(page, ctaCardHtml, {ignoreCardContents: true}); - }); - - test('renders CTA Card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - test('button and button settings are visible by default', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); - expect(await page.isVisible('[data-testid="button-text"]')).toBe(true); - expect(await page.isVisible('[data-testid="button-url"]')).toBe(true); - }); - - test('can toggle button on card and expands settings', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); - await page.click('[data-testid="button-settings"]'); - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(false); - - await page.click('[data-testid="button-settings"]'); - - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); - }); - - test('can set button text', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.fill('[data-testid="button-text"]', 'Click me'); - expect(await page.textContent('[data-testid="cta-button"]')).toBe('Click me'); - }); - - test('can set button url', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); - const buttonContainer = await page.$('[data-test-cta-button-current-url]'); - const currentUrl = await buttonContainer.getAttribute('data-test-cta-button-current-url'); - expect(currentUrl).toBe('https://example.com/somepost'); - }); - - // NOTE: an improvement would be to pass in suggested url options, but the construction now doesn't make that straightforward - test('suggested urls display', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - const buttonTextInput = await page.getByTestId('button-url'); - await expect(buttonTextInput).toHaveValue(''); - - await page.getByTestId('button-url').fill('Home'); - await page.waitForSelector('[data-testid="button-url-listOption"]'); - - await expect(await page.getByTestId('button-url-listOption')).toContainText('Homepage'); - await page.getByTestId('button-url-listOption').click(); - const buttonContainer = await page.$('[data-test-cta-button-current-url]'); - const currentUrl = await buttonContainer.getAttribute('data-test-cta-button-current-url'); - // current view can be any url, so check for a valid url - const validUrlRegex = /^(https?:\/\/)([\w.-]+)(:[0-9]+)?(\/[\w.-]*)*(\?.*)?(#.*)?$/; - // Assert the URL is valid - expect(currentUrl).toMatch(validUrlRegex); - }); - - test('button doesnt disappear when toggled, has text, has url and loses focus', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.fill('[data-testid="button-text"]', 'Click me'); - await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); - expect(await page.textContent('[data-testid="cta-button"]')).toBe('Click me'); - const buttonContainer = await page.$('[data-test-cta-button-current-url]'); - const currentUrl = await buttonContainer.getAttribute('data-test-cta-button-current-url'); - expect(currentUrl).toBe('https://example.com/somepost'); - - // lose focus and editing mode - await page.keyboard.press('Escape'); - await page.keyboard.press('Enter'); - - // check card exited edit mode - const card = page.locator('[data-kg-card="call-to-action"]'); - await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); - await expect(card).toHaveAttribute('data-kg-card-selected', 'false'); - - expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); - }); - - test('can toggle sponsor label', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.click('[data-testid="sponsor-label-toggle"]'); - expect(await page.isVisible('[data-testid="sponsor-label-editor"]')).toBe(false); - await page.click('[data-testid="sponsor-label-toggle"]'); - expect(await page.isVisible('[data-testid="sponsor-label-editor"]')).toBe(true); - }); - - test('sponsor label is active by default', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - const sponsorLabel = await page.locator('[data-testid="sponsor-label-editor"]'); - await expect(sponsorLabel).toBeVisible(); - }); - - test('sponsor label text is SPONSORED by default', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - const sponsorLabel = await page.locator('[data-testid="sponsor-label-editor"]'); - await expect(sponsorLabel).toContainText('SPONSORED'); - }); - - test('can modify sponsor label text', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - const sponsorEditor = await page.locator('[data-testid="sponsor-label-editor"]'); - await page.click('[data-testid="sponsor-label-editor"]'); - // clear the default text by hitting backspace 9 times - for (let i = 0; i < 9; i++) { - await page.keyboard.press('Backspace'); - } - await expect(sponsorEditor).toContainText(''); - await page.keyboard.type('Sponsored by Ghost'); - const content = page.locator('[data-testid="sponsor-label-editor"]'); - await expect(content).toContainText('Sponsored by Ghost'); - }); - - test('content editor placeholder is visible', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - const contentEditor = await page.locator('[data-testid="cta-card-content-editor"]'); - await expect(contentEditor).toContainText('Write something worth clicking...'); - }); - - test('can modify content editor text', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.click('[data-testid="cta-card-content-editor"]'); - await page.keyboard.type('This is a new CTA Card.'); - const content = page.locator('[data-testid="cta-card-content-editor"]'); - await expect(content).toContainText('This is a new CTA Card.'); - }); - - test('can add and remove CTA Card image', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - const imgLocator = page.locator('[data-kg-card="call-to-action"] img[src^="blob:"]'); - const imgElement = await imgLocator.first(); - await expect(imgElement).toHaveAttribute('src', /blob:/); - await page.click('[data-testid="media-upload-remove"]'); - await expect(imgLocator).not.toBeVisible(); - }); - - test('has a progress bar while uploading', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - const fileChooserPromise = page.waitForEvent('filechooser'); - await page.click('[data-testid="media-upload-placeholder"]'); - const fileChooser = await fileChooserPromise; - // Set up a MutationObserver before triggering upload to detect progress bar - // even if it appears and disappears within a single event loop tick - const progressBarSeen = page.evaluate(() => { - return new Promise((resolve) => { - const observer = new MutationObserver(() => { - if (document.querySelector('[data-testid="progress-bar"]')) { - observer.disconnect(); - resolve(true); - } - }); - observer.observe(document.body, {childList: true, subtree: true}); - // Timeout after 5s - setTimeout(() => { observer.disconnect(); resolve(false); }, 5000); - }); - }); - await fileChooser.setFiles([filePath]); - const wasSeen = await progressBarSeen; - expect(wasSeen).toBe(true); - - const imgLocator = page.locator('[data-kg-card="call-to-action"] img[src^="blob:"]'); - const imgElement = await imgLocator.first(); - await expect(imgElement).toHaveAttribute('src', /blob:/); - - // check for progress bar to disappear - const progressBar = page.locator('[data-testid="progress-bar"]'); - await expect(progressBar).not.toBeVisible(); - }); - - test('can drag and drop image over upload button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('media-upload-placeholder').dispatchEvent('dragover', {dataTransfer}); - // Dragover text should be visible - // check that "Drop it like it's hot" is visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); - - await expect (await page.getByTestId('cta-card-image')).toBeVisible(); - }); - - test('has image preview', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-testid="media-upload-filled"]'); - const previewImage = await page.locator('[data-testid="media-upload-filled"] img'); - await expect(previewImage).toBeVisible(); - }); - - test('default layout is minimal', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - // find data-cta-layout and check if it data-cta-layout="minimal" - const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; - await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'minimal'); - }); - - test('can toggle layout to immersive', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - await page.click('[data-testid="immersive-layout"]'); - const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; - await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'immersive'); - }); - - test('can toggle layout to immersive and then back to minimal', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - await page.click('[data-testid="immersive-layout"]'); - const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; - await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'immersive'); - - await page.click('[data-testid="minimal-layout"]'); - await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'minimal'); - }); - - test('alignment settings are hidden when layout is minimal', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - - // Verify alignment settings are not visible by default (minimal layout) - await expect(page.getByTestId('left-align')).not.toBeVisible(); - await expect(page.getByTestId('center-align')).not.toBeVisible(); - }); - - test('alignment settings are visible when layout is immersive', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - await page.click('[data-testid="immersive-layout"]'); - - // Verify alignment settings are now visible - await expect(page.getByTestId('left-align')).toBeVisible(); - await expect(page.getByTestId('center-align')).toBeVisible(); - - // Switch back to minimal layout and verify alignment settings are hidden - await page.click('[data-testid="minimal-layout"]'); - await expect(page.getByTestId('left-align')).not.toBeVisible(); - await expect(page.getByTestId('center-align')).not.toBeVisible(); - }); - - test('can change text alignment in immersive layout', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.click('[data-testid="cta-card-content-editor"]'); - await page.keyboard.type('Test content for alignment'); - await page.getByTestId('tab-design').click(); - await page.click('[data-testid="immersive-layout"]'); - - // Test left alignment (default) - const contentEditor = page.locator('[data-testid="cta-card-content-editor"]'); - await expect(contentEditor).toHaveClass(/text-left/); - - // Test center alignment - await page.click('[data-testid="center-align"]'); - await expect(contentEditor).toHaveClass(/text-center/); - - // Test switching back to left alignment - await page.click('[data-testid="left-align"]'); - await expect(contentEditor).toHaveClass(/text-left/); - }); - - test('can change background colors', async function () { - const colors = ['none', 'white', 'grey', 'green', 'blue', 'yellow', 'red', 'pink', 'purple']; - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - await page.getByTestId('tab-design').click(); - - const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; - await expect(page.locator(firstChildSelector)).not.toHaveClass(/bg-(green|blue|yellow|red|pink|purple)/); // shouldn't have any of the classes yet - for (const color of colors) { - const colorOptionsButton = page.locator('[data-testid="cta-background-color-picker"] [data-testid="color-options-button"]'); - await colorOptionsButton.click(); - await selectNamedColor(page, color, null); - const className = CALLTOACTION_COLORS[color]; - await expect(page.locator(firstChildSelector)).toHaveClass(new RegExp(className)); - } - }); - - test('background color popup closes on outside click', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - await page.getByTestId('tab-design').click(); - - const colorOptions = page.getByTestId('cta-background-color-picker'); - await colorOptions.getByTestId('color-options-button').click(); - - await expect(colorOptions.getByTestId('color-options-popover')).toBeVisible(); - - await page.click('[data-testid="cta-link-color-picker"]'); - - await expect(colorOptions.getByTestId('color-options-popover')).not.toBeVisible(); - }); - - test('can change link color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - - // Get the initial link color (should be text color by default) - const ctaCard = await page.locator('[data-kg-card="call-to-action"] > :first-child'); - const initialColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); - expect(initialColor.trim()).toBe('#394047'); // Default text color - - // Change to accent color - await page.locator('[data-testid="cta-link-color-picker"] button').click(); - await page.locator('[data-testid="color-picker-accent"]').click(); - const accentColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); - expect(accentColor.trim()).toBe('#ff0095'); // Default accent color - - // Change back to text color - await page.locator('[data-testid="cta-link-color-picker"] button').click(); - await page.locator('[data-testid="color-picker-text"]').click(); - const finalColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); - expect(finalColor.trim()).toBe('#394047'); // Back to default text color - }); - - test('default button color is accent', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - expect(await page.getAttribute('[data-testid="cta-button"]', 'class')).toContain('bg-accent'); - }); - - test('can change button color to black', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - // Switch to Design tab for button color settings - await page.getByTestId('tab-design').click(); - - const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); - await colorOptionsButton.click(); - await selectTitledColor(page, 'Black', null); - // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', findByColorTitle: 'Black'}); - - expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(0, 0, 0);'); - }); - - test('can change button color to grey', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - // Switch to Design tab for button color settings - await page.getByTestId('tab-design').click(); - const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); - await colorOptionsButton.click(); - await selectTitledColor(page, 'Grey', null); - expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(240, 240, 240);'); - }); - - test('can use color picker to change button color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-design').click(); - const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); - await colorOptionsButton.click(); - await page.locator('[data-testid="color-picker-toggle"]').click(); - await selectCustomColor(page, 'ff0000', null); - // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', customColor: 'ff0000'}); - expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(255, 0, 0);'); - }); - - test('button text color changes with button color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - await page.getByTestId('tab-content').click(); - await page.fill('[data-testid="button-text"]', 'Click me'); - - await page.getByTestId('tab-design').click(); - const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); - // await colorOptionsButton.click(); - // await selectTitledColor(page, 'Grey', null); - // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', customColor: 'FFFFFF'}); - expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('color: rgb(255, 255, 255);'); - - // change button color to black - await colorOptionsButton.click(); - await selectTitledColor(page, 'Black', null); - expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('color: rgb(0, 0, 0);'); - }); - - test('can change visibility settings', async function () { - await focusEditor(page); - const card = await insertCard(page, {cardName: 'call-to-action'}); - - // switch to visibility settings - await card.getByTestId('tab-visibility').click(); - - // check visibility icon and toggles match default state - await expect(page.getByTestId('visibility-indicator')).not.toBeVisible(); - await expect(card.getByTestId('visibility-toggle-web-nonMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-web-paidMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-email-paidMembers')).toBeChecked(); - - // change toggles - await card.getByTestId('visibility-toggle-web-paidMembers').click(); - await card.getByTestId('visibility-toggle-email-paidMembers').click(); - - // visibility icon appears - await expect(page.getByTestId('visibility-indicator')).toBeVisible(); - - // serialized state gets updated - const serializedState = JSON.parse(await getEditorStateJSON(page)); - expect(serializedState.root.children[0].visibility).toEqual({ - web: { - nonMember: true, - memberSegment: 'status:free' - }, - email: { - memberSegment: 'status:free' - } - }); - }); - - test('can toggle settings from visibility icon', async function () { - await focusEditor(page); - const card = await insertCard(page, {cardName: 'call-to-action'}); - - await page.fill('[data-testid="button-text"]', 'Click me'); - await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); - await card.getByTestId('tab-visibility').click(); - // activate visibility settings - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - - await page.getByTestId('post-title').click(); - - await page.getByTestId('visibility-indicator').click(); - await expect(page.getByTestId('settings-panel')).toBeVisible(); - }); - - test('can toggle visibility settings from visibility icon', async function () { - await focusEditor(page); - const card = await insertCard(page, {cardName: 'call-to-action'}); - - await page.fill('[data-testid="button-text"]', 'Click me'); - await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); - await card.getByTestId('tab-visibility').click(); - // activate visibility settings - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - - await page.getByTestId('post-title').click(); - - await page.getByTestId('visibility-indicator').click(); - await expect(page.getByTestId('settings-panel')).toBeVisible(); - await expect(page.getByTestId('tab-contents-visibility')).toBeVisible(); - }); - - test('can import serialized visibility settings', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - ...serializedTestCard, - visibility: { - web: { - nonMember: false, - memberSegment: 'status:free' - }, - email: { - memberSegment: 'status:free' - } - } - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - // assert visibility icon is visible - await expect(page.getByTestId('visibility-indicator')).toBeVisible(); - - // put card into edit mode - await page.dblclick('[data-kg-card="call-to-action"]'); - - // check toggles match the serialized data - await page.click('[data-testid="tab-visibility"]'); - await expect(page.getByTestId('visibility-toggle-web-nonMembers')).not.toBeChecked(); - await expect(page.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); - await expect(page.getByTestId('visibility-toggle-web-paidMembers')).not.toBeChecked(); - await expect(page.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); - await expect(page.getByTestId('visibility-toggle-email-paidMembers')).not.toBeChecked(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.ts b/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.ts new file mode 100644 index 0000000000..e1f39fa9db --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/call-to-action-card.test.ts @@ -0,0 +1,651 @@ +import path from 'path'; +import {CALLTOACTION_COLORS} from '../../../src/utils/callToActionColors.js'; +import {assertHTML, createDataTransfer, focusEditor, getEditorStateJSON, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import {selectCustomColor, selectNamedColor, selectTitledColor} from '../../utils/color-select-helper'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Call To Action Card', async () => { + let page: Page; + let serializedTestCard: Record; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false'}); + + serializedTestCard = { + type: 'call-to-action', + backgroundColor: 'green', + buttonColor: '#F0F0F0', + buttonText: 'Get access now', + buttonTextColor: '#000000', + buttonUrl: 'http://someblog.com/somepost', + hasImage: true, + hasSponsorLabel: true, + sponsorLabel: '

    SPONSORED

    ', + imageUrl: '/content/images/2022/11/koenig-lexical.jpg', + layout: 'minimal', + showButton: true, + showDividers: true, + textValue: '

    This is a new CTA Card.

    ' + }; + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized CTA card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [serializedTestCard], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + const ctaCardHtml = html` +
    +
    +
    +
    +

    Sponsored

    +
    +
    +
    + Placeholder +
    +
    +
    +
    +
    +

    + This is a new CTA Card. +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
      +
    • + +
    • +
    • +
    • + +
    • +
    +
    +
    +
    +`; + await assertHTML(page, ctaCardHtml, {ignoreCardContents: true}); + }); + + test('renders CTA Card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + test('button and button settings are visible by default', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); + expect(await page.isVisible('[data-testid="button-text"]')).toBe(true); + expect(await page.isVisible('[data-testid="button-url"]')).toBe(true); + }); + + test('can toggle button on card and expands settings', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); + await page.click('[data-testid="button-settings"]'); + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(false); + + await page.click('[data-testid="button-settings"]'); + + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); + }); + + test('can set button text', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.fill('[data-testid="button-text"]', 'Click me'); + expect(await page.textContent('[data-testid="cta-button"]')).toBe('Click me'); + }); + + test('can set button url', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); + const buttonContainer = await page.$('[data-test-cta-button-current-url]'); + const currentUrl = await buttonContainer!.getAttribute('data-test-cta-button-current-url'); + expect(currentUrl).toBe('https://example.com/somepost'); + }); + + // NOTE: an improvement would be to pass in suggested url options, but the construction now doesn't make that straightforward + test('suggested urls display', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + const buttonTextInput = await page.getByTestId('button-url'); + await expect(buttonTextInput).toHaveValue(''); + + await page.getByTestId('button-url').fill('Home'); + await page.waitForSelector('[data-testid="button-url-listOption"]'); + + await expect(await page.getByTestId('button-url-listOption')).toContainText('Homepage'); + await page.getByTestId('button-url-listOption').click(); + const buttonContainer = await page.$('[data-test-cta-button-current-url]'); + const currentUrl = await buttonContainer!.getAttribute('data-test-cta-button-current-url'); + // current view can be any url, so check for a valid url + const validUrlRegex = /^(https?:\/\/)([\w.-]+)(:[0-9]+)?(\/[\w.-]*)*(\?.*)?(#.*)?$/; + // Assert the URL is valid + expect(currentUrl).toMatch(validUrlRegex); + }); + + test('button doesnt disappear when toggled, has text, has url and loses focus', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.fill('[data-testid="button-text"]', 'Click me'); + await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); + expect(await page.textContent('[data-testid="cta-button"]')).toBe('Click me'); + const buttonContainer = await page.$('[data-test-cta-button-current-url]'); + const currentUrl = await buttonContainer!.getAttribute('data-test-cta-button-current-url'); + expect(currentUrl).toBe('https://example.com/somepost'); + + // lose focus and editing mode + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + + // check card exited edit mode + const card = page.locator('[data-kg-card="call-to-action"]'); + await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(card).toHaveAttribute('data-kg-card-selected', 'false'); + + expect(await page.isVisible('[data-testid="cta-button"]')).toBe(true); + }); + + test('can toggle sponsor label', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.click('[data-testid="sponsor-label-toggle"]'); + expect(await page.isVisible('[data-testid="sponsor-label-editor"]')).toBe(false); + await page.click('[data-testid="sponsor-label-toggle"]'); + expect(await page.isVisible('[data-testid="sponsor-label-editor"]')).toBe(true); + }); + + test('sponsor label is active by default', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + const sponsorLabel = await page.locator('[data-testid="sponsor-label-editor"]'); + await expect(sponsorLabel).toBeVisible(); + }); + + test('sponsor label text is SPONSORED by default', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + const sponsorLabel = await page.locator('[data-testid="sponsor-label-editor"]'); + await expect(sponsorLabel).toContainText('SPONSORED'); + }); + + test('can modify sponsor label text', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + const sponsorEditor = await page.locator('[data-testid="sponsor-label-editor"]'); + await page.click('[data-testid="sponsor-label-editor"]'); + // clear the default text by hitting backspace 9 times + for (let i = 0; i < 9; i++) { + await page.keyboard.press('Backspace'); + } + await expect(sponsorEditor).toContainText(''); + await page.keyboard.type('Sponsored by Ghost'); + const content = page.locator('[data-testid="sponsor-label-editor"]'); + await expect(content).toContainText('Sponsored by Ghost'); + }); + + test('content editor placeholder is visible', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + const contentEditor = await page.locator('[data-testid="cta-card-content-editor"]'); + await expect(contentEditor).toContainText('Write something worth clicking...'); + }); + + test('can modify content editor text', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.click('[data-testid="cta-card-content-editor"]'); + await page.keyboard.type('This is a new CTA Card.'); + const content = page.locator('[data-testid="cta-card-content-editor"]'); + await expect(content).toContainText('This is a new CTA Card.'); + }); + + test('can add and remove CTA Card image', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + const imgLocator = page.locator('[data-kg-card="call-to-action"] img[src^="blob:"]'); + const imgElement = await imgLocator.first(); + await expect(imgElement).toHaveAttribute('src', /blob:/); + await page.click('[data-testid="media-upload-remove"]'); + await expect(imgLocator).not.toBeVisible(); + }); + + test('has a progress bar while uploading', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.click('[data-testid="media-upload-placeholder"]'); + const fileChooser = await fileChooserPromise; + // Set up a MutationObserver before triggering upload to detect progress bar + // even if it appears and disappears within a single event loop tick + const progressBarSeen = page.evaluate(() => { + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (document.querySelector('[data-testid="progress-bar"]')) { + observer.disconnect(); + resolve(true); + } + }); + observer.observe(document.body, {childList: true, subtree: true}); + // Timeout after 5s + setTimeout(() => { observer.disconnect(); resolve(false); }, 5000); + }); + }); + await fileChooser.setFiles([filePath]); + const wasSeen = await progressBarSeen; + expect(wasSeen).toBe(true); + + const imgLocator = page.locator('[data-kg-card="call-to-action"] img[src^="blob:"]'); + const imgElement = await imgLocator.first(); + await expect(imgElement).toHaveAttribute('src', /blob:/); + + // check for progress bar to disappear + const progressBar = page.locator('[data-testid="progress-bar"]'); + await expect(progressBar).not.toBeVisible(); + }); + + test('can drag and drop image over upload button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('media-upload-placeholder').dispatchEvent('dragover', {dataTransfer}); + // Dragover text should be visible + // check that "Drop it like it's hot" is visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); + + await expect (await page.getByTestId('cta-card-image')).toBeVisible(); + }); + + test('has image preview', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-testid="media-upload-filled"]'); + const previewImage = await page.locator('[data-testid="media-upload-filled"] img'); + await expect(previewImage).toBeVisible(); + }); + + test('default layout is minimal', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + // find data-cta-layout and check if it data-cta-layout="minimal" + const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; + await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'minimal'); + }); + + test('can toggle layout to immersive', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + await page.click('[data-testid="immersive-layout"]'); + const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; + await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'immersive'); + }); + + test('can toggle layout to immersive and then back to minimal', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + await page.click('[data-testid="immersive-layout"]'); + const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; + await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'immersive'); + + await page.click('[data-testid="minimal-layout"]'); + await expect(page.locator(firstChildSelector)).toHaveAttribute('data-cta-layout', 'minimal'); + }); + + test('alignment settings are hidden when layout is minimal', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + + // Verify alignment settings are not visible by default (minimal layout) + await expect(page.getByTestId('left-align')).not.toBeVisible(); + await expect(page.getByTestId('center-align')).not.toBeVisible(); + }); + + test('alignment settings are visible when layout is immersive', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + await page.click('[data-testid="immersive-layout"]'); + + // Verify alignment settings are now visible + await expect(page.getByTestId('left-align')).toBeVisible(); + await expect(page.getByTestId('center-align')).toBeVisible(); + + // Switch back to minimal layout and verify alignment settings are hidden + await page.click('[data-testid="minimal-layout"]'); + await expect(page.getByTestId('left-align')).not.toBeVisible(); + await expect(page.getByTestId('center-align')).not.toBeVisible(); + }); + + test('can change text alignment in immersive layout', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.click('[data-testid="cta-card-content-editor"]'); + await page.keyboard.type('Test content for alignment'); + await page.getByTestId('tab-design').click(); + await page.click('[data-testid="immersive-layout"]'); + + // Test left alignment (default) + const contentEditor = page.locator('[data-testid="cta-card-content-editor"]'); + await expect(contentEditor).toHaveClass(/text-left/); + + // Test center alignment + await page.click('[data-testid="center-align"]'); + await expect(contentEditor).toHaveClass(/text-center/); + + // Test switching back to left alignment + await page.click('[data-testid="left-align"]'); + await expect(contentEditor).toHaveClass(/text-left/); + }); + + test('can change background colors', async function () { + const colors = ['none', 'white', 'grey', 'green', 'blue', 'yellow', 'red', 'pink', 'purple']; + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + await page.getByTestId('tab-design').click(); + + const firstChildSelector = '[data-kg-card="call-to-action"] > :first-child'; + await expect(page.locator(firstChildSelector)).not.toHaveClass(/bg-(green|blue|yellow|red|pink|purple)/); // shouldn't have any of the classes yet + for (const color of colors) { + const colorOptionsButton = page.locator('[data-testid="cta-background-color-picker"] [data-testid="color-options-button"]'); + await colorOptionsButton.click(); + await selectNamedColor(page, color, undefined); + const className = (CALLTOACTION_COLORS as Record)[color]; + await expect(page.locator(firstChildSelector)).toHaveClass(new RegExp(className)); + } + }); + + test('background color popup closes on outside click', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + await page.getByTestId('tab-design').click(); + + const colorOptions = page.getByTestId('cta-background-color-picker'); + await colorOptions.getByTestId('color-options-button').click(); + + await expect(colorOptions.getByTestId('color-options-popover')).toBeVisible(); + + await page.click('[data-testid="cta-link-color-picker"]'); + + await expect(colorOptions.getByTestId('color-options-popover')).not.toBeVisible(); + }); + + test('can change link color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + + // Get the initial link color (should be text color by default) + const ctaCard = await page.locator('[data-kg-card="call-to-action"] > :first-child'); + const initialColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); + expect(initialColor.trim()).toBe('#394047'); // Default text color + + // Change to accent color + await page.locator('[data-testid="cta-link-color-picker"] button').click(); + await page.locator('[data-testid="color-picker-accent"]').click(); + const accentColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); + expect(accentColor.trim()).toBe('#ff0095'); // Default accent color + + // Change back to text color + await page.locator('[data-testid="cta-link-color-picker"] button').click(); + await page.locator('[data-testid="color-picker-text"]').click(); + const finalColor = await ctaCard.evaluate(el => getComputedStyle(el).getPropertyValue('--cta-link-color')); + expect(finalColor.trim()).toBe('#394047'); // Back to default text color + }); + + test('default button color is accent', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + expect(await page.getAttribute('[data-testid="cta-button"]', 'class')).toContain('bg-accent'); + }); + + test('can change button color to black', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + // Switch to Design tab for button color settings + await page.getByTestId('tab-design').click(); + + const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); + await colorOptionsButton.click(); + await selectTitledColor(page, 'Black', undefined); + // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', findByColorTitle: 'Black'}); + + expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(0, 0, 0);'); + }); + + test('can change button color to grey', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + // Switch to Design tab for button color settings + await page.getByTestId('tab-design').click(); + const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); + await colorOptionsButton.click(); + await selectTitledColor(page, 'Grey', undefined); + expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(240, 240, 240);'); + }); + + test('can use color picker to change button color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-design').click(); + const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); + await colorOptionsButton.click(); + await page.locator('[data-testid="color-picker-toggle"]').click(); + await selectCustomColor(page, 'ff0000', undefined); + // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', customColor: 'ff0000'}); + expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('background-color: rgb(255, 0, 0);'); + }); + + test('button text color changes with button color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + await page.getByTestId('tab-content').click(); + await page.fill('[data-testid="button-text"]', 'Click me'); + + await page.getByTestId('tab-design').click(); + const colorOptionsButton = page.locator('[data-testid="cta-button-color"] [data-testid="color-selector-button"]'); + // await colorOptionsButton.click(); + // await selectTitledColor(page, 'Grey', undefined); + // await cardBackgroundColorSettings(page, {cardColorPickerTestId: 'cta-button-color', customColor: 'FFFFFF'}); + expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('color: rgb(255, 255, 255);'); + + // change button color to black + await colorOptionsButton.click(); + await selectTitledColor(page, 'Black', undefined); + expect(await page.getAttribute('[data-testid="cta-button"]', 'style')).toContain('color: rgb(0, 0, 0);'); + }); + + test('can change visibility settings', async function () { + await focusEditor(page); + const card = await insertCard(page, {cardName: 'call-to-action'}); + + // switch to visibility settings + await card.getByTestId('tab-visibility').click(); + + // check visibility icon and toggles match default state + await expect(page.getByTestId('visibility-indicator')).not.toBeVisible(); + await expect(card.getByTestId('visibility-toggle-web-nonMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-web-paidMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-email-paidMembers')).toBeChecked(); + + // change toggles + await card.getByTestId('visibility-toggle-web-paidMembers').click(); + await card.getByTestId('visibility-toggle-email-paidMembers').click(); + + // visibility icon appears + await expect(page.getByTestId('visibility-indicator')).toBeVisible(); + + // serialized state gets updated + const serializedState = JSON.parse(await getEditorStateJSON(page)); + expect(serializedState.root.children[0].visibility).toEqual({ + web: { + nonMember: true, + memberSegment: 'status:free' + }, + email: { + memberSegment: 'status:free' + } + }); + }); + + test('can toggle settings from visibility icon', async function () { + await focusEditor(page); + const card = await insertCard(page, {cardName: 'call-to-action'}); + + await page.fill('[data-testid="button-text"]', 'Click me'); + await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); + await card.getByTestId('tab-visibility').click(); + // activate visibility settings + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + + await page.getByTestId('post-title').click(); + + await page.getByTestId('visibility-indicator').click(); + await expect(page.getByTestId('settings-panel')).toBeVisible(); + }); + + test('can toggle visibility settings from visibility icon', async function () { + await focusEditor(page); + const card = await insertCard(page, {cardName: 'call-to-action'}); + + await page.fill('[data-testid="button-text"]', 'Click me'); + await page.fill('[data-testid="button-url"]', 'https://example.com/somepost'); + await card.getByTestId('tab-visibility').click(); + // activate visibility settings + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + + await page.getByTestId('post-title').click(); + + await page.getByTestId('visibility-indicator').click(); + await expect(page.getByTestId('settings-panel')).toBeVisible(); + await expect(page.getByTestId('tab-contents-visibility')).toBeVisible(); + }); + + test('can import serialized visibility settings', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + ...serializedTestCard, + visibility: { + web: { + nonMember: false, + memberSegment: 'status:free' + }, + email: { + memberSegment: 'status:free' + } + } + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + // assert visibility icon is visible + await expect(page.getByTestId('visibility-indicator')).toBeVisible(); + + // put card into edit mode + await page.dblclick('[data-kg-card="call-to-action"]'); + + // check toggles match the serialized data + await page.click('[data-testid="tab-visibility"]'); + await expect(page.getByTestId('visibility-toggle-web-nonMembers')).not.toBeChecked(); + await expect(page.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); + await expect(page.getByTestId('visibility-toggle-web-paidMembers')).not.toBeChecked(); + await expect(page.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); + await expect(page.getByTestId('visibility-toggle-email-paidMembers')).not.toBeChecked(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/callout-card.test.js b/packages/koenig-lexical/test/e2e/cards/callout-card.test.js deleted file mode 100644 index 2443df42b8..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/callout-card.test.js +++ /dev/null @@ -1,377 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {selectNamedColor} from '../../utils/color-select-helper'; - -test.describe('Callout Card', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized callout card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'callout', - calloutText: '

    Hello World

    ', - calloutEmoji: '😚', - backgroundColor: 'blue' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - // NOTE: don't ignore contents, we care that the data is deserialized and displayed correctly - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    Hello World

    -
    -
    -
    -
    -
    -
    -
    - `); - - // check the background color - await expect(page.getByTestId('callout-bg-blue')).toBeVisible(); - }); - - test('renders callout card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('has settings panel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // the settings panel consists of emoji-toggle and colour picker - const emojiToggle = page.locator('[data-testid="emoji-toggle"]'); - await expect(emojiToggle).toBeVisible(); - const colorPicker = page.locator('[data-testid="callout-color-picker"]'); - await expect(colorPicker).toBeVisible(); - }); - - test('can edit callout card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await page.keyboard.type('Hello World'); - - const calloutCard = page.locator('[data-kg-card="callout"]'); - await expect(calloutCard).toContainText('💡Hello World '); - }); - - test('can toggle emoji', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - const toggle = page.locator('[data-testid="emoji-toggle"]'); - await toggle.click(); - // click on data-kg-card="callout" - await page.click('[data-kg-card="callout"]'); - await page.keyboard.type('Hello World'); - - const calloutCard = page.locator('[data-kg-card="callout"]'); - await expect(calloutCard).not.toContainText('💡'); - }); - - test('can render emoji picker', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await page.getByRole('button', {name: '💡'}).click(); - const emojiPickerContainer = page.locator('[data-testid="emoji-picker-container"]'); - await expect(emojiPickerContainer).toBeVisible(); - }); - - test('colour picker renders all colours', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // await Promise.all(calloutColorPicker.map(async (color) => { - // const colorPicker = page.locator(`[data-testid="color-picker-${color.name}"]`); - // await expect(colorPicker).toBeVisible(); - // })); - }); - - test('can change background color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // click data-testid="color-options-button" to open the color picker - - await selectNamedColor(page, 'green', 'color-options-button'); - - // const colorOptionsButton = page.locator('[data-testid="color-options-button"]'); - - // await colorOptionsButton.click(); - - // const colorPicker = page.locator(`[data-testid="color-picker-green"]`); - // await colorPicker.click(); - - // // ensure data-testid="callout-bg-blue" is visible - const greenCallout = page.locator('[data-testid="callout-bg-green"]'); - await expect(greenCallout).toBeVisible(); - }); - - test('can select an emoji', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await page.getByRole('button', {name: '💡'}).click(); - const lolEmoji = page.locator('[aria-label="😂"]').nth(0); // nth(0) is required because there could two emojis with the same label (eg from frequently used) - await lolEmoji.click(); - // await page.keyboard.type('Joke of the day'); - const calloutCard = page.locator('[data-kg-card="callout"]'); - await expect(calloutCard).toContainText('😂'); - }); - - test('has edit toolbar', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // press arrow down - // TODO: this is a bug! ArrowDown should only be required once - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); // press twice to make sure card gets unselected - - // press arrow up - await page.keyboard.press('ArrowUp'); - - const editButton = page.locator('[data-testid="edit-callout-card"]'); - await expect(editButton).toBeVisible(); - }); - - test('can toggle edit', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // press arrow down - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); // press twice to make sure card gets unselected - - // press arrow up - await page.keyboard.press('ArrowUp'); - - const editButton = page.locator('[data-testid="edit-callout-card"]'); - await editButton.click(); - - const calloutCard = page.locator('[data-kg-card="callout"]'); - await expect(calloutCard).toHaveAttribute('data-kg-card-editing', 'true'); - }); - - test('syncs display state content', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - await page.keyboard.type('testing nesting'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    - testing nesting -

    -
    -
    -
    -
    -
    -
    -
    -


    -


    - `, {ignoreCardContents: false}); - }); - - test('can toggle edit mode with CMD+ENTER', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - await page.keyboard.type('testing nesting'); - - await page.keyboard.press(`${ctrlOrCmd}+Enter`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    - testing nesting -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - - await page.keyboard.press(`${ctrlOrCmd}+Enter`); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can leave edit mode with ESCAPE', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - await page.keyboard.type('testing nesting'); - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - await page.keyboard.type('testing nesting'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="callout"]')).toHaveCount(2); - }); - - test('keeps focus on previous editor when changing size opts', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - // Start editing the content - await page.keyboard.type('Hello '); - - // Change color - // await page.locator(`[data-testid="color-picker-green"]`).click(); - await selectNamedColor(page, 'green', 'color-options-button'); - - // Continue editing the content - await page.keyboard.type('world'); - - // Expect content to have 'Hello World' - const content = page.locator('[data-kg-card="callout"]'); - await expect(content).toContainText('Hello world'); - }); - - test('can undo/redo without losing content', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await page.keyboard.type('Hello world'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - await expect(page.locator('[data-kg-card="callout"]')).toBeVisible(); - await expect(page.locator('[data-kg-card-toolbar="callout"]')).toBeVisible(); - - // NOTE: don't ignore contents, we care that the data is displayed correctly - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    Hello world

    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/callout-card.test.ts b/packages/koenig-lexical/test/e2e/cards/callout-card.test.ts new file mode 100644 index 0000000000..90b521c3e3 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/callout-card.test.ts @@ -0,0 +1,378 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {selectNamedColor} from '../../utils/color-select-helper'; +import type {Page} from '@playwright/test'; + +test.describe('Callout Card', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized callout card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'callout', + calloutText: '

    Hello World

    ', + calloutEmoji: '😚', + backgroundColor: 'blue' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + // NOTE: don't ignore contents, we care that the data is deserialized and displayed correctly + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    Hello World

    +
    +
    +
    +
    +
    +
    +
    + `); + + // check the background color + await expect(page.getByTestId('callout-bg-blue')).toBeVisible(); + }); + + test('renders callout card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('has settings panel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // the settings panel consists of emoji-toggle and colour picker + const emojiToggle = page.locator('[data-testid="emoji-toggle"]'); + await expect(emojiToggle).toBeVisible(); + const colorPicker = page.locator('[data-testid="callout-color-picker"]'); + await expect(colorPicker).toBeVisible(); + }); + + test('can edit callout card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await page.keyboard.type('Hello World'); + + const calloutCard = page.locator('[data-kg-card="callout"]'); + await expect(calloutCard).toContainText('💡Hello World '); + }); + + test('can toggle emoji', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + const toggle = page.locator('[data-testid="emoji-toggle"]'); + await toggle.click(); + // click on data-kg-card="callout" + await page.click('[data-kg-card="callout"]'); + await page.keyboard.type('Hello World'); + + const calloutCard = page.locator('[data-kg-card="callout"]'); + await expect(calloutCard).not.toContainText('💡'); + }); + + test('can render emoji picker', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await page.getByRole('button', {name: '💡'}).click(); + const emojiPickerContainer = page.locator('[data-testid="emoji-picker-container"]'); + await expect(emojiPickerContainer).toBeVisible(); + }); + + test('colour picker renders all colours', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // await Promise.all(calloutColorPicker.map(async (color) => { + // const colorPicker = page.locator(`[data-testid="color-picker-${color.name}"]`); + // await expect(colorPicker).toBeVisible(); + // })); + }); + + test('can change background color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // click data-testid="color-options-button" to open the color picker + + await selectNamedColor(page, 'green', 'color-options-button'); + + // const colorOptionsButton = page.locator('[data-testid="color-options-button"]'); + + // await colorOptionsButton.click(); + + // const colorPicker = page.locator(`[data-testid="color-picker-green"]`); + // await colorPicker.click(); + + // // ensure data-testid="callout-bg-blue" is visible + const greenCallout = page.locator('[data-testid="callout-bg-green"]'); + await expect(greenCallout).toBeVisible(); + }); + + test('can select an emoji', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await page.getByRole('button', {name: '💡'}).click(); + const lolEmoji = page.locator('[aria-label="😂"]').nth(0); // nth(0) is required because there could two emojis with the same label (eg from frequently used) + await lolEmoji.click(); + // await page.keyboard.type('Joke of the day'); + const calloutCard = page.locator('[data-kg-card="callout"]'); + await expect(calloutCard).toContainText('😂'); + }); + + test('has edit toolbar', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // press arrow down + // TODO: this is a bug! ArrowDown should only be required once + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); // press twice to make sure card gets unselected + + // press arrow up + await page.keyboard.press('ArrowUp'); + + const editButton = page.locator('[data-testid="edit-callout-card"]'); + await expect(editButton).toBeVisible(); + }); + + test('can toggle edit', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // press arrow down + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); // press twice to make sure card gets unselected + + // press arrow up + await page.keyboard.press('ArrowUp'); + + const editButton = page.locator('[data-testid="edit-callout-card"]'); + await editButton.click(); + + const calloutCard = page.locator('[data-kg-card="callout"]'); + await expect(calloutCard).toHaveAttribute('data-kg-card-editing', 'true'); + }); + + test('syncs display state content', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + await page.keyboard.type('testing nesting'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    + testing nesting +

    +
    +
    +
    +
    +
    +
    +
    +


    +


    + `, {ignoreCardContents: false}); + }); + + test('can toggle edit mode with CMD+ENTER', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + await page.keyboard.type('testing nesting'); + + await page.keyboard.press(`${ctrlOrCmd}+Enter`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    + testing nesting +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + + await page.keyboard.press(`${ctrlOrCmd}+Enter`); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can leave edit mode with ESCAPE', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + await page.keyboard.type('testing nesting'); + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + await page.keyboard.type('testing nesting'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="callout"]')).toHaveCount(2); + }); + + test('keeps focus on previous editor when changing size opts', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + // Start editing the content + await page.keyboard.type('Hello '); + + // Change color + // await page.locator(`[data-testid="color-picker-green"]`).click(); + await selectNamedColor(page, 'green', 'color-options-button'); + + // Continue editing the content + await page.keyboard.type('world'); + + // Expect content to have 'Hello World' + const content = page.locator('[data-kg-card="callout"]'); + await expect(content).toContainText('Hello world'); + }); + + test('can undo/redo without losing content', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await page.keyboard.type('Hello world'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + await expect(page.locator('[data-kg-card="callout"]')).toBeVisible(); + await expect(page.locator('[data-kg-card-toolbar="callout"]')).toBeVisible(); + + // NOTE: don't ignore contents, we care that the data is displayed correctly + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    Hello world

    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/code-block-card.test.js b/packages/koenig-lexical/test/e2e/cards/code-block-card.test.js deleted file mode 100644 index e57548d38b..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/code-block-card.test.js +++ /dev/null @@ -1,308 +0,0 @@ -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, pasteText, selectBackwards} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Code Block card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized code block card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'codeblock', - code: '', - language: 'javascript', - caption: 'A code block' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    <script></script>
    -
    javascript
    -
    -
    -
    -
    -
    -
    -

    - A code block -

    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: false}); - }); - - test.describe('shortcuts', () => { - test('renders with ``` + space', async function () { - await focusEditor(page); - await page.keyboard.type('``` '); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders with ```lang + space', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders with ``` + enter', async function () { - await focusEditor(page); - await page.keyboard.type('```'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders with ```lang + enter', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - }); - - test('renders with ``` + tab', async function () { - await focusEditor(page); - await page.keyboard.type('```'); - await page.keyboard.press('Tab'); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders with ```lang + tab', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript'); - await page.keyboard.press('Tab'); - - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('it hides the language input when typing in the code editor and shows it when the mouse moves', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - - // Type in the editor - await page.keyboard.type('Here are some words'); - - const languageInput = await page.locator('[data-testid="code-card-language"]'); - - // The language input should be hidden - await expect(languageInput).toHaveClass(/opacity-0/); - await expect(languageInput).not.toHaveClass(/opacity-100/); - - // Move the mouse - await page.mouse.move(0,0); - await page.mouse.move(100,100); - - // The language input should be visible - await expect(languageInput).toHaveClass(/opacity-100/); - await expect(languageInput).not.toHaveClass(/opacity-0/); - }); - - test('can undo/redo without losing caption', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - - await page.keyboard.type('Here are some words'); - await page.keyboard.press('Escape'); - await page.click('[data-testid="codeblock-caption"]'); - await page.keyboard.type('My caption'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    Here are some words
    -
    javascript
    -
    -
    -
    -
    -
    -
    -

    - My caption -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: false, ignoreCardToolbarContents: true}); - }); - - test('can undo/redo content in code editor', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - - await pasteText(page, 'Here are some words'); - await expect(page.getByText('Here are some words')).toBeVisible(); - await page.keyboard.press('Backspace'); - await expect(page.getByText('Here are some word')).toBeVisible(); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - await expect(page.getByText('Here are some words')).toBeVisible(); - await page.keyboard.press('Escape'); - await page.click('[data-testid="codeblock-caption"]'); - await page.keyboard.type('My caption'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    Here are some words
    -
    javascript
    -
    -
    -
    -
    -
    -
    -

    - My caption -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: false, ignoreCardToolbarContents: true}); - }); - - test('goes into display mode when losing focus', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - - await page.keyboard.type('Here are some words'); - await page.getByTestId('post-title').click(); - await page.keyboard.type('post title'); // click outside of the editor - - await assertHTML(page, html` -
    -
    -
    -
    Here are some words
    -
    javascript
    -
    -
    -
    - `); - }); - - test('can cut text', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - - await page.keyboard.type('const test = true;'); - - for (let i = 0; i < 8; i++) { - await page.keyboard.press('ArrowLeft'); - } - - // select "test" - highlight plugin marks it and causes issues with .closest('.cm-editor') in shouldIgnoreEvent() - // see https://github.com/TryGhost/Product/issues/3785 - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd()}+x`); - - await assertHTML(page, html` -
    - const - = true; -
    - `, {selector: '.cm-content'}); - - // NOTE: for some reason CodeMirror+Playwright don't work well together and cut/copied content - // doesn't make it to the clipboard to enable testing that we can re-paste the cut content - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/code-block-card.test.ts b/packages/koenig-lexical/test/e2e/cards/code-block-card.test.ts new file mode 100644 index 0000000000..cb0add3bef --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/code-block-card.test.ts @@ -0,0 +1,309 @@ +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, pasteText, selectBackwards} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Code Block card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized code block card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'codeblock', + code: '', + language: 'javascript', + caption: 'A code block' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    <script></script>
    +
    javascript
    +
    +
    +
    +
    +
    +
    +

    + A code block +

    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: false}); + }); + + test.describe('shortcuts', () => { + test('renders with ``` + space', async function () { + await focusEditor(page); + await page.keyboard.type('``` '); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders with ```lang + space', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders with ``` + enter', async function () { + await focusEditor(page); + await page.keyboard.type('```'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders with ```lang + enter', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + }); + + test('renders with ``` + tab', async function () { + await focusEditor(page); + await page.keyboard.type('```'); + await page.keyboard.press('Tab'); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders with ```lang + tab', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript'); + await page.keyboard.press('Tab'); + + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('it hides the language input when typing in the code editor and shows it when the mouse moves', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + + // Type in the editor + await page.keyboard.type('Here are some words'); + + const languageInput = await page.locator('[data-testid="code-card-language"]'); + + // The language input should be hidden + await expect(languageInput).toHaveClass(/opacity-0/); + await expect(languageInput).not.toHaveClass(/opacity-100/); + + // Move the mouse + await page.mouse.move(0,0); + await page.mouse.move(100,100); + + // The language input should be visible + await expect(languageInput).toHaveClass(/opacity-100/); + await expect(languageInput).not.toHaveClass(/opacity-0/); + }); + + test('can undo/redo without losing caption', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + + await page.keyboard.type('Here are some words'); + await page.keyboard.press('Escape'); + await page.click('[data-testid="codeblock-caption"]'); + await page.keyboard.type('My caption'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    Here are some words
    +
    javascript
    +
    +
    +
    +
    +
    +
    +

    + My caption +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: false, ignoreCardToolbarContents: true}); + }); + + test('can undo/redo content in code editor', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + + await pasteText(page, 'Here are some words'); + await expect(page.getByText('Here are some words')).toBeVisible(); + await page.keyboard.press('Backspace'); + await expect(page.getByText('Here are some word')).toBeVisible(); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + await expect(page.getByText('Here are some words')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.click('[data-testid="codeblock-caption"]'); + await page.keyboard.type('My caption'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    Here are some words
    +
    javascript
    +
    +
    +
    +
    +
    +
    +

    + My caption +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: false, ignoreCardToolbarContents: true}); + }); + + test('goes into display mode when losing focus', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + + await page.keyboard.type('Here are some words'); + await page.getByTestId('post-title').click(); + await page.keyboard.type('post title'); // click outside of the editor + + await assertHTML(page, html` +
    +
    +
    +
    Here are some words
    +
    javascript
    +
    +
    +
    + `); + }); + + test('can cut text', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + + await page.keyboard.type('const test = true;'); + + for (let i = 0; i < 8; i++) { + await page.keyboard.press('ArrowLeft'); + } + + // select "test" - highlight plugin marks it and causes issues with .closest('.cm-editor') in shouldIgnoreEvent() + // see https://github.com/TryGhost/Product/issues/3785 + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd()}+x`); + + await assertHTML(page, html` +
    + const + = true; +
    + `, {selector: '.cm-content'}); + + // NOTE: for some reason CodeMirror+Playwright don't work well together and cut/copied content + // doesn't make it to the clipboard to enable testing that we can re-paste the cut content + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/email-card.test.js b/packages/koenig-lexical/test/e2e/cards/email-card.test.js deleted file mode 100644 index 53cb573268..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/email-card.test.js +++ /dev/null @@ -1,273 +0,0 @@ -import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -async function insertEmailCard(page) { - await focusEditor(page); - - await page.keyboard.type('/email'); - - try { - await Promise.race([ - page.waitForSelector('[data-kg-cardmenu]', {timeout: 5000}), - page.waitForSelector('[data-kg-card-menu-item="Email content"]', {timeout: 5000}) - ]); - } catch (e) { - await page.keyboard.press('Escape'); - await page.keyboard.type('/email'); - await page.waitForSelector('[data-kg-cardmenu]', {timeout: 5000}); - } - - await page.waitForSelector('[data-kg-card-menu-item="Email content"]', {state: 'visible'}); - await page.locator('[data-kg-card-menu-item="Email content"]').click(); - await page.waitForSelector('[data-kg-card="email"]'); -} - -test.describe('Email card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized email card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'email', - html: '

    A paragraph

    ' - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    Hidden on website
    -
    -
    -
    -
    -

    - A paragraph -

    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - - test('renders email card node in edit mode from slash command', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await assertHTML(page, html` -
    -
    -
    -
    Hidden on website
    -
    -
    -
    -
    -

    - Hey - - {first_name, "there"} - - , -

    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - - test('contains `Hey {first_name, "there"}` by default when rendered', async function () { - await focusEditor(page); - await insertEmailCard(page); - - const htmlContent = page.locator('.kg-email-html'); - await expect(htmlContent).toContainText('Hey {first_name, "there"},'); - }); - - test('renders in display mode when unfocused', async function () { - await focusEditor(page); - await insertEmailCard(page); - - const emailCard = page.locator('[data-kg-card="email"]'); - await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'true'); - - // Match real-world pacing and avoid local selection timing flake. - await page.waitForTimeout(25); - await page.keyboard.press('ArrowDown'); - - await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'false'); - }); - - test('renders an action toolbar', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Shift focus away from email card - await page.keyboard.press('Escape'); - - const editButton = page.locator('[data-kg-card-toolbar="email"]'); - await expect(editButton).toBeVisible(); - }); - - test('is removed when left empty', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Remove all existing content - await page.keyboard.press(`${ctrlOrCmd(page)}+A`); - await page.keyboard.press('Backspace'); - - // Shift focus away from email card - await page.keyboard.press('ArrowDown'); - - const emailCard = page.locator('[data-kg-card="email"]'); - await expect(emailCard).not.toBeVisible(); - }); - - test('it can contain lists', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Create a list - await page.keyboard.press('Enter'); - await page.keyboard.type('- List item 1'); - await page.keyboard.press('Enter'); - - const emailCard = page.locator('[data-kg-card="email"] [data-kg="editor"] ul > li:first-child'); - await expect(emailCard).toHaveText('List item 1'); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="email"]')).toHaveCount(2); - }); - - test('can undo/redo without losing html content', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await page.keyboard.press('Enter'); - await page.keyboard.type('- List item 1'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Escape'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - const emailCard = page.locator('[data-kg-card="email"] [data-kg="editor"] ul > li:first-child'); - await expect(emailCard).toHaveText('List item 1'); - }); - - // placeholders like {test} or {test, "string"} should be formatted as code - test('formats typed placeholders', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await page.keyboard.press(`Enter`); - await page.keyboard.type(`testing {this}?`); - - await assertHTML(page, html` -
    -
    -
    -
    Hidden on website
    -
    -
    -
    -
    -

    - Hey - - {first_name, "there"} - - , -

    -

    - testing - - {this} - - ? -

    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - - // remove the formatting using backspace - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
    -
    -
    -
    Hidden on website
    -
    -
    -
    -
    -

    - Hey - - {first_name, "there"} - - , -

    -

    - testing {this -

    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/email-card.test.ts b/packages/koenig-lexical/test/e2e/cards/email-card.test.ts new file mode 100644 index 0000000000..d84a9a6c41 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/email-card.test.ts @@ -0,0 +1,274 @@ +import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +async function insertEmailCard(page: Page) { + await focusEditor(page); + + await page.keyboard.type('/email'); + + try { + await Promise.race([ + page.waitForSelector('[data-kg-cardmenu]', {timeout: 5000}), + page.waitForSelector('[data-kg-card-menu-item="Email content"]', {timeout: 5000}) + ]); + } catch { + await page.keyboard.press('Escape'); + await page.keyboard.type('/email'); + await page.waitForSelector('[data-kg-cardmenu]', {timeout: 5000}); + } + + await page.waitForSelector('[data-kg-card-menu-item="Email content"]', {state: 'visible'}); + await page.locator('[data-kg-card-menu-item="Email content"]').click(); + await page.waitForSelector('[data-kg-card="email"]'); +} + +test.describe('Email card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized email card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'email', + html: '

    A paragraph

    ' + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    Hidden on website
    +
    +
    +
    +
    +

    + A paragraph +

    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + + test('renders email card node in edit mode from slash command', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await assertHTML(page, html` +
    +
    +
    +
    Hidden on website
    +
    +
    +
    +
    +

    + Hey + + {first_name, "there"} + + , +

    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + + test('contains `Hey {first_name, "there"}` by default when rendered', async function () { + await focusEditor(page); + await insertEmailCard(page); + + const htmlContent = page.locator('.kg-email-html'); + await expect(htmlContent).toContainText('Hey {first_name, "there"},'); + }); + + test('renders in display mode when unfocused', async function () { + await focusEditor(page); + await insertEmailCard(page); + + const emailCard = page.locator('[data-kg-card="email"]'); + await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'true'); + + // Match real-world pacing and avoid local selection timing flake. + await page.waitForTimeout(25); + await page.keyboard.press('ArrowDown'); + + await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'false'); + }); + + test('renders an action toolbar', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Shift focus away from email card + await page.keyboard.press('Escape'); + + const editButton = page.locator('[data-kg-card-toolbar="email"]'); + await expect(editButton).toBeVisible(); + }); + + test('is removed when left empty', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Remove all existing content + await page.keyboard.press(`${ctrlOrCmd(page)}+A`); + await page.keyboard.press('Backspace'); + + // Shift focus away from email card + await page.keyboard.press('ArrowDown'); + + const emailCard = page.locator('[data-kg-card="email"]'); + await expect(emailCard).not.toBeVisible(); + }); + + test('it can contain lists', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Create a list + await page.keyboard.press('Enter'); + await page.keyboard.type('- List item 1'); + await page.keyboard.press('Enter'); + + const emailCard = page.locator('[data-kg-card="email"] [data-kg="editor"] ul > li:first-child'); + await expect(emailCard).toHaveText('List item 1'); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="email"]')).toHaveCount(2); + }); + + test('can undo/redo without losing html content', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await page.keyboard.press('Enter'); + await page.keyboard.type('- List item 1'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + const emailCard = page.locator('[data-kg-card="email"] [data-kg="editor"] ul > li:first-child'); + await expect(emailCard).toHaveText('List item 1'); + }); + + // placeholders like {test} or {test, "string"} should be formatted as code + test('formats typed placeholders', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await page.keyboard.press(`Enter`); + await page.keyboard.type(`testing {this}?`); + + await assertHTML(page, html` +
    +
    +
    +
    Hidden on website
    +
    +
    +
    +
    +

    + Hey + + {first_name, "there"} + + , +

    +

    + testing + + {this} + + ? +

    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + + // remove the formatting using backspace + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
    +
    +
    +
    Hidden on website
    +
    +
    +
    +
    +

    + Hey + + {first_name, "there"} + + , +

    +

    + testing {this +

    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.js b/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.js deleted file mode 100644 index 089ca9f0e3..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.js +++ /dev/null @@ -1,591 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, isMac} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -async function insertEmailCard(page) { - await page.keyboard.type('/email-cta'); - await page.waitForSelector('[data-kg-card-menu-item="Email call to action"]'); - await page.click('[data-kg-card-menu-item="Email call to action"]'); - await page.waitForSelector('[data-kg-card="email-cta"]'); -} - -test.describe('Email card', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('is hidden in the card menu when deprecated', async function () { - // turn on card deprecations so we can test user-visible behaviour - await initialize({page, uri: '/#/?content=false&hideDeprecatedCards=true'}); - await focusEditor(page); - await page.keyboard.press('/'); - await page.keyboard.type('cta'); - await expect(page.locator('[data-kg-card-menu-item="Call to action"]')).toBeVisible(); - await expect(page.locator('[data-kg-card-menu-item="Email call to action"]')).not.toBeVisible(); - }); - - test('is visible for testing when deprecated flag is off', async function () { - // default test behaviour is to show deprecated cards - await initialize({page, uri: '/#/?content=false&hideDeprecatedCards=false'}); - await focusEditor(page); - await page.keyboard.press('/'); - await page.keyboard.type('cta'); - await expect(page.locator('[data-kg-card-menu-item="Call to action"]')).toBeVisible(); - await expect(page.locator('[data-kg-card-menu-item="Email call to action"]')).toBeVisible(); - }); - - test.describe('import JSON', async () => { - test('can import a email CTA card node', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'email-cta', - alignment: 'left', - html: '

    Hello

    ', - segment: 'status:free', - showButton: false, - showDividers: false - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -

    Hello

    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - - test('can import a email CTA card node with dividers', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'email-cta', - alignment: 'left', - html: '

    Hello

    ', - segment: 'status:free', - showButton: false, - showDividers: true - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -
    -

    Hello

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - - test('can import a email CTA card node with centered content', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'email-cta', - alignment: 'center', - html: '

    Hello

    ', - segment: 'status:free', - showButton: false, - showDividers: false - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -

    Hello

    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true, ignoreClasses: false}); - }); - - test('can import a email CTA card node with a button', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'email-cta', - alignment: 'left', - html: '

    Hello

    ', - segment: 'status:free', - showButton: true, - buttonText: 'Subscribe', - buttonUrl: 'https://example.com', - showDividers: false - }], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -

    Hello

    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - }); - - test.describe('settings panel', async () => { - test('renders a settings panel', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await expect(page.getByTestId('settings-panel')).toBeVisible(); - }); - - test('allows to center content', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Click on center align button - const leftAlignButton = await page.getByTestId('center-align'); - leftAlignButton.click(); - - // Check that the content is centered - const content = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical'); - await expect(content).toHaveClass(/text-center/); - }); - - test('allows to hide/show dividers', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Dividers are enabled by default - const dividersSettings = await page.getByTestId('dividers-settings'); - await expect(dividersSettings).toBeVisible(); - await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); - - // Check that the dividers are rendered - const topDivider = await page.getByTestId('top-divider'); - const bottomDivider = await page.getByTestId('bottom-divider'); - await expect(topDivider).toBeVisible(); - await expect(bottomDivider).toBeVisible(); - - // Disable dividers - await dividersSettings.setChecked(false); - - // Check that the dividers are now hidden - await expect(topDivider).toBeHidden(); - await expect(bottomDivider).toBeHidden(); - }); - - test('allows click on toggle label to toggle checkbox', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Dividers are enabled by default - const dividersSettings = await page.getByTestId('dividers-settings'); - await expect(dividersSettings).toBeVisible(); - await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); - - await page.getByText('Separators').click(); - - await expect(page.locator('[data-testid="dividers-settings"] input')).not.toBeChecked(); - - await page.getByText('Separators').click(); - - await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); - }); - - test('allows to show/hide a button', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Button is disabled by default - const buttonSettings = await page.getByTestId('button-settings'); - await expect(buttonSettings).toBeVisible(); - await expect(page.locator('[data-testid="button-settings"] input')).not.toBeChecked(); - - // Check that the button is hidden by default - const button = await page.getByTestId('cta-button'); - await expect(button).toBeHidden(); - - // Enable button and add text / url - await buttonSettings.check(); - await page.getByTestId('button-text').fill('Subscribe'); - await page.getByTestId('button-url').fill('https://example.com'); - - // Check that the button is now visible - await expect(button).toBeVisible(); - }); - }); - - test('renders the email CTA card node with a settings panel from slash command', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -
    -


    -
    -
    -
    Email only text... (optional)
    -
    -
    -
    -
    -
    -
    -
    Visibility
    -
    - -
    -

    Visible for this audience when delivered by email. This card is not published on your site.

    -
    -
    -
    Content alignment
    -
    -
    -
      -
    • - -
    • -
    • - -
    • -
    -
    -
    -
    - - -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); - - test('renders in display mode when unfocused', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Fill the card with some content, so that it's not deleted when we shift focus away - await page.keyboard.type('Hello World'); - - // Shift focus away from email card - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - - const emailCard = page.locator('[data-kg-card="email-cta"]'); - await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'false'); - }); - - test('renders an action toolbar', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Fill the card with some content, so that it's not deleted when we shift focus away - await page.keyboard.type('Hello World'); - - // Shift focus away from email card - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - - // Shift focus back to email card - await page.keyboard.press('ArrowUp'); - - const editButton = page.locator('[data-kg-card-toolbar="email-cta"]'); - await expect(editButton).toBeVisible(); - }); - - test('is removed when left empty', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Shift focus away from email card - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - - const emailCard = page.locator('[data-kg-card="email-cta"]'); - await expect(emailCard).not.toBeVisible(); - }); - - test('it can contain lists', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Create a list - await page.keyboard.type('- List item 1'); - - const emailCard = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical > div > div > ul > li:first-child'); - await expect(emailCard).toHaveText('List item 1'); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Fill the card with some content, so that it's not deleted when we shift focus away - await page.keyboard.type('Hello World'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="email-cta"]')).toHaveCount(2); - }); - - test('keeps focus on previous editor when changing size opts', async function () { - await focusEditor(page); - await insertEmailCard(page); - - // Start editing the content - await page.keyboard.type('Hello '); - - // Change alignment to center - await page.getByTestId('center-align').click(); - - // Continue editing the content - await page.keyboard.type('world'); - - // Expect content to have 'Hello World' - const content = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical'); - await expect(content).toHaveText('Hello world'); - }); - - test('can undo/redo without losing html content', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await page.keyboard.type('Hello'); - // Exit card edit mode, then use Enter+Backspace×2 to delete so undo - // has a proper history entry. Direct Escape→Backspace doesn't create a - // main editor content update between card insertion and deletion, so the - // two operations merge in the undo history. - await page.keyboard.press('Escape'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - // verify the card is restored and selected after undo - await expect(page.locator('[data-kg-card="email-cta"]')).toBeVisible(); - await expect(page.locator('[data-kg-card="email-cta"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - // verify the nested editor content is preserved - const contentEditor = page.locator('[data-kg-card="email-cta"] [data-kg="editor"]'); - await expect(contentEditor).toContainText('Hello'); - }); - - // placeholders like {test} or {test, "string"} should be formatted as code - test('formats typed placeholders', async function () { - await focusEditor(page); - await insertEmailCard(page); - - await page.keyboard.type(`testing {this}?`); - await page.keyboard.press('Escape'); // use escape to avoid the settings panel - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -
    -

    - testing - - {this} - - ? -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - - // remove the formatting using backspace - await page.keyboard.press(`${ctrlOrCmd}+Enter`); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Escape'); // avoid settings panel - - await assertHTML(page, html` -
    -
    -
    -
    -
    Hidden on website and paid newsletter
    -
    -
    -
    -
    -

    - testing {this -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.ts b/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.ts new file mode 100644 index 0000000000..55a43610ce --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/email-cta-card.test.ts @@ -0,0 +1,592 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, isMac} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +async function insertEmailCard(page: Page) { + await page.keyboard.type('/email-cta'); + await page.waitForSelector('[data-kg-card-menu-item="Email call to action"]'); + await page.click('[data-kg-card-menu-item="Email call to action"]'); + await page.waitForSelector('[data-kg-card="email-cta"]'); +} + +test.describe('Email card', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('is hidden in the card menu when deprecated', async function () { + // turn on card deprecations so we can test user-visible behaviour + await initialize({page, uri: '/#/?content=false&hideDeprecatedCards=true'}); + await focusEditor(page); + await page.keyboard.press('/'); + await page.keyboard.type('cta'); + await expect(page.locator('[data-kg-card-menu-item="Call to action"]')).toBeVisible(); + await expect(page.locator('[data-kg-card-menu-item="Email call to action"]')).not.toBeVisible(); + }); + + test('is visible for testing when deprecated flag is off', async function () { + // default test behaviour is to show deprecated cards + await initialize({page, uri: '/#/?content=false&hideDeprecatedCards=false'}); + await focusEditor(page); + await page.keyboard.press('/'); + await page.keyboard.type('cta'); + await expect(page.locator('[data-kg-card-menu-item="Call to action"]')).toBeVisible(); + await expect(page.locator('[data-kg-card-menu-item="Email call to action"]')).toBeVisible(); + }); + + test.describe('import JSON', async () => { + test('can import a email CTA card node', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'email-cta', + alignment: 'left', + html: '

    Hello

    ', + segment: 'status:free', + showButton: false, + showDividers: false + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +

    Hello

    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + + test('can import a email CTA card node with dividers', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'email-cta', + alignment: 'left', + html: '

    Hello

    ', + segment: 'status:free', + showButton: false, + showDividers: true + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +
    +

    Hello

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + + test('can import a email CTA card node with centered content', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'email-cta', + alignment: 'center', + html: '

    Hello

    ', + segment: 'status:free', + showButton: false, + showDividers: false + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +

    Hello

    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true, ignoreClasses: false}); + }); + + test('can import a email CTA card node with a button', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'email-cta', + alignment: 'left', + html: '

    Hello

    ', + segment: 'status:free', + showButton: true, + buttonText: 'Subscribe', + buttonUrl: 'https://example.com', + showDividers: false + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +

    Hello

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + }); + + test.describe('settings panel', async () => { + test('renders a settings panel', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await expect(page.getByTestId('settings-panel')).toBeVisible(); + }); + + test('allows to center content', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Click on center align button + const leftAlignButton = await page.getByTestId('center-align'); + leftAlignButton.click(); + + // Check that the content is centered + const content = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical'); + await expect(content).toHaveClass(/text-center/); + }); + + test('allows to hide/show dividers', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Dividers are enabled by default + const dividersSettings = await page.getByTestId('dividers-settings'); + await expect(dividersSettings).toBeVisible(); + await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); + + // Check that the dividers are rendered + const topDivider = await page.getByTestId('top-divider'); + const bottomDivider = await page.getByTestId('bottom-divider'); + await expect(topDivider).toBeVisible(); + await expect(bottomDivider).toBeVisible(); + + // Disable dividers + await dividersSettings.setChecked(false); + + // Check that the dividers are now hidden + await expect(topDivider).toBeHidden(); + await expect(bottomDivider).toBeHidden(); + }); + + test('allows click on toggle label to toggle checkbox', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Dividers are enabled by default + const dividersSettings = await page.getByTestId('dividers-settings'); + await expect(dividersSettings).toBeVisible(); + await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); + + await page.getByText('Separators').click(); + + await expect(page.locator('[data-testid="dividers-settings"] input')).not.toBeChecked(); + + await page.getByText('Separators').click(); + + await expect(page.locator('[data-testid="dividers-settings"] input')).toBeChecked(); + }); + + test('allows to show/hide a button', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Button is disabled by default + const buttonSettings = await page.getByTestId('button-settings'); + await expect(buttonSettings).toBeVisible(); + await expect(page.locator('[data-testid="button-settings"] input')).not.toBeChecked(); + + // Check that the button is hidden by default + const button = await page.getByTestId('cta-button'); + await expect(button).toBeHidden(); + + // Enable button and add text / url + await buttonSettings.check(); + await page.getByTestId('button-text').fill('Subscribe'); + await page.getByTestId('button-url').fill('https://example.com'); + + // Check that the button is now visible + await expect(button).toBeVisible(); + }); + }); + + test('renders the email CTA card node with a settings panel from slash command', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +
    +


    +
    +
    +
    Email only text... (optional)
    +
    +
    +
    +
    +
    +
    +
    Visibility
    +
    + +
    +

    Visible for this audience when delivered by email. This card is not published on your site.

    +
    +
    +
    Content alignment
    +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    + + +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); + + test('renders in display mode when unfocused', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Fill the card with some content, so that it's not deleted when we shift focus away + await page.keyboard.type('Hello World'); + + // Shift focus away from email card + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + const emailCard = page.locator('[data-kg-card="email-cta"]'); + await expect(emailCard).toHaveAttribute('data-kg-card-editing', 'false'); + }); + + test('renders an action toolbar', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Fill the card with some content, so that it's not deleted when we shift focus away + await page.keyboard.type('Hello World'); + + // Shift focus away from email card + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + // Shift focus back to email card + await page.keyboard.press('ArrowUp'); + + const editButton = page.locator('[data-kg-card-toolbar="email-cta"]'); + await expect(editButton).toBeVisible(); + }); + + test('is removed when left empty', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Shift focus away from email card + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + const emailCard = page.locator('[data-kg-card="email-cta"]'); + await expect(emailCard).not.toBeVisible(); + }); + + test('it can contain lists', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Create a list + await page.keyboard.type('- List item 1'); + + const emailCard = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical > div > div > ul > li:first-child'); + await expect(emailCard).toHaveText('List item 1'); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Fill the card with some content, so that it's not deleted when we shift focus away + await page.keyboard.type('Hello World'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="email-cta"]')).toHaveCount(2); + }); + + test('keeps focus on previous editor when changing size opts', async function () { + await focusEditor(page); + await insertEmailCard(page); + + // Start editing the content + await page.keyboard.type('Hello '); + + // Change alignment to center + await page.getByTestId('center-align').click(); + + // Continue editing the content + await page.keyboard.type('world'); + + // Expect content to have 'Hello World' + const content = page.locator('[data-kg-card="email-cta"] > div > div.koenig-lexical'); + await expect(content).toHaveText('Hello world'); + }); + + test('can undo/redo without losing html content', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await page.keyboard.type('Hello'); + // Exit card edit mode, then use Enter+Backspace×2 to delete so undo + // has a proper history entry. Direct Escape→Backspace doesn't create a + // main editor content update between card insertion and deletion, so the + // two operations merge in the undo history. + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + // verify the card is restored and selected after undo + await expect(page.locator('[data-kg-card="email-cta"]')).toBeVisible(); + await expect(page.locator('[data-kg-card="email-cta"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + // verify the nested editor content is preserved + const contentEditor = page.locator('[data-kg-card="email-cta"] [data-kg="editor"]'); + await expect(contentEditor).toContainText('Hello'); + }); + + // placeholders like {test} or {test, "string"} should be formatted as code + test('formats typed placeholders', async function () { + await focusEditor(page); + await insertEmailCard(page); + + await page.keyboard.type(`testing {this}?`); + await page.keyboard.press('Escape'); // use escape to avoid the settings panel + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +
    +

    + testing + + {this} + + ? +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + + // remove the formatting using backspace + await page.keyboard.press(`${ctrlOrCmd}+Enter`); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Escape'); // avoid settings panel + + await assertHTML(page, html` +
    +
    +
    +
    +
    Hidden on website and paid newsletter
    +
    +
    +
    +
    +

    + testing {this +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true, ignoreCardToolbarContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/embed-card.test.js b/packages/koenig-lexical/test/e2e/cards/embed-card.test.js deleted file mode 100644 index 76d7169c8f..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/embed-card.test.js +++ /dev/null @@ -1,378 +0,0 @@ -import {assertHTML, createSnippet, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Embed card', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized embed card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'embed', - html: '', - metadata: { - author_name: 'Bad Obsession Motorsport', - author_url: 'https://www.youtube.com/@BadObsessionMotorsport', - height: 113, - provider_name: 'YouTube', - provider_url: 'https://www.youtube.com/', - thumbnail_height: 360, - thumbnail_url: 'https://i.ytimg.com/vi/7hCPODjJO7s/hqdefault.jpg', - thumbnail_width: '480', - title: 'Project Binky - Episode 1 - Austin Mini GT-Four - Turbo Charged 4WD Mini', - version: '1.0', - width: 200 - }, - embedType: 'video', - url: 'https://www.youtube.com/watch?v=7hCPODjJO7s', - caption: 'This is a caption' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -

    - This is a - caption -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: false}); - }); - - test('renders embed card node', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can interact with url input after inserting', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); - - await urlInput.fill('test'); - await expect(urlInput).toHaveValue('test'); - }); - - test.describe('Valid URL handling', async () => { - test('shows loading wheel', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('embed-url-loading-container')).toBeVisible(); - await expect(await page.getByTestId('embed-url-loading-spinner')).toBeVisible(); - }); - - test('displays expected metadata', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('embed-iframe')).toBeVisible(); - }); - - // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it - test('caption displays on insert', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - const captionInput = await page.getByTestId('embed-caption'); - await expect(captionInput).toContainText('Type caption for embed (optional)'); - }); - }); - - test.describe('Error Handling', async () => { - test('bad url entry shows error message', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - await expect(await page.getByTestId('embed-url-error-message')).toContainText('Oops, that link didn\'t work.'); - }); - - test('retry button bring back url input', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const retryButton = await page.getByTestId('embed-url-error-retry'); - await retryButton.click(); - - const urlInputRetry = await page.getByTestId('embed-url'); - await expect(urlInputRetry).toHaveValue('badurl'); - await expect(retryButton).not.toBeVisible(); - }); - - test('should convert url to link if can\'t extract metadata', async function () { - await focusEditor(page); - await pasteText(page, 'https://ghost.org/should-convert-to-link'); - - await expect(page.locator('a[href="https://ghost.org/should-convert-to-link"]')).toBeVisible(); - }); - - test('paste as link button removes card and inserts text node link', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await expect(urlInput).toHaveAttribute('placeholder', 'Paste URL to add embedded content...'); - - await urlInput.fill('https://ghost.org/should-convert-to-link'); - await expect(urlInput).toHaveValue('https://ghost.org/should-convert-to-link'); - await urlInput.press('Enter'); - - const pasteAsLinkButton = await page.getByTestId('embed-url-error-pasteAsLink'); - await pasteAsLinkButton.click(); - - await assertHTML(page, html` -

    - - https://ghost.org/should-convert-to-link - -

    -


    - `); - }); - - test('close button removes card', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); - - await urlInput.fill('badurl'); - await expect(urlInput).toHaveValue('badurl'); - await urlInput.press('Enter'); - - const closeButton = await page.getByTestId('embed-url-error-close'); - await closeButton.click(); - - await assertHTML(page, html`


    `); - }); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - const urlInput = await page.getByTestId('embed-url'); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - await expect(await page.getByTestId('embed-iframe')).toBeVisible(); - - // create snippet - await page.keyboard.press('Escape'); - await expect(page.locator('[data-kg-card="embed"]')).toHaveAttribute('data-kg-card-selected', 'true'); - await expect(page.locator('[data-kg-card="embed"]')).toHaveAttribute('data-kg-card-editing', 'false'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="embed"]')).toHaveCount(2); - }); - - // NOTE: tested in paste-behaviour.test.js - // test('can convert link to embed card on paste', async function () { - // await focusEditor(page); - // await pasteText(page, 'https://ghost.org/'); - // await expect(await page.getByTestId('embed-url-loading-container')).toBeVisible(); - // await expect(await page.getByTestId('embed-url-loading-container')).toBeHidden(); - // await expect(await page.getByTestId('embed-iframe')).toBeVisible(); - // }); - - // flaky test - test.skip('can delete and undo without losing caption', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'embed', - html: '', - metadata: { - author_name: 'Bad Obsession Motorsport', - author_url: 'https://www.youtube.com/@BadObsessionMotorsport', - height: 113, - provider_name: 'YouTube', - provider_url: 'https://www.youtube.com/', - thumbnail_height: 360, - thumbnail_url: 'https://i.ytimg.com/vi/7hCPODjJO7s/hqdefault.jpg', - thumbnail_width: '480', - title: 'Project Binky - Episode 1 - Austin Mini GT-Four - Turbo Charged 4WD Mini', - version: '1.0', - width: 200 - }, - embedType: 'video', - url: 'https://www.youtube.com/watch?v=7hCPODjJO7s' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await focusEditor(page); - await expect(page.getByTestId('embed-iframe')).toBeVisible(); - - await page.click('[data-kg-card="embed"]'); - await page.click('[data-testid="embed-caption"]'); - await page.keyboard.type('test caption'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - await page.waitForSelector('[title="embed-card-iframe"][style]'); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -

    - test caption -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInlineStyles: true}); - }); - - test('escape removes url input component', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); - - test('escape removes url error component', async function () { - await focusEditor(page); - await insertEmbedCard(page); - - await page.keyboard.type('badurl'); - await page.keyboard.press('Enter'); - - await expect(await page.getByTestId('embed-url-error-message')).toContainText('Oops, that link didn\'t work.'); - - await page.keyboard.press('Escape'); - - await assertHTML(page, html` -


    - `, {ignoreCardContents: true}); - }); -}); - -async function insertEmbedCard(page) { - await page.keyboard.type(`/embed`); - await expect(await page.locator('[data-kg-card-menu-item="Other..."][data-kg-cardmenu-selected="true"]')).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator(`[data-kg-card="embed"]`)).toBeVisible(); -} diff --git a/packages/koenig-lexical/test/e2e/cards/embed-card.test.ts b/packages/koenig-lexical/test/e2e/cards/embed-card.test.ts new file mode 100644 index 0000000000..3867639bc0 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/embed-card.test.ts @@ -0,0 +1,379 @@ +import {assertHTML, createSnippet, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Embed card', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized embed card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'embed', + html: '', + metadata: { + author_name: 'Bad Obsession Motorsport', + author_url: 'https://www.youtube.com/@BadObsessionMotorsport', + height: 113, + provider_name: 'YouTube', + provider_url: 'https://www.youtube.com/', + thumbnail_height: 360, + thumbnail_url: 'https://i.ytimg.com/vi/7hCPODjJO7s/hqdefault.jpg', + thumbnail_width: '480', + title: 'Project Binky - Episode 1 - Austin Mini GT-Four - Turbo Charged 4WD Mini', + version: '1.0', + width: 200 + }, + embedType: 'video', + url: 'https://www.youtube.com/watch?v=7hCPODjJO7s', + caption: 'This is a caption' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    + This is a + caption +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: false}); + }); + + test('renders embed card node', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can interact with url input after inserting', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); + + await urlInput.fill('test'); + await expect(urlInput).toHaveValue('test'); + }); + + test.describe('Valid URL handling', async () => { + test('shows loading wheel', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('embed-url-loading-container')).toBeVisible(); + await expect(await page.getByTestId('embed-url-loading-spinner')).toBeVisible(); + }); + + test('displays expected metadata', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('embed-iframe')).toBeVisible(); + }); + + // TODO: the caption editor is very nested, and we don't have an actual input field here, so we aren't testing for filling it + test('caption displays on insert', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + const captionInput = await page.getByTestId('embed-caption'); + await expect(captionInput).toContainText('Type caption for embed (optional)'); + }); + }); + + test.describe('Error Handling', async () => { + test('bad url entry shows error message', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + await expect(await page.getByTestId('embed-url-error-message')).toContainText('Oops, that link didn\'t work.'); + }); + + test('retry button bring back url input', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const retryButton = await page.getByTestId('embed-url-error-retry'); + await retryButton.click(); + + const urlInputRetry = await page.getByTestId('embed-url'); + await expect(urlInputRetry).toHaveValue('badurl'); + await expect(retryButton).not.toBeVisible(); + }); + + test('should convert url to link if can\'t extract metadata', async function () { + await focusEditor(page); + await pasteText(page, 'https://ghost.org/should-convert-to-link'); + + await expect(page.locator('a[href="https://ghost.org/should-convert-to-link"]')).toBeVisible(); + }); + + test('paste as link button removes card and inserts text node link', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await expect(urlInput).toHaveAttribute('placeholder', 'Paste URL to add embedded content...'); + + await urlInput.fill('https://ghost.org/should-convert-to-link'); + await expect(urlInput).toHaveValue('https://ghost.org/should-convert-to-link'); + await urlInput.press('Enter'); + + const pasteAsLinkButton = await page.getByTestId('embed-url-error-pasteAsLink'); + await pasteAsLinkButton.click(); + + await assertHTML(page, html` +

    + + https://ghost.org/should-convert-to-link + +

    +


    + `); + }); + + test('close button removes card', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await expect(urlInput).toHaveAttribute('placeholder','Paste URL to add embedded content...'); + + await urlInput.fill('badurl'); + await expect(urlInput).toHaveValue('badurl'); + await urlInput.press('Enter'); + + const closeButton = await page.getByTestId('embed-url-error-close'); + await closeButton.click(); + + await assertHTML(page, html`


    `); + }); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + const urlInput = await page.getByTestId('embed-url'); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + await expect(await page.getByTestId('embed-iframe')).toBeVisible(); + + // create snippet + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-card="embed"]')).toHaveAttribute('data-kg-card-selected', 'true'); + await expect(page.locator('[data-kg-card="embed"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="embed"]')).toHaveCount(2); + }); + + // NOTE: tested in paste-behaviour.test.js + // test('can convert link to embed card on paste', async function () { + // await focusEditor(page); + // await pasteText(page, 'https://ghost.org/'); + // await expect(await page.getByTestId('embed-url-loading-container')).toBeVisible(); + // await expect(await page.getByTestId('embed-url-loading-container')).toBeHidden(); + // await expect(await page.getByTestId('embed-iframe')).toBeVisible(); + // }); + + // flaky test + test.skip('can delete and undo without losing caption', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'embed', + html: '', + metadata: { + author_name: 'Bad Obsession Motorsport', + author_url: 'https://www.youtube.com/@BadObsessionMotorsport', + height: 113, + provider_name: 'YouTube', + provider_url: 'https://www.youtube.com/', + thumbnail_height: 360, + thumbnail_url: 'https://i.ytimg.com/vi/7hCPODjJO7s/hqdefault.jpg', + thumbnail_width: '480', + title: 'Project Binky - Episode 1 - Austin Mini GT-Four - Turbo Charged 4WD Mini', + version: '1.0', + width: 200 + }, + embedType: 'video', + url: 'https://www.youtube.com/watch?v=7hCPODjJO7s' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await focusEditor(page); + await expect(page.getByTestId('embed-iframe')).toBeVisible(); + + await page.click('[data-kg-card="embed"]'); + await page.click('[data-testid="embed-caption"]'); + await page.keyboard.type('test caption'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + await page.waitForSelector('[title="embed-card-iframe"][style]'); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    + test caption +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInlineStyles: true}); + }); + + test('escape removes url input component', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); + + test('escape removes url error component', async function () { + await focusEditor(page); + await insertEmbedCard(page); + + await page.keyboard.type('badurl'); + await page.keyboard.press('Enter'); + + await expect(await page.getByTestId('embed-url-error-message')).toContainText('Oops, that link didn\'t work.'); + + await page.keyboard.press('Escape'); + + await assertHTML(page, html` +


    + `, {ignoreCardContents: true}); + }); +}); + +async function insertEmbedCard(page: Page) { + await page.keyboard.type(`/embed`); + await expect(await page.locator('[data-kg-card-menu-item="Other..."][data-kg-cardmenu-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator(`[data-kg-card="embed"]`)).toBeVisible(); +} diff --git a/packages/koenig-lexical/test/e2e/cards/file-card.test.js b/packages/koenig-lexical/test/e2e/cards/file-card.test.js deleted file mode 100644 index b069bb75cf..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/file-card.test.js +++ /dev/null @@ -1,158 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('File card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized file card nodes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'file', - src: '/content/images/2022/11/koenig-lexical.jpg', - fileTitle: 'This is a title', - fileCaption: 'This is a description', - fileName: 'koenig-lexical.jpg', - fileSize: '1.2 MB' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - // page.pause(); - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('renders file card node', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/print-img.pdf'); - - await focusEditor(page); - const fileChooserPromise = page.waitForEvent('filechooser'); - await insertCard(page, {cardName: 'file'}); - const fileChooser = await fileChooserPromise; - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - // Close the fileChooser by selecting a file - // Without this line, fileChooser stays open for subsequent tests - await fileChooser.setFiles([filePath]); - }); - - test('can upload a file', async function () { - await focusEditor(page); - await uploadFile(page); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); // TODO: assert on HTML of inner card (not working due to error in prettier) - }); - - test('can upload dropped file', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/print-img.pdf'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - - // Open file card and dismiss files chooser to prepare card for file dropping - await insertCard(page, {cardName: 'file'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([]); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'print-img.pdf', fileType: 'application/pdf'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Dragover text should not be visible - // expect data-kg-file-card="dataset - await expect(await page.locator('[data-kg-file-card="dataset"]')).toBeVisible(); - }); - - test('file input opens immediately when added via card menu', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="File"]') - ]); - - expect(fileChooser).not.toBeNull(); - }); - - test('file input opens immediately when added via slash menu', async function () { - await focusEditor(page); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'file'}) - ]); - - expect(fileChooser).not.toBeNull(); - }); - - test('can edit file card title', async function () { - await focusEditor(page); - await uploadFile(page); - await page.locator('[data-kg-file-card="fileTitle"]').fill('Free printable pdf'); - await expect(await page.locator('[data-kg-file-card="fileTitle"]')).toHaveValue('Free printable pdf'); - }); - - test('can edit file card description', async function () { - await focusEditor(page); - await uploadFile(page); - await page.locator('[data-kg-file-card="fileDescription"]').fill('Enjoy this free download of a puppy pdf'); - await expect(await page.locator('[data-kg-file-card="fileDescription"]')).toHaveValue('Enjoy this free download of a puppy pdf'); - }); -}); - -async function uploadFile(page, fileName = 'print-img.pdf') { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); - - const fileChooserPromise = page.waitForEvent('filechooser'); - await insertCard(page, {cardName: 'file'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); -} diff --git a/packages/koenig-lexical/test/e2e/cards/file-card.test.ts b/packages/koenig-lexical/test/e2e/cards/file-card.test.ts new file mode 100644 index 0000000000..b61fee059b --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/file-card.test.ts @@ -0,0 +1,159 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('File card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized file card nodes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'file', + src: '/content/images/2022/11/koenig-lexical.jpg', + fileTitle: 'This is a title', + fileCaption: 'This is a description', + fileName: 'koenig-lexical.jpg', + fileSize: '1.2 MB' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + // page.pause(); + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('renders file card node', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/print-img.pdf'); + + await focusEditor(page); + const fileChooserPromise = page.waitForEvent('filechooser'); + await insertCard(page, {cardName: 'file'}); + const fileChooser = await fileChooserPromise; + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + // Close the fileChooser by selecting a file + // Without this line, fileChooser stays open for subsequent tests + await fileChooser.setFiles([filePath]); + }); + + test('can upload a file', async function () { + await focusEditor(page); + await uploadFile(page); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); // TODO: assert on HTML of inner card (not working due to error in prettier) + }); + + test('can upload dropped file', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/print-img.pdf'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + + // Open file card and dismiss files chooser to prepare card for file dropping + await insertCard(page, {cardName: 'file'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([]); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'print-img.pdf', fileType: 'application/pdf'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Dragover text should not be visible + // expect data-kg-file-card="dataset + await expect(await page.locator('[data-kg-file-card="dataset"]')).toBeVisible(); + }); + + test('file input opens immediately when added via card menu', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="File"]') + ]); + + expect(fileChooser).not.toBeNull(); + }); + + test('file input opens immediately when added via slash menu', async function () { + await focusEditor(page); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'file'}) + ]); + + expect(fileChooser).not.toBeNull(); + }); + + test('can edit file card title', async function () { + await focusEditor(page); + await uploadFile(page); + await page.locator('[data-kg-file-card="fileTitle"]').fill('Free printable pdf'); + await expect(await page.locator('[data-kg-file-card="fileTitle"]')).toHaveValue('Free printable pdf'); + }); + + test('can edit file card description', async function () { + await focusEditor(page); + await uploadFile(page); + await page.locator('[data-kg-file-card="fileDescription"]').fill('Enjoy this free download of a puppy pdf'); + await expect(await page.locator('[data-kg-file-card="fileDescription"]')).toHaveValue('Enjoy this free download of a puppy pdf'); + }); +}); + +async function uploadFile(page: Page, fileName = 'print-img.pdf') { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await insertCard(page, {cardName: 'file'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); +} diff --git a/packages/koenig-lexical/test/e2e/cards/gallery-card.test.js b/packages/koenig-lexical/test/e2e/cards/gallery-card.test.js deleted file mode 100644 index 5b81134dc2..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/gallery-card.test.js +++ /dev/null @@ -1,507 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, ctrlOrCmd, dragMouse, focusEditor, getEditorState, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Gallery card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized gallery card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'gallery', - version: 1, - images: [{ - row: 0, - fileName: 'retreat-1.jpg', - src: '/content/images/2023/04/retreat-1.jpg', - width: 3840, - height: 2160, - title: 'Title 1', - alt: 'Alt 1', - caption: 'This is the first caption' - }, { - row: 0, - fileName: 'retreat-2.jpg', - src: '/content/images/2023/04/retreat-2.jpg', - width: 3840, - height: 2160, - title: 'Title 2', - alt: 'Alt 2', - caption: 'This is another caption' - }] - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    - Alt 1 -
    -
    - -
    -
    -
    -
    - Alt 2 -
    -
    - -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - `); - }); - - test('can insert gallery card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Type caption for gallery (optional)
    -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('can upload images', async function () { - const fileChooserPromise = page.waitForEvent('filechooser'); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - await page.click('[name="placeholder-button"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-gallery="true"]'); - await expect(page.getByTestId('progress-bar')).not.toBeVisible(); - - // Re-click the card to ensure it's selected - // (Chrome for Testing may lose card selection after file upload) - await page.click('[data-kg-card="gallery"]'); - await expect(page.locator('[data-kg-card-toolbar="gallery"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Type caption for gallery (optional)
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('can drop images when empty', async function () { - const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - const secondImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - - // create and dispatch a file drag over - const dataTransfer = await createDataTransfer(page, [ - {filePath: firstImagePath, fileName: 'large-image.jpg', fileType: 'image/jpeg'}, - {filePath: secondImagePath, fileName: 'large-image.png', fileType: 'image/png'} - ]); - await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); - - // dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // drop files - await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); - - // check images were uploaded - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); - }); - - test('can drop images when populated', async function () { - const prePopulatedImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - const secondImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - - await page.click('[name="placeholder-button"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([prePopulatedImagePath]); - - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(1); - - // create and dispatch a file drag over - const dataTransfer = await createDataTransfer(page, [ - {filePath: firstImagePath, fileName: 'first-dropped.jpg', fileType: 'image/jpeg'}, - {filePath: secondImagePath, fileName: 'second-dropped.png', fileType: 'image/png'} - ]); - await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); - - // dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // drop files - await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); - - // check images were uploaded - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(3); - }); - - test('limits uploads to 9 images', async function () { - const filePaths = Array.from(Array(10).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - await page.click('[name="placeholder-button"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(filePaths); - - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(9); - await expect(page.getByTestId('gallery-error')).toContainText('9 images'); - - await expect(page.getByTestId('clear-gallery-error')).toBeVisible(); - await page.getByTestId('clear-gallery-error').dispatchEvent('click'); - - await expect(page.getByTestId('gallery-error')).not.toBeVisible(); - }); - - test('can add images via toolbar', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - - const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - const dataTransfer = await createDataTransfer(page, [ - {filePath: firstImagePath, fileName: 'first-dropped.jpg', fileType: 'image/jpeg'} - ]); - await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); - await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); - - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(1); - await expect(page.locator('[data-kg-card-toolbar="gallery"]')).toBeVisible(); - - const fileChooserPromise = page.waitForEvent('filechooser'); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-1.png'); - await page.click('[data-kg-card-toolbar="gallery"] [data-testid="add-gallery-image"]'); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); - }); - - test('can undo/redo without losing nested editor content', async () => { - await test.step('insert and upload images to gallery card', async () => { - const filePaths = Array.from(Array(2).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - await page.click('[name="placeholder-button"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(filePaths); - - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); - }); - - // Wait for upload to complete and images to be saved to node state - // (preview images appear in the DOM before the upload finishes) - await page.waitForFunction(() => { - const state = window.lexicalEditor.getEditorState().toJSON(); - const gallery = state.root.children.find(c => c.type === 'gallery'); - return gallery && gallery.images && gallery.images.length === 2; - }, {timeout: 5000}); - - // Re-click the card to ensure it's selected after upload - // (Chrome for Testing may lose card selection after file upload) - await page.locator('[data-kg-card="gallery"]').click(); - await expect(page.locator('[data-kg-card="gallery"][data-kg-card-selected="true"]')).toBeVisible(); - - // Wait for caption to be ready and click it - await expect(page.locator('[data-testid="gallery-card-caption"]')).toBeVisible(); - await page.locator('[data-testid="gallery-card-caption"]').click(); - await page.keyboard.type('Caption'); - await page.keyboard.press('Enter'); - - // Wait for editor state to settle after exiting caption - await page.waitForTimeout(100); - - // First Backspace: deletes the empty paragraph and selects the gallery card - await page.keyboard.press('Backspace'); - await page.waitForTimeout(100); - - // Second Backspace: deletes the selected gallery card - await page.keyboard.press('Backspace'); - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(0); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - await expect(page.locator('[data-kg-card="gallery"]')).toBeVisible(); - - // verify the gallery content is preserved after undo - await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); - const captionEditor = page.locator('[data-kg-card="gallery"] figcaption'); - await expect(captionEditor).toContainText('Caption'); - }); - - // Skipped test because I couldn't get the drag to initiate with the image - // rather than the whole gallery. - // - // test('can drag image card out of gallery card', async function () { - // await test.step('insert and upload images to gallery card', async () => { - // const filePaths = Array.from(Array(2).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); - // const fileChooserPromise = page.waitForEvent('filechooser'); - - // await focusEditor(page); - // await insertCard(page, {cardName: 'gallery'}); - // await page.click('[name="placeholder-button"]'); - - // const fileChooser = await fileChooserPromise; - // await fileChooser.setFiles(filePaths); - - // await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); - // }); - - // const firstImageBBox = await page.locator('[data-testid="gallery-image"]').nth(0).boundingBox(); - // const paragraphBBox = await page.locator('p:not(figure p)').boundingBox(); - - // await dragMouse(page, firstImageBBox, paragraphBBox, 'middle', 'start', true, 100, 100); - - // await assertHTML(page, ` - // `, {ignoreCardContents: true}); - // }); - - test('can drag populated image card onto empty gallery card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'image'}); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('[data-kg-card="image"] button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - // Wait for upload to fully complete - the image node needs its src - // set in the Lexical state (not just the preview) for drag-to-gallery to work - await page.waitForFunction(() => { - const state = window.lexicalEditor.getEditorState().toJSON(); - const imageNode = state.root.children.find(c => c.type === 'image'); - return imageNode && imageNode.src; - }, {timeout: 5000}); - - // Click outside to deselect the image card before dragging - // (Chrome for Testing keeps the card selected after upload) - await page.click('p:not(figure p)'); - - const imageBBox = await page.locator('[data-kg-card="image"]').nth(0).boundingBox(); - const galleryBBox = await page.locator('[data-kg-card="gallery"]').nth(0).boundingBox(); - - await dragMouse(page, imageBBox, galleryBBox, 'middle', 'middle', true, 100, 100); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: false}); - }); - - test('exports all 9 images', async function () { - // necessary to check the saved data because the gallery card state is not - // directly synchronized with the editor state at time of testing - // (it keeps it's own state for easier handling of loading states, etc.) - - await test.step('insert and upload images to gallery card', async () => { - const filePaths = Array.from(Array(9).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - await insertCard(page, {cardName: 'gallery'}); - await page.click('[name="placeholder-button"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(filePaths); - - await expect(page.getByTestId('progress-bar')).not.toBeVisible(); - await expect(page.getByTestId('gallery-image')).toHaveCount(9); - }); - - // Wait for all images to be saved to the Lexical node state - // (preview images appear in the DOM before the upload completes and - // updates the node, so we need to poll the serialized state directly) - await page.waitForFunction(() => { - const state = window.lexicalEditor.getEditorState().toJSON(); - const gallery = state.root.children.find(c => c.type === 'gallery'); - return gallery && gallery.images && gallery.images.length === 9; - }, {timeout: 5000}); - - const editorState = await getEditorState(page); - - expect(editorState.root.children[0].type).toEqual('gallery'); - expect(editorState.root.children[0].images).toHaveLength(9); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/gallery-card.test.ts b/packages/koenig-lexical/test/e2e/cards/gallery-card.test.ts new file mode 100644 index 0000000000..24d6610c76 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/gallery-card.test.ts @@ -0,0 +1,508 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, ctrlOrCmd, dragMouse, focusEditor, getEditorState, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Gallery card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized gallery card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'gallery', + version: 1, + images: [{ + row: 0, + fileName: 'retreat-1.jpg', + src: '/content/images/2023/04/retreat-1.jpg', + width: 3840, + height: 2160, + title: 'Title 1', + alt: 'Alt 1', + caption: 'This is the first caption' + }, { + row: 0, + fileName: 'retreat-2.jpg', + src: '/content/images/2023/04/retreat-2.jpg', + width: 3840, + height: 2160, + title: 'Title 2', + alt: 'Alt 2', + caption: 'This is another caption' + }] + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    + Alt 1 +
    +
    + +
    +
    +
    +
    + Alt 2 +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + `); + }); + + test('can insert gallery card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Type caption for gallery (optional)
    +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('can upload images', async function () { + const fileChooserPromise = page.waitForEvent('filechooser'); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + await page.click('[name="placeholder-button"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-gallery="true"]'); + await expect(page.getByTestId('progress-bar')).not.toBeVisible(); + + // Re-click the card to ensure it's selected + // (Chrome for Testing may lose card selection after file upload) + await page.click('[data-kg-card="gallery"]'); + await expect(page.locator('[data-kg-card-toolbar="gallery"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Type caption for gallery (optional)
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('can drop images when empty', async function () { + const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + const secondImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + + // create and dispatch a file drag over + const dataTransfer = await createDataTransfer(page, [ + {filePath: firstImagePath, fileName: 'large-image.jpg', fileType: 'image/jpeg'}, + {filePath: secondImagePath, fileName: 'large-image.png', fileType: 'image/png'} + ]); + await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); + + // dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // drop files + await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); + + // check images were uploaded + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); + }); + + test('can drop images when populated', async function () { + const prePopulatedImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + const secondImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + + await page.click('[name="placeholder-button"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([prePopulatedImagePath]); + + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(1); + + // create and dispatch a file drag over + const dataTransfer = await createDataTransfer(page, [ + {filePath: firstImagePath, fileName: 'first-dropped.jpg', fileType: 'image/jpeg'}, + {filePath: secondImagePath, fileName: 'second-dropped.png', fileType: 'image/png'} + ]); + await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); + + // dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // drop files + await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); + + // check images were uploaded + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(3); + }); + + test('limits uploads to 9 images', async function () { + const filePaths = Array.from(Array(10).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + await page.click('[name="placeholder-button"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(9); + await expect(page.getByTestId('gallery-error')).toContainText('9 images'); + + await expect(page.getByTestId('clear-gallery-error')).toBeVisible(); + await page.getByTestId('clear-gallery-error').dispatchEvent('click'); + + await expect(page.getByTestId('gallery-error')).not.toBeVisible(); + }); + + test('can add images via toolbar', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + + const firstImagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + const dataTransfer = await createDataTransfer(page, [ + {filePath: firstImagePath, fileName: 'first-dropped.jpg', fileType: 'image/jpeg'} + ]); + await page.getByTestId('gallery-container').dispatchEvent('dragover', {dataTransfer}); + await page.getByTestId('gallery-container').dispatchEvent('drop', {dataTransfer}); + + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(1); + await expect(page.locator('[data-kg-card-toolbar="gallery"]')).toBeVisible(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-1.png'); + await page.click('[data-kg-card-toolbar="gallery"] [data-testid="add-gallery-image"]'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); + }); + + test('can undo/redo without losing nested editor content', async () => { + await test.step('insert and upload images to gallery card', async () => { + const filePaths = Array.from(Array(2).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + await page.click('[name="placeholder-button"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); + }); + + // Wait for upload to complete and images to be saved to node state + // (preview images appear in the DOM before the upload finishes) + await page.waitForFunction(() => { + const state = window.lexicalEditor.getEditorState().toJSON() as {root: {children: Array<{type?: string; images?: unknown[]}>}}; + const gallery = state.root.children.find(c => c.type === 'gallery'); + return gallery && gallery.images && gallery.images.length === 2; + }, {timeout: 5000}); + + // Re-click the card to ensure it's selected after upload + // (Chrome for Testing may lose card selection after file upload) + await page.locator('[data-kg-card="gallery"]').click(); + await expect(page.locator('[data-kg-card="gallery"][data-kg-card-selected="true"]')).toBeVisible(); + + // Wait for caption to be ready and click it + await expect(page.locator('[data-testid="gallery-card-caption"]')).toBeVisible(); + await page.locator('[data-testid="gallery-card-caption"]').click(); + await page.keyboard.type('Caption'); + await page.keyboard.press('Enter'); + + // Wait for editor state to settle after exiting caption + await page.waitForTimeout(100); + + // First Backspace: deletes the empty paragraph and selects the gallery card + await page.keyboard.press('Backspace'); + await page.waitForTimeout(100); + + // Second Backspace: deletes the selected gallery card + await page.keyboard.press('Backspace'); + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(0); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + await expect(page.locator('[data-kg-card="gallery"]')).toBeVisible(); + + // verify the gallery content is preserved after undo + await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); + const captionEditor = page.locator('[data-kg-card="gallery"] figcaption'); + await expect(captionEditor).toContainText('Caption'); + }); + + // Skipped test because I couldn't get the drag to initiate with the image + // rather than the whole gallery. + // + // test('can drag image card out of gallery card', async function () { + // await test.step('insert and upload images to gallery card', async () => { + // const filePaths = Array.from(Array(2).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); + // const fileChooserPromise = page.waitForEvent('filechooser'); + + // await focusEditor(page); + // await insertCard(page, {cardName: 'gallery'}); + // await page.click('[name="placeholder-button"]'); + + // const fileChooser = await fileChooserPromise; + // await fileChooser.setFiles(filePaths); + + // await expect(page.locator('[data-testid="gallery-image"]')).toHaveCount(2); + // }); + + // const firstImageBBox = await page.locator('[data-testid="gallery-image"]').nth(0).boundingBox(); + // const paragraphBBox = await page.locator('p:not(figure p)').boundingBox(); + + // await dragMouse(page, firstImageBBox, paragraphBBox, 'middle', 'start', true, 100, 100); + + // await assertHTML(page, ` + // `, {ignoreCardContents: true}); + // }); + + test('can drag populated image card onto empty gallery card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'image'}); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('[data-kg-card="image"] button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + // Wait for upload to fully complete - the image node needs its src + // set in the Lexical state (not just the preview) for drag-to-gallery to work + await page.waitForFunction(() => { + const state = window.lexicalEditor.getEditorState().toJSON() as {root: {children: Array<{type?: string; src?: string}>}}; + const imageNode = state.root.children.find(c => c.type === 'image'); + return imageNode && imageNode.src; + }, {timeout: 5000}); + + // Click outside to deselect the image card before dragging + // (Chrome for Testing keeps the card selected after upload) + await page.click('p:not(figure p)'); + + const imageBBox = await page.locator('[data-kg-card="image"]').nth(0).boundingBox(); + const galleryBBox = await page.locator('[data-kg-card="gallery"]').nth(0).boundingBox(); + + await dragMouse(page, imageBBox!, galleryBBox!, 'middle', 'middle', true, 100, 100); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: false}); + }); + + test('exports all 9 images', async function () { + // necessary to check the saved data because the gallery card state is not + // directly synchronized with the editor state at time of testing + // (it keeps it's own state for easier handling of loading states, etc.) + + await test.step('insert and upload images to gallery card', async () => { + const filePaths = Array.from(Array(9).keys()).map(n => path.relative(process.cwd(), __dirname + `/../fixtures/large-image-${n}.png`)); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + await insertCard(page, {cardName: 'gallery'}); + await page.click('[name="placeholder-button"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + + await expect(page.getByTestId('progress-bar')).not.toBeVisible(); + await expect(page.getByTestId('gallery-image')).toHaveCount(9); + }); + + // Wait for all images to be saved to the Lexical node state + // (preview images appear in the DOM before the upload completes and + // updates the node, so we need to poll the serialized state directly) + await page.waitForFunction(() => { + const state = window.lexicalEditor.getEditorState().toJSON() as {root: {children: Array<{type?: string; images?: unknown[]}>}}; + const gallery = state.root.children.find(c => c.type === 'gallery'); + return gallery && gallery.images && gallery.images.length === 9; + }, {timeout: 5000}); + + const editorState = await getEditorState(page); + + expect(editorState.root.children[0].type).toEqual('gallery'); + expect(editorState.root.children[0].images).toHaveLength(9); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/header-card.test.js b/packages/koenig-lexical/test/e2e/cards/header-card.test.js deleted file mode 100644 index bbc4b10806..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/header-card.test.js +++ /dev/null @@ -1,772 +0,0 @@ -import path from 'path'; -import {assertHTML, focusEditor, html, initialize, isMac} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -import {selectCustomColor, selectNamedColor, selectTitledColor} from '../../utils/color-select-helper'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function createHeaderCard({page, version = 1}) { - await focusEditor(page); - if (version === 1) { - await page.keyboard.type('/v1_header'); - await page.waitForSelector('[data-kg-card-menu-item="Header1"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="header"]'); - } - - if (version === 2) { - await page.keyboard.type('/header'); - await page.waitForSelector('[data-kg-card-menu-item="Header"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="header"]'); - } -} - -test.describe('Header card V1', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized header card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - version: 1, - type: 'header', - size: 'small', - style: 'image', - buttonEnabled: false, - buttonUrl: '', - buttonText: '', - header: 'hello world', - subheader: 'hello sub', - backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    hello world

    -
    -
    -
    -
    -
    -
    -

    hello sub

    -
    -
    -
    -
    -
    -
    -
    - `, {}); - }); - - test('renders header card node', async function () { - await createHeaderCard({page, version: 1}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can edit header', async function () { - await createHeaderCard({page}); - - await page.keyboard.type('Hello world'); - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - await expect(firstEditor).toHaveText('Hello world'); - }); - - test('can edit sub header', async function () { - await createHeaderCard({page}); - - await page.keyboard.type('Hello world'); - - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello subheader'); - - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - - await expect(firstEditor).toHaveText('Hello world'); - await expect(secondEditor).toHaveText('Hello subheader'); - }); - - test('can edit sub header via arrow keys', async function () { - await createHeaderCard({page}); - - await page.keyboard.type('Hello'); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('blah blah blah something very long'); - - // Go back up again and add an extra word - await page.keyboard.press('ArrowUp'); - await page.keyboard.type(' world'); - - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - - await expect(firstEditor).toHaveText('Hello world'); - await expect(secondEditor).toHaveText('blah blah blah something very long'); - }); - - test('can add and remove button', async function () { - await createHeaderCard({page}); - - // click on the toggle with data-testid="header-button-toggle" - await page.click('[data-testid="header-button-toggle"]'); - - // check button is visible - await expect(page.getByTestId('header-card-button')).toHaveText('Add button text'); - - // Enter some text for the button in data-testid="header-button-text" - await page.click('[data-testid="header-button-text"]'); - await page.keyboard.type('Click me'); - - // Enter some url for the button in data-testid="header-button-url" - await page.click('[data-testid="header-button-url"]'); - await page.keyboard.type('https://example.com'); - - // check button is visible, and not an tag (so not clickable) - // Page contains `` - await expect(page.getByTestId('header-card-button')).toHaveText('Click me'); - - // Can toggle button off again - await page.click('[data-testid="header-button-toggle"]'); - - // check button is not visible by using expect - await expect(page.getByTestId('header-card-button')).toHaveCount(0); - }); - - test('can change the size', async function () { - await createHeaderCard({page}); - - // Default size is small - const smallButton = page.locator('[aria-label="S"]'); - await expect(smallButton).toHaveAttribute('aria-checked', 'true'); - - // Get height of the card - const box = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); - const height = box.height; - - // Click on the medium button - const mediumButton = page.locator('[aria-label="M"]'); - await mediumButton.click(); - await expect(mediumButton).toHaveAttribute('aria-checked', 'true'); - - // Check that the height has changed - const box2 = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); - const height2 = box2.height; - - expect(height2).toBeGreaterThan(height); - - // Switch to large - const largeButton = page.locator('[aria-label="L"]'); - await largeButton.click(); - await expect(largeButton).toHaveAttribute('aria-checked', 'true'); - - // Check that the height has changed - const box3 = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); - const height3 = box3.height; - - expect(height3).toBeGreaterThan(height2); - }); - - test('can change the background color', async function () { - await createHeaderCard({page}); - - // default background color is black - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-black /); - await selectNamedColor(page, 'light', 'color-options-button'); - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-grey-100 /); - - await selectNamedColor(page, 'dark', 'color-options-button'); - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-black /); - - await selectNamedColor(page, 'accent', 'color-options-button'); - - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-accent /); - }); - - test('can add and remove background image', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await createHeaderCard({page}); - - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.click('[data-testid="color-options-button"]'); - await page.click('[data-testid="background-image-color-button"]'); - // await page.click('[data-testid="background-image-color-button"]'); - - // Set files - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - // Check if it is set as a background image - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); - - // Check if it is also set as an image in the panel - await expect(page.getByTestId('image-picker-background')).toHaveAttribute('src', /blob:/); - }); - - test('can select the text by dragging and replace it', async function () { - await createHeaderCard({page}); - - await page.keyboard.type('HelloHello'); - - // Get locator to 'Hello Hello' span - const helloSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] span').nth(0); - - // Get the bounding box of the span - const box = await helloSpan.boundingBox(); - const y = box.y + box.height / 2; - const startX = box.x + box.width / 2; - const endX = box.x + box.width; - - await page.mouse.move(startX, y); - await page.mouse.down(); - await page.mouse.move(endX, y); - await page.mouse.up(); - - await page.keyboard.type(' world'); - await expect(page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0)).toHaveText('Hello world'); - }); - - test('can select the text by dragging and bold it', async function () { - await createHeaderCard({page}); - - await page.keyboard.type('HelloHello'); - - // Get locator to 'Hello Hello' span - const helloSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] span').nth(0); - - // Get the bounding box of the span - const box = await helloSpan.boundingBox(); - const y = box.y + box.height / 2; - const startX = box.x + box.width / 2; - const endX = box.x + box.width; - - await page.mouse.move(startX, y); - await page.mouse.down(); - await page.mouse.move(endX, y); - await page.mouse.up(); - - // click data-kg-toolbar-button="bold" - await page.locator('[data-kg-toolbar-button="bold"]').click(); - - // check it is now bold - const boldSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] strong').nth(0); - await expect(boldSpan).toHaveText('Hello'); - await expect(helloSpan).toHaveText('Hello'); - - // check if text is still selected by continuing typing - await page.keyboard.type(' world'); - - // check the typed text is still bold - await expect(boldSpan).toHaveText(' world'); - await expect(helloSpan).toHaveText('Hello'); - }); - - test('keeps focus on previous editor when changing size opts', async function () { - await createHeaderCard({page}); - - // Start editing the header - await page.keyboard.type('Hello '); - - // Change size to medium - await page.getByLabel('M').click(); - - // Continue editing the subheader - await page.keyboard.type('world'); - - // Expect header to have 'Hello World' - const header = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - await expect(header).toHaveText('Hello world'); - }); - - test('can undo/redo without losing nested editor content', async () => { - await createHeaderCard({page}); - - await page.keyboard.type('Test title'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Test description'); - // Exit card edit mode, then use Enter+Backspace×2 to delete so undo - // has a proper history entry. Direct Escape→Backspace doesn't create a - // main editor content update between card insertion and deletion, so the - // two operations merge in the undo history (known Lexical limitation with - // decorator nodes whose nested editors don't create main editor updates). - await page.keyboard.press('Escape'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - // verify the card is restored and selected after undo - await expect(page.locator('[data-kg-card="header"]')).toBeVisible(); - await expect(page.locator('[data-kg-card="header"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - // verify the nested editor content is preserved - const headerEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - await expect(headerEditor).toContainText('Test title'); - const subheaderEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - await expect(subheaderEditor).toContainText('Test description'); - }); -}); - -test.describe('Header card V2', () => { - // const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized header card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - version: 2, - type: 'header', - size: 'small', - style: 'image', - buttonEnabled: false, - buttonUrl: '', - buttonText: '', - header: 'hello world', - subheader: 'hello sub', - backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a', - alignment: 'left', - buttonColor: '#ffffff', - buttonTextColor: '#000000', - backgroundColor: 'accent', - textColor: '#ffffff', - swapped: false - - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - await page.waitForSelector('[data-kg-card="header"]'); - await page.waitForSelector('[data-kg-card="header"] [data-kg="editor"]'); - await expect(page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0)).toHaveText('hello world'); - }); - - test('renders header card node', async function () { - await createHeaderCard({page, version: 2}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can edit header', async function () { - await createHeaderCard({page, version: 2}); - - await page.keyboard.type('Hello world'); - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - await expect(firstEditor).toHaveText('Hello world'); - }); - - test('can edit sub header', async function () { - await createHeaderCard({page, version: 2}); - - await page.keyboard.type('Hello world'); - - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello subheader'); - - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - - await expect(firstEditor).toHaveText('Hello world'); - await expect(secondEditor).toHaveText('Hello subheader'); - }); - - test('can edit sub header via arrow keys', async function () { - await createHeaderCard({page, version: 2}); - - await page.keyboard.type('Hello'); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('blah blah blah something very long'); - - // Go back up again and add an extra word - await page.keyboard.press('ArrowUp'); - await page.keyboard.type(' world'); - - const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - - await expect(firstEditor).toHaveText('Hello world'); - await expect(secondEditor).toHaveText('blah blah blah something very long'); - }); - - test('can add and remove button', async function () { - await createHeaderCard({page, version: 2}); - - // click on the toggle with data-testid="header-button-toggle" - await page.click('[data-testid="header-button-toggle"]'); - - // check button is visible - await expect(page.getByTestId('header-card-button')).toHaveText('Add button text'); - - // Enter some text for the button in data-testid="header-button-text" - await page.click('[data-testid="header-button-text"]'); - await page.keyboard.type('Click me'); - - // Enter some url for the button in data-testid="header-button-url" - await page.click('[data-testid="header-button-url"]'); - await page.keyboard.type('https://example.com'); - - // check button is visible, and not an
    tag (so not clickable) - // Page contains `` - await expect(page.getByTestId('header-card-button')).toHaveText('Click me'); - - // Can toggle button off again - await page.click('[data-testid="header-button-toggle"]'); - - // check button is not visible by using expect - await expect(page.getByTestId('header-card-button')).toHaveCount(0); - }); - - test('can change the button background color and text color', async function () { - await createHeaderCard({page, version: 2}); - - await page.click('[data-testid="header-button-toggle"]'); - - await page.click('[data-testid="header-button-color"] [data-testid="color-selector-button"]'); - - await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); - - await page.click('[data-testid="settings-panel"]'); - - // // // Selected colour should be applied inline - await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await page.click('[data-testid="header-button-color"] [data-testid="color-selector-button"]'); - await selectCustomColor(page, '#f7f7f7', null); - - await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('background-color', 'rgb(247, 247, 247)'); - await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); - }); - - test('can change the background color and text color', async function () { - await createHeaderCard({page, version: 2}); - - await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); - - await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); - - await page.click('[data-testid="settings-panel"]'); - - // Selected colour should be applied inline - const container = page.getByTestId('header-card-container'); - await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); - await selectCustomColor(page, '#f7f7f7', null); - - await expect(container).toHaveCSS('background-color', 'rgb(247, 247, 247)'); - await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); - }); - - test('can change to grey, black, brand background color', async function () { - await createHeaderCard({page, version: 2}); - - await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); - - await selectTitledColor(page, 'Grey', 'color-picker-toggle'); - - const container = page.getByTestId('header-card-container'); - await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); - await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); - - await selectTitledColor(page, 'Black', 'color-picker-toggle'); - - await expect(container).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await selectTitledColor(page, 'Brand color', 'color-picker-toggle'); - - await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 149)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - }); - - test('can switch between background image and color', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - await createHeaderCard({page, version: 2}); - // Choose an image - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.click('[data-testid="color-selector-button"]'); - await page.click('[data-testid="header-background-image-toggle"]'); - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - - // Switch to a color swatch - - await page.click('[data-testid="header-background-color"] button[title="Black"]'); - - await expect(page.locator('[data-kg-card="header"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - - await page.click('[data-testid="color-selector-button"]'); - await page.click('[data-testid="header-background-image-toggle"]'); - - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - - await page.click('[data-testid="color-selector-button"]'); - - await page.click('[data-testid="color-picker-toggle"]'); - - await expect(page.locator('[data-kg-card="header"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - }); - - test('can add and remove background image in split layout', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - const fileChooserPromise = page.waitForEvent('filechooser'); - await createHeaderCard({page, version: 2}); - - await page.click('[data-testid="settings-panel"]'); - await page.waitForSelector('[data-testid="header-layout-split"]'); - await page.locator('[data-testid="header-layout-split"]').click(); - - await expect(page.locator('[data-testid="header-background-image-toggle"]')).toHaveCount(0); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - - await page.click('[data-testid="header-card-container"] [data-testid="media-upload-placeholder"]'); - - // Set files - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-testid="header-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - }); - - test('changes the alignment options from the settings panel', async function () { - await createHeaderCard({page, version: 2}); - - // Default: centre alignment - const header = page.getByTestId('header-heading-editor'); - await expect(header).toHaveClass(/text-center/); - - // Change aligment to left - const alignmentLeft = page.locator('[data-testid="header-alignment-left"]'); - await alignmentLeft.click(); - await expect(header).not.toHaveClass(/text-center/); - }); - - test('keeps focus on previous editor when changing layout opts', async function () { - await createHeaderCard({page, version: 2}); - - // Start editing the header - await page.locator('[data-kg-card="header"] [data-kg="editor"] [contenteditable]').nth(0).fill(''); - await page.keyboard.type('Hello '); - - // Change layout to regular - await page.locator('[data-testid="header-layout-regular"]').click(); - - // Continue editing the header - await page.keyboard.type('world'); - - // Expect header to have 'Hello World' - const header = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); - await expect(header).toHaveText('Hello world'); - }); - - test('keeps focus on previous editor when changing alignment opts', async function () { - await createHeaderCard({page, version: 2}); - - // Start editing the subheader - await page.keyboard.press('Enter'); - await page.locator('[data-kg-card="header"] [data-kg="editor"] [contenteditable]').nth(1).fill(''); - await page.keyboard.type('Hello '); - - // Change alignment to center - await page.locator('[data-testid="header-alignment-center"]').click(); - - // Continue editing the subheader - await page.keyboard.type('world'); - - // Expect subheader to have 'Hello World' - const subheader = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); - await expect(subheader).toHaveText('Hello world'); - }); - - test('can swap split layout sides on image', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - await createHeaderCard({page, version: 2}); - // Mouse position from earlier test can mean a tooltip is covering the split layout button - await page.mouse.move(0, 0); - await page.locator('[data-testid="header-layout-split"]').click(); - await expect(page.locator('[data-testid="header-background-image-toggle"]')).toHaveCount(0); - // Set files - const fileChooserPromise = page.waitForEvent('filechooser'); - await page.click('[data-testid="media-upload-placeholder"]'); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - await expect(page.locator('[data-testid="header-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - // Click swap - await page.click('[data-testid="header-swapped"]'); - // Check the parent class name was updated - const swappedContainer = await page.locator('[data-testid="header-card-content"]'); - await expect(swappedContainer).toHaveClass(/sm:flex-row-reverse/); - }); - test('can import serialized header card nodes with br', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - version: 2, - type: 'header', - size: 'small', - style: 'image', - buttonEnabled: false, - buttonUrl: '', - buttonText: '', - header: 'hello world
    byebye world', - subheader: 'hello sub
    byebye sub', - backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a', - alignment: 'left', - buttonColor: '#ffffff', - buttonTextColor: '#000000', - backgroundColor: 'accent', - textColor: '#ffffff', - swapped: false - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - await page.waitForSelector('[data-kg-card="header"]'); - await page.waitForSelector('[data-kg-card="header"] [data-kg="editor"]'); - await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p span').nth(0)).toHaveText('hello world'); - await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p br').nth(0)).toBeAttached(); - await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p span').nth(1)).toHaveText('byebye world'); - await expect(page.getByTestId('header-subheader-editor').locator('p span').nth(0)).toHaveText('hello sub'); - await expect(page.getByTestId('header-subheader-editor').locator('p br').nth(0)).toBeAttached(); - await expect(page.getByTestId('header-subheader-editor').locator('p span').nth(1)).toHaveText('byebye sub'); - }); - test('can add a shift-enter to header and subheader', async function () { - await createHeaderCard({page, version: 2}); - - await page.keyboard.type('Hello world'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('This is second line'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello subheader'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('This is second subheader'); - await page.keyboard.press('Escape'); - await page.waitForSelector('[data-kg-card-editing="false"]'); - await assertHTML(page, html` -
    -

    Hello world -
    - This is second line -

    -
    `, - {selector: '[data-kg-card="header"] [data-kg="editor"]'}); - await assertHTML(page, html` -
    -

    Hello subheader -
    - This is second subheader -

    -
    `, - {selector: '[data-kg-card="header"] [data-testid="header-subheader-editor"] [data-kg="editor"]'}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/header-card.test.ts b/packages/koenig-lexical/test/e2e/cards/header-card.test.ts new file mode 100644 index 0000000000..7f68ce7fe5 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/header-card.test.ts @@ -0,0 +1,773 @@ +import path from 'path'; +import {assertHTML, focusEditor, html, initialize, isMac} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import {selectCustomColor, selectNamedColor, selectTitledColor} from '../../utils/color-select-helper'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function createHeaderCard({page, version = 1}: {page: Page; version?: number}) { + await focusEditor(page); + if (version === 1) { + await page.keyboard.type('/v1_header'); + await page.waitForSelector('[data-kg-card-menu-item="Header1"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="header"]'); + } + + if (version === 2) { + await page.keyboard.type('/header'); + await page.waitForSelector('[data-kg-card-menu-item="Header"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="header"]'); + } +} + +test.describe('Header card V1', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized header card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + version: 1, + type: 'header', + size: 'small', + style: 'image', + buttonEnabled: false, + buttonUrl: '', + buttonText: '', + header: 'hello world', + subheader: 'hello sub', + backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    hello world

    +
    +
    +
    +
    +
    +
    +

    hello sub

    +
    +
    +
    +
    +
    +
    +
    + `, {}); + }); + + test('renders header card node', async function () { + await createHeaderCard({page, version: 1}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can edit header', async function () { + await createHeaderCard({page}); + + await page.keyboard.type('Hello world'); + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + await expect(firstEditor).toHaveText('Hello world'); + }); + + test('can edit sub header', async function () { + await createHeaderCard({page}); + + await page.keyboard.type('Hello world'); + + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello subheader'); + + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + + await expect(firstEditor).toHaveText('Hello world'); + await expect(secondEditor).toHaveText('Hello subheader'); + }); + + test('can edit sub header via arrow keys', async function () { + await createHeaderCard({page}); + + await page.keyboard.type('Hello'); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('blah blah blah something very long'); + + // Go back up again and add an extra word + await page.keyboard.press('ArrowUp'); + await page.keyboard.type(' world'); + + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + + await expect(firstEditor).toHaveText('Hello world'); + await expect(secondEditor).toHaveText('blah blah blah something very long'); + }); + + test('can add and remove button', async function () { + await createHeaderCard({page}); + + // click on the toggle with data-testid="header-button-toggle" + await page.click('[data-testid="header-button-toggle"]'); + + // check button is visible + await expect(page.getByTestId('header-card-button')).toHaveText('Add button text'); + + // Enter some text for the button in data-testid="header-button-text" + await page.click('[data-testid="header-button-text"]'); + await page.keyboard.type('Click me'); + + // Enter some url for the button in data-testid="header-button-url" + await page.click('[data-testid="header-button-url"]'); + await page.keyboard.type('https://example.com'); + + // check button is visible, and not an
    tag (so not clickable) + // Page contains `` + await expect(page.getByTestId('header-card-button')).toHaveText('Click me'); + + // Can toggle button off again + await page.click('[data-testid="header-button-toggle"]'); + + // check button is not visible by using expect + await expect(page.getByTestId('header-card-button')).toHaveCount(0); + }); + + test('can change the size', async function () { + await createHeaderCard({page}); + + // Default size is small + const smallButton = page.locator('[aria-label="S"]'); + await expect(smallButton).toHaveAttribute('aria-checked', 'true'); + + // Get height of the card + const box = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); + const height = box!.height; + + // Click on the medium button + const mediumButton = page.locator('[aria-label="M"]'); + await mediumButton.click(); + await expect(mediumButton).toHaveAttribute('aria-checked', 'true'); + + // Check that the height has changed + const box2 = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); + const height2 = box2!.height; + + expect(height2).toBeGreaterThan(height); + + // Switch to large + const largeButton = page.locator('[aria-label="L"]'); + await largeButton.click(); + await expect(largeButton).toHaveAttribute('aria-checked', 'true'); + + // Check that the height has changed + const box3 = await page.locator('[data-kg-card="header"] > div:first-child').nth(0).boundingBox(); + const height3 = box3!.height; + + expect(height3).toBeGreaterThan(height2); + }); + + test('can change the background color', async function () { + await createHeaderCard({page}); + + // default background color is black + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-black /); + await selectNamedColor(page, 'light', 'color-options-button'); + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-grey-100 /); + + await selectNamedColor(page, 'dark', 'color-options-button'); + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-black /); + + await selectNamedColor(page, 'accent', 'color-options-button'); + + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveClass(/ bg-accent /); + }); + + test('can add and remove background image', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await createHeaderCard({page}); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.click('[data-testid="color-options-button"]'); + await page.click('[data-testid="background-image-color-button"]'); + // await page.click('[data-testid="background-image-color-button"]'); + + // Set files + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + // Check if it is set as a background image + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); + + // Check if it is also set as an image in the panel + await expect(page.getByTestId('image-picker-background')).toHaveAttribute('src', /blob:/); + }); + + test('can select the text by dragging and replace it', async function () { + await createHeaderCard({page}); + + await page.keyboard.type('HelloHello'); + + // Get locator to 'Hello Hello' span + const helloSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] span').nth(0); + + // Get the bounding box of the span + const box = await helloSpan.boundingBox(); + const y = box!.y + box!.height / 2; + const startX = box!.x + box!.width / 2; + const endX = box!.x + box!.width; + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y); + await page.mouse.up(); + + await page.keyboard.type(' world'); + await expect(page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0)).toHaveText('Hello world'); + }); + + test('can select the text by dragging and bold it', async function () { + await createHeaderCard({page}); + + await page.keyboard.type('HelloHello'); + + // Get locator to 'Hello Hello' span + const helloSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] span').nth(0); + + // Get the bounding box of the span + const box = await helloSpan.boundingBox(); + const y = box!.y + box!.height / 2; + const startX = box!.x + box!.width / 2; + const endX = box!.x + box!.width; + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y); + await page.mouse.up(); + + // click data-kg-toolbar-button="bold" + await page.locator('[data-kg-toolbar-button="bold"]').click(); + + // check it is now bold + const boldSpan = page.locator('[data-kg-card="header"] [data-kg="editor"] strong').nth(0); + await expect(boldSpan).toHaveText('Hello'); + await expect(helloSpan).toHaveText('Hello'); + + // check if text is still selected by continuing typing + await page.keyboard.type(' world'); + + // check the typed text is still bold + await expect(boldSpan).toHaveText(' world'); + await expect(helloSpan).toHaveText('Hello'); + }); + + test('keeps focus on previous editor when changing size opts', async function () { + await createHeaderCard({page}); + + // Start editing the header + await page.keyboard.type('Hello '); + + // Change size to medium + await page.getByLabel('M').click(); + + // Continue editing the subheader + await page.keyboard.type('world'); + + // Expect header to have 'Hello World' + const header = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + await expect(header).toHaveText('Hello world'); + }); + + test('can undo/redo without losing nested editor content', async () => { + await createHeaderCard({page}); + + await page.keyboard.type('Test title'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Test description'); + // Exit card edit mode, then use Enter+Backspace×2 to delete so undo + // has a proper history entry. Direct Escape→Backspace doesn't create a + // main editor content update between card insertion and deletion, so the + // two operations merge in the undo history (known Lexical limitation with + // decorator nodes whose nested editors don't create main editor updates). + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + // verify the card is restored and selected after undo + await expect(page.locator('[data-kg-card="header"]')).toBeVisible(); + await expect(page.locator('[data-kg-card="header"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + // verify the nested editor content is preserved + const headerEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + await expect(headerEditor).toContainText('Test title'); + const subheaderEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + await expect(subheaderEditor).toContainText('Test description'); + }); +}); + +test.describe('Header card V2', () => { + // const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized header card v2 nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + version: 2, + type: 'header', + size: 'small', + style: 'image', + buttonEnabled: false, + buttonUrl: '', + buttonText: '', + header: 'hello world', + subheader: 'hello sub', + backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a', + alignment: 'left', + buttonColor: '#ffffff', + buttonTextColor: '#000000', + backgroundColor: 'accent', + textColor: '#ffffff', + swapped: false + + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + await page.waitForSelector('[data-kg-card="header"]'); + await page.waitForSelector('[data-kg-card="header"] [data-kg="editor"]'); + await expect(page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0)).toHaveText('hello world'); + }); + + test('renders header card v2 node', async function () { + await createHeaderCard({page, version: 2}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can edit header v2', async function () { + await createHeaderCard({page, version: 2}); + + await page.keyboard.type('Hello world'); + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + await expect(firstEditor).toHaveText('Hello world'); + }); + + test('can edit sub header v2', async function () { + await createHeaderCard({page, version: 2}); + + await page.keyboard.type('Hello world'); + + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello subheader'); + + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + + await expect(firstEditor).toHaveText('Hello world'); + await expect(secondEditor).toHaveText('Hello subheader'); + }); + + test('can edit sub header via arrow keys v2', async function () { + await createHeaderCard({page, version: 2}); + + await page.keyboard.type('Hello'); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('blah blah blah something very long'); + + // Go back up again and add an extra word + await page.keyboard.press('ArrowUp'); + await page.keyboard.type(' world'); + + const firstEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + const secondEditor = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + + await expect(firstEditor).toHaveText('Hello world'); + await expect(secondEditor).toHaveText('blah blah blah something very long'); + }); + + test('can add and remove button v2', async function () { + await createHeaderCard({page, version: 2}); + + // click on the toggle with data-testid="header-button-toggle" + await page.click('[data-testid="header-button-toggle"]'); + + // check button is visible + await expect(page.getByTestId('header-card-button')).toHaveText('Add button text'); + + // Enter some text for the button in data-testid="header-button-text" + await page.click('[data-testid="header-button-text"]'); + await page.keyboard.type('Click me'); + + // Enter some url for the button in data-testid="header-button-url" + await page.click('[data-testid="header-button-url"]'); + await page.keyboard.type('https://example.com'); + + // check button is visible, and not an
    tag (so not clickable) + // Page contains `` + await expect(page.getByTestId('header-card-button')).toHaveText('Click me'); + + // Can toggle button off again + await page.click('[data-testid="header-button-toggle"]'); + + // check button is not visible by using expect + await expect(page.getByTestId('header-card-button')).toHaveCount(0); + }); + + test('can change the button background color and text color', async function () { + await createHeaderCard({page, version: 2}); + + await page.click('[data-testid="header-button-toggle"]'); + + await page.click('[data-testid="header-button-color"] [data-testid="color-selector-button"]'); + + await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); + + await page.click('[data-testid="settings-panel"]'); + + // // // Selected colour should be applied inline + await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await page.click('[data-testid="header-button-color"] [data-testid="color-selector-button"]'); + await selectCustomColor(page, '#f7f7f7', undefined); + + await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('background-color', 'rgb(247, 247, 247)'); + await expect(page.locator('[data-testid="header-card-button"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); + }); + + test('can change the background color and text color', async function () { + await createHeaderCard({page, version: 2}); + + await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); + + await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); + + await page.click('[data-testid="settings-panel"]'); + + // Selected colour should be applied inline + const container = page.getByTestId('header-card-container'); + await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); + await selectCustomColor(page, '#f7f7f7', undefined); + + await expect(container).toHaveCSS('background-color', 'rgb(247, 247, 247)'); + await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); + }); + + test('can change to grey, black, brand background color', async function () { + await createHeaderCard({page, version: 2}); + + await page.click('[data-testid="header-background-color"] [data-testid="color-selector-button"]'); + + await selectTitledColor(page, 'Grey', 'color-picker-toggle'); + + const container = page.getByTestId('header-card-container'); + await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); + await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); + + await selectTitledColor(page, 'Black', 'color-picker-toggle'); + + await expect(container).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await selectTitledColor(page, 'Brand color', 'color-picker-toggle'); + + await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 149)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + }); + + test('can switch between background image and color', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + await createHeaderCard({page, version: 2}); + // Choose an image + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.click('[data-testid="color-selector-button"]'); + await page.click('[data-testid="header-background-image-toggle"]'); + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + + // Switch to a color swatch + + await page.click('[data-testid="header-background-color"] button[title="Black"]'); + + await expect(page.locator('[data-kg-card="header"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + + await page.click('[data-testid="color-selector-button"]'); + await page.click('[data-testid="header-background-image-toggle"]'); + + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + + await page.click('[data-testid="color-selector-button"]'); + + await page.click('[data-testid="color-picker-toggle"]'); + + await expect(page.locator('[data-kg-card="header"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="header"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + }); + + test('can add and remove background image in split layout', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + const fileChooserPromise = page.waitForEvent('filechooser'); + await createHeaderCard({page, version: 2}); + + await page.click('[data-testid="settings-panel"]'); + await page.waitForSelector('[data-testid="header-layout-split"]'); + await page.locator('[data-testid="header-layout-split"]').click(); + + await expect(page.locator('[data-testid="header-background-image-toggle"]')).toHaveCount(0); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + + await page.click('[data-testid="header-card-container"] [data-testid="media-upload-placeholder"]'); + + // Set files + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-testid="header-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + }); + + test('changes the alignment options from the settings panel', async function () { + await createHeaderCard({page, version: 2}); + + // Default: centre alignment + const header = page.getByTestId('header-heading-editor'); + await expect(header).toHaveClass(/text-center/); + + // Change aligment to left + const alignmentLeft = page.locator('[data-testid="header-alignment-left"]'); + await alignmentLeft.click(); + await expect(header).not.toHaveClass(/text-center/); + }); + + test('keeps focus on previous editor when changing layout opts', async function () { + await createHeaderCard({page, version: 2}); + + // Start editing the header + await page.locator('[data-kg-card="header"] [data-kg="editor"] [contenteditable]').nth(0).fill(''); + await page.keyboard.type('Hello '); + + // Change layout to regular + await page.locator('[data-testid="header-layout-regular"]').click(); + + // Continue editing the header + await page.keyboard.type('world'); + + // Expect header to have 'Hello World' + const header = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(0); + await expect(header).toHaveText('Hello world'); + }); + + test('keeps focus on previous editor when changing alignment opts', async function () { + await createHeaderCard({page, version: 2}); + + // Start editing the subheader + await page.keyboard.press('Enter'); + await page.locator('[data-kg-card="header"] [data-kg="editor"] [contenteditable]').nth(1).fill(''); + await page.keyboard.type('Hello '); + + // Change alignment to center + await page.locator('[data-testid="header-alignment-center"]').click(); + + // Continue editing the subheader + await page.keyboard.type('world'); + + // Expect subheader to have 'Hello World' + const subheader = page.locator('[data-kg-card="header"] [data-kg="editor"]').nth(1); + await expect(subheader).toHaveText('Hello world'); + }); + + test('can swap split layout sides on image', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + await createHeaderCard({page, version: 2}); + // Mouse position from earlier test can mean a tooltip is covering the split layout button + await page.mouse.move(0, 0); + await page.locator('[data-testid="header-layout-split"]').click(); + await expect(page.locator('[data-testid="header-background-image-toggle"]')).toHaveCount(0); + // Set files + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.click('[data-testid="media-upload-placeholder"]'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + await expect(page.locator('[data-testid="header-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + // Click swap + await page.click('[data-testid="header-swapped"]'); + // Check the parent class name was updated + const swappedContainer = await page.locator('[data-testid="header-card-content"]'); + await expect(swappedContainer).toHaveClass(/sm:flex-row-reverse/); + }); + test('can import serialized header card nodes with br', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + version: 2, + type: 'header', + size: 'small', + style: 'image', + buttonEnabled: false, + buttonUrl: '', + buttonText: '', + header: 'hello world
    byebye world', + subheader: 'hello sub
    byebye sub', + backgroundImageSrc: 'blob:http://localhost:5173/fa0956a8-5fb4-4732-9368-18f9d6d8d25a', + alignment: 'left', + buttonColor: '#ffffff', + buttonTextColor: '#000000', + backgroundColor: 'accent', + textColor: '#ffffff', + swapped: false + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + await page.waitForSelector('[data-kg-card="header"]'); + await page.waitForSelector('[data-kg-card="header"] [data-kg="editor"]'); + await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p span').nth(0)).toHaveText('hello world'); + await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p br').nth(0)).toBeAttached(); + await expect(page.locator('[data-kg-card="header"] [data-kg="editor"] p span').nth(1)).toHaveText('byebye world'); + await expect(page.getByTestId('header-subheader-editor').locator('p span').nth(0)).toHaveText('hello sub'); + await expect(page.getByTestId('header-subheader-editor').locator('p br').nth(0)).toBeAttached(); + await expect(page.getByTestId('header-subheader-editor').locator('p span').nth(1)).toHaveText('byebye sub'); + }); + test('can add a shift-enter to header and subheader', async function () { + await createHeaderCard({page, version: 2}); + + await page.keyboard.type('Hello world'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('This is second line'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello subheader'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('This is second subheader'); + await page.keyboard.press('Escape'); + await page.waitForSelector('[data-kg-card-editing="false"]'); + await assertHTML(page, html` +
    +

    Hello world +
    + This is second line +

    +
    `, + {selector: '[data-kg-card="header"] [data-kg="editor"]'}); + await assertHTML(page, html` +
    +

    Hello subheader +
    + This is second subheader +

    +
    `, + {selector: '[data-kg-card="header"] [data-testid="header-subheader-editor"] [data-kg="editor"]'}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/html-card.test.js b/packages/koenig-lexical/test/e2e/cards/html-card.test.js deleted file mode 100644 index 05859dab45..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/html-card.test.js +++ /dev/null @@ -1,205 +0,0 @@ -import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Html card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized html card nodes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'html', - html: '

    test content

    ' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -

    test content

    -
    -
    -
    -
    - `, {ignoreCardContents: false}); - }); - - test('renders without style elements and attributes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'html', - html: '
    Loading...
    ' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -
    Loading...
    -
    -
    -
    -
    - `, {ignoreCardContents: false, ignoreInlineStyles: false}); - }); - - test('renders html card node from slash entry', async function () { - await focusEditor(page); - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can add snippet', async function () { - await focusEditor(page); - // insert new card - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - - // fill card - await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); - // waiting for html editor - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - await page.locator('[data-kg-card="html"]').click(); - await page.keyboard.type('text in html card'); - await expect(page.getByText('text in html card')).toBeVisible(); - await page.keyboard.press('Escape'); - - // create snippet - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="html"]')).toHaveCount(2); - }); - - test('can undo/redo content in html editor', async function () { - await focusEditor(page); - // insert new card - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); - // waiting for html editor - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - - await page.keyboard.type('Here are some words', {delay: 20}); - await expect(page.getByText('Here are some words')).toBeVisible(); - // CodeMirror groups changes within 500ms into a single undo transaction, - // wait to ensure backspace is a separate undo group from the typing - await page.waitForTimeout(600); - await page.keyboard.press('Backspace'); - await expect(page.getByText('Here are some word')).toBeVisible(); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - await expect(page.getByText('Here are some words')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByText('Here are some words')).toBeVisible(); - }); - - test('goes into display mode when losing focus', async function () { - await focusEditor(page); - // insert new card - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); - // waiting for html editor - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - - await page.keyboard.type('Here are some words'); - await page.getByTestId('post-title').click(); - await page.keyboard.type('post title'); // click outside of the editor - - await assertHTML(page, html` -
    -
    -
    -
    -
    - Here are some words -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('has working visibility icon in toolbar', async function () { - await focusEditor(page); - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - await page.keyboard.type('Testing'); - await page.keyboard.press('Meta+Enter'); - - await expect(page.locator('[data-kg-card-toolbar="html"]')).toBeVisible(); - await expect(page.locator('[data-kg-card-toolbar="html"] [data-testid="show-visibility"]')).toBeVisible(); - - await page.click('[data-testid="show-visibility"]'); - - await expect(page.locator('[data-testid="tab-contents-visibility"]')).toBeVisible(); - }); - - test('does not show visibility settings panel by default in edit mode', async function () { - await focusEditor(page); - await page.keyboard.type('/html'); - await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-testid="tab-contents-visibility"]')).not.toBeVisible(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/html-card.test.ts b/packages/koenig-lexical/test/e2e/cards/html-card.test.ts new file mode 100644 index 0000000000..c479fa2a58 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/html-card.test.ts @@ -0,0 +1,206 @@ +import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Html card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized html card nodes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'html', + html: '

    test content

    ' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +

    test content

    +
    +
    +
    +
    + `, {ignoreCardContents: false}); + }); + + test('renders without style elements and attributes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'html', + html: '
    Loading...
    ' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +
    Loading...
    +
    +
    +
    +
    + `, {ignoreCardContents: false, ignoreInlineStyles: false}); + }); + + test('renders html card node from slash entry', async function () { + await focusEditor(page); + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can add snippet', async function () { + await focusEditor(page); + // insert new card + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + + // fill card + await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); + // waiting for html editor + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + await page.locator('[data-kg-card="html"]').click(); + await page.keyboard.type('text in html card'); + await expect(page.getByText('text in html card')).toBeVisible(); + await page.keyboard.press('Escape'); + + // create snippet + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="html"]')).toHaveCount(2); + }); + + test('can undo/redo content in html editor', async function () { + await focusEditor(page); + // insert new card + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); + // waiting for html editor + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + + await page.keyboard.type('Here are some words', {delay: 20}); + await expect(page.getByText('Here are some words')).toBeVisible(); + // CodeMirror groups changes within 500ms into a single undo transaction, + // wait to ensure backspace is a separate undo group from the typing + await page.waitForTimeout(600); + await page.keyboard.press('Backspace'); + await expect(page.getByText('Here are some word')).toBeVisible(); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + await expect(page.getByText('Here are some words')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Here are some words')).toBeVisible(); + }); + + test('goes into display mode when losing focus', async function () { + await focusEditor(page); + // insert new card + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="html"][data-kg-card-editing="true"]')).toBeVisible(); + // waiting for html editor + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + + await page.keyboard.type('Here are some words'); + await page.getByTestId('post-title').click(); + await page.keyboard.type('post title'); // click outside of the editor + + await assertHTML(page, html` +
    +
    +
    +
    +
    + Here are some words +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('has working visibility icon in toolbar', async function () { + await focusEditor(page); + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + await page.keyboard.type('Testing'); + await page.keyboard.press('Meta+Enter'); + + await expect(page.locator('[data-kg-card-toolbar="html"]')).toBeVisible(); + await expect(page.locator('[data-kg-card-toolbar="html"] [data-testid="show-visibility"]')).toBeVisible(); + + await page.click('[data-testid="show-visibility"]'); + + await expect(page.locator('[data-testid="tab-contents-visibility"]')).toBeVisible(); + }); + + test('does not show visibility settings panel by default in edit mode', async function () { + await focusEditor(page); + await page.keyboard.type('/html'); + await page.waitForSelector('[data-kg-card-menu-item="HTML"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-testid="tab-contents-visibility"]')).not.toBeVisible(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/image-card.test.js b/packages/koenig-lexical/test/e2e/cards/image-card.test.js deleted file mode 100644 index 2fa7e98677..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/image-card.test.js +++ /dev/null @@ -1,1557 +0,0 @@ -import fs from 'fs-extra'; -import path from 'path'; -import { - assertHTML, - assertRootChildren, - createDataTransfer, - createSnippet, - ctrlOrCmd, - dragMouse, - enterUntilScrolled, - expectUnchangedScrollPosition, - focusEditor, - getEditorStateJSON, - html, - initialize, - insertCard, - isMac, - pasteHtml, - pasteText -} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Image card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized image card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'image', - src: '/content/images/2022/11/koenig-lexical.jpg', - width: 3840, - height: 2160, - title: 'This is a title', - altText: 'This is some alt text', - caption: 'This is a caption', - cardWidth: 'wide' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - This is a - caption -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardContents: false}); - }); - - test('can upload image with `data:` url', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'image', - src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPgAAAD4CAYAAADB0SsLAAAAAXNSR0IArs4c6QAAIABJREFUeF7snQeYVOX1xs+d2Qos2zu9d+lNQVABe+xGjV0xiS35x8RYY4vRaDTGFo1RjAVbYgMLKtgFUVCQ3sv2ZZdlaQvM3P/zO3e+2dllgd1lKTN77/MguDtz58537/udc97znnMscY+GroC3jjf46nuSlJSU1tHR0XF+f3RrEbuV7fUliXhbevyS6hd/nEes1uKR1rYtiZZtxYllJ/hFWlgiLcSWONuyY5x/WzG2JXF8rv6/c/D/MSKyI/B3bD2vqyrkPbx3O++zRbbq+W3ZLpa9g/+3bGuHWLKdf3v4vW1V2pa93bKkQvyyyS/2Jstjl9ni3Sbi22L5vBt90faGaJ+vKjo6unL9+vXb6nlNvGy/1roBnxOxL7Ui9pvt/xer/XDtFcRt2rSJ3+r3p3l3Wqm2V3LF78+1LE+GiJ1ui7S3bEkRy06yxWptiaSI2C1ELK9lObfA/L2ny7Zte4/faG+/259l2Ns17e135nqqr8v2iVhbbZEyS+xNYlsbbYt/yxoRq8S2/cXi8eRZPsljM2jh8ZTWYyNo0P3Zn3UI5/e6AHfuXr0eFqyvxMS08Yi3oyV2R9sv7cSSbpYtbW1LUh3gSgIPfygAagMw9P95nW3797J5WLucS7Sj9vSgWZZnN0vXUNDXBdimuK7a3zX0O5jPrLUhVLIRiNiFHrFW+m1ZL2IvF4+91C9SLDt2rC8rK9u0h7Wo130MZ8A29NqbK8BDH4TdwIU1rqqSXNtrd/f47Z62SB/bsntYYnUSsVMMoBxwVltW/r1vwDq3KBSUDQVjQ2/yoXp9zU2urk2Mzat642JNzBqaaw5dY2fDscrElsUistIS+cnvsRZZPmtJScn6VSJS10a513t9qNbmYH1ucwH4Xm9ySk5OW4/f6ucRe5jfln4i0t8Suw0PXN1WBpdz98M8oAfr5kXi5+x9g9w9pGFDsG27yhYpFJEfLLG+sy2Z6/fY88ry89fVsUbNCvCRDPA93sj09DZdLMseKSJH2mIPFkt6W5YVW9NaYJk1dqzhIrsgPnTbSk3wh1r/msAPgL5SbFkG4EXkK9u2vi4pWb+81tVHPNgjDeB13rDMzMwMv2WNtvzecbblHyEivYx1DjwMIWB2XEYXyIcOyA3/5FCPygDfAb3ZtAN8wkIRa4ZtWdO89q7ZRUVFxSGfFZFgjxSAm5sTdJ2x0uL1Hy+2nCoiwy3LUvKrLkC7YG44pA73d+zu6tcGvF0pIjPFknfE5/mglnXf7Xk63L/vHgnYcL3wEOa7Bqgty3+aLXKqWPZIj8erpI1DYhl3245yAR3Gd72Rl14N+N0tvN/vrxJbvhWPvBZpYA9HC15jdyV1FRUVf5pt2b8QsY/ZHdRBPrYu0UQjHxf3beG/AjXc+qA7r2AX+dyyrRd37dr2VkhKLiytejgBnAUOWuvUrKyhXr/3ctuyT/d4POk1LbUbQ4c/AA/eN6hF3oVmTkos23rTtuXJkpK8H2rF6/VWLx68b1JHZudQfng9PzsU2N70rKzTxe+5TiwZ5fF4xO/3B9xvF9T1XE/3ZXtZgdpgr37G5COx/E+XFBa+EU5AP5wteA1gZ2TkXmCLfbPlsbqbBSb/6QglLNf9dmF7AFYgmCbVFCqHbfvn27Y8FhfjfSFETlvDuzwAF9LoUx6OAN8rsI180iXKGn3P3Tc2cAVCrbpJr/r8/jVi2/eWFhc8HTjdYRmjH04Ar7FA6VlZZ4nfc4+x2NW6aNdaN/D5dF/epCvgWHUjjPL7ffPFsu8Kcd0PK6AfLgAPWm3IM4/f86DlsUYFXKIAmeECu0mfU/dk+7kC1UDX59Rvf+H3+G/YUFj4bYhFP+RE3KEGeHC3C6S7HrAte2JtomM/74T7dncFDuAKOOm2kJDxH76d228LpNcOuTU/lAAPWu20rNyTxLYf93o87Z10l1YNucTZAXws3VM39QrYPhOf+/3+EtuyLi0tzJt6qK35oQK4Abc3IyP3CfHIROOOu+RZUz947vkO1goYzzNYCuyXp4uL834d0G8cEqb9UABcv2h6em5/sexXPF5Pd7/f58bZB+spdD/nIKyA47ajqvT7/EvEtn4eEMocdJAfTIAHv1xGRu5FtmU/Hyj+QBpY395hB+HmuB/hrkCTrUAVbLs2sbCti4uL8/5zsF32gwXwILjTM3MesSzrupACEDfWbrLnyT3R4bcCyrar/NW27X+UFOVffzBBfjAAXk2mZea85fV4fua65IffY+he0YFcgWqX3ef3v11alH/awQL5gQa4gpsUmCc6dprX4x0GuF0i7UA+TO65D8cVMAQccbnP75vl31k1PiSVdsDy5QcS4EFwe6Njv/R4vH0DpXhuvH04PoHuNR2sFajyeDyxKOB8O6uOOtAgP1AArwFuy/L0dQpDXDLtYD1F7ucc1isQIN/8BxzkBwLgITF39kzHLdcietdyH9bPnHtxB3kF1JLjrpcWFQw/UDH5gQC4XmtakFBzwX2QHxz348JnBQIgr0G8NenVNzXAHRFLZs4jHo/nOoctdyWnTXrH3JNF2ArYPhXE+P0mhdakYpimBLheWFpG9kSv1/uUC+4Iew7dr3MAV8ABuc/nuypQX95kIG8qgOsFaamn7ZnlrIST4D+Aq+Ke2l2BCFmBaqz4Lf+wQMlpk4C8KQCuF8I8r+07/Ys8ltXerQaLkOfO/RoHcQWcajS/ba+Ji/b0DLSD2m+QNxnA0zNyJnm8notdxvwgPhPuR0XaCjg5cp//+ZLi/EsCvf/3SwSzvwB3SLWsrLMs8b7uWu5Ie97c73PwV8Cx5Lb4zg60gdovK74/AA8Rs8Qttywr3QX4wX8c3E+MtBUIxuMlvp3bu+yv0m2/AZ6RkfuU5bUmuqx5pD1o7vc5dCsQSJ01gaveWIA7KbGsrDEe8c7AcrsFJIfucXA/ORJXIEC6iW9saWHhp42NxxsLcF3RtMzsmR7LM8x1zSPxAXO/06FdAcOq+0OlrA2+pMYAXK03XVksr/W865o3eM3dN7grUN8VUFbd9tmmG0yDCbfGAFzcnHd974/7OncF9mcF6syNN+iEDQV4IC2Wc43H8jzq5rwbtNbui90VaMQKOISb7fP/vrg4/8GGxuINBbhrvRtxi9y3uCvQ+BWoM21W79M1BOCu9a73srovdFegSVcgEIs33Io3BOCu9W7Se+aezF2B+q5AdSxeWpTfOTBIoV5vri/Ag8y5eOR5Ny1Wr7XdrxeZedTmb3Myj8cSv9+ucW6nBTWzq2v+3O/313kNDLV3j3BbAQfk4pcGMer1Bbiuhpv3btqHAqBFRXnF6/GKx1sNOr/PLz6/T3bu3CU+n0927XLqDXbs3Kl/x0RH69+815wjOipaz+H1Vlfohv6b84Qe/D+fw8Fn8Rl72hCa9lu7Z2vcCjQuL14fgO+mWnPrvBt+i7DE0dFREh0drSAEYFu2bJWSovw9nswT3VKSkxKkZYsWQfCa927fvl127Ngpm7dslW2bNzb8gkLewedkZqRIixYt9Kc7d+yUnbt2BjeW/Tq5++YmXIGGq9vqDfDqclC3DdOe7pixgDEx0RIb6/SYNFZye1WVFBfm1XhrZnYb6dOnt7TJzZW0tDSJi4sLbAIxEh8fL61atdSfxcbESExMjERFRanF5nN27dqlAOTvLVu3qLXfsaNKdgasvH623x98rXmPz+fX12zbtk02bdokFRUVUlxSIjNnfS/+nVuC1xffKklSkhN1Q+KoqqrSDcV175sQrw0+VcM16vsCuFrvzMzMDL94l4tIQmAES4MvLZLfYKwzYOTILyjazaqmpmfJ0KFDpFfPntKuXVtp2bKlgjY+Ll5aJyZIQkKC4y77fLKjaofs8u2SXTt3BUEa6j47bnmUREVHSZQ3SmJiYwQXnb/5/+iYaAUirnxtNx2AY/23V21XD2L79m2yVf+ukqodVVJZWSlLly6T7+fMka+//Dx427j+pKRE2bp1q2vZD9HDHIK9Sp9Xepfl56/bV168XgBPz3KFLbXvKYvt9XoUWMS+RcWlQVB37tpdBgwYINlZWdKtW1fp0qWLWmMOABcVFS30owRogEoBHbDIdT07Jj6P8joxd7Vlro6r/YxUV6tdk2iDlIuKjhbey6bAplLbCnM+s2FwDq6Hg+tbsHChLFiwUKZMfU82lBRKemaOtGzZQrZt2+7G7IcG6E5TCNt/bUlh/mP7C3D9CukZOZ9bHmtUc2fPDRAAKw9/3ro1wVs8eOhwOfGE46Vdu3aSm5srHTq0F9tvS3l5ub4Wgmz7tm1qKSs3V6qLjKvMOXGvbbua8bYsyDKPxMTESizWGYBGRWksHuqqY8E9lsexyNtxobHCzmbheAO46I4bb0DN5gIXEB8XFwA1m03163gfn4U3EhcfJ61atpLomBhZtXKlLFq8WP71zL/1eycmp0tycqLruh90kDeMbNubBa+e4+2x5/I9mrN7zndPSGgllZWbg7H0seMmyJEjR8iQwYMlNS1VSaotm7fI+vXrpbCoSOPbiopNUlZWJpWbN8uWLVsUTFhkJdtgz7GwUbjSzs+I3fkDkM0B2OLj4xR0rVu3lsTWrSUpOUk/j1gdQDrW26/WF4Js8+YtsqmiQrZs3aqbCSAH8GwC/JtNhM2Dz+IcfB7eCIdDsDmbhG4sLVsqR8DrKjZuVPf9n089LatXLpeMrFxp0SJe18WNzw882mtg0G8N2Nfc8X0DPDP3Vo/Hurs56s4BjCHMeIBxUXmgr7zicunTu7d07NhBwVBcXCJr1qyVVatW6b83VW6SHTscN1eBEx0t0cTMASB7vKTGPAoIA2pe57zWIdI4kpOTJSM9Q917YnRvlFe2b9uucbDj3jvWmtfruaKiFfiJia31s0ijcUDG7dyxQ99n3gvwibfZhPie0dEOsccfgA+wTShgHls8iIyMDElMSlRAz5gxQx7++6NSUV4ihCWw75zXBfoBB3pgvpl9W0lR3j17c9P3FYPjnn9veayBzdE9J9Zcu65A2eXctu3l17/6lfTr20datWolpaUb5Icff5QlS5eqGx4KZmOZzW22PB611sZKExPHxkRLfLzjcmOd+RsLy2tSUlI0fsdiAxbY7vKNG4NWGLfbEzifYdk5p8eyguDCuvOHmJ8NBYDv8vk0FudnbEyGzINkKyktVU/DWG6uJyGhtYI9NO7n3wA9KztbkpOSZN26dfLGf/8nzz37jH5dgI734ObUDyTIA33b/PackuL8QXv7pD0BfDf3/EBe7uF2bh5irOaKZUukW49ectGFv5BhQ4eq1VyxcqV89/0cWb9uvcS3iNcUVl0HLrBxv2G11fUOgJqNIzY2LsRS2uo6JyYmSU52lgITS0taCsA55Fh0MCY2oFW3vJaAhZ+pOIY/O520FgDnOg3QjRVnU4BpDyXZNlVsUrATTmDh8UQIC0yO3HxXrimhVYLktsnRjWjRwkXy4suT5f2p7+pmyIa2bfv2w+3WRt717MNN3zvAg+5588h986DHxcWqS1tUsF4uv/IqOfnEEyQuPl5mz54tP/44TzZsKJNWCa3UHUYBVjt9pZY6yqvAMe437jWgNjEz5BlWGJKNVBVWu2OHDpKTm6OkGeC0/X7B8uMys1EQR28s36jWcdv26pia89S2lsFQIJBOM+47sXJiYqICkoNzOhuJE06Y9FvrxNZq5eEPNsM5lBTLxo0VQfcd78F4EYQUqSkp0r5De/UIpn30kdx+2616vg6duijn4LrtB2JfMSOP9u6m79VFb07sOSCBRIM44nj0scc1xbVgwSL54osvNW8MYHGDAaCRfpo8s6ahAhbRIa7ipFWrBAVU69YJCg4+A8EI1tGAKicnRwYOHKDubsWmTWo1yX8jjIEk42eOy1udEgNgtY/qNJnDxu/NRYY0SyG+z8hQr8B83o6qKv1uRpzDRmYsN95AXn6+hiYchivgc9gY+M5t27aRzMxMKSkukZcnv1LDbSdmr62VPxCPffM5Z9BN/6KkOH/0nr53XQB32iHn5LT1+mQZY38jnT3nIUXEgUs+8qjR8tvrr1eCavqMT1X0wUNOzFpbz62uMoISCLIAA058npycJKmpqUqM8XvADEhxw/lZUmKipKSmSm5OjrrfxNhFRUUaAhi2nXgcy20+w8TchlAzfxsizKTbTFoM76Cqaof4fLvE643ScMAh0GKDlhqRDd+bDACH6tF9Pv2uhsxTryY+XuICbLvf9ktRYZEUFhWrBwDQDci5Jr5bx04dlSD8Ye6P8ujjj6tgpl37Tpo9wGNxj/1fgRBMVvm80nVPopc9Apyea82hciwU3CecdIpcd+01Crb/vfmWE5t6vcpWG6tmCjoct9dhxnF9AXZ6epqkp2dI64RWegdxTXGBAWlWZpakpaVqTA3ISTvh+q5bu05BTS7cWGGT9wb8hAIqQolyhCpGtMLnc17ceJWyBlRsANCAntfExToEHmGAE587RB4HuXlfoMgkuFHFROtGppLWXWwSVeqqo3gjfMDic/3wAxs2bJDVq9dIVZUTa/PQOW5+tHIJ3bp30/dPm/aR/OH3NwTddjYf8vYu276/QN93hdkeAd4ctOeh4D7/ggvlqqsmypIlS+Sdd6cosI0rXhvcxh2HTYaAAthZWVman3a04Vv1wcYVTk9LU0CgWCstKdWNICU1RQyZFQpGgGrUZgbMWPHahwFaYutEDSvMAShhtfPz81Ukw4aSlZ2l1pffQdSxQThx8Rap2o5L7shhnX/7dLPgGonTzUbA+QkZKjZWqNXm9ckpyeoRsEGsWbNG3Xe8FN5jvAu+d69ePTVGX7lylbz40svqtqNzz8xIc635/uJb9q1N3yPJlp6Rs8DyWN0jNT0WGnNPOP4kufXWm2TJkqUy+ZVXJaFVqxpWm/tgLDcAwM0FBLjhbdrkaq4a60u8bIBNTI1riwUvLCyUkpIS6dmjh7Rt11ZjVF6rsWxUtBJ7qlCLdgQrRoNu5Kuc28T3fG5C6wR9TcWmCgXVsmXL5MuvvpYvvvhKc9K1D3Tko0eNkgEDjqBph4KYECEzM0NiomOcghSfI2wxrjp/szGQAeAanRRfjG4M5WXl+tmo3HDFyTiY61iflxfMzRvVHLF5506ddS2++PJLeeDBvynXAQnnFrHsD8r33QiiNsCbhXqNBxrru3bNSo2577/vXsnPy5cn//m0PrAQasZqh4Ibqwb4Ic4Adm5OrrrG5MEhzrBoWHHiZYCwceNGKSoqVot21FFHKpFWUFCgMTnniomNVU8BtxoG2shNd6oufZeCns9r0bKFxMXF62vXrl0na9aukbVr18rHn0yXpYsXBp8QgMxmgYsfWuuNOx1alkp56ITxx0jHDu2VCMT76Nqls4pkOHD9Vaoa6yjoALbRwxtSceuWLQKYidVNyMF7SjeUyooVK9V9xxVnLYj/8WRy2+TqGnHtU6a8J4/+4+8qHGIjcZn2hgO9Pqq2OgGelpE90ev1PhWpPc+Nwqsgb528/8GHSmY9+tgTCkTVhwcaIRhwO1Y7ThVi6enp0r59O7XeaMvLAiIXI/eEKIM0w1oRuyJaGTliuMTGxcqG0g1BS6xu73ZUads07QXoDVnVskVLSUlN1jsO8BG5kKJbuGiRLFq0KMj0A+jU1BSNmTmX4+7bNdhqJy52YmNTi15bR0/eOjs7Wy1yTk62dOzYUb8jmxWeAyQj35fvrym/Fi0VtBzFxcVSumGDXifhCn/YDPh5QYHjufDd+Hw2uMysTD0fG9g338yUe/9yn3AfEMhs2lTZ8Ke82b8j4KbvofikToBnZOa8bHk850UiwHngIcVwEZ99bpL069dXHn/8SdWOq8Jrh9M1xbjkuKdYMdhmLF2HDh20xJOHmnjUFIMQ4/L/mzdXKnsNyHFNsdzEzFh4rD0xK6kwfo8QBHKNgzw51jopKUk3h9Vr1qj8ddpHH8sPc2YHH2NT5MEPjHur11uPNkwmdWZIO2PpuY4NZRU16sFDcdO7b3/JysqUtNQ0BT5xdXZ2ln4vYvG2bdvWICNx67H+u3bt1O9ZUrpBCvLzdTNjDYjJsfaEJDDyTz71tEx55y112d0qtYbuWAGizbYnFxfln19btlonyRbJ8TdAAtz3/PleOe1nP5P33n9f3nvvAxWv1Aa3IZxw23nA27Zpo6uP0gtLaayYEazAJgPYioqN0r17dxk16ih9PRYTK4tQBWtMqsgIRUgrEcM6ktRKmfvDD7Js2XJ583+v63sBdGLrBBXVmPi4qWWgDinmPApmo0CwwwGTvnXr9t1ie6rn+vbpo+EKazNixAjp07uXEnWVmyr1ekkdtmjZUj0BNjjCFdaOnDqegdnYsPAvvviSPPH4Yy7IG4pvCebDl5QU5/eu3ZAxFOCB+LtNF/H4FtOWKZLy36GkGoz5//3fb2TVytXy7KRJuqS1mXLAjbuJiw0ZhSiEc6DXJq40+V/AjcXF8gBiLHjfvv1k1FFH6nk3b96soMZVBdhYNcgnUmlp6WnabCG/oECmT58h/33jteDtNXljrDTewcEUiYQ2egx18QE91tn5XlvUQzniiCNUgrthQ7mMHDlcjj56tLr6paWlurGh6iMlmJOdo+/De4Gb0HDC9uv/ayowOkqmTJkqd/zpdpd8azDIeYPt81l2v7LCQkiZ4Iij3QGelXWWJd7XI4k9NxLUgsJS6dmjq9x37z1KKE2e/IosWbpMySxTxmkIJiwTriSpJkCOZa6s3BTIKTs91Zw67O3qkmOFtm3bKsOHD5eRI0aoBStEEFJQIGXlFIo4uvCU5BT9bOL3+T/9JF999Y18+MFUvaXZuW2VoedacJtNTN6o+32A3mSuCR5jY4UTMx8/fpz06NFdiTV+ftKJJ0r79u21qg4eAoVcUnKyknqxcXECQacS3Sivkp3IYGHx6UYzfcYM+c311ynI3Xx5fW9iwIqL7+ySwsI39grwtIycv3q9nt9HUnmoaalEowKY8gH9j5C5P/wob/z3v8rq4pqbGm3AbsCdkpKspBKEEPG1kxuuBrdTtEGjBacUc8KE8TJk8CC1bitWrJCi4mKNkx3vIUE3DMQlP/74o7z3/gfy3bcz9Q6ah9lp/FCzI0t9b/GheJ1x59Ht9x84RL0W1o9MwYgRw2XQoIEapwN84m8Yfgg8dABVqPYqN8uOnTt0XWDqd+50NPEfffyJTLzyimCtuat+2+fd1fJRn8//QGlx/h/2bsEzc6ZZljUuUix4qGv+m9/+Tk772anqFr47ZapWhpnGCrUtN/pxcs7E2oCbOBnxCHE2DxyANs0LkYNitQYMHKAik2VLl0tZuVN6yQaSmZGh1nve/J/kzbfeUmCTqmqTk6mWPtxVXcaaE3OfcspJ0qVzJ1m1arWmHMeOOVr16eTN4SBYz/Yd2mmjSQ48Faw8IhzCIFOdN336p3L22Wfqa0ytuVudtiegByy4bX9UUpQ/PvRVNUi2SJwaah6+3r16yC03/VFzyosWLZb33/+wRm4X64zlhvRCHUYqRwUgAWkpQHVY8i0aVxuXHNCffvpp0rVrF1m1cpW2NeJ1HMYTQOXF50GcGWBHWlti1hktO/n2004/U3r37q0MOtZ72LCh2iCDTY60IuEJGQnceA5ENGyarKtKfjPSlYX/6aef5Ol/PSPPT3o2CHS31rwukNc5hVTjcANwp8AkK6tXlHgX4CZGCsFG7zEELX9/5B/KbFOhhepr/vyflDnnwGoQIwJu0x6Jem5cRpWWpqRonA3BZsCNF4DbffZZZ6o6DInr0mXLg7E2DzACjs8+/0L+ev9farjiCE8i8XB06F7t2YaAaOyYMerlkBVAGAQ3QXqNn7GWrGu79u10I2WtOGDa2fyw5mmpqbpJfPHlV/L8f16QTz76MNj00W0RVf0EGazy9y7x9Q4l2moAPD3CCDaTEpt41a/klJNPUleYAo933pkSZIMNW461xV0nTuSAucaSA1SsOKywEkY7dsjGigpp2yZXzjj9dM2Ps1mQt9bilKhoaZObow/mm2+/I2+89opWUnE0F7UW7jYgJ39+0oknqO6Aqjz4B1Jpffv2VWadMAe3vk3bNqqFRwzEpolIBjkvVW7IgGHuyZd/9vnn8sADf9MN2yXhapuIuom2GgDPyMq5y7I8t0UCwYY1AZgA9i/3/lnFKSjGcM9RUGlTA49Xa7WJFYnBzR+WjjQYohY2gILCQikv36iMOeBG1nniCSfoBvDTTwtk7bp1Cm6nJrqtrFy5Uh56+O9afsqD2BxJIhMapaemyNlnn6VrRVxODzhUgT17dpcj+vXTVGQC6UgkwoG0Gptt2YYyJesSkxjAkOyMctpBTXpeUObKfWLzdGNzBbszgdT2311cmH+7IdpqAjyoYPNX4bmGsxtpXPObbr5VpaKAFAv91ltvqxiD3GvLVowGSqrRwdSJCb2q2MJ65BfkayNFHj7cSthhXE2sytIlS3cD9/z587U00ijOmrMriSUvK6/QnP8vLjhf1Xrr1q3XzMLmLVt0gx00aIB07dJFuvform45G4DhL9AIUB0H/8FGsLFio7ai5v4sXbZMO7tSa+4q4KoB7vP73y4tyj+tNsD1FemZ2fMsy9M33Bl0rEdRcZkMHzZIrr36arXcWFjc7BkzPgu0TopTfbVJe4VOAEFv3qtnDxWorF69Wi0wlps00OBBg5QsooMqbrlhyrHcBtzks3lNuLPjTbHBcy9QwvH3JRdfpBwHbaWJwwmDEL1QHQfIjzpypMpgTYgEIYrqbc3atbohw4dg1XHt4UzgU956513597+e0j5wDilac8hiU3yH8DiHYdL980uKCvqZaw6y6CkpKa290XH0K0oPZ4ItNC3253vvUwEGmm4EKzNnzpQVK1ap3js1NVnTXk5zA0cPTucTrDwgRjNNYQeuOeA+7phjpGevHgpomGHOiSUi5mbIAa7///32en3QOFxwO48Y9wNLjihdBKJCAAAgAElEQVQmJztTLjj/PF0b1HtGGowIiKYa3Iszzzhdxhx9dLA8FU+J17EpcNBwIm99nmwoK1MJLKCnBBUFnAF5c5yhFoLZEt/O7V3KysqoR/YC8CCD7rU9C1jEcAa4IXgYSvDLiRO1KMS0Mfr665lKktGggTSYMwTA2fFNhRlMO5vC4sVLtDQTthwBCy47RBCu+qrVqzUVhpXu0L69WvKrf/2rZv2A7cvKmfsyZuxxcswxY6SkpFQ3Sqw4ADetpSmzPeP006R7t27atRaSEx6EsIpN1dwrOuEgJDK92n+cN09u+N3/KcvOvWxuIN9T6WgQ4GlZuSd5RKaEu3tuYu/b/3SH9O3TV1asXKGxHRYAWSjlkJA4zpgfJ13FEAIOLPvRo0dpKuz7OXMVxGPGHK290wA6VVjr1q/X+m/AjViDxg133HmXziZjGmekpsD2BeD6/N6AnFoAXHHqxlG4QaBRZUfBC0AHtIyBYhOmKKVTp46aMuPAXTfFNtQSkBWB3KSP/LLly1XmakDe/Nx1M15YTi4tzEP/XG3BMzJybrC8ngfCuUTUxL1MHLnx9zeoa41+HOCSyoJo40HgAMTBZoFep3KKMURUjH0zc6ZamOOOPUbTOcTuzrCDUh21y/uyMjPVnX/siSc1Pwub21zSYPUB855eY0D+uxv+oPE4gMUFN11m0acD9uzsTDnh+OM1JOJ3aA1o5qhZjYJClbgC4BXLV2iTCRMqLViwQC057ro2kfRXz3zbn+sOj/c6teG2z//74uL8B2sAPD0z5xGPx3NdOAPc5L2x3j179NTOJ/RF4+Z/+ulnwbibJoeQNz6/P2i9eYAANFZg4cJFaslpa0SxSGssfhVxY2GwMyoNEpjoAcEDi9uc2fKGPPyGAB04oK+cesrJupGSpcBNd5pV+NVqc39Qw40efZSqB1G/scn26NlDG1jCrlOggse0ePFi9argVNq1bSNffzNT/nzPXc0whWZ6pfv/UVKUf30NgKdl5rzlsayfhauLbuIuOpz88Ub09qJuOV1Ot2zZLHPmzFVXXedzBaSkoe75+HHH6e8/+HCajBw5QgsgyG/ThEELKAoLg3F3p06dtGCEz2muee6GgLr2a7lXdHH5xUWXSLeuXVQBaKy4mbJKrpzsx7HHjFWZa1lZuW6ubMp9+vSW9u3aqSXHQm/eslk3ZSN1Zbrru+++G6wvbz6b726adHXR9Qj3FJmx3tde9xuNm2lEyIEccvnyFVqnzbGpcnONcT+M88EtnzB+nMyZ+4Pk5mSrW47raFRuqNLQUPMwtWvbVjeJ/7vhD4G+adHNODXTOJgbERLu+iUXX6wu+Pr1NGt0es/7bVsHTJimECefdKICGoUbbDtEJyCHiIMToWKNlCYghzchY4Ln9uijj8unMz5Wd715cCO7p8oU4CZFZllWejhacBhE/lC2+ODfHtI4m0oxSBoAPm/e/EAzhs2a2oK55TApGCrMkFNCmEGoGSKHtsQcMPE8fMgoaR5IqyeaM7hxd+MAzrtMLH7WOT/XgY4QbnAYxN+mew1CGLwn7g33CBedDrWAnA4xjG0mLs/Ly9PusIB96dKlen/JesDSm7JTno/Ij8dtH41aaDpkUmUK8PR0urj4fwrXKSbG5SMFQ7oKOSPuGmw51oKe3LDgWiiy0zDnDrEGQzt69CiNsdkQeEiwAs48sZYa25mUWLeuXXU2tuuaNx7Yoe/UNlWVm+U3111Tw4rTVdYQoDq0weMoC/GyuH+IXZwmG1XaKopCFoAPyNEjcA9Vaty+vbbAYlZac5C0VqfKbJ/4vT1KStYvV4CnZmUN9dieWeGa/zbWAPccNRR5athX5n7hXrOr8yDh2jHUjwPijQN3vk+fXtr+FxcQV5zfpSQn6Tlg4vVc2TmaT7/rnntl5arVkpRII4jmqppqGoCbjZmJMsOGDlGtOlYcgo28NwebAHUElJTSIPPII0eqTj20mcaxxxyjNQUmjIJvob8dP8Pj+ufT/wpOPY10V9202/KJb9iGwsJvHQsexlVkod1aqB0mV03TBf4mtqYWm0YLkDiGqTVjfWkDTLknnUHJqaI753BaA7cIDjJAC918iZumAXNdZzEPY+XmLXLVlZerO75y1SrdhI0V11AqUDfAqCgATg59zeq1mh4j5CINChmHdUeMRM0A7bDokIPlh6X/1S8nakssmPrIdtVrVpUFAJ5zjcfyPBqOVWSmamlA/34abxGbEXvpSKG0NPlpwUJ10YnrsAzUeZNr5Rg8aKA2a+D3WHnq4NGkO9VLTkNADlw9WPQrLr/MTYk1Md6N98V0mWHDhmgxCl5XKMD5yNChE0eOHClZ2ZnqjptZ50xOOfKokToeis2dxhso3+h8CzFqNOvNwFXXqjJ/oE+6Ary6TDS85oCH6s7/cONNMqB/f1m3fp3Gc7jn3OgZMz7V4X6QN6RgADiWgtdcc/WvdJDBV19/o62Y2N1pzcRg+8rNlVoQQRwO8fbcpOfl5ZdeaBaxXBNjeK+nMw0xqR+npZax4twvGj+Yg6kuZgAD8lXqytGlU2eOFSeMwrp37txZQU6O3LjqkK54bTfedLOUl1dIixb0bI/U8CogdgmUjToAz8h9yvJaE8NN5MLDYcb+PvKPR1XtRIEID0nbNm01fn7/gw+CgHbcPcbd2qo3p+cXmmbELU5cHqXuHnGfqeHu1KmD6tKvv+5aF9wHCPmh1X/oEdatz5ON5eW7WXGTtsTt5j6ROuNerVi5Sgk3hC6IZ4jXaWMNGffTggV6X9EusNnTXSeyhUk1BxIqwMNV5EIMx81ENPHoY4+ra716zVq1wpBiixYvkm9nf6cxHNbAjMiFbDvn7LPEsOJmYibW2hmNu0OtAvpnVGyPP/GkTt5w02IHCOEBMo005+VXXiUZ6em66ZqUWXAiS8CKs5EDckKojh06aJ/6VasB+Q7p3q27uupOg0e/CpKKiktU38DGfsMf0C/gzdUc8XTgvtnBPnOwP5vWhQcAnj3TY3mGhVsO3MRv9P8iPsZNg2BBqEKXlk8++UTW5znEmbZTCmjOKTi54PzzVVvOg2RSMlgFrD8bAAcPES7gtddc7SrWDvBzalJmnTp20Jx3eXmZ9mcDlPAnBuRYcer4+/btoxs2/d5GDB+m9570GDnwCRPGqZyVWJ4mEXPn/qCbAGW9oVY8MjvtGID7Z5UWFQzXarL0zOy54djowVSOnXnWOdoxhGmXMKcm5vrfm28Hmz0YNpbJoeRTjxk7VmNvilGcOJD5Y0nVnVRbJUhGRrrce9/9WkwCAxu5cdsBRm89Tx8qfqHhBunIbYhfAuo27pOZFQeTjvVGfYglH3fccXqvadBBjE5LLTZqrDqEG7JlyDbETLfffodUbKrUdl6Rx6jXHGVkoWLzRMfN81hW+3Cz4EaeeullV8g555ytTDjxFq2W6MPNKByTTzUAh2jDIsOW83AQu3EYYQuWgKNzp06yeMlSTa8gdQy3oQT1xNRh9zJTEXjF5ZfqPYIJNxJW7qVOSY2KUhIVzUNefoF89dVXatWp20eYBJiPPfYY6datm050pT3UDz9gxT0atz//wovy4n8mRaiEtbqFsn/n9n5WZmZmhl+8sEwJzDcKSN0Ouxtf1wUZgP/66mt08AA5VGdGd6JWkn362ec1WFgekC6dO8sll1ykwwmoFTfkGmk13HMAzvuZavLvZ59zmfOD/CSYtGf3bl21IyuuenFJaTALgk4dZRsTX8ePH6dXR1tlLHevnj3lmLFj1JNDrHT8BOf3EG5MmiFco9oMbQQpVTiV7VVVYTVNZt+3I4jhSo/4uliOTDU8hw0aF530CkIHbiCgR/k0/6f5Mvu779UKGOvN7n7RRb+Q/v2PkO+++17jdQ7YV9h4UwSB9UYNd9mll7ix976fqCZ/hXHVqTZjSsryFSudCa0hqU4+dPDgQapjZyMHwNTqHz9hvHZrXbJ0qQwdMkR69uqp9xki9YcfflT5Mj3Yf/u7G2TDhjLVxEdS6FVbrmqlp+f2F489lwULN6lqaOfU4cOG6mhahtPjps+cNUtbGptpmJAzpM1u/uON2qb3888/V2vNYdhz46pD0k2a9Lw89+wzbmqsyeFbvxOy2ZaUlst11/xSPStcddP5xegZaAqB54ZqjWEWHICZvm+EaRQJ0Q+fARZY6tWr10hRUZHqGqbP+FT+cu89EZcyC8Ww3/IPs9KyssZ4xDvDLHs4Db8zOz0tkxC5QKBAwiBq4IYvXLRIXXZzMCPryssv0/gcZtUciYlJavk5IOiwFqeccnKExmj1A9ihflVoYwjKRakTYERxqNyY9k60dsJje+31NzQVurlys7ZwooMr/w+xhhVnkAJdYNj0+dnyFSuCysRIYtNDRz/7xTfWCmcdugH4fff/VXr36qWyRawyKZEZn36qJaMAnPQYaja6tDCNhPZNxOgcWArYc7TnuOqdu3TS3ul33XmH654fYpSbYpTQklI6spomjciSSZENGjhQAa7ttHx+LUQZc/Rovd+w6EceeaTQzBEX37Rd5r4/8OBDEVovXq1HtzIyci+yvNbz4aZic2JnZ0TOQw8/IpAy3EysODePmWAQLwAcq078fe6552hHz88++1z7ccOMo1WmZhwrQFqMJgP33nufzuxuBrrlQwzhvX98qJDJ9HCjHz25btx1ilIgR4m74UwYO8yMcWSqzGafeOXlqm6kZz2aBsBNcwjOkZ2TI5MmTYrQri+mN5t9sZWeZQpNwkuHHgpwZKowqFhw+mVzAGLYVANwfofb1rt3L7XuZgIoVhvGnNi9a7cu8s03szQ1xshaBtObGdiHNRIi+OJCZawAObTdMipGiLLzzjtX7xMDCk2dAfeXcOv8889TDfsRR/TTMlOO8o3lOj5p+vTpWtsfeRt5QK5q+6+1wrmbqrHgyFT79umjVhqAc7MhUdixjYtOkQm5VfKlNOUz+W8ELlhwiJfEpER5/PEnlVyLbL1yeO0I5j6f+/MLpG/f3rJq9RoVwHCwcdOcETb9k+kzNCzjwB2HdD3/PKdjTJeuXTQMKywqlhbx8dr8A+b9ggvOl8zsNvqeyBG9mOaL9m1WuFaShVpwAD5wwAApLCrSUlEF+PQZGosZgLdKSJArr7hc+7HRlcUw6PwezTlETMXGChk7dow76+oww39oSy6qBlGgoXJTUPp8Wgd+wXk/VzZ9+vRPtRwYgNOMkXqD0087Tfu09erdS2e40/qJ1yBVvvCiSyKwwqy6oswK53bJoRac/lxr1qwJxuAAHPeNkbQ08KNj6sSJV6iLR+/s0BQZAwyYaDJl6lQdHOh2Sj3MEB6YPENREXUHpMZKSko0DcaBNYdIwyV/afJkDb8AOAdNPFAudurYUfXrdHrZVLlJJ8cicT3jzHNk0eJlkp2VFkGNGavbJ0OyBUpFw2+iqMmDY8GHDRumTfBhS7HgH3/8SQ2Ac0MpSMEto8AkFODdunUVGgZce/1vmk1rn8MPwnu/ItxnXGxmgyNN7t69myxbvkKbeMCsQ5BSKvrKa68ruWoAboZG0prLkG3Uiffu1VOzLSefeprMm78wIgFu++ynrYwwHhlcG+ALFy4MVoZNm/aRAhxJI/E39d+o2GitC4vqpNM86sIPGjRQtmzeIj8//xf6lCGUiZx4LNygvOfrNSIO/v7lVVfqC+kAw72iOcfxx0/QOv4XXnxRm3ZQ8kvum2aMNONsnZggffv21bHPLVpSUpwtv7jw4khMlTmzwv3+yVZ1LbgddjPBjRYdFp3umhQUAE7+TPvoYxU3AHDkqgMG9A/kwOdrWaEBOCq2UUcdJR9/Ml2ng8Ke48a5x+G5AiY3juUl143CjUEWNIgYMmSw9kr/zwsvKfkGwDmIxak2zM3JlT59e+vPADlTUn7/hz/KO2/9L9JETVWWZcX6bfttYvBplmWNC7dKMm6SAfjfH/mHDB82TMkzLDI57Q8+/FDTZK0TWiuhgpSVEsIff5ynbZUNwBMSWutMsldfe13H3bgAPzyBba4qtMkmhBspTqfnnl/d9VNPPUU78Hz00ceqaCMnjifHZjB2zNHaK6Bt2zYya9ZsTZl+Mn26xuiRlRatnnBipWfkfG55rFHhDHCELsOHD5Xvv5+juvLkpCSZ8t77GosBcHKjI0cOF9rr0uGDBoq4dfyh7JDikiefelpLCCMvJ3p4A7YxVxcqYz3/vPM0cwLpRuqLvmxJiYnyvzff0plmALyiokJ69Oghp5/2M90ERo0epRs9paNbt23VefCRVVkWrAn/wkrLDM9uLqEW/K8PPCh02pz93XeaAoFNnfree7Jk6TLt7BITHa03/ujRo2sAHCUbsTkEzW233RGJsVhj8BMW7zEZlIlX/UpFLHR2wS3Hap944gnauQWPDmUbrjo/P/PM03WzR8JKt5iY2BgF+YN/e1gLTyLHewsCfI4VzjPJjIv+53vvk6OOOlItOC46JYbvvDtFNedp6WkK8FGjRsmoo46sAXCKSoYNGyqtWraSM846J9A22RNR5YNhgdZGXCQEqTN4cKv86bablXcx8TgAZub75Fdfc7rB+Pwarl15xWW6+bdt11YFT+rBZefIp59+KgzNYOhCZJSPVs8ow0VfbHms7uHsot919z0yetQo+Xb2bHXRe3TvLh9O+0i+/Xa2Ahy1GkUJtS042vVjjhmrDwFEW/OcKd0IdB0GbwlNm6FwO/bYsdrJBQ16+/btlXN5d8pUrQvnID1KfT9tnqg3GDFiuP4c8FNiSsESbbEjY1BhddsmWPTV4diuKdRFZx742DFj1EXHBevZs4fMmvWtqpoAOPlTmgPwGqw6MTgVSbjoxGVIWmnx4wL8MEBuAy/BpEpvufV2LShZsmSpFh3RfpmJo//735s6rZRpN/Rt49mAYB0/bpxmWGjMyOu++WamptIiIxavbtukALdE2odbu6ZQgHNzxx13rFpwXLeePXqomIUdnBicGd90cQHg9E2njxcA54CkQeJ4wvETmslomwYi6DB/eWiLp+uuvVqt8cqVK6V///4qbPnww2lKtlI41LFjBzn66NGqYMOby811ZtcRj7PJ33n3n2XdunUR4KY7bZtskTWkyYoZTxZu3VxCAX7Tzbdqp1S6uHCgSkPRNnnyq5LQOqFOgFNsQqOHM844TWZ+M1Muu2Ki5supUIqkFj6HOT6b5PKMFaeklHQpnhx69WFDh2qfvi+//Frlqdxf+uGzCSBuogKRGnKqzCgfnvXtt8E+AJWVm8O2kjAEyyURAXDyoSeddKLMmjVL3TPiLOqEn/rXMxp/04eLhhBjxoyWFctXah4cxRP65GOOHSuff/aF3HLbbRHZo6tJEHSYn8RY8bZtcuTmm/6oTRaXL1+u7DqaiM+/+ELWr8/TasPLLrtELTj1BxCzeXn5KoSh+yo9/SgVDvdUaW2Ab6Kjajhb8FCAkyqhyB+QP/HkP/XRTEpOlp49uuuoYJhWyBhuMm2eBgwcIJ99+rn87eGHZcHCxe5Y4MMczHVdXuiMOqw47ZSZC84m37FjRxW+UGC0es0a+eVVE7UlF1JWNndcc2JyJMz07Xtp8iuqbAtnkIdguRILzhiP2HAH+CmnnKTEGgDHYtMT+7333teYKjUtTbp26Sxjjj5am+5xoxE/HH300dK1axeZMX2G/OuZZ908eBiCO/SSTW6c0IzhkWzkXbp0UW9t/vx5qos495yztSMM89+PO+5YteB+269ddplGi2T50X/8PawrCmsDPOwtOLv2GaefJrO+nS2bN1c6THqPHpoimfHpZzonnNTJ6NFHyaaKTRqX4a7BpLZr304B/uLLk91KsjAHeGgbbUhXYmoabSJyWbVqtfw4b56SqVSR0QOA9l0QbKjZsOB4AgD+lpv/qIQrG0E4NSE1ty/iLDh90c855yzNeyNXJAYbOmSw/hvL3KtXDx1nQ64bd2zxkiVqwVE80V73s88+U2njf994LaxdszDH535fvrHg/QcOkVtu+qPWi1dUbNSOPZBo9EUfPHiwDqdMTkrWIYU8BzT6oCqtuKRYUpJT5KZbbtEmEOE62igE4FW46PZ+r+whOkHoZJOLLrxQd2xia1yyfn37qhSRud6UBTJwEHFDXFy8zJs3T+jIyWQMdnJyoG+/8647xeQQ3cem+tjQcdLIl6ksIxbPzMzS+e+MtmrXrp02/yAGHzZsiGzbvl217JBseHxdu3SR+x94UGbO+l4yM1LCPqMSEQBn5Ozll12ivc5xvbHgWVmZ2v8aF52fIYIgbZKckqyvYyOgfpiRw0xAee/9D+Tf/3oqrGOvpgJKuJ4nVN3GQEpSYk5hkS3R0VE6+CA9PU0LjGDXBw8eqO2eSktKlUn/7rs5KnOmBTPeXCSo2iIC4EgVaZFLwz0a5JMqQ5tMAQo7+OzZ30vPnt2Ftk5okOf+8KO6bgbgAP7DadOCLXQjqRF+uIJ1f67beHbPPjdJc9k8E0iYIVjppkp6lOEY/Qf01zln/JyGEaTT6Pwze/Z3SrSFM5MejMfD2UU3pMqE40+Sa67+lXZUhTkF4BAk6JFpvMjsKuZOA3DisQULF2pvthNOmKAuOqWDNIgId/Z0f0ARSe81sTgFJKRGyaQA7A0bSvVr4obDxQweMlgLkbDy1CN88sl06dSpk+bQGXwBwClACefW2WFtwU1dcL++veSmG3+vrhgMOfEWAMctR65Iuiw5OUnz3impqdqcce3atWrBM9LTVZ9O0/y/P/w310WPAKSH1ovffdedKl2Njo5WWSrAJjbHGIwadZQCHCadVBl9/OgNwPRR03wznBVt3MqwBnjo5ItJz/9HElu3VuvMTaSQxID6m5n0Qd+hajbSJljvpcuWaZcPunssWLhINcsuwCMA3YEhmjwbRQXrhW4/mRmZKl2m2yp/SKGuXbdOJowfr0rH/IICbb/88ScAvLOUbijVLi+R0Bs/rAEeypo+8+9nJTc3V+iYyTRJM5aIgQi4WN/O/k569eyhBAtimB9+nKckC32zly9fIR98OE3+9uBfIyLuigyY7t+3MHE4KdQJE8ZrnM3zQtEJY67w9Ojwk5qWKnl5tPDyaZsnSDbSa1SWRUIbp7AHeGpqiixdvFCe/OfTenMg1ZAimrbIxFTIFWnIiMKNCSZYcyx9xw4dFeTIV6kfp6tHJBAr+weNyHi3icPHjD1OGz0wvoj7Dni7dOms93zgwAG64cOiM16YUUZdu3aV4uKSiBlfFfYAT0pKlBXLlggjhCHRGAtLCoybyYEememTZm4ZpaMo3djBUTiheIJkoc3yHX+6XVMjWP9wVDBFBjSb7lu0bp2gzwabf7euXVT4xDgj2jTRWbdfv74K8I3lG1XNxrAM4vP8gkIX4E13Gxp/ptC8JyNmz/v5uSpaMKkyzgx7ihvesVMHKSosUgIF4g23jPbIDLTbvGWL7t4333qnihvCVaLY+JWMzHeaLAts+sknn6gip/y8fElJTZayDeU6bDIr05kHv7Fio3z66WcKcNKtv7n+ugiIwW0fPdl2URwerrcYxnTHjp2SkNBKHrj/fh0qyChZXHQ2AABOamzIkEHBiSeUEeKWYblHjhyh7htTRe+6+249lzv4IFyfhprXbWaa4Y299MLzkpiUJCXFxerBAeqk5CRJTUlV6SpDCSk3pjgFHgc9eriSbBFTbKJpAMtSlRJzwmHSGQO8YuUKddEBOWDFTadVD5YcxRr6c4/XmRMNo0qMvmDBQrnjrrt1qF3rhFbuZJPIwLgYK47oha4+AHvb1m3a/IFQDpBTechQQgRQuPLwMX+9/y9hy8fU1qKHbTWZeQYNoUIczmgagEvMbYYbtG6dqBVlEGpUDRF/M49s0aLF6q4DcmKzm26+Rb7+8nOtJHK7ukQGwg3Aaa/8u9/9VkG9uXKzDsNYvy5PWrSIlyFDh2hbJ+rGKR+e9PwLKlsOV8I1YqrJagP8/AsulAvOP08BTJWQYdJxwWHPBw4aqD2yv/rqa82JL1q8RJvkM+ECocONN94sH34w1QV4ZGBbv4VJl5162hnywF/v059VVGzSfPiyZcvUU4NoReyCNgIv7557/yJT3nkrbLXoEdPRxTyHZl4Vo2Vv/P0NKk9FzEI+nAMxA+WiuGgey6PSRY/Xq9MwcOVJl8TFxsk9f743glrnRhBK9+Or8GzgjcGtvPbqSzJ82HC9/7DoVJehQacvAB4fCseu3boK1v67b2eG7UYfMT3ZzH03TfC5iffcfYekpabJmrVrFLzs0NHRMVpFROtkE4PFt4gP9uGCgEtISJCpU9/XAYSQK1u3bnNTZfsBrMPlrdx/o5WAo/n5ueeosAl5Mr35OIYMGSJRXq9mXyglveiSSyW/oCiM23dFSFfV0IfIuGK0UB45YrgKWUKJNtoncyNpl7xm9Vrp2auHjgzmdbhl6NYXL1mqHT8AOCWn7gjhwwWmjb+OUIDjoV17zTWyavUq+eKLL9VNN2220zMypGLjRrXkdNjlCN9sSq2+6OE6+CD0thsy5eJLLpOTTzpJu3NQB26YdGaWDR06RF30efPna0ye2yZXFi1crGmSLl27SGlpqTz00CPy5v9eD1uCpfFQiMx3hjZkpG/AnX+6XdauWyuff/5FEMRUl3Xu3FmFMOgkzjzj9DDvkR8hk01CH0l2Ymp8C/LWCbp0AE8+nBvM78h9Dh0yRFoltNI8J2IWZpVBxuXnF0j7Du2lRXy8TJk6Vf544x8iQoccmZBt2LcKBfill10hd95xu6xZu1aHE/JMwM+QSenTx5kbPu/H+XL22WeGuaIxQmaT1b7Vxk1nzhRtk7mRxNwA3LI8miajmgxRCyWlJjeOa86GAPjz1ufJ3X++V0mWSOjo0TA4RN6rQwuSaK994x9ukBUrV8l7772n4GZYJT0BevfpLdnZ2fLxRx/LBRecH+Y8jHHR/bPCerpo7cfRsOknnHSKXHbJJeqmI1pqmaEAACAASURBVF01JNwR/fqppXaaM27Rm3v8hHFCzS/VRtQMc9Nfenmy2/whQrAeGoM/8o9H5aqJV2rTzbfeelvvNZ1eeA569eqpbvrbb78jE6+8IsxnlIXMB0/PyPne8lgDw3G6aO1n0EgTqQN++l/PKElSUFAotu1XV51i/u49uqvYBcaUnw0bNkw6dmivI2zYDJgnTRx20YW/cC14BICcZwIxy+qVy+Wll16WM888Qz07/g0vA7g5mEjbs2dPeeXVV+WG3/1fmHMwxkW3P2J88OeWxxoVCQDnRoX2xqZjB2NjTbqMfmxUlgFgfk7VGP25hg8fqs33GD27qaJCLf5zz/9H3njtFRfkYQ5y8zwcO26C3H/fvRq6lZdvlJcnT9YaBCoKYdOpT2Bg4VNPPS2333ZruHf2qfJ4PLE+v/9tykWnWZY1LlIAbmSr9Gm75OKLpKycwXIVasWx6Ew3YVzsjz/+qI8uYGbCRXJSkrrqTCWNi43Vv01FkduEMXxRHtpa+/c3/E5i4+Kkavt2eXnyq0HrDcAZitGvbz/5871/UR06qdIwvu8KcL/P/zzjg9/yWNbPIgXgoSWkDz38iOTmZMvyFSv1CfX5dsnoUaO0wd73c+boz0iN0E6ZGIwbWr6xXNauWStx8fEy+ZVX1YqHqyY5fGHZdFduLDgiF5hyevBx/1997XWNwfHuzJx4OgLdetvtyr/QzYVy4vA8bJ/H4/XaPvtpKyMz52XL4znP7/dXUT4dnl+o+qpDAU5aBBkiZBukGtJVilGys7Pk++/nqGvGDeZGM53U9tuyvWq7rFu7TqWNCCJovhfOY2zC/X7uz/UbySZW/Ll/P6OcC8UkoQBng+f+n3/+efpRN910izw/6dkwt+AOwH0+/wPE4JM8Xs/FfrrOhXFdeOiDEFpC+vgTT6orZsi21NQ0OXLkCPnu+++VaONAvTRixAjp0b2b1ogD9LVr1+nvnnn2WZnx6ZcRMeVif8ASju817jnpsWHDhqqgicpBppm8+eZbCmxqFvr07i3jxx+n5Ns1116vFYXhnSINWHDbfzcx+CMej+e6SAI4D6OJxSkcGHfccVpcgMX2ej3aLpn6X0bVcGDFqRlHAVdWXi5bt2yRqh1V2vWDyjSK/103Pfwgblo2/eeFF9V6o17s3r2b9mWjRXJUVLQWHPE89D/iCPn6m29Uqsy9pkdb+LbtMi66//dWWkbOX71ez+8jDeCQZ9u3V0lycqIOhef/kaICZoYQtmzVSrtoAngOCDYGwiNbhGCDYd1YXq79s1999XUtI2VX55zh3Ag//GDauCs2G/zJp54mF5znlBAfe8xYnVHHoEFaaRsZMyQr/QI++OBDFbl069FLNmwoC+P7bFx031VWemburR6PdXekAZzHIrQnF0PhseLEXLhp6NKnTH1PhxBycLNx2Y6fMEE2VW6S8rJy/f+NFRXKuDPpIrzdtsYBJVzfZaz3XXffIwicmPt96aUXq2ptyZKlMv+nBbJpEy2Uu2ualOOFF1/SFFn4t0sO5MHFd7aVnpVzjcfyPBqJAMfSAlz6tWHFSZPl5+draeipp5ws8+bN15nRaJJNLM70k+7du2szAKw4jSBoBvDSS5ODVhwCzj0O3xUIFTzBnjM6GKt98UUXan++2bNny6rVa2Tr1i06vw7xEz36fvu7G5Rvyc5KU5I1HA+HWPT7LMvj9YucbGVk5F5kea3nIxHgoYw6sTguWl5+gd7Y8Uy1iI2Vd6dMDbrpxpIzNxwNO8RcakqKWnlGE5t5VZA07nH4roCRLNMT/c47/yRPPPFP6du3t5xx+ukSHx8nX3/9jZKpkK/HHDNW++WTGh0+fFjYlwqHNHsQv+UfZqVnZZ1liff1SMmD1/XYmXgMRh3rTf+t3r1767xw4i4GFmLFnbh9u86QhnVdGcift0pIkPXr1smzk553GfXDF9fBKzOhGe45bZAfefQxbanNPPiq7VUyZ+5cKSsrl65dOmtuHPHLlClTVYMe7s0+QgHus/y9rbSs3JM8IlOMWQ9f5nDPT5654YwZPuvMMyS/IF8Z0p+dekpw6AHxNgcgJ04fN+44bak7f/58SUtL09998eWXOhzBZdQPX5SHas//+783tZHiy5Nf0SoyZtGtz8uTn35aoL35kDIzH57jttvv0Px3uN/b0I6qPq90tVKzsoZ6bM8svmQo+g/fW9i4KzNWnFJSBswtWbpE3bN2bdvJG//9b1CWSAtmXHvi9VNOOVkb9NHDKyMjXco2lMkLL73sqtsadwsO+LtCa7+ZSUZ8/fa77+p00Vtu+qMcccQRsmTJElm4aJG0yc1V0VOLli1k2bLl8uurr9WW2UmJCWHeUddp1yQiJb6d27tYKVlZvaLEuwCLFukALygslYED+so1V/862FIZBdu8H+fJV19/raw5B+DGisOwHj1mtCxdslTj8OSUZC01RaPujjg64Hht1AcYcQvjitis6YceEx0jf/3rfZKVmaGz4Im/KSzJyc4Wv98nr7z6WnBccBjrzwPrVd3NJTbGM8BKyclp6/XJMmSqkQzw2hJWWHRGyDJhkofinXenaM9swG1AjsLt6KOPlh49ustP83/SZvnIHBFORII71ygEHcZvMqEYue8rL79Mpr73vqZGqfO/794/q5KNXmxkR+iw26JlS1m+bLk89PDf5b9vvBb27rlza4IAn1NSnD/ISklJae2NjlsuIun8MlLkqnt6DkNddUoHIddGjx6l0tVZs74NpsyMmIWN4Wc/O1XTK4sWLlIrT/9sGvghhuB87pCEwwP1BuCEYbTJpqCEg3p/Kslo0/XlV18psUabJhSLn0yfIVdcflkE5L7NPaiuBS8pyh9vtWnTJr5qh3+u5bG6RzKTbr4+KZStW7cLfzMuODkpWfod0VdHHhGLw6JXvzZKXXnAfeIJJ+i4o/XrmSW9U959d6q+P8zLCg8PZDbBVeB9VlXtkI4dO8ifbrtFvvzqa1Ukol487WenysUXXyQFBQWyYsVKzZ5woIl46KG/ay/8cCfXqpfQAbjY9uTiovzzLX4RSV1d9vWsYJHj4mJ1lhmtnX5x/nnSrn076dWzp/y0YKF88cUXQTfduOo8OLjpSFl37tgplZsrZfXqNXL//Q/I519+E9bCiH2tVzj8PrTv2oN/e0g35KlT3xPL45F58+bJX+79s5x04gnyzTczlUdBlooefc73c+WCiy6T9DSHSY+QNtlOLbjf/4+SovzrHYBHWNOHfT2UtTttnnH6adrRIyo6Wt57731Vrhk2nXOZnm5aN967l8bhW7dslfc/+ECuveZq14rva8EP8O9Dm3zgbs/+7judWgJnAqFGJxeaOcydO1d69uqpDR+YJvr4409EQGlo7cWtLjQpLs5/0FjwSZbHurg5uOhmOUJBzvzoiy++ULp26SrLli/TQfChrzOW3OPxqhVnSALHhtIN8uQ/nwo2aKRgxS1EOcBoruP0hjlHyEQTxXffnaKVYkhU27RtIzfd+AflTgi/kpOT1HrDt9B3L9yFLbsvR8BF98vFxcV5/1GAV1eURUbTh4Y8YubhuP1Pd8gvf3mVxuLTp0/XUlIYdTPA0FjyhITWWk+elp6mFoIc+S233K469fAvUmjIyh3614Zu0r+46BI5+8wztKikuLhEfH6f6stPPPF4uWriRCkr26AlwaQ7abB5x51364DByIm9zf0ItEwW39jSwsJPFeAZGTk3WF7PA5GoR9/XY4jFjY2JkbVrVgq50/PP+7k+DFSaoXbiIeIPDCylpbweZduRR45U2SsbwsxZM2XC+PGSmJyu5amuJd/XqjfN73HNy8orpFPHDnL7rTdLYVGRWmbuFUQolYB0SB05YoT+vzOOyidT3/tAfvXLiWHelmn3NawtUy0rLFwYAHjuReKR55uTix66PDDqdNekte6rr76maTFaOiF+wdXj4TAgJzbnaNumrZYZkktlcN2XX34lN996uyyY/4O6fS7ImwbEezpLKLH26GOPS1Zmlrz3/vuyc9cuJUK3bd8mbXJz5OabbpK0tFSp2LRJf87YIsZMc38iL8UZTHNXesTXpaioqNhx0bOyxnjEO6O5AtyIYCo2VSrDPuOTj4QGfNOmfaR92QzIac7HYUBOo/whgwdp3LfL55PvZn8nDzz4kHw64+MIjO0OLGAbcvZQcMOfHD9hvHz2+Rc6vIJxwByMkJ54xeVy/PHH6wa9adMmDam4PzRVjMz0ZrWKraQ4n1lMPodkS2/TRTx+1GwRLVfd20MU+tD87oY/yM033aguHVaBho0cO3ZUqftnQI673rFjR+3KirteUbFR5s79Qd6ZMlX+/a+nVM7Keaktdsm3hkB4z68NjbuxxBde+AttyLFo0WLxEU75fLJ5yxbtd48kuU2bXM1/42l9+OE0JdbCv2PLntaneqJJSXH+aMUz/8nMzMzwi/en5qJm29Py1J6CcfLJJ6sF/+yzzxXYxG/kxOmxzqwzLDnAZWIKo4kBOUPkFy1apEILLAVxeatWLdxWT02A71C5MSAlvw2h9u233+pGaqw3CsNf/+oqmTBhfA3X/Oc/v0Dw0lq0iItQ9WEQ4M+XFOdfIiJeBbha8Yycxc1Fzba3uA4X3RSlPPLwQzpWGInqnLk/6Ntw93DVQ0GOC0/qDIUUpNuChQu0THHt2rXaBmjFsiXK1m7ZutW15I0EugF3yYYy2bZ5o0ye/Kqe6ePp09VqQ4hy4HXRGvk3v7lOS0GJvUmX3X3PvfLifyZFOD9S3U21uDD/dgNwSst8zU3ssjeQ0+IJwo2SQ4QT9PdasHCRuoHGVTfpMyx5bKzT8ql9u/YKciz5d4gtli2XLVs265y0pYsXuiDfD3Cz8RLqlBTla7FPYuvWWkyC1YZY067fhFE7d8pvr79OBg0aqCkxuJHnnpvUTEZC18yB1wB4RkbuU5bXmtgcU2V1PXcmPw5DSycQjsWLFgdmjtsaj4eCHHcdS56ZkS4jR46QlJRUbcNL/XFsbJy88OKL2m/bZdgbhnJjuat27BCGSpLlQIr6+hv/rQFuj9erlpoqwTPPOF3vBcecOXNk7NgxYd+KqX6rVjMHXsuCR2531fotTs1XkTrz+20F8TNP/1Mlqgbkq9es0YfLxOPmnVSm8WClpCTL0CFDlIkH5Fh+fjZp0n+CDLubRtv3XTGE2tp1BeLfuUVH+6akpshrr72h9fom7gbcWzZv0fFTV1x+qW6uHIVFhXLddb9tFs0yQzu5iN/Tp6RkPRWiGoM7Lnoz6M2270eq+hWhbC1FKbDqGRkZQZBTS84gBROP8wtDvAFyao8HDx4k7du1k5kzZ8mSpcvU1aeN0PtT33Ut+T5uRmhWA6Lylckv6oBImjMgNfV6PLrJGnATdzNsMjMzI5jWfPBvD2vFX3jPGavvUxuw3ra9Ji7a03P9+vXb9JkMAjw9t7947LmR3PShvktlXhcK8l9ffY1cccXl0jqhtf56wYIF2jBibyDHbR84YIBWov3ww4/y04IFkpGeIa+9/oa8+b/X3Vx5HTeENUeAQlMGyEk211tvvkniW8TLc889r5NhDbh5O005+vXrKz8/91z1mCBB0SWwEQD4yE2J1V683VJkariDAA+kyjDrCS7Ia1rypKREfdhuufV2OfvsM6VVy1aqdV64YKFWJVVWbtKJKebAkiNrpcCBtaQUtd8R/XQm+cKFi5Rpf3fKlGAdcniPyWnotrnn15t4m/UoLswTNtVfXnWVgvillyfLhg0btDuLSYfBmA8bNkRHQnfs1FEVhXhPH3/yicbhRofQTBpyaJkoE0WLi/OuMoY7mCZj2dMzs+dZlqdvc1W07enRA6Qw64CciqWxY8Zo55edu3Yq8VYb5DyoXm+U5snNUIWePXvI4EGDtNc6MTlW6sNp0+SJxx9TF5KHuLkKYmpb7dT0LLnt1pt1VlxBYYG88MJLGnPjjsOWo0lAinrySSdKt65dlXTLzc1Ry/3VV1/LscceoxkLNuHmM2rKSZH5bf+1JYX5j9UGuJrzSJsV3nS2xakJh3ijUQRFKUePHqVN9GnSR+UZggtSYqHEm4nJATmbRJfOnWXwkMFaj5yfX6Du/fsffKgD55vfA1l9d8hYYI1hySccf5Jcd+3V0rdvH5k//ycNZ4zFNvE2637mmWdIUmKiloCypqTN6LZz0SWXysxZ30ubnMxmozuoOc3EqSKrE+AZWTl3WZbntkiZFd6UAOdcAJyjIG+dMBLnqCOPVFecuHDVqtXqQiJrBbhGCGMq0AzIO7RvL4MGDdLNAX00bPoHH34YnEMeHRWt422bwxFa5OOJbim333qjpiThKWbNmqV5bg5juVlnBhnQnYWRUowjGj/uOCU/yXk/9PAj8ud77mqGZbvBIpMqeqGX5ecz+zoYg7OGLpNeD0SZdk+bN2+Vys1b5dlnnlRhC83+YHYBeXl5ubqTWBZTYupsDuTJo9SSt2nTRgfi0XyAgxwvE1YuuxR1oSj55lc3NPKAbtaQWLqouFRVaWjKTz/tNOnTt7dujrjZ06d/qpsg4KYKjIGQjIGm4UZBfr7M/+knteI0zsRrevOtt5RUI9xh04zEAR57fkR3LzIJ8kGBfzgADzDp/Mwl2upeTvOAGlWVEcJAnJF3XbVytfZsw6IgnzRiGFx8/vBgc2RlZur4pKSkJImOidY2ULO/nS1PPPmU5m05eFh5uCNB4sq68f1JFa5avV7z2v0HDpFLLr5Qhg8bJolJiSrt/eSTGdpuiXFRHHhFGenpOpEmt02uzJkzVzfQ8ePGSadOHRXctGI68+xztRKQz2kmpFrIAxpMkb1dWpR/mjHYiuNQgJsWypZlpbtE2573SwNyr8erjSKYgXXuuecoWOnwYkDOw4dFInWjrqaC3BtsHMHQO8QZSYlJwQGIuKFMPaXtEL26DdDZALDqMMz8O1yq08xakVHgupEA9+7bX84+6wwZO3aspKelafUXzRHpWQ54W7VqpZta5aZKGTfuWJ1As23rNp3pjRz4yKNGqs6c9V2xcoXc+5f7ddpMM1YJBhot2reVFOXdUxfAq930ZtaAsR6eeZ0v4cHF5caN5KG9/Mqr5Pprr1E2d83atbJi+Uq15FhwQG6q0QC4x2NpbTIHEy67d+8WnH8GcHH58RC+//57efuddzWdZg7IONo3O91JnNLVw/Xge+CdkH0wxx133qUjhehky7rQxpguLIwPYi1Zp9INpdK9W1dtvJGbk6tZB7yjfv36aSGJKSIpKizSySV/f/hvzRncwWEHjAsuLczD/VOPPNSCBwHenPuzNQYooSm00884W66/7hplgOnMumDBwiDIYYIBu4kNAbixwrjtVKNlZ2UHL4Hce2JikpSUlKieffqMT9WCoWfnAOiAh1gfl5TzHqi4k+8YeoT+P5uVOUwIYjYvA2xc8Z+fe7aMOuooSpM1vcg4KOLo+fMXaDoLC4+WnFDnhBMmaEoRaz73hx8lLTVVOnfppGkw/k0BCQ0vP5k+PdjVdutWiE27MbcwrN8TEkpX+rzSO5RgqxPgrmS14fc7VFbJu7EqzDxjcgYKNtxu09vNpHyqQeFYcmrNmcbBxA1As2vnLlVzmeaOpNaYjDlz5rc68fS5Z58JXmhmdhu1fhxaXbVzl7rDdR1mU+HB4A+fZX5GyGEOQOdcF3XwzuZhzmliXCq3zFFVtVPj6tCDDe+4447RUcyEIRBlTPYkji4uLlaOglZZeCOAnumfo0ePllYtW+oMMTwgppCQgeAa2AS5Bt7LutKYw6Qvm1/cbVa65qii2vc8dGsOEG10d/EtZoSRS7TVH+xGhcWDShqNVkKXX36ppKWmqaXKy8vbo0tt+q7jngJUGvNDOqGOs/22lp8itImO5kH3KWOftz5PKHr5cNpH2h009CAWNQKb0J8bgPI34GLCC9mA2sCsc2OIbikJrVpoaSwCHixuXFy8koaku/g8UoBdunSWDh06qG6cn7VObC2FBUU6zXXxkqVKGuJ5IDfdtKlS04lo9keNGiWpKSnaMIPvxXnS09PVE0KhhhKQ9+KqL1m8RO68+x5Ztz5fUpLJhVeFDSdR/yeq3q+sU8Fm3l3T93J+6k3PyFnQ3Js/1Ht5Q15oFFkAkvpvWPD77r1XBgzoL8UlxdoEAmKI3DiHYdZDCTNADdCZhEl+F3cXN5wpHQhCTCdXQIKHAOkGY49VRAbLZvLJRx/u8/LJO3fp3F5j/4RWCVocE9+ihURHocBzQIsaD46A/2fjAbBYXH7Ge2Ct+Rmuc1Jykkp4ORgXRH+0FStXyfLly1QMpN81oESr3LxZN6/BgwbKiOHDtXc5DPry5Ss0B447vqlikyxavETatW0jw4cPU80AHgybxcOPPKKhittEg9XevQbcxN/8tjbAHSuekTPJ4/Vc7NaG7xMndb7AFKmYPC+a6rPPOkutMrnywsJCfR/AcVzMKImNiVV3mYO8OKDm55kZGTpuB0vOz7TNc1ysuu+ADWEMP9+x09HCY/HoQWY2EOJ+XHaYfGXitQS22rU2cXNMTGywmSSvp97d8AaA0aS5AD1WnB7j2VlZkpmVJXGxsfp5pLRWrV6tYKVtks/nNKnEfSaexqUnDKA4ZODAAWrlCV8g0ThX+w7OQAk2KuaGjR41KshnsGHgmj/73POaRnTBHZrKtn0+y+5Hm+RQgm2PAE/LyJ7o8XieclNljQO4eRd6c1xZyCaqmq7+9a9kxIgRanmXr1ihlhcQA3S1jjGxahXNwAX06aSLWrZoISmpqWplASh6bIDL+7yBunU+c/v2bbJ9m7MJsAEktG6tjDND7kmxEdNWbKzQMcmbKiv1b66BP4CYcxpmnuvh2lu2bKWeQ3xcvOrnSWFR2UXaygh6aIu0fv16tdyAmeaHHKGtlLKzs6Vdu7bSrVs3iYmOlrKych3tS16cVCEb2Nq165yKu4x0HeucnpEua9aslfLyMtlUUSnPTpqkpbYuuM0TtmeBi3lFnRY8JSurV5R4F0CsuHH4/oGcd/MQl5aWSUV5iZx51jkyceIVmv5ZtWqVxptYVlxxLDLAwg0HoMSeAI9YdcvWLfo74lRcYgPsXTt36kw1Xmt5rGAtdEXFJi1g4eB3gD4mNkaivE6jSAWz7df/57P5HeePiaZpRZSeC9Dxul2+XeoyY0UZLoCl5poqKioEd5uDzYC42rji/KxFfLy0bdtGOnboKOnpaeoRLF+xUjZsKNXwg0IR3QCpslu0WEMXOtQeccQRsnPnDo3Z+X7If5EGG8sdiQq/Rj5le42/67Lgwc9xK8saueR1vC1U7GFSRxSsjDvuWAXwosWL1SXFNeYhx10m5qWAAuuLZWcTUO16oJMJ6aQWLeIVIIBTLabfSRMB6GjAqrPLnTw8rDzx+qaKCiXpNm6sCG4ARi9vgM918B42CDYK0leA3PEyYnRDoCxTvY2AMg9wm7gd9z0nJ1s6deokrRMSZPOWzRpfk/JDpksXWgwH9fQrV67Uzax3r17SvXt3/b6rV6+WNavXalzPpvLY40/Id9/OdC33bs9WwIKL7+ySwsI3arvnewK4xuHVPdqa37yypoN2zTOZ2BwGG6b90suukIsvulC6dOkiZWVlOs+aqjTqywEQsS5WPbF1orROhNSK1xPiiuOyYtUNQYcLDeAAvJHD8losPXG6sd5YbRNP83sENZV0Hq2oCOrocb1JXWlsv2OHMu78IVzgZ7DZAJ7DkGwJrVoFyjZzNQvQKqGVbCzfKGvWrtFNAsD36N5dYmJjpbCgQLudMpsdQg1ijewAZbfLli7V87JJrFi5Uv7+yD9USOS65TWfpdAWTbULTEJfWSeL7rZwOlAQd/LKuMDE08aa//WBB+X4CRMEcQvWFREHajgsJwfqN5jr1gmt9MHH4gHYHVU71MpqBdv2bcrQGwItuEHExOj7+UwUcE5ThCiJolY9OkaZbdMwEouPy+6ksRxJLcRY1fYqJfEU7IF/4xEo+WbDCThdWJKSk/U64Rg2lJXp9eFlMBwiMytTpadcLyQhHgigxpPAshOPc77U1FTxWJb2lf/bg391+8rv8VHcfchBXS/dI8BDO7xAxZMXP3CPffM8M1JOkzcnNkea2f+IIxSAAAoSLD8vX60d7jnuNgAiBsdiQniZfDfgwAXfvm2bWlqngYQDSsOkh65yqFsOM87/6yYQALYy+wFOwBBscfFxurmYa4D8iw1ozImVzWbEBsFh9POO17FdFWu4/ur2l5crg8535FAysEUL3Rg++ugTefWVlyQ7t63+rrk2wtg7KgINHvy768/3ZcHVs3MbQBycTYe41fQf4xPRtI8/7lhp3769WlmABjMOEHCdS0pL1bJruiyQkzbxOO4vB2DD8hqwAypc7FCm3KjdQsUvBpQ1NwIsuVddf8pb6Z4CsI0XwbUhE8WjIKcO494ivoW6+xByXAN9yx2gOsU3Rs3H9bNZkS5DrDJv/ny5/4GHVXiDWCcc9PYH5ynZ/VOCLrrfGlBSksdUjqD+vP4Az8ie6PV6n3Lz4Qf2NprYHFeX6jSkp8TmA/r315QRB6kpWG3YbY7ysnIFuxmJa+JqAINngGUPFdDwe3WxA1YdwJs8N+cLjcux4rjtWGrOFRcfr669ylYDLrmTUvOpF0E6y2N59NxY5vyCQg0VTPWcbjqBCa1kZuAI1AtJSNDNiI441H8ztJHvDpEXCSWyB+6pCbjntn9+SVFBv719Tl0uetCCp+TktPX6hKGEsW667MDdLgMwtZSxsUG3HSXchPHjZOjQocqoO5psJ8ds8tz8e8vmzUEhCXGviccBoKNKc1JjqNSMG048TqzLUbv01ACZc1XtcGakq8cQExsg67xCqSsD/SDoyGmTY4co5LVYZj7HfC9+ZoCdkpzihCA7qhTYn3/+pQKbAyKNTeJwr5I7sE9Cvc6u6TGfz/9AaXH+H/ZkvTnTngAeBLk7yXiHbwAADKVJREFU0qheC96kLzJDEMvLKzR3znHxJZepZpvmB4AbYo34nYN4nFp03QBsZwPAoqrl9Pk0HsdV5m/c5VBBS2jXGQN2o6yDlSdtBRlnQMcmBOhRzOGGO55AdXrPyHCrz+XIXNmgOGDKyf/TjolZ6gbYfBc31t73YxRayecT37ANhYXf7hfAMzJyLxKPPO+q2va9+E39CuJzU4hCWo3jtNPP1E4w1EUbFxerh+gEoBm2XWNkJq0wAVUnoTqqNy1TDYBfLWxgQ8D9d1JojlIOkg+m3BG6+HSDMKk0fsZ11SbnTJ27QwI6HVlg14nRly1fpjLdd96dIhtKCoPsOBsHm0xzLPVs3PNSf/e8XhbcZdMbdxua8l2Oe0xKy6M5YWP1hg4bKiNHjJC0tFSVkrIhwEzDuBv9uAMyR2dO6syMPDZuuSljNUDDrSc25/1sGCjKjIXGshtQGwvtMO2QZS1UZQcZp7rzHTslLz9P0110a6H4hsOUtmL5XWA36ilx1Gu2/24zQTS0uKT2GffmovNaR/SSmfOyWNZ5rhVv1A1pkjcZFxkGG6CHuu/E6ieecILWkyMJpQ2SMtbaDGJXQKhCyswZsWvAXZ+2TxBuoQcueGg1GQQcOX2krHn5+bq50O74+UnPBt8W3ypJ0lNT9Lqx5uHUcqpJbl4TncTwYPy9S3y96youaRTA07KyxnjEO8MFeBPdqSY4jcNQW6pSg3HG7eWg31nXLp21cUS3bl21Hp341pGSRgUr1RwL6hStmCMUzJybB8lpSxWvqTEYc1PyiesPD7Bo0SJtt0RRiCHLOB85bK6N3DebU/NtyNAENzt4CuOe2x+VFOWP31vsbd6yLwsePLWrTW/KG9W059I+boGOLqZE1XwC+eQB/Z35aDRQMFp3XGpiZSq7jJteDXRnMwDExOSbKzerLJbXUXkGqL/+Zpb8MGd2jS/CqCBcdKSs7jimpr3Hztn2Xvtd1yfWB+ABbXrODZbX84CbEz8QN67pzhlKzAFOBCSGiTefMnjocGmTmyupaWla8WXUcBBw/AkVv2zbtlUKCopkwcIFwTia8+B2I501wyBcsqzp7mHdZwpa75LYaE97Mz10X59ab4C7wwn3tZSH3++NG2+aOnCFEGgFhaX1atMU+o0Y4ZucnKgbABYat9/0anMZ8INy7+ud+w69mvoAnNc7nV4ycx7xeDzXuVb8oNzQJv0QQ9KZJoXEx5BeezuMNUdhZ/LUoW58k16ge7I9rkBo5Zj4PX1KStaTSqlTmlr7JA0COI0gvLY1z23I6D6N7goc1BVwUmN+/+Tiovzz6wturrC+AK+24sF+bW6d+EG9xe6HNdsVMBbcb/n3qVxrrAWvBrg7v6zZPmjuFz8UK7DnuWP1uZqGWHDXitdnRd3XuCvQRCtgdOeQmI2x3g110UOsuDscoYnuoXsadwX2tgJVlmXF+m17t6mh9V22hlrwapC7jHp919h9nbsCDV6BGuXZ+2jqsLeTNxrgZtSwiKS7LZ0afP/cN7grsI8VcFoy2T776eLivKsawpyHnrgxAA9acQYkOB1fXEbdfV7dFWi6FQj2QKz0iK9LEcX39cx7176GxgI81FWfZlnWOLcQpelur3umZr8Cmvf22/5rSwrzH2ssuBtDsoWuvCppQqeguK56s38w3QXY7xUwaTH/rNKiguH7e7r9seBBK56R4Rai7O+NcN/vrkBTEWtNEYPvZsnd3m3uA+quwP6uQP16nTfkU/bXggetuNvaqSHL7r7WXYHaK1C/SSUNXbemAHg1q56Ve5JHZIq5CLeMsKG3w31981yBatbc55XeZfn5dNisV7XYvtarqQAewqrn3urxWHe7JaX7Wnr39+4KSGA8t99nWR6vX+Tk0sK8qU0F7v1l0eu6P07duFtx5j677grUcwWaPu5uapJtN8KNH0C6eTyeca4Ipp732X1Zc1yBRtd513exmtJFN5+pVrxNmzbxVTt9syzL09e2bfr1OpPx3MNdAXcFWAFHzOLzf1FSnD86sCRNEncfSAteA+SOXj32Sxfk7hPtrkCNFdAqMfv/27uaEDmKKPy96kk2Wdkou5l12GxEMSZ6UBRDomggevEiJIKIuQWU4NGDXiQRMTmJFw+CiAgRgiAePAgquWi8BBERI5JICCG4cXdmM5isJrs72/VJ9c90T0/PzE52p+ev9rTTXd1V9ep973v1+lUV9bmRDc7eYAPFdQd3J+bgde56CHKlnIetu27V3EoAVXC7laWny+XyjfUMqiXl2wkXvQ7knru+rL9TjtpnQO5bL9qxthIYNgn4brl2z2UB7k4zeI27bn6YI5BEqUP+JzSv+tpzcYZtuG1/h0ICfgqq9pZ/aq1Pj2xQBzrplmcxB08OXHV+MVmYeheQYwGD2+DbUKj4MHfSz1AzINeuPlkqXj3cqYBampQ77aIn3XXz280XCi+C6lMRGTOWzQjAuuzDDIJB7bufoeYzOI6V5mZOZAnurFz0VDYfn5ra7qzgVDAv985dsi77oCr6sPXL0+WVINa0QJFDsQw1j+SykkiWDF4XfDMX8ndvOwrweLBUbglgzgI9q+G39ay/BHyiMifImPm26+CV9cwtb7e93QK4aWd1Xp7Pb3uUSn/kKGevf8RO9aC1dvtjy1sJdEkCEWt7DdB8s1i8+n7WLnmy890EeNiWKAA3OfUGBW8rpcaCs7TsJ7UuqautdnUSCCPkVdZ29Y+u0q+VZ2f/CEgsU5e8FwFew+ZmXTmZO07hkWjjd23n56vTN1sqIwlEU0qMBO54ieTR+eLfH3ebteMi6AUGT5+be2473xHgQOyEB8voGSmwrSZdAvXANjkd8qFbWTwWy0rrKmv3MsBDNq8KaKJQ2CNUbxmgB5bS3LPBOIvATCWQwtgAcRJUJ4LjfGs80Uwb16SyXmPwJJvXAN2hep3gS96G8KSXHeQ/YDPiekWhBq8dJnjm65hPMK4rkC+o5b1SaebXmDveM6zd6wye1JEwndUTdD4/vYOij4jgsIjkzbUoK445mzQzeBDLvkchqM2OK0EWmtYLQvl8RbkfBAG0Om8z+3a2rrGXGbwp0M0qtVxu80GCr0Kwz1jXMBvOX39uv6e3Hn5bIpRAFA332boa99G8IMAnIu5nwQkjfQHsar/6cIhrGN203z98Qb1M8qBZex7fXzp04y2z9+FId7zJEVOHoPZyxg1bA19rkVPzszPfxjLP6nSv401cYwX9xOBpXa0T+NZCYb9QvQDiOQh2hczus7ufjGDY3QJ+jZrTh4/Hvll7OpAAtVn4dIbkl47or2Js3VeMnRyWfgd42J80y+pMFAqPO1DPU2M/BHtMbnDI7hbwfYjQNptc63ZHrndMBxYAnDWg1jn5JkgpbaZTbbag+8UHBeBxSaa6UWZxS25FniH4LIAnRMkub8i9lT4mIl/L8NELbYS++2raugWJOXQdQ5s3eNmRxAWB/EDlnlbkmQZMbYpntiCkde9uv8QgArwl2E0K4XihsCunnd0AnqLoJwHZGWd485II9J4pCJTGj6za5a23r3RrfbIRmEODHRlt7YJyUQS/aPJ7ofqpVJo5lwBvfNORgQB1XL6DDvA0sKdaZ8PwSssjQjxGcLcQD0Jwbwj6CPBJ4NeqqwX/WuEbHQaQ9qZQvmGUu9YDQwnEeQJnFeT3FeX+XJ6dvZDCxgMN6mEFeFJfWg2yMz41NaW0vl9ptRuQBwg+RME9AhQM8OMufvjyiNlrIrRV9q8RvqiaLasG0SsIgRjJJ0xOqpFEQj7+tCgO4oSBXQB4mZBLIP40YCbxm+veuhSki7Y71mu3Sj36hmFi8FZDkNwfLtVd8zaQXMI2OivT0LJTKZkGsYPEdAD+cQBjjZSz3hA0Zyy/vJkeeCqea9QJw2zJe+0YjCQQmwMyCc7mbUtmGibriq018Hvpb8hp2LgMwRUQVyFyEeKe18CVHHk5MXe2gG6gGBbgzWGftilkw3maAX+lUhmriGxVcO5TGhMAJynYLkCewFYQExRMCDAKYEt4IETSIKQ1qxFg2wFyKyuXvN8I+GG5ZvdjiUdhMNN8irrhARf4D4JrAsxr4i8FFAEpauVe0UBxAzk/Nzd3rUWwq63xabfvg1DeAvz2RnFNilU1BI4zAq3HFDAJOHcI3M3UMq4gW6CwRWtsUpA7CY5QcJdnFIhNFG70/5eNFGzyON43GObP+x0w/ujq8vS9/ICbwUMbASwDWPTYFPCuC7EI4TIolQCci+aeAm6SWIbgOunt+X0Dwn+ouSyKZcK5RbglAv9CqQUsLl4fHR2tBLuKrkb6jXbeHbiA2GqE0W6Z/wFlDhT8ki3dTwAAAABJRU5ErkJggg==', - width: 248, - height: 248, - title: '', - altText: '', - caption: '' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - await expect(page.locator('img')).toHaveAttribute('src', /blob:/); - }); - - test('renders image card node', async function () { - await insertEmptyImageCard(page); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('can upload an image', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - }); - - test('can get image dimensions from external URL', async function () { - await page.route('https://example.com/large-image.png', route => route.fulfill({ - status: 200, - contentType: 'image/png', - body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') - })); - await focusEditor(page); - await page.keyboard.type('/image https://example.com/large-image.png'); - await page.keyboard.press('Enter'); - // Wait for card to be rendered before checking height and width - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - // Check that the image has the correct height and width populated - await assertRootChildren(page, JSON.stringify([{ - type: 'image', - version: 1, - src: 'https://example.com/large-image.png', - width: 248, - height: 248, - title: '', - alt: '', - caption: '', - cardWidth: 'regular', - href: '' - },{ - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }])); - }); - - test('can get image dimensions when rendering serialized node with missing data', async function () { - await page.route('https://example.com/large-image.png', route => route.fulfill({ - status: 200, - contentType: 'image/png', - body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') - })); - - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'image', - src: 'https://example.com/large-image.png', - title: 'This is a title', - alt: 'This is some alt text', - caption: 'This is a caption', - cardWidth: 'wide' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - const editorState = JSON.parse(await getEditorStateJSON(page)); - - expect(editorState.root.children[0].type).toEqual('image'); - // missing width & height are extracted from the image - expect(editorState.root.children[0].width, 'width').toEqual(248); - expect(editorState.root.children[0].height, 'height').toEqual(248); - }); - - test('does not change existing image dimensions when rendering serialized node', async function () { - await page.route('https://example.com/large-image.png', route => route.fulfill({ - status: 200, - contentType: 'image/png', - body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') - })); - - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'image', - src: 'https://example.com/large-image.png', - title: 'This is a title', - alt: 'This is some alt text', - caption: 'This is a caption', - cardWidth: 'wide', - width: 1000, - height: 1000 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - const editorState = JSON.parse(await getEditorStateJSON(page)); - - expect(editorState.root.children[0].type).toEqual('image'); - // existing width & height are kept from the serialized state - expect(editorState.root.children[0].width, 'width').toEqual(1000); - expect(editorState.root.children[0].height, 'height').toEqual(1000); - }); - - test('can toggle to alt text', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - // placeholder is replaced with uploading image - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - // wait for upload to complete - await expect(await page.getByTestId('progress-bar')).toBeHidden(); - - await page.click('button[name="alt-toggle-button"]'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('renders caption if present', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - // placeholder is replaced with uploading image - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - // wait for upload to complete - await expect(await page.getByTestId('progress-bar')).toBeHidden(); - - await page.click('[data-testid="image-caption-editor"]'); - await page.keyboard.type('This is a caption'); - await expect(await page.locator('text="This is a caption"')).toBeVisible(); - }); - - // NOTE: still works, but it's a focus issue - test.skip('can paste html to caption', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-testid="image-caption-editor"]'); - await page.click('[data-testid="image-caption-editor"]'); - await pasteText(page, 'This is link
    ghost.org/changelog/markdown/', 'text/html'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true}); - }); - - test('renders image card toolbar', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); - }); - - test('image card toolbar has Regular button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Regular"]')).not.toBeNull(); - }); - - test('image card toolbar has Wide button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Wide"]')).not.toBeNull(); - }); - - test('image card toolbar has Full button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Full"]')).not.toBeNull(); - }); - - test('image card toolbar has Link button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Link"]')).not.toBeNull(); - }); - - test('image card toolbar has Replace button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Replace"]')).not.toBeNull(); - }); - - test('image card toolbar has Snippet button', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Snippet"]')).not.toBeNull(); - }); - - test('toolbar can toggle image sizes', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - // placeholder is replaced with uploading image - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - // wait for upload to complete - await expect(await page.getByTestId('progress-bar')).toBeHidden(); - - await page.click('[data-kg-card="image"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); - - await page.click('[data-kg-card-toolbar="image"] button[aria-label="Wide width"]'); - expect (await page.locator('[data-kg-card-width="wide"]')).not.toBeNull(); - - await page.click('[data-kg-card-toolbar="image"] button[aria-label="Full width"]'); - expect (await page.locator('[data-kg-card-width="full"]')).not.toBeNull(); - - await page.click('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]'); - expect (await page.locator('[data-kg-card-width="regular"]')).not.toBeNull(); - }); - - test('toolbar does not disappear on click', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await insertEmptyImageCard(page); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - // placeholder is replaced with uploading image - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - // wait for upload to complete - await expect(await page.getByTestId('progress-bar')).toBeHidden(); - - await page.click('figure'); - - await page.click('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]'); - - expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); - }); - - test('file input opens immediately when added via card menu', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Image"]') - ]); - - expect(fileChooser).not.toBeNull(); - }); - - test('can handle drag over & leave', async function () { - await insertEmptyImageCard(page); - - const imageCard = await page.locator('[data-kg-card="image"]'); - expect(imageCard).not.toBeNull(); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - - await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragenter', {dataTransfer}); - - expect(await page.locator('[data-kg-card-drag-text="true"]')).not.toBeNull(); - - await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragleave', {dataTransfer}); - - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toHaveCount(0); - }); - - test('can handle image drop on empty card', async function () { - await insertEmptyImageCard(page); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - - await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragenter', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('drop', {dataTransfer}); - - // placeholder is replaced with uploading image - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - // wait for upload to complete - await expect(page.getByTestId('progress-bar')).toBeHidden(); - await expect(page.locator('[data-kg-card="image"] img')).toHaveAttribute('alt', ''); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -


    - `); - }); - - test('replaces image when new image file dropped on populated card', async function () { - await focusEditor(page); - await insertImage(page); - - const originalSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-1.png'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image-1.png', fileType: 'image/png'}]); - - await page.locator('[data-kg-card="image"] [data-testid="image-card-populated"]').dispatchEvent('dragenter', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card="image"] [data-testid="drag-overlay"]')).toBeVisible(); - - await page.locator('[data-kg-card="image"] [data-testid="image-card-populated"]').dispatchEvent('drop', {dataTransfer}); - - // wait for upload to complete (progress bar may be too transient to catch) - await expect(page.getByTestId('progress-bar')).toBeHidden({timeout: 15000}); - - const newSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); - - expect(originalSrc).not.toEqual(newSrc); - }); - - test('adds extra paragraph when image is inserted at end of document', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Image"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('does not add extra paragraph when image is inserted mid-document', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('ArrowUp'); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Image"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -

    Testing

    - `, {ignoreCardContents: true}); - }); - - test('can insert unsplash image', async () => { - const testData = [ - { - id: 'SgvrLyGKnHw', - created_at: '2023-02-27T20:39:45Z', - updated_at: '2023-03-01T06:08:01Z', - promoted_at: '2023-03-01T06:08:01Z', - width: 5504, - height: 8256, - color: '#8c8c8c', - blur_hash: 'LHD]Vg4m%fIA_3D%%2MxIoWCs.s:', - description: null, - alt_description: 'a group of people walking down a street next to tall buildings', - urls: { - raw: 'http://127.0.0.1:5173/Koenig-editor-1.png', - full: 'http://127.0.0.1:5173/Koenig-editor-1.png', - regular: 'http://127.0.0.1:5173/Koenig-editor-1.png', - small: 'http://127.0.0.1:5173/Koenig-editor-1.png', - thumb: 'http://127.0.0.1:5173/Koenig-editor-1.png', - small_s3: 'http://127.0.0.1:5173/Koenig-editor-1.png' - }, - links: { - self: 'https://api.unsplash.com/photos/SgvrLyGKnHw', - html: 'https://unsplash.com/photos/SgvrLyGKnHw', - download: 'https://unsplash.com/photos/SgvrLyGKnHw/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjc3NjUxMzk5', - download_location: 'https://api.unsplash.com/photos/SgvrLyGKnHw/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjc3NjUxMzk5' - }, - likes: 1, - liked_by_user: false, - current_user_collections: [], - sponsorship: null, - topic_submissions: {}, - user: { - id: '9_671Bq5l40', - updated_at: '2023-03-01T06:08:01Z', - username: 'jamillatrach', - name: 'Latrach Med Jamil', - first_name: 'Latrach', - last_name: 'Med Jamil', - twitter_username: null, - portfolio_url: null, - bio: 'Just trying to share what I have --\r\n\r\nInstagram.com/jamillatrach/', - location: 'Düsseldorf', - links: { - self: 'https://api.unsplash.com/users/jamillatrach', - html: 'https://unsplash.com/@jamillatrach', - photos: 'https://api.unsplash.com/users/jamillatrach/photos', - likes: 'https://api.unsplash.com/users/jamillatrach/likes', - portfolio: 'https://api.unsplash.com/users/jamillatrach/portfolio', - following: 'https://api.unsplash.com/users/jamillatrach/following', - followers: 'https://api.unsplash.com/users/jamillatrach/followers' - }, - profile_image: { - small: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=32\u0026h=32', - medium: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=64\u0026h=64', - large: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=128\u0026h=128' - }, - instagram_username: 'jamillatrach', - total_collections: 0, - total_likes: 4, - total_photos: 451, - accepted_tos: true, - for_hire: false, - social: { - instagram_username: 'jamillatrach', - portfolio_url: null, - twitter_username: null, - paypal_email: null - } - } - } - ]; - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - - // mock unsplash api - await page.route('https://api.unsplash.com/photos?per_page=30', route => route.fulfill({ - status: 200, - body: JSON.stringify(testData) - })); - await page.click('[data-kg-unsplash-insert-button]'); - await assertHTML(page, html` -
    -
    -
    -
    - a group of people walking down a street next to tall buildings -
    -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('can insert tenor image', async () => { - await mockTenorApi(page); - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="GIF"]'); - - // chose second gif from list - await expect(await page.locator('[data-gif-index="1"]')).toBeVisible(); - await page.click('[data-gif-index="1"]'); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Type caption for image (optional)
    -
    -
    - -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('can insert a gif with the keyboard', async () => { - await mockTenorApi(page); - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="GIF"]'); - - // chose third gif from list - await expect(await page.locator('[data-gif-index="2"]')).toBeVisible(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Enter'); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Type caption for image (optional)
    -
    -
    - -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('can close the gif selector on Esc', async () => { - await mockTenorApi(page); - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="GIF"]'); - - await expect(await page.getByTestId('gif-selector')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(await page.getByTestId('gif-selector')).toBeHidden(); - }); - - test('can show a gif selector error', async () => { - await mockTenorApi(page, {status: 400}); - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="GIF"]'); - - await expect(await page.getByTestId('gif-selector-error')).toBeVisible(); - }); - - test('can add snippet', async function () { - // insert image - await insertImage(page); - - // create snippet - click card to ensure stable selected (not editing) state - await page.keyboard.press('Escape'); - await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-editing', 'false'); - await expect(page.getByTestId('create-snippet')).toBeVisible(); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="image"]')).toHaveCount(2); - }); - - test('can select caption text without scrolling', async function () { - // Type in some text, so that we can scroll - await focusEditor(page); - await enterUntilScrolled(page); - await insertImage(page); - - const rootParagraphs = page.locator('.koenig-lexical > [data-kg="editor"] > div > p'); - const paragraphCount = await rootParagraphs.count(); - - await page.keyboard.type('Captiontest--Captiontest'); - - const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); - - // Check contains text - await expect(captionEditor).toHaveText('Captiontest--Captiontest'); - - // Ensure caption is scrolled into view before mouse operations - // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) - await page.evaluate(() => { - document.querySelector('[data-testid="image-caption-editor"]').scrollIntoView({block: 'center'}); - }); - await page.waitForTimeout(100); - - await expectUnchangedScrollPosition(page, async () => { - // Select the text using mouse drag (middle to end) - const box = await captionEditor.boundingBox(); - const y = box.y + box.height / 2; - const startX = box.x + box.width / 2; - const endX = box.x + box.width; - - await page.mouse.move(startX, y); - await page.mouse.down(); - await page.mouse.move(endX, y); - await page.mouse.up(); - - await page.keyboard.type('world'); - - // Check contains text - await expect(captionEditor).toHaveText('Captiontest-world'); - - // Press the enter key - await page.keyboard.press('Enter'); - - // Check if the image card is now deselected - await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-selected', 'false'); - - // Check total paragraph count increased - await expect(rootParagraphs).toHaveCount(paragraphCount + 1); - - // Add some text - await page.keyboard.type('last one'); - - // Check contains text - await expect(rootParagraphs.filter({hasText: 'last one'})).toHaveCount(1); - }); - }); - - test('can select caption text and make it italic', async function () { - // Type in some text, so that we can scroll - await focusEditor(page); - await enterUntilScrolled(page); - await insertImage(page); - - await page.keyboard.type('Captiontest--Captiontest'); - - const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); - - // Check contains text - await expect(captionEditor).toHaveText('Captiontest--Captiontest'); - - // Ensure caption is scrolled into view before mouse operations - // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) - await page.evaluate(() => { - document.querySelector('[data-testid="image-caption-editor"]').scrollIntoView({block: 'center'}); - }); - await page.waitForTimeout(100); - - await expectUnchangedScrollPosition(page, async () => { - // Select the left side of the text (deliberately a test in the other direction) - const box = await captionEditor.boundingBox(); - const y = box.y + box.height / 2; - const startX = box.x + box.width / 2; - const endX = box.x; - - await page.mouse.move(startX, y); - await page.mouse.down(); - await page.mouse.move(endX, y); - await page.mouse.up(); - - // Click italic button - await page.locator('[data-kg-toolbar-button="italic"]').click(); - - // Check contains text - await expect(captionEditor).toHaveText('-Captiontest'); - const italicSpan = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p em').nth(0); - await expect(italicSpan).toHaveText('Captiontest-'); - }); - }); - - test('does not remove text when pressing ENTER in the middle of a caption', async function () { - // Type in some text, so that we can scroll - await focusEditor(page); - await enterUntilScrolled(page); - await insertImage(page); - - const rootParagraphs = page.locator('.koenig-lexical > [data-kg="editor"] > div > p'); - const paragraphCount = await rootParagraphs.count(); - - await page.keyboard.type('Captiontest--Captiontest'); - - const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); - - // Check contains text - await expect(captionEditor).toHaveText('Captiontest--Captiontest'); - - // Ensure caption is scrolled into view before mouse operations - // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) - await page.evaluate(() => { - document.querySelector('[data-testid="image-caption-editor"]').scrollIntoView({block: 'center'}); - }); - await page.waitForTimeout(100); - - await expectUnchangedScrollPosition(page, async () => { - // Click at the middle of the caption text - const box = await captionEditor.boundingBox(); - const y = box.y + box.height / 2; - const x = box.x + box.width / 2; - - await page.mouse.move(x, y); - await page.mouse.down(); - await page.mouse.up(); - await page.keyboard.type('**'); // To test the cursor is at the middle, otherwise were not testing anything - await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); - - // Press the enter key - await page.keyboard.press('Enter'); - await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); - - // Check if the image card is now deselected - await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-selected', 'false'); - - // Check total paragraph count increased - await expect(rootParagraphs).toHaveCount(paragraphCount + 1); - - // Add some text - await page.keyboard.type('last one'); - - // Check contains text - await expect(rootParagraphs.filter({hasText: 'last one'})).toHaveCount(1); - - // Caption still ok? - await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); - - // Select the caption again (click centers on the element by default) - await captionEditor.click(); - await page.keyboard.type('_'); // To test the cursor is at the middle, otherwise were not testing anything - - // Press the enter key - await page.keyboard.press('Enter'); - await expect(captionEditor).toHaveText('Captiontest-*_*-Captiontest'); - }); - }); - - test.describe('image should be a top-level element', () => { - test('can insert image to nested list', async function () { - await insertImage(page); - const modifier = isMac ? 'Meta' : 'Control'; - await page.keyboard.press(`${modifier}+KeyC`); - await page.keyboard.press('Enter'); - await page.keyboard.type('- First item'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Tab'); - await page.keyboard.press(`${modifier}+KeyV`); - - // image should be top-level and shouldn't have ul as a parent - await expect(page.locator('ul:has([data-kg-card="image"])')).toHaveCount(0); - }); - - test('can paste image with text from google doc', async function () { - await focusEditor(page); - await pasteHtml( - page, - '

    Some text



    ', - 'text/html' - ); - - // image should be top-level and shouldn't have paragraph as a parent - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - await expect(page.locator('p:has([data-kg-card="image"])')).toHaveCount(0); - }); - }); - - test.describe('caption', function () { - test.beforeEach(async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'image', - src: '/content/images/2022/11/koenig-lexical.jpg', - width: 3840, - height: 2160, - title: 'This is a title', - alt: 'This is some alt text', - caption: 'This is a caption', - cardWidth: 'wide' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - }); - - test('can delete node and undo without losing caption', async function () { - await page.click('.koenig-lexical'); - await page.keyboard.press('ArrowUp'); - await page.waitForSelector('[data-kg-card="image"][data-kg-card-selected="true"]'); - await page.keyboard.press('Backspace'); - await expect(page.locator('[data-kg-card="image"]')).not.toBeVisible(); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); - }); - - test('can toggle between alt and caption', async function () { - await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); - await page.click('[data-kg-card="image"]'); // alt toggle not shown until selected - await page.click('[data-testid="alt-toggle-button"]'); - await expect(page.getByTestId('image-caption-editor')).toHaveValue('This is some alt text'); - await page.click('[data-testid="alt-toggle-button"]'); - await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); - }); - }); - - test.skip('can drag image card onto image card to create gallery', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - await focusEditor(page); - - const [fileChooser1] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'image', nth: 0}) - ]); - await fileChooser1.setFiles([filePath]); - - await page.keyboard.press('Enter'); - - const [fileChooser2] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'image', nth: 1}) - ]); - await fileChooser2.setFiles([filePath]); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - const imageCard1BBox = await page.locator('[data-kg-card="image"]').nth(0).boundingBox(); - const imageCard2BBox = await page.locator('[data-kg-card="image"]').nth(1).boundingBox(); - - await dragMouse(page, imageCard2BBox, imageCard1BBox, 'middle', 'middle', true, 100, 100); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); -}); - -async function insertImage(page, image = 'large-image.png') { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${image}`); - - await insertEmptyImageCard(page); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); -} - -async function insertEmptyImageCard(page) { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'image', - version: 1, - src: '', - width: null, - height: null, - title: '', - alt: '', - caption: '', - cardWidth: 'regular', - href: '' - }, { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); -} - -function tenorTestData() { - return ( - { - locale: 'en', - results: [ - { - id: '6897265628617702942', - title: '', - media_formats: { - tinygif: { - url: 'https://media.tenor.com/X7gCi8NE_h4AAAAM/cat-funny.gif', - duration: 0, - preview: '', - dims: [ - 220, - 204 - ], - size: 522164 - }, - gif: { - url: 'https://media.tenor.com/X7gCi8NE_h4AAAAC/cat-funny.gif', - duration: 0, - preview: '', - dims: [ - 498, - 460 - ], - size: 4870544 - }, - tinygifpreview: { - url: 'https://media.tenor.com/X7gCi8NE_h4AAAAF/cat-funny.png', - duration: 0, - preview: '', - dims: [ - 220, - 204 - ], - size: 21743 - }, - gifpreview: { - url: 'https://media.tenor.com/X7gCi8NE_h4AAAAe/cat-funny.png', - duration: 0, - preview: '', - dims: [ - 640, - 592 - ], - size: 141384 - }, - mp4: { - url: 'https://media.tenor.com/X7gCi8NE_h4AAAPo/cat-funny.mp4', - duration: 3.7, - preview: '', - dims: [ - 640, - 592 - ], - size: 754491 - } - }, - created: 1580334888.9161069, - content_description: 'Cat Funny GIF', - itemurl: 'https://tenor.com/view/cat-funny-fall-submit-play-gif-16179688', - url: 'https://tenor.com/bf3eS.gif', - tags: [ - 'cat', - 'funny', - 'fall', - 'submit', - 'play' - ], - flags: [], - hasaudio: false - }, - { - id: '11657229184981764452', - title: '', - media_formats: { - tinygifpreview: { - url: 'https://media.tenor.com/ocbMLlwniWQAAAAF/steve-harvey-oh.png', - duration: 0, - preview: '', - dims: [ - 220, - 124 - ], - size: 11388 - }, - tinygif: { - url: 'https://media.tenor.com/ocbMLlwniWQAAAAM/steve-harvey-oh.gif', - duration: 0, - preview: '', - dims: [ - 220, - 124 - ], - size: 173121 - }, - gifpreview: { - url: 'https://media.tenor.com/ocbMLlwniWQAAAAe/steve-harvey-oh.png', - duration: 0, - preview: '', - dims: [ - 640, - 360 - ], - size: 65023 - }, - gif: { - url: 'https://media.tenor.com/ocbMLlwniWQAAAAC/steve-harvey-oh.gif', - duration: 0, - preview: '', - dims: [ - 498, - 280 - ], - size: 1669457 - }, - mp4: { - url: 'https://media.tenor.com/ocbMLlwniWQAAAPo/steve-harvey-oh.mp4', - duration: 2.4, - preview: '', - dims: [ - 640, - 360 - ], - size: 377541 - } - }, - created: 1600453059.6729331, - content_description: 'Steve Harvey Oh GIF', - itemurl: 'https://tenor.com/view/steve-harvey-oh-you-crazy-point-stop-gif-18502036', - url: 'https://tenor.com/bpNn6.gif', - tags: [ - 'Steve Harvey', - 'oh', - 'You Crazy', - 'point', - 'stop' - ], - flags: [], - hasaudio: false - }, - { - id: '5363605506377337635', - title: '', - media_formats: { - gif: { - url: 'https://media.tenor.com/Sm9aylrzSyMAAAAC/cats-animals.gif', - duration: 0, - preview: '', - dims: [ - 498, - 431 - ], - size: 1574979 - }, - gifpreview: { - url: 'https://media.tenor.com/Sm9aylrzSyMAAAAe/cats-animals.png', - duration: 0, - preview: '', - dims: [ - 640, - 554 - ], - size: 153379 - }, - tinygifpreview: { - url: 'https://media.tenor.com/Sm9aylrzSyMAAAAF/cats-animals.png', - duration: 0, - preview: '', - dims: [ - 220, - 190 - ], - size: 25196 - }, - tinygif: { - url: 'https://media.tenor.com/Sm9aylrzSyMAAAAM/cats-animals.gif', - duration: 0, - preview: '', - dims: [ - 220, - 190 - ], - size: 236117 - }, - mp4: { - url: 'https://media.tenor.com/Sm9aylrzSyMAAAPo/cats-animals.mp4', - duration: 1.2, - preview: '', - dims: [ - 640, - 554 - ], - size: 265062 - } - }, - created: 1616817775.272332, - content_description: 'Cats Animals GIF', - itemurl: 'https://tenor.com/view/cats-animals-reaction-wow-surprised-gif-20914356', - url: 'https://tenor.com/bzUWu.gif', - tags: [ - 'cats', - 'animals', - 'reaction', - 'wow', - 'surprised' - ], - flags: [], - hasaudio: false - } - ] - } - ); -} - -const tenorUrl = /https:\/\/tenor\.googleapis\.com\/v2\//; -async function mockTenorApi(page, {status} = {status: 200}) { - await page.route(tenorUrl, route => route.fulfill({ - status, - body: JSON.stringify(tenorTestData()) - })); -} - -function klipyTestData() { - return { - results: [ - { - id: '2484942301552561', - title: 'Klipy Cat', - media_formats: { - tinygif: { - url: 'https://static.klipy.com/gif/klipy-cat-tiny.gif', - duration: 0, - preview: '', - dims: [220, 220], - size: 271080 - }, - gif: { - url: 'https://static.klipy.com/gif/klipy-cat.gif', - duration: 0, - preview: '', - dims: [498, 498], - size: 273268 - } - }, - created: 1765483200, - content_description: 'Klipy Cat GIF', - itemurl: 'https://klipy.com/gifs/klipy-cat', - url: 'https://static.klipy.com/gif/klipy-cat.gif', - tags: ['cat'], - flags: [], - hasaudio: false - } - ], - next: '' - }; -} - -const klipyUrl = /https:\/\/api\.klipy\.com\/v2\//; -async function mockKlipyApi(page, {status} = {status: 200}) { - await page.route(klipyUrl, route => route.fulfill({ - status, - body: JSON.stringify(klipyTestData()) - })); -} - -test.describe('Image card - Klipy GIF provider', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can insert klipy image', async () => { - await mockKlipyApi(page); - // ?gifProvider=klipy makes the demo resolve the GIF card to Klipy - await initialize({page, uri: '/?gifProvider=klipy#/?content=false'}); - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await page.click('button[data-kg-card-menu-item="GIF"]'); - - await expect(await page.locator('[data-gif-index="0"]')).toBeVisible(); - await page.click('[data-gif-index="0"]'); - - // toBeAttached rather than toBeVisible: the fixture GIF URL is not - // network-loaded in tests, so visibility would depend on an external fetch - await expect(await page.getByTestId('image-card-populated')).toBeAttached(); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Type caption for image (optional)
    -
    -
    - -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/image-card.test.ts b/packages/koenig-lexical/test/e2e/cards/image-card.test.ts new file mode 100644 index 0000000000..536c664861 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/image-card.test.ts @@ -0,0 +1,1556 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { + assertHTML, + assertRootChildren, + createDataTransfer, + createSnippet, + ctrlOrCmd, + dragMouse, + enterUntilScrolled, + expectUnchangedScrollPosition, + focusEditor, + getEditorStateJSON, + html, + initialize, + insertCard, + isMac, + pasteHtml +} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Image card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized image card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'image', + src: '/content/images/2022/11/koenig-lexical.jpg', + width: 3840, + height: 2160, + title: 'This is a title', + altText: 'This is some alt text', + caption: 'This is a caption', + cardWidth: 'wide' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + This is a + caption +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardContents: false}); + }); + + test('can upload image with `data:` url', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPgAAAD4CAYAAADB0SsLAAAAAXNSR0IArs4c6QAAIABJREFUeF7snQeYVOX1xs+d2Qos2zu9d+lNQVABe+xGjV0xiS35x8RYY4vRaDTGFo1RjAVbYgMLKtgFUVCQ3sv2ZZdlaQvM3P/zO3e+2dllgd1lKTN77/MguDtz58537/udc97znnMscY+GroC3jjf46nuSlJSU1tHR0XF+f3RrEbuV7fUliXhbevyS6hd/nEes1uKR1rYtiZZtxYllJ/hFWlgiLcSWONuyY5x/WzG2JXF8rv6/c/D/MSKyI/B3bD2vqyrkPbx3O++zRbbq+W3ZLpa9g/+3bGuHWLKdf3v4vW1V2pa93bKkQvyyyS/2Jstjl9ni3Sbi22L5vBt90faGaJ+vKjo6unL9+vXb6nlNvGy/1roBnxOxL7Ui9pvt/xer/XDtFcRt2rSJ3+r3p3l3Wqm2V3LF78+1LE+GiJ1ui7S3bEkRy06yxWptiaSI2C1ELK9lObfA/L2ny7Zte4/faG+/259l2Ns17e135nqqr8v2iVhbbZEyS+xNYlsbbYt/yxoRq8S2/cXi8eRZPsljM2jh8ZTWYyNo0P3Zn3UI5/e6AHfuXr0eFqyvxMS08Yi3oyV2R9sv7cSSbpYtbW1LUh3gSgIPfygAagMw9P95nW3797J5WLucS7Sj9vSgWZZnN0vXUNDXBdimuK7a3zX0O5jPrLUhVLIRiNiFHrFW+m1ZL2IvF4+91C9SLDt2rC8rK9u0h7Wo130MZ8A29NqbK8BDH4TdwIU1rqqSXNtrd/f47Z62SB/bsntYYnUSsVMMoBxwVltW/r1vwDq3KBSUDQVjQ2/yoXp9zU2urk2Mzat642JNzBqaaw5dY2fDscrElsUistIS+cnvsRZZPmtJScn6VSJS10a513t9qNbmYH1ucwH4Xm9ySk5OW4/f6ucRe5jfln4i0t8Suw0PXN1WBpdz98M8oAfr5kXi5+x9g9w9pGFDsG27yhYpFJEfLLG+sy2Z6/fY88ry89fVsUbNCvCRDPA93sj09DZdLMseKSJH2mIPFkt6W5YVW9NaYJk1dqzhIrsgPnTbSk3wh1r/msAPgL5SbFkG4EXkK9u2vi4pWb+81tVHPNgjDeB13rDMzMwMv2WNtvzecbblHyEivYx1DjwMIWB2XEYXyIcOyA3/5FCPygDfAb3ZtAN8wkIRa4ZtWdO89q7ZRUVFxSGfFZFgjxSAm5sTdJ2x0uL1Hy+2nCoiwy3LUvKrLkC7YG44pA73d+zu6tcGvF0pIjPFknfE5/mglnXf7Xk63L/vHgnYcL3wEOa7Bqgty3+aLXKqWPZIj8erpI1DYhl3245yAR3Gd72Rl14N+N0tvN/vrxJbvhWPvBZpYA9HC15jdyV1FRUVf5pt2b8QsY/ZHdRBPrYu0UQjHxf3beG/AjXc+qA7r2AX+dyyrRd37dr2VkhKLiytejgBnAUOWuvUrKyhXr/3ctuyT/d4POk1LbUbQ4c/AA/eN6hF3oVmTkos23rTtuXJkpK8H2rF6/VWLx68b1JHZudQfng9PzsU2N70rKzTxe+5TiwZ5fF4xO/3B9xvF9T1XE/3ZXtZgdpgr37G5COx/E+XFBa+EU5AP5wteA1gZ2TkXmCLfbPlsbqbBSb/6QglLNf9dmF7AFYgmCbVFCqHbfvn27Y8FhfjfSFETlvDuzwAF9LoUx6OAN8rsI180iXKGn3P3Tc2cAVCrbpJr/r8/jVi2/eWFhc8HTjdYRmjH04Ar7FA6VlZZ4nfc4+x2NW6aNdaN/D5dF/epCvgWHUjjPL7ffPFsu8Kcd0PK6AfLgAPWm3IM4/f86DlsUYFXKIAmeECu0mfU/dk+7kC1UDX59Rvf+H3+G/YUFj4bYhFP+RE3KEGeHC3C6S7HrAte2JtomM/74T7dncFDuAKOOm2kJDxH76d228LpNcOuTU/lAAPWu20rNyTxLYf93o87Z10l1YNucTZAXws3VM39QrYPhOf+/3+EtuyLi0tzJt6qK35oQK4Abc3IyP3CfHIROOOu+RZUz947vkO1goYzzNYCuyXp4uL834d0G8cEqb9UABcv2h6em5/sexXPF5Pd7/f58bZB+spdD/nIKyA47ajqvT7/EvEtn4eEMocdJAfTIAHv1xGRu5FtmU/Hyj+QBpY395hB+HmuB/hrkCTrUAVbLs2sbCti4uL8/5zsF32gwXwILjTM3MesSzrupACEDfWbrLnyT3R4bcCyrar/NW27X+UFOVffzBBfjAAXk2mZea85fV4fua65IffY+he0YFcgWqX3ef3v11alH/awQL5gQa4gpsUmCc6dprX4x0GuF0i7UA+TO65D8cVMAQccbnP75vl31k1PiSVdsDy5QcS4EFwe6Njv/R4vH0DpXhuvH04PoHuNR2sFajyeDyxKOB8O6uOOtAgP1AArwFuy/L0dQpDXDLtYD1F7ucc1isQIN/8BxzkBwLgITF39kzHLdcietdyH9bPnHtxB3kF1JLjrpcWFQw/UDH5gQC4XmtakFBzwX2QHxz348JnBQIgr0G8NenVNzXAHRFLZs4jHo/nOoctdyWnTXrH3JNF2ArYPhXE+P0mhdakYpimBLheWFpG9kSv1/uUC+4Iew7dr3MAV8ABuc/nuypQX95kIG8qgOsFaamn7ZnlrIST4D+Aq+Ke2l2BCFmBaqz4Lf+wQMlpk4C8KQCuF8I8r+07/Ys8ltXerQaLkOfO/RoHcQWcajS/ba+Ji/b0DLSD2m+QNxnA0zNyJnm8notdxvwgPhPuR0XaCjg5cp//+ZLi/EsCvf/3SwSzvwB3SLWsrLMs8b7uWu5Ie97c73PwV8Cx5Lb4zg60gdovK74/AA8Rs8Qttywr3QX4wX8c3E+MtBUIxuMlvp3bu+yv0m2/AZ6RkfuU5bUmuqx5pD1o7vc5dCsQSJ01gaveWIA7KbGsrDEe8c7AcrsFJIfucXA/ORJXIEC6iW9saWHhp42NxxsLcF3RtMzsmR7LM8x1zSPxAXO/06FdAcOq+0OlrA2+pMYAXK03XVksr/W865o3eM3dN7grUN8VUFbd9tmmG0yDCbfGAFzcnHd974/7OncF9mcF6syNN+iEDQV4IC2Wc43H8jzq5rwbtNbui90VaMQKOISb7fP/vrg4/8GGxuINBbhrvRtxi9y3uCvQ+BWoM21W79M1BOCu9a73srovdFegSVcgEIs33Io3BOCu9W7Se+aezF2B+q5AdSxeWpTfOTBIoV5vri/Ag8y5eOR5Ny1Wr7XdrxeZedTmb3Myj8cSv9+ucW6nBTWzq2v+3O/313kNDLV3j3BbAQfk4pcGMer1Bbiuhpv3btqHAqBFRXnF6/GKx1sNOr/PLz6/T3bu3CU+n0927XLqDXbs3Kl/x0RH69+815wjOipaz+H1Vlfohv6b84Qe/D+fw8Fn8Rl72hCa9lu7Z2vcCjQuL14fgO+mWnPrvBt+i7DE0dFREh0drSAEYFu2bJWSovw9nswT3VKSkxKkZYsWQfCa927fvl127Ngpm7dslW2bNzb8gkLewedkZqRIixYt9Kc7d+yUnbt2BjeW/Tq5++YmXIGGq9vqDfDqclC3DdOe7pixgDEx0RIb6/SYNFZye1WVFBfm1XhrZnYb6dOnt7TJzZW0tDSJi4sLbAIxEh8fL61atdSfxcbESExMjERFRanF5nN27dqlAOTvLVu3qLXfsaNKdgasvH623x98rXmPz+fX12zbtk02bdokFRUVUlxSIjNnfS/+nVuC1xffKklSkhN1Q+KoqqrSDcV175sQrw0+VcM16vsCuFrvzMzMDL94l4tIQmAES4MvLZLfYKwzYOTILyjazaqmpmfJ0KFDpFfPntKuXVtp2bKlgjY+Ll5aJyZIQkKC4y77fLKjaofs8u2SXTt3BUEa6j47bnmUREVHSZQ3SmJiYwQXnb/5/+iYaAUirnxtNx2AY/23V21XD2L79m2yVf+ukqodVVJZWSlLly6T7+fMka+//Dx427j+pKRE2bp1q2vZD9HDHIK9Sp9Xepfl56/bV168XgBPz3KFLbXvKYvt9XoUWMS+RcWlQVB37tpdBgwYINlZWdKtW1fp0qWLWmMOABcVFS30owRogEoBHbDIdT07Jj6P8joxd7Vlro6r/YxUV6tdk2iDlIuKjhbey6bAplLbCnM+s2FwDq6Hg+tbsHChLFiwUKZMfU82lBRKemaOtGzZQrZt2+7G7IcG6E5TCNt/bUlh/mP7C3D9CukZOZ9bHmtUc2fPDRAAKw9/3ro1wVs8eOhwOfGE46Vdu3aSm5srHTq0F9tvS3l5ub4Wgmz7tm1qKSs3V6qLjKvMOXGvbbua8bYsyDKPxMTESizWGYBGRWksHuqqY8E9lsexyNtxobHCzmbheAO46I4bb0DN5gIXEB8XFwA1m03163gfn4U3EhcfJ61atpLomBhZtXKlLFq8WP71zL/1eycmp0tycqLruh90kDeMbNubBa+e4+2x5/I9mrN7zndPSGgllZWbg7H0seMmyJEjR8iQwYMlNS1VSaotm7fI+vXrpbCoSOPbiopNUlZWJpWbN8uWLVsUTFhkJdtgz7GwUbjSzs+I3fkDkM0B2OLj4xR0rVu3lsTWrSUpOUk/j1gdQDrW26/WF4Js8+YtsqmiQrZs3aqbCSAH8GwC/JtNhM2Dz+IcfB7eCIdDsDmbhG4sLVsqR8DrKjZuVPf9n089LatXLpeMrFxp0SJe18WNzw882mtg0G8N2Nfc8X0DPDP3Vo/Hurs56s4BjCHMeIBxUXmgr7zicunTu7d07NhBwVBcXCJr1qyVVatW6b83VW6SHTscN1eBEx0t0cTMASB7vKTGPAoIA2pe57zWIdI4kpOTJSM9Q917YnRvlFe2b9uucbDj3jvWmtfruaKiFfiJia31s0ijcUDG7dyxQ99n3gvwibfZhPie0dEOsccfgA+wTShgHls8iIyMDElMSlRAz5gxQx7++6NSUV4ihCWw75zXBfoBB3pgvpl9W0lR3j17c9P3FYPjnn9veayBzdE9J9Zcu65A2eXctu3l17/6lfTr20datWolpaUb5Icff5QlS5eqGx4KZmOZzW22PB611sZKExPHxkRLfLzjcmOd+RsLy2tSUlI0fsdiAxbY7vKNG4NWGLfbEzifYdk5p8eyguDCuvOHmJ8NBYDv8vk0FudnbEyGzINkKyktVU/DWG6uJyGhtYI9NO7n3wA9KztbkpOSZN26dfLGf/8nzz37jH5dgI734ObUDyTIA33b/PackuL8QXv7pD0BfDf3/EBe7uF2bh5irOaKZUukW49ectGFv5BhQ4eq1VyxcqV89/0cWb9uvcS3iNcUVl0HLrBxv2G11fUOgJqNIzY2LsRS2uo6JyYmSU52lgITS0taCsA55Fh0MCY2oFW3vJaAhZ+pOIY/O520FgDnOg3QjRVnU4BpDyXZNlVsUrATTmDh8UQIC0yO3HxXrimhVYLktsnRjWjRwkXy4suT5f2p7+pmyIa2bfv2w+3WRt717MNN3zvAg+5588h986DHxcWqS1tUsF4uv/IqOfnEEyQuPl5mz54tP/44TzZsKJNWCa3UHUYBVjt9pZY6yqvAMe437jWgNjEz5BlWGJKNVBVWu2OHDpKTm6OkGeC0/X7B8uMys1EQR28s36jWcdv26pia89S2lsFQIJBOM+47sXJiYqICkoNzOhuJE06Y9FvrxNZq5eEPNsM5lBTLxo0VQfcd78F4EYQUqSkp0r5De/UIpn30kdx+2616vg6duijn4LrtB2JfMSOP9u6m79VFb07sOSCBRIM44nj0scc1xbVgwSL54osvNW8MYHGDAaCRfpo8s6ahAhbRIa7ipFWrBAVU69YJCg4+A8EI1tGAKicnRwYOHKDubsWmTWo1yX8jjIEk42eOy1udEgNgtY/qNJnDxu/NRYY0SyG+z8hQr8B83o6qKv1uRpzDRmYsN95AXn6+hiYchivgc9gY+M5t27aRzMxMKSkukZcnv1LDbSdmr62VPxCPffM5Z9BN/6KkOH/0nr53XQB32iHn5LT1+mQZY38jnT3nIUXEgUs+8qjR8tvrr1eCavqMT1X0wUNOzFpbz62uMoISCLIAA058npycJKmpqUqM8XvADEhxw/lZUmKipKSmSm5OjrrfxNhFRUUaAhi2nXgcy20+w8TchlAzfxsizKTbTFoM76Cqaof4fLvE643ScMAh0GKDlhqRDd+bDACH6tF9Pv2uhsxTryY+XuICbLvf9ktRYZEUFhWrBwDQDci5Jr5bx04dlSD8Ye6P8ujjj6tgpl37Tpo9wGNxj/1fgRBMVvm80nVPopc9Apyea82hciwU3CecdIpcd+01Crb/vfmWE5t6vcpWG6tmCjoct9dhxnF9AXZ6epqkp2dI64RWegdxTXGBAWlWZpakpaVqTA3ISTvh+q5bu05BTS7cWGGT9wb8hAIqQolyhCpGtMLnc17ceJWyBlRsANCAntfExToEHmGAE587RB4HuXlfoMgkuFHFROtGppLWXWwSVeqqo3gjfMDic/3wAxs2bJDVq9dIVZUTa/PQOW5+tHIJ3bp30/dPm/aR/OH3NwTddjYf8vYu276/QN93hdkeAd4ctOeh4D7/ggvlqqsmypIlS+Sdd6cosI0rXhvcxh2HTYaAAthZWVman3a04Vv1wcYVTk9LU0CgWCstKdWNICU1RQyZFQpGgGrUZgbMWPHahwFaYutEDSvMAShhtfPz81Ukw4aSlZ2l1pffQdSxQThx8Rap2o5L7shhnX/7dLPgGonTzUbA+QkZKjZWqNXm9ckpyeoRsEGsWbNG3Xe8FN5jvAu+d69ePTVGX7lylbz40svqtqNzz8xIc635/uJb9q1N3yPJlp6Rs8DyWN0jNT0WGnNPOP4kufXWm2TJkqUy+ZVXJaFVqxpWm/tgLDcAwM0FBLjhbdrkaq4a60u8bIBNTI1riwUvLCyUkpIS6dmjh7Rt11ZjVF6rsWxUtBJ7qlCLdgQrRoNu5Kuc28T3fG5C6wR9TcWmCgXVsmXL5MuvvpYvvvhKc9K1D3Tko0eNkgEDjqBph4KYECEzM0NiomOcghSfI2wxrjp/szGQAeAanRRfjG4M5WXl+tmo3HDFyTiY61iflxfMzRvVHLF5506ddS2++PJLeeDBvynXAQnnFrHsD8r33QiiNsCbhXqNBxrru3bNSo2577/vXsnPy5cn//m0PrAQasZqh4Ibqwb4Ic4Adm5OrrrG5MEhzrBoWHHiZYCwceNGKSoqVot21FFHKpFWUFCgMTnniomNVU8BtxoG2shNd6oufZeCns9r0bKFxMXF62vXrl0na9aukbVr18rHn0yXpYsXBp8QgMxmgYsfWuuNOx1alkp56ITxx0jHDu2VCMT76Nqls4pkOHD9Vaoa6yjoALbRwxtSceuWLQKYidVNyMF7SjeUyooVK9V9xxVnLYj/8WRy2+TqGnHtU6a8J4/+4+8qHGIjcZn2hgO9Pqq2OgGelpE90ev1PhWpPc+Nwqsgb528/8GHSmY9+tgTCkTVhwcaIRhwO1Y7ThVi6enp0r59O7XeaMvLAiIXI/eEKIM0w1oRuyJaGTliuMTGxcqG0g1BS6xu73ZUads07QXoDVnVskVLSUlN1jsO8BG5kKJbuGiRLFq0KMj0A+jU1BSNmTmX4+7bNdhqJy52YmNTi15bR0/eOjs7Wy1yTk62dOzYUb8jmxWeAyQj35fvrym/Fi0VtBzFxcVSumGDXifhCn/YDPh5QYHjufDd+Hw2uMysTD0fG9g338yUe/9yn3AfEMhs2lTZ8Ke82b8j4KbvofikToBnZOa8bHk850UiwHngIcVwEZ99bpL069dXHn/8SdWOq8Jrh9M1xbjkuKdYMdhmLF2HDh20xJOHmnjUFIMQ4/L/mzdXKnsNyHFNsdzEzFh4rD0xK6kwfo8QBHKNgzw51jopKUk3h9Vr1qj8ddpHH8sPc2YHH2NT5MEPjHur11uPNkwmdWZIO2PpuY4NZRU16sFDcdO7b3/JysqUtNQ0BT5xdXZ2ln4vYvG2bdvWICNx67H+u3bt1O9ZUrpBCvLzdTNjDYjJsfaEJDDyTz71tEx55y112d0qtYbuWAGizbYnFxfln19btlonyRbJ8TdAAtz3/PleOe1nP5P33n9f3nvvAxWv1Aa3IZxw23nA27Zpo6uP0gtLaayYEazAJgPYioqN0r17dxk16ih9PRYTK4tQBWtMqsgIRUgrEcM6ktRKmfvDD7Js2XJ583+v63sBdGLrBBXVmPi4qWWgDinmPApmo0CwwwGTvnXr9t1ie6rn+vbpo+EKazNixAjp07uXEnWVmyr1ekkdtmjZUj0BNjjCFdaOnDqegdnYsPAvvviSPPH4Yy7IG4pvCebDl5QU5/eu3ZAxFOCB+LtNF/H4FtOWKZLy36GkGoz5//3fb2TVytXy7KRJuqS1mXLAjbuJiw0ZhSiEc6DXJq40+V/AjcXF8gBiLHjfvv1k1FFH6nk3b96soMZVBdhYNcgnUmlp6WnabCG/oECmT58h/33jteDtNXljrDTewcEUiYQ2egx18QE91tn5XlvUQzniiCNUgrthQ7mMHDlcjj56tLr6paWlurGh6iMlmJOdo+/De4Gb0HDC9uv/ayowOkqmTJkqd/zpdpd8azDIeYPt81l2v7LCQkiZ4Iij3QGelXWWJd7XI4k9NxLUgsJS6dmjq9x37z1KKE2e/IosWbpMySxTxmkIJiwTriSpJkCOZa6s3BTIKTs91Zw67O3qkmOFtm3bKsOHD5eRI0aoBStEEFJQIGXlFIo4uvCU5BT9bOL3+T/9JF999Y18+MFUvaXZuW2VoedacJtNTN6o+32A3mSuCR5jY4UTMx8/fpz06NFdiTV+ftKJJ0r79u21qg4eAoVcUnKyknqxcXECQacS3Sivkp3IYGHx6UYzfcYM+c311ynI3Xx5fW9iwIqL7+ySwsI39grwtIycv3q9nt9HUnmoaalEowKY8gH9j5C5P/wob/z3v8rq4pqbGm3AbsCdkpKspBKEEPG1kxuuBrdTtEGjBacUc8KE8TJk8CC1bitWrJCi4mKNkx3vIUE3DMQlP/74o7z3/gfy3bcz9Q6ah9lp/FCzI0t9b/GheJ1x59Ht9x84RL0W1o9MwYgRw2XQoIEapwN84m8Yfgg8dABVqPYqN8uOnTt0XWDqd+50NPEfffyJTLzyimCtuat+2+fd1fJRn8//QGlx/h/2bsEzc6ZZljUuUix4qGv+m9/+Tk772anqFr47ZapWhpnGCrUtN/pxcs7E2oCbOBnxCHE2DxyANs0LkYNitQYMHKAik2VLl0tZuVN6yQaSmZGh1nve/J/kzbfeUmCTqmqTk6mWPtxVXcaaE3OfcspJ0qVzJ1m1arWmHMeOOVr16eTN4SBYz/Yd2mmjSQ48Faw8IhzCIFOdN336p3L22Wfqa0ytuVudtiegByy4bX9UUpQ/PvRVNUi2SJwaah6+3r16yC03/VFzyosWLZb33/+wRm4X64zlhvRCHUYqRwUgAWkpQHVY8i0aVxuXHNCffvpp0rVrF1m1cpW2NeJ1HMYTQOXF50GcGWBHWlti1hktO/n2004/U3r37q0MOtZ72LCh2iCDTY60IuEJGQnceA5ENGyarKtKfjPSlYX/6aef5Ol/PSPPT3o2CHS31rwukNc5hVTjcANwp8AkK6tXlHgX4CZGCsFG7zEELX9/5B/KbFOhhepr/vyflDnnwGoQIwJu0x6Jem5cRpWWpqRonA3BZsCNF4DbffZZZ6o6DInr0mXLg7E2DzACjs8+/0L+ev9farjiCE8i8XB06F7t2YaAaOyYMerlkBVAGAQ3QXqNn7GWrGu79u10I2WtOGDa2fyw5mmpqbpJfPHlV/L8f16QTz76MNj00W0RVf0EGazy9y7x9Q4l2moAPD3CCDaTEpt41a/klJNPUleYAo933pkSZIMNW461xV0nTuSAucaSA1SsOKywEkY7dsjGigpp2yZXzjj9dM2Ps1mQt9bilKhoaZObow/mm2+/I2+89opWUnE0F7UW7jYgJ39+0oknqO6Aqjz4B1Jpffv2VWadMAe3vk3bNqqFRwzEpolIBjkvVW7IgGHuyZd/9vnn8sADf9MN2yXhapuIuom2GgDPyMq5y7I8t0UCwYY1AZgA9i/3/lnFKSjGcM9RUGlTA49Xa7WJFYnBzR+WjjQYohY2gILCQikv36iMOeBG1nniCSfoBvDTTwtk7bp1Cm6nJrqtrFy5Uh56+O9afsqD2BxJIhMapaemyNlnn6VrRVxODzhUgT17dpcj+vXTVGQC6UgkwoG0Gptt2YYyJesSkxjAkOyMctpBTXpeUObKfWLzdGNzBbszgdT2311cmH+7IdpqAjyoYPNX4bmGsxtpXPObbr5VpaKAFAv91ltvqxiD3GvLVowGSqrRwdSJCb2q2MJ65BfkayNFHj7cSthhXE2sytIlS3cD9/z587U00ijOmrMriSUvK6/QnP8vLjhf1Xrr1q3XzMLmLVt0gx00aIB07dJFuvform45G4DhL9AIUB0H/8FGsLFio7ai5v4sXbZMO7tSa+4q4KoB7vP73y4tyj+tNsD1FemZ2fMsy9M33Bl0rEdRcZkMHzZIrr36arXcWFjc7BkzPgu0TopTfbVJe4VOAEFv3qtnDxWorF69Wi0wlps00OBBg5QsooMqbrlhyrHcBtzks3lNuLPjTbHBcy9QwvH3JRdfpBwHbaWJwwmDEL1QHQfIjzpypMpgTYgEIYrqbc3atbohw4dg1XHt4UzgU956513597+e0j5wDilac8hiU3yH8DiHYdL980uKCvqZaw6y6CkpKa290XH0K0oPZ4ItNC3253vvUwEGmm4EKzNnzpQVK1ap3js1NVnTXk5zA0cPTucTrDwgRjNNYQeuOeA+7phjpGevHgpomGHOiSUi5mbIAa7///32en3QOFxwO48Y9wNLjihdBKJCAAAgAElEQVQmJztTLjj/PF0b1HtGGowIiKYa3Iszzzhdxhx9dLA8FU+J17EpcNBwIm99nmwoK1MJLKCnBBUFnAF5c5yhFoLZEt/O7V3KysqoR/YC8CCD7rU9C1jEcAa4IXgYSvDLiRO1KMS0Mfr665lKktGggTSYMwTA2fFNhRlMO5vC4sVLtDQTthwBCy47RBCu+qrVqzUVhpXu0L69WvKrf/2rZv2A7cvKmfsyZuxxcswxY6SkpFQ3Sqw4ADetpSmzPeP006R7t27atRaSEx6EsIpN1dwrOuEgJDK92n+cN09u+N3/KcvOvWxuIN9T6WgQ4GlZuSd5RKaEu3tuYu/b/3SH9O3TV1asXKGxHRYAWSjlkJA4zpgfJ13FEAIOLPvRo0dpKuz7OXMVxGPGHK290wA6VVjr1q/X+m/AjViDxg133HmXziZjGmekpsD2BeD6/N6AnFoAXHHqxlG4QaBRZUfBC0AHtIyBYhOmKKVTp46aMuPAXTfFNtQSkBWB3KSP/LLly1XmakDe/Nx1M15YTi4tzEP/XG3BMzJybrC8ngfCuUTUxL1MHLnx9zeoa41+HOCSyoJo40HgAMTBZoFep3KKMURUjH0zc6ZamOOOPUbTOcTuzrCDUh21y/uyMjPVnX/siSc1Pwub21zSYPUB855eY0D+uxv+oPE4gMUFN11m0acD9uzsTDnh+OM1JOJ3aA1o5qhZjYJClbgC4BXLV2iTCRMqLViwQC057ro2kfRXz3zbn+sOj/c6teG2z//74uL8B2sAPD0z5xGPx3NdOAPc5L2x3j179NTOJ/RF4+Z/+ulnwbibJoeQNz6/P2i9eYAANFZg4cJFaslpa0SxSGssfhVxY2GwMyoNEpjoAcEDi9uc2fKGPPyGAB04oK+cesrJupGSpcBNd5pV+NVqc39Qw40efZSqB1G/scn26NlDG1jCrlOggse0ePFi9argVNq1bSNffzNT/nzPXc0whWZ6pfv/UVKUf30NgKdl5rzlsayfhauLbuIuOpz88Ub09qJuOV1Ot2zZLHPmzFVXXedzBaSkoe75+HHH6e8/+HCajBw5QgsgyG/ThEELKAoLg3F3p06dtGCEz2muee6GgLr2a7lXdHH5xUWXSLeuXVQBaKy4mbJKrpzsx7HHjFWZa1lZuW6ubMp9+vSW9u3aqSXHQm/eslk3ZSN1Zbrru+++G6wvbz6b726adHXR9Qj3FJmx3tde9xuNm2lEyIEccvnyFVqnzbGpcnONcT+M88EtnzB+nMyZ+4Pk5mSrW47raFRuqNLQUPMwtWvbVjeJ/7vhD4G+adHNODXTOJgbERLu+iUXX6wu+Pr1NGt0es/7bVsHTJimECefdKICGoUbbDtEJyCHiIMToWKNlCYghzchY4Ln9uijj8unMz5Wd715cCO7p8oU4CZFZllWejhacBhE/lC2+ODfHtI4m0oxSBoAPm/e/EAzhs2a2oK55TApGCrMkFNCmEGoGSKHtsQcMPE8fMgoaR5IqyeaM7hxd+MAzrtMLH7WOT/XgY4QbnAYxN+mew1CGLwn7g33CBedDrWAnA4xjG0mLs/Ly9PusIB96dKlen/JesDSm7JTno/Ij8dtH41aaDpkUmUK8PR0urj4fwrXKSbG5SMFQ7oKOSPuGmw51oKe3LDgWiiy0zDnDrEGQzt69CiNsdkQeEiwAs48sZYa25mUWLeuXXU2tuuaNx7Yoe/UNlWVm+U3111Tw4rTVdYQoDq0weMoC/GyuH+IXZwmG1XaKopCFoAPyNEjcA9Vaty+vbbAYlZac5C0VqfKbJ/4vT1KStYvV4CnZmUN9dieWeGa/zbWAPccNRR5athX5n7hXrOr8yDh2jHUjwPijQN3vk+fXtr+FxcQV5zfpSQn6Tlg4vVc2TmaT7/rnntl5arVkpRII4jmqppqGoCbjZmJMsOGDlGtOlYcgo28NwebAHUElJTSIPPII0eqTj20mcaxxxyjNQUmjIJvob8dP8Pj+ufT/wpOPY10V9202/KJb9iGwsJvHQsexlVkod1aqB0mV03TBf4mtqYWm0YLkDiGqTVjfWkDTLknnUHJqaI753BaA7cIDjJAC918iZumAXNdZzEPY+XmLXLVlZerO75y1SrdhI0V11AqUDfAqCgATg59zeq1mh4j5CINChmHdUeMRM0A7bDokIPlh6X/1S8nakssmPrIdtVrVpUFAJ5zjcfyPBqOVWSmamlA/34abxGbEXvpSKG0NPlpwUJ10YnrsAzUeZNr5Rg8aKA2a+D3WHnq4NGkO9VLTkNADlw9WPQrLr/MTYk1Md6N98V0mWHDhmgxCl5XKMD5yNChE0eOHClZ2ZnqjptZ50xOOfKokToeis2dxhso3+h8CzFqNOvNwFXXqjJ/oE+6Ary6TDS85oCH6s7/cONNMqB/f1m3fp3Gc7jn3OgZMz7V4X6QN6RgADiWgtdcc/WvdJDBV19/o62Y2N1pzcRg+8rNlVoQQRwO8fbcpOfl5ZdeaBaxXBNjeK+nMw0xqR+npZax4twvGj+Yg6kuZgAD8lXqytGlU2eOFSeMwrp37txZQU6O3LjqkK54bTfedLOUl1dIixb0bI/U8CogdgmUjToAz8h9yvJaE8NN5MLDYcb+PvKPR1XtRIEID0nbNm01fn7/gw+CgHbcPcbd2qo3p+cXmmbELU5cHqXuHnGfqeHu1KmD6tKvv+5aF9wHCPmh1X/oEdatz5ON5eW7WXGTtsTt5j6ROuNerVi5Sgk3hC6IZ4jXaWMNGffTggV6X9EusNnTXSeyhUk1BxIqwMNV5EIMx81ENPHoY4+ra716zVq1wpBiixYvkm9nf6cxHNbAjMiFbDvn7LPEsOJmYibW2hmNu0OtAvpnVGyPP/GkTt5w02IHCOEBMo005+VXXiUZ6em66ZqUWXAiS8CKs5EDckKojh06aJ/6VasB+Q7p3q27uupOg0e/CpKKiktU38DGfsMf0C/gzdUc8XTgvtnBPnOwP5vWhQcAnj3TY3mGhVsO3MRv9P8iPsZNg2BBqEKXlk8++UTW5znEmbZTCmjOKTi54PzzVVvOg2RSMlgFrD8bAAcPES7gtddc7SrWDvBzalJmnTp20Jx3eXmZ9mcDlPAnBuRYcer4+/btoxs2/d5GDB+m9570GDnwCRPGqZyVWJ4mEXPn/qCbAGW9oVY8MjvtGID7Z5UWFQzXarL0zOy54djowVSOnXnWOdoxhGmXMKcm5vrfm28Hmz0YNpbJoeRTjxk7VmNvilGcOJD5Y0nVnVRbJUhGRrrce9/9WkwCAxu5cdsBRm89Tx8qfqHhBunIbYhfAuo27pOZFQeTjvVGfYglH3fccXqvadBBjE5LLTZqrDqEG7JlyDbETLfffodUbKrUdl6Rx6jXHGVkoWLzRMfN81hW+3Cz4EaeeullV8g555ytTDjxFq2W6MPNKByTTzUAh2jDIsOW83AQu3EYYQuWgKNzp06yeMlSTa8gdQy3oQT1xNRh9zJTEXjF5ZfqPYIJNxJW7qVOSY2KUhIVzUNefoF89dVXatWp20eYBJiPPfYY6datm050pT3UDz9gxT0atz//wovy4n8mRaiEtbqFsn/n9n5WZmZmhl+8sEwJzDcKSN0Ouxtf1wUZgP/66mt08AA5VGdGd6JWkn362ec1WFgekC6dO8sll1ykwwmoFTfkGmk13HMAzvuZavLvZ59zmfOD/CSYtGf3bl21IyuuenFJaTALgk4dZRsTX8ePH6dXR1tlLHevnj3lmLFj1JNDrHT8BOf3EG5MmiFco9oMbQQpVTiV7VVVYTVNZt+3I4jhSo/4uliOTDU8hw0aF530CkIHbiCgR/k0/6f5Mvu779UKGOvN7n7RRb+Q/v2PkO+++17jdQ7YV9h4UwSB9UYNd9mll7ix976fqCZ/hXHVqTZjSsryFSudCa0hqU4+dPDgQapjZyMHwNTqHz9hvHZrXbJ0qQwdMkR69uqp9xki9YcfflT5Mj3Yf/u7G2TDhjLVxEdS6FVbrmqlp+f2F489lwULN6lqaOfU4cOG6mhahtPjps+cNUtbGptpmJAzpM1u/uON2qb3888/V2vNYdhz46pD0k2a9Lw89+wzbmqsyeFbvxOy2ZaUlst11/xSPStcddP5xegZaAqB54ZqjWEWHICZvm+EaRQJ0Q+fARZY6tWr10hRUZHqGqbP+FT+cu89EZcyC8Ww3/IPs9KyssZ4xDvDLHs4Db8zOz0tkxC5QKBAwiBq4IYvXLRIXXZzMCPryssv0/gcZtUciYlJavk5IOiwFqeccnKExmj1A9ihflVoYwjKRakTYERxqNyY9k60dsJje+31NzQVurlys7ZwooMr/w+xhhVnkAJdYNj0+dnyFSuCysRIYtNDRz/7xTfWCmcdugH4fff/VXr36qWyRawyKZEZn36qJaMAnPQYaja6tDCNhPZNxOgcWArYc7TnuOqdu3TS3ul33XmH654fYpSbYpTQklI6spomjciSSZENGjhQAa7ttHx+LUQZc/Rovd+w6EceeaTQzBEX37Rd5r4/8OBDEVovXq1HtzIyci+yvNbz4aZic2JnZ0TOQw8/IpAy3EysODePmWAQLwAcq078fe6552hHz88++1z7ccOMo1WmZhwrQFqMJgP33nufzuxuBrrlQwzhvX98qJDJ9HCjHz25btx1ilIgR4m74UwYO8yMcWSqzGafeOXlqm6kZz2aBsBNcwjOkZ2TI5MmTYrQri+mN5t9sZWeZQpNwkuHHgpwZKowqFhw+mVzAGLYVANwfofb1rt3L7XuZgIoVhvGnNi9a7cu8s03szQ1xshaBtObGdiHNRIi+OJCZawAObTdMipGiLLzzjtX7xMDCk2dAfeXcOv8889TDfsRR/TTMlOO8o3lOj5p+vTpWtsfeRt5QK5q+6+1wrmbqrHgyFT79umjVhqAc7MhUdixjYtOkQm5VfKlNOUz+W8ELlhwiJfEpER5/PEnlVyLbL1yeO0I5j6f+/MLpG/f3rJq9RoVwHCwcdOcETb9k+kzNCzjwB2HdD3/PKdjTJeuXTQMKywqlhbx8dr8A+b9ggvOl8zsNvqeyBG9mOaL9m1WuFaShVpwAD5wwAApLCrSUlEF+PQZGosZgLdKSJArr7hc+7HRlcUw6PwezTlETMXGChk7dow76+oww39oSy6qBlGgoXJTUPp8Wgd+wXk/VzZ9+vRPtRwYgNOMkXqD0087Tfu09erdS2e40/qJ1yBVvvCiSyKwwqy6oswK53bJoRac/lxr1qwJxuAAHPeNkbQ08KNj6sSJV6iLR+/s0BQZAwyYaDJl6lQdHOh2Sj3MEB6YPENREXUHpMZKSko0DcaBNYdIwyV/afJkDb8AOAdNPFAudurYUfXrdHrZVLlJJ8cicT3jzHNk0eJlkp2VFkGNGavbJ0OyBUpFw2+iqMmDY8GHDRumTfBhS7HgH3/8SQ2Ac0MpSMEto8AkFODdunUVGgZce/1vmk1rn8MPwnu/ItxnXGxmgyNN7t69myxbvkKbeMCsQ5BSKvrKa68ruWoAboZG0prLkG3Uiffu1VOzLSefeprMm78wIgFu++ynrYwwHhlcG+ALFy4MVoZNm/aRAhxJI/E39d+o2GitC4vqpNM86sIPGjRQtmzeIj8//xf6lCGUiZx4LNygvOfrNSIO/v7lVVfqC+kAw72iOcfxx0/QOv4XXnxRm3ZQ8kvum2aMNONsnZggffv21bHPLVpSUpwtv7jw4khMlTmzwv3+yVZ1LbgddjPBjRYdFp3umhQUAE7+TPvoYxU3AHDkqgMG9A/kwOdrWaEBOCq2UUcdJR9/Ml2ng8Ke48a5x+G5AiY3juUl143CjUEWNIgYMmSw9kr/zwsvKfkGwDmIxak2zM3JlT59e+vPADlTUn7/hz/KO2/9L9JETVWWZcX6bfttYvBplmWNC7dKMm6SAfjfH/mHDB82TMkzLDI57Q8+/FDTZK0TWiuhgpSVEsIff5ynbZUNwBMSWutMsldfe13H3bgAPzyBba4qtMkmhBspTqfnnl/d9VNPPUU78Hz00ceqaCMnjifHZjB2zNHaK6Bt2zYya9ZsTZl+Mn26xuiRlRatnnBipWfkfG55rFHhDHCELsOHD5Xvv5+juvLkpCSZ8t77GosBcHKjI0cOF9rr0uGDBoq4dfyh7JDikiefelpLCCMvJ3p4A7YxVxcqYz3/vPM0cwLpRuqLvmxJiYnyvzff0plmALyiokJ69Oghp5/2M90ERo0epRs9paNbt23VefCRVVkWrAn/wkrLDM9uLqEW/K8PPCh02pz93XeaAoFNnfree7Jk6TLt7BITHa03/ujRo2sAHCUbsTkEzW233RGJsVhj8BMW7zEZlIlX/UpFLHR2wS3Hap944gnauQWPDmUbrjo/P/PM03WzR8JKt5iY2BgF+YN/e1gLTyLHewsCfI4VzjPJjIv+53vvk6OOOlItOC46JYbvvDtFNedp6WkK8FGjRsmoo46sAXCKSoYNGyqtWraSM846J9A22RNR5YNhgdZGXCQEqTN4cKv86bablXcx8TgAZub75Fdfc7rB+Pwarl15xWW6+bdt11YFT+rBZefIp59+KgzNYOhCZJSPVs8ow0VfbHms7uHsot919z0yetQo+Xb2bHXRe3TvLh9O+0i+/Xa2Ahy1GkUJtS042vVjjhmrDwFEW/OcKd0IdB0GbwlNm6FwO/bYsdrJBQ16+/btlXN5d8pUrQvnID1KfT9tnqg3GDFiuP4c8FNiSsESbbEjY1BhddsmWPTV4diuKdRFZx742DFj1EXHBevZs4fMmvWtqpoAOPlTmgPwGqw6MTgVSbjoxGVIWmnx4wL8MEBuAy/BpEpvufV2LShZsmSpFh3RfpmJo//735s6rZRpN/Rt49mAYB0/bpxmWGjMyOu++WamptIiIxavbtukALdE2odbu6ZQgHNzxx13rFpwXLeePXqomIUdnBicGd90cQHg9E2njxcA54CkQeJ4wvETmslomwYi6DB/eWiLp+uuvVqt8cqVK6V///4qbPnww2lKtlI41LFjBzn66NGqYMOby811ZtcRj7PJ33n3n2XdunUR4KY7bZtskTWkyYoZTxZu3VxCAX7Tzbdqp1S6uHCgSkPRNnnyq5LQOqFOgFNsQqOHM844TWZ+M1Muu2Ki5supUIqkFj6HOT6b5PKMFaeklHQpnhx69WFDh2qfvi+//Frlqdxf+uGzCSBuogKRGnKqzCgfnvXtt8E+AJWVm8O2kjAEyyURAXDyoSeddKLMmjVL3TPiLOqEn/rXMxp/04eLhhBjxoyWFctXah4cxRP65GOOHSuff/aF3HLbbRHZo6tJEHSYn8RY8bZtcuTmm/6oTRaXL1+u7DqaiM+/+ELWr8/TasPLLrtELTj1BxCzeXn5KoSh+yo9/SgVDvdUaW2Ab6Kjajhb8FCAkyqhyB+QP/HkP/XRTEpOlp49uuuoYJhWyBhuMm2eBgwcIJ99+rn87eGHZcHCxe5Y4MMczHVdXuiMOqw47ZSZC84m37FjRxW+UGC0es0a+eVVE7UlF1JWNndcc2JyJMz07Xtp8iuqbAtnkIdguRILzhiP2HAH+CmnnKTEGgDHYtMT+7333teYKjUtTbp26Sxjjj5am+5xoxE/HH300dK1axeZMX2G/OuZZ908eBiCO/SSTW6c0IzhkWzkXbp0UW9t/vx5qos495yztSMM89+PO+5YteB+269ddplGi2T50X/8PawrCmsDPOwtOLv2GaefJrO+nS2bN1c6THqPHpoimfHpZzonnNTJ6NFHyaaKTRqX4a7BpLZr304B/uLLk91KsjAHeGgbbUhXYmoabSJyWbVqtfw4b56SqVSR0QOA9l0QbKjZsOB4AgD+lpv/qIQrG0E4NSE1ty/iLDh90c855yzNeyNXJAYbOmSw/hvL3KtXDx1nQ64bd2zxkiVqwVE80V73s88+U2njf994LaxdszDH535fvrHg/QcOkVtu+qPWi1dUbNSOPZBo9EUfPHiwDqdMTkrWIYU8BzT6oCqtuKRYUpJT5KZbbtEmEOE62igE4FW46PZ+r+whOkHoZJOLLrxQd2xia1yyfn37qhSRud6UBTJwEHFDXFy8zJs3T+jIyWQMdnJyoG+/8647xeQQ3cem+tjQcdLIl6ksIxbPzMzS+e+MtmrXrp02/yAGHzZsiGzbvl217JBseHxdu3SR+x94UGbO+l4yM1LCPqMSEQBn5Ozll12ivc5xvbHgWVmZ2v8aF52fIYIgbZKckqyvYyOgfpiRw0xAee/9D+Tf/3oqrGOvpgJKuJ4nVN3GQEpSYk5hkS3R0VE6+CA9PU0LjGDXBw8eqO2eSktKlUn/7rs5KnOmBTPeXCSo2iIC4EgVaZFLwz0a5JMqQ5tMAQo7+OzZ30vPnt2Ftk5okOf+8KO6bgbgAP7DadOCLXQjqRF+uIJ1f67beHbPPjdJc9k8E0iYIVjppkp6lOEY/Qf01zln/JyGEaTT6Pwze/Z3SrSFM5MejMfD2UU3pMqE40+Sa67+lXZUhTkF4BAk6JFpvMjsKuZOA3DisQULF2pvthNOmKAuOqWDNIgId/Z0f0ARSe81sTgFJKRGyaQA7A0bSvVr4obDxQweMlgLkbDy1CN88sl06dSpk+bQGXwBwClACefW2WFtwU1dcL++veSmG3+vrhgMOfEWAMctR65Iuiw5OUnz3impqdqcce3atWrBM9LTVZ9O0/y/P/w310WPAKSH1ovffdedKl2Njo5WWSrAJjbHGIwadZQCHCadVBl9/OgNwPRR03wznBVt3MqwBnjo5ItJz/9HElu3VuvMTaSQxID6m5n0Qd+hajbSJljvpcuWaZcPunssWLhINcsuwCMA3YEhmjwbRQXrhW4/mRmZKl2m2yp/SKGuXbdOJowfr0rH/IICbb/88ScAvLOUbijVLi+R0Bs/rAEeypo+8+9nJTc3V+iYyTRJM5aIgQi4WN/O/k569eyhBAtimB9+nKckC32zly9fIR98OE3+9uBfIyLuigyY7t+3MHE4KdQJE8ZrnM3zQtEJY67w9Ojwk5qWKnl5tPDyaZsnSDbSa1SWRUIbp7AHeGpqiixdvFCe/OfTenMg1ZAimrbIxFTIFWnIiMKNCSZYcyx9xw4dFeTIV6kfp6tHJBAr+weNyHi3icPHjD1OGz0wvoj7Dni7dOms93zgwAG64cOiM16YUUZdu3aV4uKSiBlfFfYAT0pKlBXLlggjhCHRGAtLCoybyYEememTZm4ZpaMo3djBUTiheIJkoc3yHX+6XVMjWP9wVDBFBjSb7lu0bp2gzwabf7euXVT4xDgj2jTRWbdfv74K8I3lG1XNxrAM4vP8gkIX4E13Gxp/ptC8JyNmz/v5uSpaMKkyzgx7ihvesVMHKSosUgIF4g23jPbIDLTbvGWL7t4333qnihvCVaLY+JWMzHeaLAts+sknn6gip/y8fElJTZayDeU6bDIr05kHv7Fio3z66WcKcNKtv7n+ugiIwW0fPdl2URwerrcYxnTHjp2SkNBKHrj/fh0qyChZXHQ2AABOamzIkEHBiSeUEeKWYblHjhyh7htTRe+6+249lzv4IFyfhprXbWaa4Y299MLzkpiUJCXFxerBAeqk5CRJTUlV6SpDCSk3pjgFHgc9eriSbBFTbKJpAMtSlRJzwmHSGQO8YuUKddEBOWDFTadVD5YcxRr6c4/XmRMNo0qMvmDBQrnjrrt1qF3rhFbuZJPIwLgYK47oha4+AHvb1m3a/IFQDpBTechQQgRQuPLwMX+9/y9hy8fU1qKHbTWZeQYNoUIczmgagEvMbYYbtG6dqBVlEGpUDRF/M49s0aLF6q4DcmKzm26+Rb7+8nOtJHK7ukQGwg3Aaa/8u9/9VkG9uXKzDsNYvy5PWrSIlyFDh2hbJ+rGKR+e9PwLKlsOV8I1YqrJagP8/AsulAvOP08BTJWQYdJxwWHPBw4aqD2yv/rqa82JL1q8RJvkM+ECocONN94sH34w1QV4ZGBbv4VJl5162hnywF/v059VVGzSfPiyZcvUU4NoReyCNgIv7557/yJT3nkrbLXoEdPRxTyHZl4Vo2Vv/P0NKk9FzEI+nAMxA+WiuGgey6PSRY/Xq9MwcOVJl8TFxsk9f743glrnRhBK9+Or8GzgjcGtvPbqSzJ82HC9/7DoVJehQacvAB4fCseu3boK1v67b2eG7UYfMT3ZzH03TfC5iffcfYekpabJmrVrFLzs0NHRMVpFROtkE4PFt4gP9uGCgEtISJCpU9/XAYSQK1u3bnNTZfsBrMPlrdx/o5WAo/n5ueeosAl5Mr35OIYMGSJRXq9mXyglveiSSyW/oCiM23dFSFfV0IfIuGK0UB45YrgKWUKJNtoncyNpl7xm9Vrp2auHjgzmdbhl6NYXL1mqHT8AOCWn7gjhwwWmjb+OUIDjoV17zTWyavUq+eKLL9VNN2220zMypGLjRrXkdNjlCN9sSq2+6OE6+CD0thsy5eJLLpOTTzpJu3NQB26YdGaWDR06RF30efPna0ye2yZXFi1crGmSLl27SGlpqTz00CPy5v9eD1uCpfFQiMx3hjZkpG/AnX+6XdauWyuff/5FEMRUl3Xu3FmFMOgkzjzj9DDvkR8hk01CH0l2Ymp8C/LWCbp0AE8+nBvM78h9Dh0yRFoltNI8J2IWZpVBxuXnF0j7Du2lRXy8TJk6Vf544x8iQoccmZBt2LcKBfill10hd95xu6xZu1aHE/JMwM+QSenTx5kbPu/H+XL22WeGuaIxQmaT1b7Vxk1nzhRtk7mRxNwA3LI8miajmgxRCyWlJjeOa86GAPjz1ufJ3X++V0mWSOjo0TA4RN6rQwuSaK994x9ukBUrV8l7772n4GZYJT0BevfpLdnZ2fLxRx/LBRecH+Y8jHHR/bPCerpo7cfRsOknnHSKXHbJJeqmI1pqmaEAACAASURBVF01JNwR/fqppXaaM27Rm3v8hHFCzS/VRtQMc9Nfenmy2/whQrAeGoM/8o9H5aqJV2rTzbfeelvvNZ1eeA569eqpbvrbb78jE6+8IsxnlIXMB0/PyPne8lgDw3G6aO1n0EgTqQN++l/PKElSUFAotu1XV51i/u49uqvYBcaUnw0bNkw6dmivI2zYDJgnTRx20YW/cC14BICcZwIxy+qVy+Wll16WM888Qz07/g0vA7g5mEjbs2dPeeXVV+WG3/1fmHMwxkW3P2J88OeWxxoVCQDnRoX2xqZjB2NjTbqMfmxUlgFgfk7VGP25hg8fqs33GD27qaJCLf5zz/9H3njtFRfkYQ5y8zwcO26C3H/fvRq6lZdvlJcnT9YaBCoKYdOpT2Bg4VNPPS2333ZruHf2qfJ4PLE+v/9tykWnWZY1LlIAbmSr9Gm75OKLpKycwXIVasWx6Ew3YVzsjz/+qI8uYGbCRXJSkrrqTCWNi43Vv01FkduEMXxRHtpa+/c3/E5i4+Kkavt2eXnyq0HrDcAZitGvbz/5871/UR06qdIwvu8KcL/P/zzjg9/yWNbPIgXgoSWkDz38iOTmZMvyFSv1CfX5dsnoUaO0wd73c+boz0iN0E6ZGIwbWr6xXNauWStx8fEy+ZVX1YqHqyY5fGHZdFduLDgiF5hyevBx/1997XWNwfHuzJx4OgLdetvtyr/QzYVy4vA8bJ/H4/XaPvtpKyMz52XL4znP7/dXUT4dnl+o+qpDAU5aBBkiZBukGtJVilGys7Pk++/nqGvGDeZGM53U9tuyvWq7rFu7TqWNCCJovhfOY2zC/X7uz/UbySZW/Ll/P6OcC8UkoQBng+f+n3/+efpRN910izw/6dkwt+AOwH0+/wPE4JM8Xs/FfrrOhXFdeOiDEFpC+vgTT6orZsi21NQ0OXLkCPnu+++VaONAvTRixAjp0b2b1ogD9LVr1+nvnnn2WZnx6ZcRMeVif8ASju817jnpsWHDhqqgicpBppm8+eZbCmxqFvr07i3jxx+n5Ns1116vFYXhnSINWHDbfzcx+CMej+e6SAI4D6OJxSkcGHfccVpcgMX2ej3aLpn6X0bVcGDFqRlHAVdWXi5bt2yRqh1V2vWDyjSK/103Pfwgblo2/eeFF9V6o17s3r2b9mWjRXJUVLQWHPE89D/iCPn6m29Uqsy9pkdb+LbtMi66//dWWkbOX71ez+8jDeCQZ9u3V0lycqIOhef/kaICZoYQtmzVSrtoAngOCDYGwiNbhGCDYd1YXq79s1999XUtI2VX55zh3Ag//GDauCs2G/zJp54mF5znlBAfe8xYnVHHoEFaaRsZMyQr/QI++OBDFbl069FLNmwoC+P7bFx031VWemburR6PdXekAZzHIrQnF0PhseLEXLhp6NKnTH1PhxBycLNx2Y6fMEE2VW6S8rJy/f+NFRXKuDPpIrzdtsYBJVzfZaz3XXffIwicmPt96aUXq2ptyZKlMv+nBbJpEy2Uu2ualOOFF1/SFFn4t0sO5MHFd7aVnpVzjcfyPBqJAMfSAlz6tWHFSZPl5+draeipp5ws8+bN15nRaJJNLM70k+7du2szAKw4jSBoBvDSS5ODVhwCzj0O3xUIFTzBnjM6GKt98UUXan++2bNny6rVa2Tr1i06vw7xEz36fvu7G5Rvyc5KU5I1HA+HWPT7LMvj9YucbGVk5F5kea3nIxHgoYw6sTguWl5+gd7Y8Uy1iI2Vd6dMDbrpxpIzNxwNO8RcakqKWnlGE5t5VZA07nH4roCRLNMT/c47/yRPPPFP6du3t5xx+ukSHx8nX3/9jZKpkK/HHDNW++WTGh0+fFjYlwqHNHsQv+UfZqVnZZ1liff1SMmD1/XYmXgMRh3rTf+t3r1767xw4i4GFmLFnbh9u86QhnVdGcift0pIkPXr1smzk553GfXDF9fBKzOhGe45bZAfefQxbanNPPiq7VUyZ+5cKSsrl65dOmtuHPHLlClTVYMe7s0+QgHus/y9rbSs3JM8IlOMWQ9f5nDPT5654YwZPuvMMyS/IF8Z0p+dekpw6AHxNgcgJ04fN+44bak7f/58SUtL09998eWXOhzBZdQPX5SHas//+783tZHiy5Nf0SoyZtGtz8uTn35aoL35kDIzH57jttvv0Px3uN/b0I6qPq90tVKzsoZ6bM8svmQo+g/fW9i4KzNWnFJSBswtWbpE3bN2bdvJG//9b1CWSAtmXHvi9VNOOVkb9NHDKyMjXco2lMkLL73sqtsadwsO+LtCa7+ZSUZ8/fa77+p00Vtu+qMcccQRsmTJElm4aJG0yc1V0VOLli1k2bLl8uurr9WW2UmJCWHeUddp1yQiJb6d27tYKVlZvaLEuwCLFukALygslYED+so1V/862FIZBdu8H+fJV19/raw5B+DGisOwHj1mtCxdslTj8OSUZC01RaPujjg64Hht1AcYcQvjitis6YceEx0jf/3rfZKVmaGz4Im/KSzJyc4Wv98nr7z6WnBccBjrzwPrVd3NJTbGM8BKyclp6/XJMmSqkQzw2hJWWHRGyDJhkofinXenaM9swG1AjsLt6KOPlh49ustP83/SZvnIHBFORII71ygEHcZvMqEYue8rL79Mpr73vqZGqfO/794/q5KNXmxkR+iw26JlS1m+bLk89PDf5b9vvBb27rlza4IAn1NSnD/ISklJae2NjlsuIun8MlLkqnt6DkNddUoHIddGjx6l0tVZs74NpsyMmIWN4Wc/O1XTK4sWLlIrT/9sGvghhuB87pCEwwP1BuCEYbTJpqCEg3p/Kslo0/XlV18psUabJhSLn0yfIVdcflkE5L7NPaiuBS8pyh9vtWnTJr5qh3+u5bG6RzKTbr4+KZStW7cLfzMuODkpWfod0VdHHhGLw6JXvzZKXXnAfeIJJ+i4o/XrmSW9U959d6q+P8zLCg8PZDbBVeB9VlXtkI4dO8ifbrtFvvzqa1Ukol487WenysUXXyQFBQWyYsVKzZ5woIl46KG/ay/8cCfXqpfQAbjY9uTiovzzLX4RSV1d9vWsYJHj4mJ1lhmtnX5x/nnSrn076dWzp/y0YKF88cUXQTfduOo8OLjpSFl37tgplZsrZfXqNXL//Q/I519+E9bCiH2tVzj8PrTv2oN/e0g35KlT3xPL45F58+bJX+79s5x04gnyzTczlUdBlooefc73c+WCiy6T9DSHSY+QNtlOLbjf/4+SovzrHYBHWNOHfT2UtTttnnH6adrRIyo6Wt57731Vrhk2nXOZnm5aN967l8bhW7dslfc/+ECuveZq14rva8EP8O9Dm3zgbs/+7judWgJnAqFGJxeaOcydO1d69uqpDR+YJvr4409EQGlo7cWtLjQpLs5/0FjwSZbHurg5uOhmOUJBzvzoiy++ULp26SrLli/TQfChrzOW3OPxqhVnSALHhtIN8uQ/nwo2aKRgxS1EOcBoruP0hjlHyEQTxXffnaKVYkhU27RtIzfd+AflTgi/kpOT1HrDt9B3L9yFLbsvR8BF98vFxcV5/1GAV1eURUbTh4Y8YubhuP1Pd8gvf3mVxuLTp0/XUlIYdTPA0FjyhITWWk+elp6mFoIc+S233K469fAvUmjIyh3614Zu0r+46BI5+8wztKikuLhEfH6f6stPPPF4uWriRCkr26AlwaQ7abB5x51364DByIm9zf0ItEwW39jSwsJPFeAZGTk3WF7PA5GoR9/XY4jFjY2JkbVrVgq50/PP+7k+DFSaoXbiIeIPDCylpbweZduRR45U2SsbwsxZM2XC+PGSmJyu5amuJd/XqjfN73HNy8orpFPHDnL7rTdLYVGRWmbuFUQolYB0SB05YoT+vzOOyidT3/tAfvXLiWHelmn3NawtUy0rLFwYAHjuReKR55uTix66PDDqdNekte6rr76maTFaOiF+wdXj4TAgJzbnaNumrZYZkktlcN2XX34lN996uyyY/4O6fS7ImwbEezpLKLH26GOPS1Zmlrz3/vuyc9cuJUK3bd8mbXJz5OabbpK0tFSp2LRJf87YIsZMc38iL8UZTHNXesTXpaioqNhx0bOyxnjEO6O5AtyIYCo2VSrDPuOTj4QGfNOmfaR92QzIac7HYUBOo/whgwdp3LfL55PvZn8nDzz4kHw64+MIjO0OLGAbcvZQcMOfHD9hvHz2+Rc6vIJxwByMkJ54xeVy/PHH6wa9adMmDam4PzRVjMz0ZrWKraQ4n1lMPodkS2/TRTx+1GwRLVfd20MU+tD87oY/yM033aguHVaBho0cO3ZUqftnQI673rFjR+3KirteUbFR5s79Qd6ZMlX+/a+nVM7Keaktdsm3hkB4z68NjbuxxBde+AttyLFo0WLxEU75fLJ5yxbtd48kuU2bXM1/42l9+OE0JdbCv2PLntaneqJJSXH+aMUz/8nMzMzwi/en5qJm29Py1J6CcfLJJ6sF/+yzzxXYxG/kxOmxzqwzLDnAZWIKo4kBOUPkFy1apEILLAVxeatWLdxWT02A71C5MSAlvw2h9u233+pGaqw3CsNf/+oqmTBhfA3X/Oc/v0Dw0lq0iItQ9WEQ4M+XFOdfIiJeBbha8Yycxc1Fzba3uA4X3RSlPPLwQzpWGInqnLk/6Ntw93DVQ0GOC0/qDIUUpNuChQu0THHt2rXaBmjFsiXK1m7ZutW15I0EugF3yYYy2bZ5o0ye/Kqe6ePp09VqQ4hy4HXRGvk3v7lOS0GJvUmX3X3PvfLifyZFOD9S3U21uDD/dgNwSst8zU3ssjeQ0+IJwo2SQ4QT9PdasHCRuoHGVTfpMyx5bKzT8ql9u/YKciz5d4gtli2XLVs265y0pYsXuiDfD3Cz8RLqlBTla7FPYuvWWkyC1YZY067fhFE7d8pvr79OBg0aqCkxuJHnnpvUTEZC18yB1wB4RkbuU5bXmtgcU2V1PXcmPw5DSycQjsWLFgdmjtsaj4eCHHcdS56ZkS4jR46QlJRUbcNL/XFsbJy88OKL2m/bZdgbhnJjuat27BCGSpLlQIr6+hv/rQFuj9erlpoqwTPPOF3vBcecOXNk7NgxYd+KqX6rVjMHXsuCR2531fotTs1XkTrz+20F8TNP/1Mlqgbkq9es0YfLxOPmnVSm8WClpCTL0CFDlIkH5Fh+fjZp0n+CDLubRtv3XTGE2tp1BeLfuUVH+6akpshrr72h9fom7gbcWzZv0fFTV1x+qW6uHIVFhXLddb9tFs0yQzu5iN/Tp6RkPRWiGoM7Lnoz6M2270eq+hWhbC1FKbDqGRkZQZBTS84gBROP8wtDvAFyao8HDx4k7du1k5kzZ8mSpcvU1aeN0PtT33Ut+T5uRmhWA6Lylckv6oBImjMgNfV6PLrJGnATdzNsMjMzI5jWfPBvD2vFX3jPGavvUxuw3ra9Ji7a03P9+vXb9JkMAjw9t7947LmR3PShvktlXhcK8l9ffY1cccXl0jqhtf56wYIF2jBibyDHbR84YIBWov3ww4/y04IFkpGeIa+9/oa8+b/X3Vx5HTeENUeAQlMGyEk211tvvkniW8TLc889r5NhDbh5O005+vXrKz8/91z1mCBB0SWwEQD4yE2J1V683VJkariDAA+kyjDrCS7Ia1rypKREfdhuufV2OfvsM6VVy1aqdV64YKFWJVVWbtKJKebAkiNrpcCBtaQUtd8R/XQm+cKFi5Rpf3fKlGAdcniPyWnotrnn15t4m/UoLswTNtVfXnWVgvillyfLhg0btDuLSYfBmA8bNkRHQnfs1FEVhXhPH3/yicbhRofQTBpyaJkoE0WLi/OuMoY7mCZj2dMzs+dZlqdvc1W07enRA6Qw64CciqWxY8Zo55edu3Yq8VYb5DyoXm+U5snNUIWePXvI4EGDtNc6MTlW6sNp0+SJxx9TF5KHuLkKYmpb7dT0LLnt1pt1VlxBYYG88MJLGnPjjsOWo0lAinrySSdKt65dlXTLzc1Ry/3VV1/LscceoxkLNuHmM2rKSZH5bf+1JYX5j9UGuJrzSJsV3nS2xakJh3ijUQRFKUePHqVN9GnSR+UZggtSYqHEm4nJATmbRJfOnWXwkMFaj5yfX6Du/fsffKgD55vfA1l9d8hYYI1hySccf5Jcd+3V0rdvH5k//ycNZ4zFNvE2637mmWdIUmKiloCypqTN6LZz0SWXysxZ30ubnMxmozuoOc3EqSKrE+AZWTl3WZbntkiZFd6UAOdcAJyjIG+dMBLnqCOPVFecuHDVqtXqQiJrBbhGCGMq0AzIO7RvL4MGDdLNAX00bPoHH34YnEMeHRWt422bwxFa5OOJbim333qjpiThKWbNmqV5bg5juVlnBhnQnYWRUowjGj/uOCU/yXk/9PAj8ud77mqGZbvBIpMqeqGX5ecz+zoYg7OGLpNeD0SZdk+bN2+Vys1b5dlnnlRhC83+YHYBeXl5ubqTWBZTYupsDuTJo9SSt2nTRgfi0XyAgxwvE1YuuxR1oSj55lc3NPKAbtaQWLqouFRVaWjKTz/tNOnTt7dujrjZ06d/qpsg4KYKjIGQjIGm4UZBfr7M/+knteI0zsRrevOtt5RUI9xh04zEAR57fkR3LzIJ8kGBfzgADzDp/Mwl2upeTvOAGlWVEcJAnJF3XbVytfZsw6IgnzRiGFx8/vBgc2RlZur4pKSkJImOidY2ULO/nS1PPPmU5m05eFh5uCNB4sq68f1JFa5avV7z2v0HDpFLLr5Qhg8bJolJiSrt/eSTGdpuiXFRHHhFGenpOpEmt02uzJkzVzfQ8ePGSadOHRXctGI68+xztRKQz2kmpFrIAxpMkb1dWpR/mjHYiuNQgJsWypZlpbtE2573SwNyr8erjSKYgXXuuecoWOnwYkDOw4dFInWjrqaC3BtsHMHQO8QZSYlJwQGIuKFMPaXtEL26DdDZALDqMMz8O1yq08xakVHgupEA9+7bX84+6wwZO3aspKelafUXzRHpWQ54W7VqpZta5aZKGTfuWJ1As23rNp3pjRz4yKNGqs6c9V2xcoXc+5f7ddpMM1YJBhot2reVFOXdUxfAq930ZtaAsR6eeZ0v4cHF5caN5KG9/Mqr5Pprr1E2d83atbJi+Uq15FhwQG6q0QC4x2NpbTIHEy67d+8WnH8GcHH58RC+//57efuddzWdZg7IONo3O91JnNLVw/Xge+CdkH0wxx133qUjhehky7rQxpguLIwPYi1Zp9INpdK9W1dtvJGbk6tZB7yjfv36aSGJKSIpKizSySV/f/hvzRncwWEHjAsuLczD/VOPPNSCBwHenPuzNQYooSm00884W66/7hplgOnMumDBwiDIYYIBu4kNAbixwrjtVKNlZ2UHL4Hce2JikpSUlKieffqMT9WCoWfnAOiAh1gfl5TzHqi4k+8YeoT+P5uVOUwIYjYvA2xc8Z+fe7aMOuooSpM1vcg4KOLo+fMXaDoLC4+WnFDnhBMmaEoRaz73hx8lLTVVOnfppGkw/k0BCQ0vP5k+PdjVdutWiE27MbcwrN8TEkpX+rzSO5RgqxPgrmS14fc7VFbJu7EqzDxjcgYKNtxu09vNpHyqQeFYcmrNmcbBxA1As2vnLlVzmeaOpNaYjDlz5rc68fS5Z58JXmhmdhu1fhxaXbVzl7rDdR1mU+HB4A+fZX5GyGEOQOdcF3XwzuZhzmliXCq3zFFVtVPj6tCDDe+4447RUcyEIRBlTPYkji4uLlaOglZZeCOAnumfo0ePllYtW+oMMTwgppCQgeAa2AS5Bt7LutKYw6Qvm1/cbVa65qii2vc8dGsOEG10d/EtZoSRS7TVH+xGhcWDShqNVkKXX36ppKWmqaXKy8vbo0tt+q7jngJUGvNDOqGOs/22lp8itImO5kH3KWOftz5PKHr5cNpH2h009CAWNQKb0J8bgPI34GLCC9mA2sCsc2OIbikJrVpoaSwCHixuXFy8koaku/g8UoBdunSWDh06qG6cn7VObC2FBUU6zXXxkqVKGuJ5IDfdtKlS04lo9keNGiWpKSnaMIPvxXnS09PVE0KhhhKQ9+KqL1m8RO68+x5Ztz5fUpLJhVeFDSdR/yeq3q+sU8Fm3l3T93J+6k3PyFnQ3Js/1Ht5Q15oFFkAkvpvWPD77r1XBgzoL8UlxdoEAmKI3DiHYdZDCTNADdCZhEl+F3cXN5wpHQhCTCdXQIKHAOkGY49VRAbLZvLJRx/u8/LJO3fp3F5j/4RWCVocE9+ihURHocBzQIsaD46A/2fjAbBYXH7Ge2Ct+Rmuc1Jykkp4ORgXRH+0FStXyfLly1QMpN81oESr3LxZN6/BgwbKiOHDtXc5DPry5Ss0B447vqlikyxavETatW0jw4cPU80AHgybxcOPPKKhittEg9XevQbcxN/8tjbAHSuekTPJ4/Vc7NaG7xMndb7AFKmYPC+a6rPPOkutMrnywsJCfR/AcVzMKImNiVV3mYO8OKDm55kZGTpuB0vOz7TNc1ysuu+ADWEMP9+x09HCY/HoQWY2EOJ+XHaYfGXitQS22rU2cXNMTGywmSSvp97d8AaA0aS5AD1WnB7j2VlZkpmVJXGxsfp5pLRWrV6tYKVtks/nNKnEfSaexqUnDKA4ZODAAWrlCV8g0ThX+w7OQAk2KuaGjR41KshnsGHgmj/73POaRnTBHZrKtn0+y+5Hm+RQgm2PAE/LyJ7o8XieclNljQO4eRd6c1xZyCaqmq7+9a9kxIgRanmXr1ihlhcQA3S1jjGxahXNwAX06aSLWrZoISmpqWplASh6bIDL+7yBunU+c/v2bbJ9m7MJsAEktG6tjDND7kmxEdNWbKzQMcmbKiv1b66BP4CYcxpmnuvh2lu2bKWeQ3xcvOrnSWFR2UXaygh6aIu0fv16tdyAmeaHHKGtlLKzs6Vdu7bSrVs3iYmOlrKych3tS16cVCEb2Nq165yKu4x0HeucnpEua9aslfLyMtlUUSnPTpqkpbYuuM0TtmeBi3lFnRY8JSurV5R4F0CsuHH4/oGcd/MQl5aWSUV5iZx51jkyceIVmv5ZtWqVxptYVlxxLDLAwg0HoMSeAI9YdcvWLfo74lRcYgPsXTt36kw1Xmt5rGAtdEXFJi1g4eB3gD4mNkaivE6jSAWz7df/57P5HeePiaZpRZSeC9Dxul2+XeoyY0UZLoCl5poqKioEd5uDzYC42rji/KxFfLy0bdtGOnboKOnpaeoRLF+xUjZsKNXwg0IR3QCpslu0WEMXOtQeccQRsnPnDo3Z+X7If5EGG8sdiQq/Rj5le42/67Lgwc9xK8saueR1vC1U7GFSRxSsjDvuWAXwosWL1SXFNeYhx10m5qWAAuuLZWcTUO16oJMJ6aQWLeIVIIBTLabfSRMB6GjAqrPLnTw8rDzx+qaKCiXpNm6sCG4ARi9vgM918B42CDYK0leA3PEyYnRDoCxTvY2AMg9wm7gd9z0nJ1s6deokrRMSZPOWzRpfk/JDpksXWgwH9fQrV67Uzax3r17SvXt3/b6rV6+WNavXalzPpvLY40/Id9/OdC33bs9WwIKL7+ySwsI3arvnewK4xuHVPdqa37yypoN2zTOZ2BwGG6b90suukIsvulC6dOkiZWVlOs+aqjTqywEQsS5WPbF1orROhNSK1xPiiuOyYtUNQYcLDeAAvJHD8losPXG6sd5YbRNP83sENZV0Hq2oCOrocb1JXWlsv2OHMu78IVzgZ7DZAJ7DkGwJrVoFyjZzNQvQKqGVbCzfKGvWrtFNAsD36N5dYmJjpbCgQLudMpsdQg1ijewAZbfLli7V87JJrFi5Uv7+yD9USOS65TWfpdAWTbULTEJfWSeL7rZwOlAQd/LKuMDE08aa//WBB+X4CRMEcQvWFREHajgsJwfqN5jr1gmt9MHH4gHYHVU71MpqBdv2bcrQGwItuEHExOj7+UwUcE5ThCiJolY9OkaZbdMwEouPy+6ksRxJLcRY1fYqJfEU7IF/4xEo+WbDCThdWJKSk/U64Rg2lJXp9eFlMBwiMytTpadcLyQhHgigxpPAshOPc77U1FTxWJb2lf/bg391+8rv8VHcfchBXS/dI8BDO7xAxZMXP3CPffM8M1JOkzcnNkea2f+IIxSAAAoSLD8vX60d7jnuNgAiBsdiQniZfDfgwAXfvm2bWlqngYQDSsOkh65yqFsOM87/6yYQALYy+wFOwBBscfFxurmYa4D8iw1ozImVzWbEBsFh9POO17FdFWu4/ur2l5crg8535FAysEUL3Rg++ugTefWVlyQ7t63+rrk2wtg7KgINHvy768/3ZcHVs3MbQBycTYe41fQf4xPRtI8/7lhp3769WlmABjMOEHCdS0pL1bJruiyQkzbxOO4vB2DD8hqwAypc7FCm3KjdQsUvBpQ1NwIsuVddf8pb6Z4CsI0XwbUhE8WjIKcO494ivoW6+xByXAN9yx2gOsU3Rs3H9bNZkS5DrDJv/ny5/4GHVXiDWCcc9PYH5ynZ/VOCLrrfGlBSksdUjqD+vP4Az8ie6PV6n3Lz4Qf2NprYHFeX6jSkp8TmA/r315QRB6kpWG3YbY7ysnIFuxmJa+JqAINngGUPFdDwe3WxA1YdwJs8N+cLjcux4rjtWGrOFRcfr669ylYDLrmTUvOpF0E6y2N59NxY5vyCQg0VTPWcbjqBCa1kZuAI1AtJSNDNiI441H8ztJHvDpEXCSWyB+6pCbjntn9+SVFBv719Tl0uetCCp+TktPX6hKGEsW667MDdLgMwtZSxsUG3HSXchPHjZOjQocqoO5psJ8ds8tz8e8vmzUEhCXGviccBoKNKc1JjqNSMG048TqzLUbv01ACZc1XtcGakq8cQExsg67xCqSsD/SDoyGmTY4co5LVYZj7HfC9+ZoCdkpzihCA7qhTYn3/+pQKbAyKNTeJwr5I7sE9Cvc6u6TGfz/9AaXH+H/ZkvTnTngAeBLk7yXiHbwAADKVJREFU0qheC96kLzJDEMvLKzR3znHxJZepZpvmB4AbYo34nYN4nFp03QBsZwPAoqrl9Pk0HsdV5m/c5VBBS2jXGQN2o6yDlSdtBRlnQMcmBOhRzOGGO55AdXrPyHCrz+XIXNmgOGDKyf/TjolZ6gbYfBc31t73YxRayecT37ANhYXf7hfAMzJyLxKPPO+q2va9+E39CuJzU4hCWo3jtNPP1E4w1EUbFxerh+gEoBm2XWNkJq0wAVUnoTqqNy1TDYBfLWxgQ8D9d1JojlIOkg+m3BG6+HSDMKk0fsZ11SbnTJ27QwI6HVlg14nRly1fpjLdd96dIhtKCoPsOBsHm0xzLPVs3PNSf/e8XhbcZdMbdxua8l2Oe0xKy6M5YWP1hg4bKiNHjJC0tFSVkrIhwEzDuBv9uAMyR2dO6syMPDZuuSljNUDDrSc25/1sGCjKjIXGshtQGwvtMO2QZS1UZQcZp7rzHTslLz9P0110a6H4hsOUtmL5XWA36ilx1Gu2/24zQTS0uKT2GffmovNaR/SSmfOyWNZ5rhVv1A1pkjcZFxkGG6CHuu/E6ieecILWkyMJpQ2SMtbaDGJXQKhCyswZsWvAXZ+2TxBuoQcueGg1GQQcOX2krHn5+bq50O74+UnPBt8W3ypJ0lNT9Lqx5uHUcqpJbl4TncTwYPy9S3y96youaRTA07KyxnjEO8MFeBPdqSY4jcNQW6pSg3HG7eWg31nXLp21cUS3bl21Hp341pGSRgUr1RwL6hStmCMUzJybB8lpSxWvqTEYc1PyiesPD7Bo0SJtt0RRiCHLOB85bK6N3DebU/NtyNAENzt4CuOe2x+VFOWP31vsbd6yLwsePLWrTW/KG9W059I+boGOLqZE1XwC+eQB/Z35aDRQMFp3XGpiZSq7jJteDXRnMwDExOSbKzerLJbXUXkGqL/+Zpb8MGd2jS/CqCBcdKSs7jimpr3Hztn2Xvtd1yfWB+ABbXrODZbX84CbEz8QN67pzhlKzAFOBCSGiTefMnjocGmTmyupaWla8WXUcBBw/AkVv2zbtlUKCopkwcIFwTia8+B2I501wyBcsqzp7mHdZwpa75LYaE97Mz10X59ab4C7wwn3tZSH3++NG2+aOnCFEGgFhaX1atMU+o0Y4ZucnKgbABYat9/0anMZ8INy7+ud+w69mvoAnNc7nV4ycx7xeDzXuVb8oNzQJv0QQ9KZJoXEx5BeezuMNUdhZ/LUoW58k16ge7I9rkBo5Zj4PX1KStaTSqlTmlr7JA0COI0gvLY1z23I6D6N7goc1BVwUmN+/+Tiovzz6wturrC+AK+24sF+bW6d+EG9xe6HNdsVMBbcb/n3qVxrrAWvBrg7v6zZPmjuFz8UK7DnuWP1uZqGWHDXitdnRd3XuCvQRCtgdOeQmI2x3g110UOsuDscoYnuoXsadwX2tgJVlmXF+m17t6mh9V22hlrwapC7jHp919h9nbsCDV6BGuXZ+2jqsLeTNxrgZtSwiKS7LZ0afP/cN7grsI8VcFoy2T776eLivKsawpyHnrgxAA9acQYkOB1fXEbdfV7dFWi6FQj2QKz0iK9LEcX39cx7176GxgI81FWfZlnWOLcQpelur3umZr8Cmvf22/5rSwrzH2ssuBtDsoWuvCppQqeguK56s38w3QXY7xUwaTH/rNKiguH7e7r9seBBK56R4Rai7O+NcN/vrkBTEWtNEYPvZsnd3m3uA+quwP6uQP16nTfkU/bXggetuNvaqSHL7r7WXYHaK1C/SSUNXbemAHg1q56Ve5JHZIq5CLeMsKG3w31981yBatbc55XeZfn5dNisV7XYvtarqQAewqrn3urxWHe7JaX7Wnr39+4KSGA8t99nWR6vX+Tk0sK8qU0F7v1l0eu6P07duFtx5j677grUcwWaPu5uapJtN8KNH0C6eTyeca4Ipp732X1Zc1yBRtd513exmtJFN5+pVrxNmzbxVTt9syzL09e2bfr1OpPx3MNdAXcFWAFHzOLzf1FSnD86sCRNEncfSAteA+SOXj32Sxfk7hPtrkCNFdAqMfv/27uaEDmKKPy96kk2Wdkou5l12GxEMSZ6UBRDomggevEiJIKIuQWU4NGDXiQRMTmJFw+CiAgRgiAePAgquWi8BBERI5JICCG4cXdmM5isJrs72/VJ9c90T0/PzE52p+ev9rTTXd1V9ep973v1+lUV9bmRDc7eYAPFdQd3J+bgde56CHKlnIetu27V3EoAVXC7laWny+XyjfUMqiXl2wkXvQ7knru+rL9TjtpnQO5bL9qxthIYNgn4brl2z2UB7k4zeI27bn6YI5BEqUP+JzSv+tpzcYZtuG1/h0ICfgqq9pZ/aq1Pj2xQBzrplmcxB08OXHV+MVmYeheQYwGD2+DbUKj4MHfSz1AzINeuPlkqXj3cqYBampQ77aIn3XXz280XCi+C6lMRGTOWzQjAuuzDDIJB7bufoeYzOI6V5mZOZAnurFz0VDYfn5ra7qzgVDAv985dsi77oCr6sPXL0+WVINa0QJFDsQw1j+SykkiWDF4XfDMX8ndvOwrweLBUbglgzgI9q+G39ay/BHyiMifImPm26+CV9cwtb7e93QK4aWd1Xp7Pb3uUSn/kKGevf8RO9aC1dvtjy1sJdEkCEWt7DdB8s1i8+n7WLnmy890EeNiWKAA3OfUGBW8rpcaCs7TsJ7UuqautdnUSCCPkVdZ29Y+u0q+VZ2f/CEgsU5e8FwFew+ZmXTmZO07hkWjjd23n56vTN1sqIwlEU0qMBO54ieTR+eLfH3ebteMi6AUGT5+be2473xHgQOyEB8voGSmwrSZdAvXANjkd8qFbWTwWy0rrKmv3MsBDNq8KaKJQ2CNUbxmgB5bS3LPBOIvATCWQwtgAcRJUJ4LjfGs80Uwb16SyXmPwJJvXAN2hep3gS96G8KSXHeQ/YDPiekWhBq8dJnjm65hPMK4rkC+o5b1SaebXmDveM6zd6wye1JEwndUTdD4/vYOij4jgsIjkzbUoK445mzQzeBDLvkchqM2OK0EWmtYLQvl8RbkfBAG0Om8z+3a2rrGXGbwp0M0qtVxu80GCr0Kwz1jXMBvOX39uv6e3Hn5bIpRAFA332boa99G8IMAnIu5nwQkjfQHsar/6cIhrGN203z98Qb1M8qBZex7fXzp04y2z9+FId7zJEVOHoPZyxg1bA19rkVPzszPfxjLP6nSv401cYwX9xOBpXa0T+NZCYb9QvQDiOQh2hczus7ufjGDY3QJ+jZrTh4/Hvll7OpAAtVn4dIbkl47or2Js3VeMnRyWfgd42J80y+pMFAqPO1DPU2M/BHtMbnDI7hbwfYjQNptc63ZHrndMBxYAnDWg1jn5JkgpbaZTbbag+8UHBeBxSaa6UWZxS25FniH4LIAnRMkub8i9lT4mIl/L8NELbYS++2raugWJOXQdQ5s3eNmRxAWB/EDlnlbkmQZMbYpntiCkde9uv8QgArwl2E0K4XihsCunnd0AnqLoJwHZGWd485II9J4pCJTGj6za5a23r3RrfbIRmEODHRlt7YJyUQS/aPJ7ofqpVJo5lwBvfNORgQB1XL6DDvA0sKdaZ8PwSssjQjxGcLcQD0Jwbwj6CPBJ4NeqqwX/WuEbHQaQ9qZQvmGUu9YDQwnEeQJnFeT3FeX+XJ6dvZDCxgMN6mEFeFJfWg2yMz41NaW0vl9ptRuQBwg+RME9AhQM8OMufvjyiNlrIrRV9q8RvqiaLasG0SsIgRjJJ0xOqpFEQj7+tCgO4oSBXQB4mZBLIP40YCbxm+veuhSki7Y71mu3Sj36hmFi8FZDkNwfLtVd8zaQXMI2OivT0LJTKZkGsYPEdAD+cQBjjZSz3hA0Zyy/vJkeeCqea9QJw2zJe+0YjCQQmwMyCc7mbUtmGibriq018Hvpb8hp2LgMwRUQVyFyEeKe18CVHHk5MXe2gG6gGBbgzWGftilkw3maAX+lUhmriGxVcO5TGhMAJynYLkCewFYQExRMCDAKYEt4IETSIKQ1qxFg2wFyKyuXvN8I+GG5ZvdjiUdhMNN8irrhARf4D4JrAsxr4i8FFAEpauVe0UBxAzk/Nzd3rUWwq63xabfvg1DeAvz2RnFNilU1BI4zAq3HFDAJOHcI3M3UMq4gW6CwRWtsUpA7CY5QcJdnFIhNFG70/5eNFGzyON43GObP+x0w/ujq8vS9/ICbwUMbASwDWPTYFPCuC7EI4TIolQCci+aeAm6SWIbgOunt+X0Dwn+ouSyKZcK5RbglAv9CqQUsLl4fHR2tBLuKrkb6jXbeHbiA2GqE0W6Z/wFlDhT8ki3dTwAAAABJRU5ErkJggg==', + width: 248, + height: 248, + title: '', + altText: '', + caption: '' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + await expect(page.locator('img')).toHaveAttribute('src', /blob:/); + }); + + test('renders image card node', async function () { + await insertEmptyImageCard(page); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('can upload an image', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + }); + + test('can get image dimensions from external URL', async function () { + await page.route('https://example.com/large-image.png', route => route.fulfill({ + status: 200, + contentType: 'image/png', + body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') + })); + await focusEditor(page); + await page.keyboard.type('/image https://example.com/large-image.png'); + await page.keyboard.press('Enter'); + // Wait for card to be rendered before checking height and width + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + // Check that the image has the correct height and width populated + await assertRootChildren(page, JSON.stringify([{ + type: 'image', + version: 1, + src: 'https://example.com/large-image.png', + width: 248, + height: 248, + title: '', + alt: '', + caption: '', + cardWidth: 'regular', + href: '' + },{ + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }])); + }); + + test('can get image dimensions when rendering serialized node with missing data', async function () { + await page.route('https://example.com/large-image.png', route => route.fulfill({ + status: 200, + contentType: 'image/png', + body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') + })); + + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'image', + src: 'https://example.com/large-image.png', + title: 'This is a title', + alt: 'This is some alt text', + caption: 'This is a caption', + cardWidth: 'wide' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + const editorState = JSON.parse(await getEditorStateJSON(page)); + + expect(editorState.root.children[0].type).toEqual('image'); + // missing width & height are extracted from the image + expect(editorState.root.children[0].width, 'width').toEqual(248); + expect(editorState.root.children[0].height, 'height').toEqual(248); + }); + + test('does not change existing image dimensions when rendering serialized node', async function () { + await page.route('https://example.com/large-image.png', route => route.fulfill({ + status: 200, + contentType: 'image/png', + body: fs.readFileSync(__dirname + '/../fixtures/large-image.png') + })); + + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'image', + src: 'https://example.com/large-image.png', + title: 'This is a title', + alt: 'This is some alt text', + caption: 'This is a caption', + cardWidth: 'wide', + width: 1000, + height: 1000 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + const editorState = JSON.parse(await getEditorStateJSON(page)); + + expect(editorState.root.children[0].type).toEqual('image'); + // existing width & height are kept from the serialized state + expect(editorState.root.children[0].width, 'width').toEqual(1000); + expect(editorState.root.children[0].height, 'height').toEqual(1000); + }); + + test('can toggle to alt text', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + // placeholder is replaced with uploading image + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + // wait for upload to complete + await expect(await page.getByTestId('progress-bar')).toBeHidden(); + + await page.click('button[name="alt-toggle-button"]'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('renders caption if present', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + // placeholder is replaced with uploading image + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + // wait for upload to complete + await expect(await page.getByTestId('progress-bar')).toBeHidden(); + + await page.click('[data-testid="image-caption-editor"]'); + await page.keyboard.type('This is a caption'); + await expect(await page.locator('text="This is a caption"')).toBeVisible(); + }); + + // NOTE: still works, but it's a focus issue + test.skip('can paste html to caption', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-testid="image-caption-editor"]'); + await page.click('[data-testid="image-caption-editor"]'); + await pasteHtml(page, 'This is link ghost.org/changelog/markdown/'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true}); + }); + + test('renders image card toolbar', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); + }); + + test('image card toolbar has Regular button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Regular"]')).not.toBeNull(); + }); + + test('image card toolbar has Wide button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Wide"]')).not.toBeNull(); + }); + + test('image card toolbar has Full button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Full"]')).not.toBeNull(); + }); + + test('image card toolbar has Link button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Link"]')).not.toBeNull(); + }); + + test('image card toolbar has Replace button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Replace"]')).not.toBeNull(); + }); + + test('image card toolbar has Snippet button', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"] button[aria-label="Snippet"]')).not.toBeNull(); + }); + + test('toolbar can toggle image sizes', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + // placeholder is replaced with uploading image + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + // wait for upload to complete + await expect(await page.getByTestId('progress-bar')).toBeHidden(); + + await page.click('[data-kg-card="image"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); + + await page.click('[data-kg-card-toolbar="image"] button[aria-label="Wide width"]'); + expect (await page.locator('[data-kg-card-width="wide"]')).not.toBeNull(); + + await page.click('[data-kg-card-toolbar="image"] button[aria-label="Full width"]'); + expect (await page.locator('[data-kg-card-width="full"]')).not.toBeNull(); + + await page.click('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]'); + expect (await page.locator('[data-kg-card-width="regular"]')).not.toBeNull(); + }); + + test('toolbar does not disappear on click', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await insertEmptyImageCard(page); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + // placeholder is replaced with uploading image + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + // wait for upload to complete + await expect(await page.getByTestId('progress-bar')).toBeHidden(); + + await page.click('figure'); + + await page.click('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]'); + + expect(await page.locator('[data-kg-card-toolbar="image"]')).not.toBeNull(); + }); + + test('file input opens immediately when added via card menu', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Image"]') + ]); + + expect(fileChooser).not.toBeNull(); + }); + + test('can handle drag over & leave', async function () { + await insertEmptyImageCard(page); + + const imageCard = await page.locator('[data-kg-card="image"]'); + expect(imageCard).not.toBeNull(); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + + await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragenter', {dataTransfer}); + + expect(await page.locator('[data-kg-card-drag-text="true"]')).not.toBeNull(); + + await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragleave', {dataTransfer}); + + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toHaveCount(0); + }); + + test('can handle image drop on empty card', async function () { + await insertEmptyImageCard(page); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + + await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('dragenter', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + await page.locator('[data-kg-card="image"] [data-testid="media-placeholder"]').dispatchEvent('drop', {dataTransfer}); + + // placeholder is replaced with uploading image + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + // wait for upload to complete + await expect(page.getByTestId('progress-bar')).toBeHidden(); + await expect(page.locator('[data-kg-card="image"] img')).toHaveAttribute('alt', ''); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +


    + `); + }); + + test('replaces image when new image file dropped on populated card', async function () { + await focusEditor(page); + await insertImage(page); + + const originalSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-1.png'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image-1.png', fileType: 'image/png'}]); + + await page.locator('[data-kg-card="image"] [data-testid="image-card-populated"]').dispatchEvent('dragenter', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card="image"] [data-testid="drag-overlay"]')).toBeVisible(); + + await page.locator('[data-kg-card="image"] [data-testid="image-card-populated"]').dispatchEvent('drop', {dataTransfer}); + + // wait for upload to complete (progress bar may be too transient to catch) + await expect(page.getByTestId('progress-bar')).toBeHidden({timeout: 15000}); + + const newSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); + + expect(originalSrc).not.toEqual(newSrc); + }); + + test('adds extra paragraph when image is inserted at end of document', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Image"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('does not add extra paragraph when image is inserted mid-document', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('ArrowUp'); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Image"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +

    Testing

    + `, {ignoreCardContents: true}); + }); + + test('can insert unsplash image', async () => { + const testData = [ + { + id: 'SgvrLyGKnHw', + created_at: '2023-02-27T20:39:45Z', + updated_at: '2023-03-01T06:08:01Z', + promoted_at: '2023-03-01T06:08:01Z', + width: 5504, + height: 8256, + color: '#8c8c8c', + blur_hash: 'LHD]Vg4m%fIA_3D%%2MxIoWCs.s:', + description: null, + alt_description: 'a group of people walking down a street next to tall buildings', + urls: { + raw: 'http://127.0.0.1:5173/Koenig-editor-1.png', + full: 'http://127.0.0.1:5173/Koenig-editor-1.png', + regular: 'http://127.0.0.1:5173/Koenig-editor-1.png', + small: 'http://127.0.0.1:5173/Koenig-editor-1.png', + thumb: 'http://127.0.0.1:5173/Koenig-editor-1.png', + small_s3: 'http://127.0.0.1:5173/Koenig-editor-1.png' + }, + links: { + self: 'https://api.unsplash.com/photos/SgvrLyGKnHw', + html: 'https://unsplash.com/photos/SgvrLyGKnHw', + download: 'https://unsplash.com/photos/SgvrLyGKnHw/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjc3NjUxMzk5', + download_location: 'https://api.unsplash.com/photos/SgvrLyGKnHw/download?ixid=MnwxMTc3M3wwfDF8YWxsfDJ8fHx8fHwyfHwxNjc3NjUxMzk5' + }, + likes: 1, + liked_by_user: false, + current_user_collections: [], + sponsorship: null, + topic_submissions: {}, + user: { + id: '9_671Bq5l40', + updated_at: '2023-03-01T06:08:01Z', + username: 'jamillatrach', + name: 'Latrach Med Jamil', + first_name: 'Latrach', + last_name: 'Med Jamil', + twitter_username: null, + portfolio_url: null, + bio: 'Just trying to share what I have --\r\n\r\nInstagram.com/jamillatrach/', + location: 'Düsseldorf', + links: { + self: 'https://api.unsplash.com/users/jamillatrach', + html: 'https://unsplash.com/@jamillatrach', + photos: 'https://api.unsplash.com/users/jamillatrach/photos', + likes: 'https://api.unsplash.com/users/jamillatrach/likes', + portfolio: 'https://api.unsplash.com/users/jamillatrach/portfolio', + following: 'https://api.unsplash.com/users/jamillatrach/following', + followers: 'https://api.unsplash.com/users/jamillatrach/followers' + }, + profile_image: { + small: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=32\u0026h=32', + medium: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=64\u0026h=64', + large: 'https://images.unsplash.com/profile-fb-1570626489-2f1895a616ca.jpg?ixlib=rb-4.0.3\u0026crop=faces\u0026fit=crop\u0026w=128\u0026h=128' + }, + instagram_username: 'jamillatrach', + total_collections: 0, + total_likes: 4, + total_photos: 451, + accepted_tos: true, + for_hire: false, + social: { + instagram_username: 'jamillatrach', + portfolio_url: null, + twitter_username: null, + paypal_email: null + } + } + } + ]; + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + + // mock unsplash api + await page.route('https://api.unsplash.com/photos?per_page=30', route => route.fulfill({ + status: 200, + body: JSON.stringify(testData) + })); + await page.click('[data-kg-unsplash-insert-button]'); + await assertHTML(page, html` +
    +
    +
    +
    + a group of people walking down a street next to tall buildings +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('can insert tenor image', async () => { + await mockTenorApi(page); + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="GIF"]'); + + // chose second gif from list + await expect(await page.locator('[data-gif-index="1"]')).toBeVisible(); + await page.click('[data-gif-index="1"]'); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Type caption for image (optional)
    +
    +
    + +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('can insert a gif with the keyboard', async () => { + await mockTenorApi(page); + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="GIF"]'); + + // chose third gif from list + await expect(await page.locator('[data-gif-index="2"]')).toBeVisible(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Enter'); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Type caption for image (optional)
    +
    +
    + +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('can close the gif selector on Esc', async () => { + await mockTenorApi(page); + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="GIF"]'); + + await expect(await page.getByTestId('gif-selector')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(await page.getByTestId('gif-selector')).toBeHidden(); + }); + + test('can show a gif selector error', async () => { + await mockTenorApi(page, {status: 400}); + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="GIF"]'); + + await expect(await page.getByTestId('gif-selector-error')).toBeVisible(); + }); + + test('can add snippet', async function () { + // insert image + await insertImage(page); + + // create snippet - click card to ensure stable selected (not editing) state + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.getByTestId('create-snippet')).toBeVisible(); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="image"]')).toHaveCount(2); + }); + + test('can select caption text without scrolling', async function () { + // Type in some text, so that we can scroll + await focusEditor(page); + await enterUntilScrolled(page); + await insertImage(page); + + const rootParagraphs = page.locator('.koenig-lexical > [data-kg="editor"] > div > p'); + const paragraphCount = await rootParagraphs.count(); + + await page.keyboard.type('Captiontest--Captiontest'); + + const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); + + // Check contains text + await expect(captionEditor).toHaveText('Captiontest--Captiontest'); + + // Ensure caption is scrolled into view before mouse operations + // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) + await page.evaluate(() => { + document.querySelector('[data-testid="image-caption-editor"]')?.scrollIntoView({block: 'center'}); + }); + await page.waitForTimeout(100); + + await expectUnchangedScrollPosition(page, async () => { + // Select the text using mouse drag (middle to end) + const box = await captionEditor.boundingBox(); + const y = box!.y + box!.height / 2; + const startX = box!.x + box!.width / 2; + const endX = box!.x + box!.width; + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y); + await page.mouse.up(); + + await page.keyboard.type('world'); + + // Check contains text + await expect(captionEditor).toHaveText('Captiontest-world'); + + // Press the enter key + await page.keyboard.press('Enter'); + + // Check if the image card is now deselected + await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-selected', 'false'); + + // Check total paragraph count increased + await expect(rootParagraphs).toHaveCount(paragraphCount + 1); + + // Add some text + await page.keyboard.type('last one'); + + // Check contains text + await expect(rootParagraphs.filter({hasText: 'last one'})).toHaveCount(1); + }); + }); + + test('can select caption text and make it italic', async function () { + // Type in some text, so that we can scroll + await focusEditor(page); + await enterUntilScrolled(page); + await insertImage(page); + + await page.keyboard.type('Captiontest--Captiontest'); + + const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); + + // Check contains text + await expect(captionEditor).toHaveText('Captiontest--Captiontest'); + + // Ensure caption is scrolled into view before mouse operations + // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) + await page.evaluate(() => { + document.querySelector('[data-testid="image-caption-editor"]')?.scrollIntoView({block: 'center'}); + }); + await page.waitForTimeout(100); + + await expectUnchangedScrollPosition(page, async () => { + // Select the left side of the text (deliberately a test in the other direction) + const box = await captionEditor.boundingBox(); + const y = box!.y + box!.height / 2; + const startX = box!.x + box!.width / 2; + const endX = box!.x; + + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y); + await page.mouse.up(); + + // Click italic button + await page.locator('[data-kg-toolbar-button="italic"]').click(); + + // Check contains text + await expect(captionEditor).toHaveText('-Captiontest'); + const italicSpan = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p em').nth(0); + await expect(italicSpan).toHaveText('Captiontest-'); + }); + }); + + test('does not remove text when pressing ENTER in the middle of a caption', async function () { + // Type in some text, so that we can scroll + await focusEditor(page); + await enterUntilScrolled(page); + await insertImage(page); + + const rootParagraphs = page.locator('.koenig-lexical > [data-kg="editor"] > div > p'); + const paragraphCount = await rootParagraphs.count(); + + await page.keyboard.type('Captiontest--Captiontest'); + + const captionEditor = page.locator('[data-testid="image-caption-editor"] [data-kg="editor"] p span'); + + // Check contains text + await expect(captionEditor).toHaveText('Captiontest--Captiontest'); + + // Ensure caption is scrolled into view before mouse operations + // (Chrome for Testing auto-scrolls on mouse interactions unlike old Chromium) + await page.evaluate(() => { + document.querySelector('[data-testid="image-caption-editor"]')?.scrollIntoView({block: 'center'}); + }); + await page.waitForTimeout(100); + + await expectUnchangedScrollPosition(page, async () => { + // Click at the middle of the caption text + const box = await captionEditor.boundingBox(); + const y = box!.y + box!.height / 2; + const x = box!.x + box!.width / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.up(); + await page.keyboard.type('**'); // To test the cursor is at the middle, otherwise were not testing anything + await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); + + // Press the enter key + await page.keyboard.press('Enter'); + await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); + + // Check if the image card is now deselected + await expect(page.locator('[data-kg-card="image"]')).toHaveAttribute('data-kg-card-selected', 'false'); + + // Check total paragraph count increased + await expect(rootParagraphs).toHaveCount(paragraphCount + 1); + + // Add some text + await page.keyboard.type('last one'); + + // Check contains text + await expect(rootParagraphs.filter({hasText: 'last one'})).toHaveCount(1); + + // Caption still ok? + await expect(captionEditor).toHaveText('Captiontest-**-Captiontest'); + + // Select the caption again (click centers on the element by default) + await captionEditor.click(); + await page.keyboard.type('_'); // To test the cursor is at the middle, otherwise were not testing anything + + // Press the enter key + await page.keyboard.press('Enter'); + await expect(captionEditor).toHaveText('Captiontest-*_*-Captiontest'); + }); + }); + + test.describe('image should be a top-level element', () => { + test('can insert image to nested list', async function () { + await insertImage(page); + const modifier = isMac() ? 'Meta' : 'Control'; + await page.keyboard.press(`${modifier}+KeyC`); + await page.keyboard.press('Enter'); + await page.keyboard.type('- First item'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Tab'); + await page.keyboard.press(`${modifier}+KeyV`); + + // image should be top-level and shouldn't have ul as a parent + await expect(page.locator('ul:has([data-kg-card="image"])')).toHaveCount(0); + }); + + test('can paste image with text from google doc', async function () { + await focusEditor(page); + await pasteHtml( + page, + '

    Some text



    ', + ); + + // image should be top-level and shouldn't have paragraph as a parent + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + await expect(page.locator('p:has([data-kg-card="image"])')).toHaveCount(0); + }); + }); + + test.describe('caption', function () { + test.beforeEach(async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'image', + src: '/content/images/2022/11/koenig-lexical.jpg', + width: 3840, + height: 2160, + title: 'This is a title', + alt: 'This is some alt text', + caption: 'This is a caption', + cardWidth: 'wide' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + }); + + test('can delete node and undo without losing caption', async function () { + await page.click('.koenig-lexical'); + await page.keyboard.press('ArrowUp'); + await page.waitForSelector('[data-kg-card="image"][data-kg-card-selected="true"]'); + await page.keyboard.press('Backspace'); + await expect(page.locator('[data-kg-card="image"]')).not.toBeVisible(); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); + }); + + test('can toggle between alt and caption', async function () { + await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); + await page.click('[data-kg-card="image"]'); // alt toggle not shown until selected + await page.click('[data-testid="alt-toggle-button"]'); + await expect(page.getByTestId('image-caption-editor')).toHaveValue('This is some alt text'); + await page.click('[data-testid="alt-toggle-button"]'); + await expect(page.getByTestId('image-caption-editor')).toHaveText('This is a caption'); + }); + }); + + test.skip('can drag image card onto image card to create gallery', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + await focusEditor(page); + + const [fileChooser1] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'image', nth: 0}) + ]); + await fileChooser1.setFiles([filePath]); + + await page.keyboard.press('Enter'); + + const [fileChooser2] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'image', nth: 1}) + ]); + await fileChooser2.setFiles([filePath]); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + const imageCard1BBox = await page.locator('[data-kg-card="image"]').nth(0).boundingBox(); + const imageCard2BBox = await page.locator('[data-kg-card="image"]').nth(1).boundingBox(); + + await dragMouse(page, imageCard2BBox!, imageCard1BBox!, 'middle', 'middle', true, 100, 100); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); +}); + +async function insertImage(page: Page, image = 'large-image.png') { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${image}`); + + await insertEmptyImageCard(page); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); +} + +async function insertEmptyImageCard(page: Page) { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'image', + version: 1, + src: '', + width: null, + height: null, + title: '', + alt: '', + caption: '', + cardWidth: 'regular', + href: '' + }, { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); +} + +function tenorTestData() { + return ( + { + locale: 'en', + results: [ + { + id: '6897265628617702942', + title: '', + media_formats: { + tinygif: { + url: 'https://media.tenor.com/X7gCi8NE_h4AAAAM/cat-funny.gif', + duration: 0, + preview: '', + dims: [ + 220, + 204 + ], + size: 522164 + }, + gif: { + url: 'https://media.tenor.com/X7gCi8NE_h4AAAAC/cat-funny.gif', + duration: 0, + preview: '', + dims: [ + 498, + 460 + ], + size: 4870544 + }, + tinygifpreview: { + url: 'https://media.tenor.com/X7gCi8NE_h4AAAAF/cat-funny.png', + duration: 0, + preview: '', + dims: [ + 220, + 204 + ], + size: 21743 + }, + gifpreview: { + url: 'https://media.tenor.com/X7gCi8NE_h4AAAAe/cat-funny.png', + duration: 0, + preview: '', + dims: [ + 640, + 592 + ], + size: 141384 + }, + mp4: { + url: 'https://media.tenor.com/X7gCi8NE_h4AAAPo/cat-funny.mp4', + duration: 3.7, + preview: '', + dims: [ + 640, + 592 + ], + size: 754491 + } + }, + created: 1580334888.9161069, + content_description: 'Cat Funny GIF', + itemurl: 'https://tenor.com/view/cat-funny-fall-submit-play-gif-16179688', + url: 'https://tenor.com/bf3eS.gif', + tags: [ + 'cat', + 'funny', + 'fall', + 'submit', + 'play' + ], + flags: [], + hasaudio: false + }, + { + id: '11657229184981764452', + title: '', + media_formats: { + tinygifpreview: { + url: 'https://media.tenor.com/ocbMLlwniWQAAAAF/steve-harvey-oh.png', + duration: 0, + preview: '', + dims: [ + 220, + 124 + ], + size: 11388 + }, + tinygif: { + url: 'https://media.tenor.com/ocbMLlwniWQAAAAM/steve-harvey-oh.gif', + duration: 0, + preview: '', + dims: [ + 220, + 124 + ], + size: 173121 + }, + gifpreview: { + url: 'https://media.tenor.com/ocbMLlwniWQAAAAe/steve-harvey-oh.png', + duration: 0, + preview: '', + dims: [ + 640, + 360 + ], + size: 65023 + }, + gif: { + url: 'https://media.tenor.com/ocbMLlwniWQAAAAC/steve-harvey-oh.gif', + duration: 0, + preview: '', + dims: [ + 498, + 280 + ], + size: 1669457 + }, + mp4: { + url: 'https://media.tenor.com/ocbMLlwniWQAAAPo/steve-harvey-oh.mp4', + duration: 2.4, + preview: '', + dims: [ + 640, + 360 + ], + size: 377541 + } + }, + created: 1600453059.6729331, + content_description: 'Steve Harvey Oh GIF', + itemurl: 'https://tenor.com/view/steve-harvey-oh-you-crazy-point-stop-gif-18502036', + url: 'https://tenor.com/bpNn6.gif', + tags: [ + 'Steve Harvey', + 'oh', + 'You Crazy', + 'point', + 'stop' + ], + flags: [], + hasaudio: false + }, + { + id: '5363605506377337635', + title: '', + media_formats: { + gif: { + url: 'https://media.tenor.com/Sm9aylrzSyMAAAAC/cats-animals.gif', + duration: 0, + preview: '', + dims: [ + 498, + 431 + ], + size: 1574979 + }, + gifpreview: { + url: 'https://media.tenor.com/Sm9aylrzSyMAAAAe/cats-animals.png', + duration: 0, + preview: '', + dims: [ + 640, + 554 + ], + size: 153379 + }, + tinygifpreview: { + url: 'https://media.tenor.com/Sm9aylrzSyMAAAAF/cats-animals.png', + duration: 0, + preview: '', + dims: [ + 220, + 190 + ], + size: 25196 + }, + tinygif: { + url: 'https://media.tenor.com/Sm9aylrzSyMAAAAM/cats-animals.gif', + duration: 0, + preview: '', + dims: [ + 220, + 190 + ], + size: 236117 + }, + mp4: { + url: 'https://media.tenor.com/Sm9aylrzSyMAAAPo/cats-animals.mp4', + duration: 1.2, + preview: '', + dims: [ + 640, + 554 + ], + size: 265062 + } + }, + created: 1616817775.272332, + content_description: 'Cats Animals GIF', + itemurl: 'https://tenor.com/view/cats-animals-reaction-wow-surprised-gif-20914356', + url: 'https://tenor.com/bzUWu.gif', + tags: [ + 'cats', + 'animals', + 'reaction', + 'wow', + 'surprised' + ], + flags: [], + hasaudio: false + } + ] + } + ); +} + +const tenorUrl = /https:\/\/tenor\.googleapis\.com\/v2\//; +async function mockTenorApi(page: Page, {status} = {status: 200}) { + await page.route(tenorUrl, route => route.fulfill({ + status, + body: JSON.stringify(tenorTestData()) + })); +} + +function klipyTestData() { + return { + results: [ + { + id: '2484942301552561', + title: 'Klipy Cat', + media_formats: { + tinygif: { + url: 'https://static.klipy.com/gif/klipy-cat-tiny.gif', + duration: 0, + preview: '', + dims: [220, 220], + size: 271080 + }, + gif: { + url: 'https://static.klipy.com/gif/klipy-cat.gif', + duration: 0, + preview: '', + dims: [498, 498], + size: 273268 + } + }, + created: 1765483200, + content_description: 'Klipy Cat GIF', + itemurl: 'https://klipy.com/gifs/klipy-cat', + url: 'https://static.klipy.com/gif/klipy-cat.gif', + tags: ['cat'], + flags: [], + hasaudio: false + } + ], + next: '' + }; +} + +const klipyUrl = /https:\/\/api\.klipy\.com\/v2\//; +async function mockKlipyApi(page: Page, {status} = {status: 200}) { + await page.route(klipyUrl, route => route.fulfill({ + status, + body: JSON.stringify(klipyTestData()) + })); +} + +test.describe('Image card - Klipy GIF provider', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can insert klipy image', async () => { + await mockKlipyApi(page); + // ?gifProvider=klipy makes the demo resolve the GIF card to Klipy + await initialize({page, uri: '/?gifProvider=klipy#/?content=false'}); + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await page.click('button[data-kg-card-menu-item="GIF"]'); + + await expect(await page.locator('[data-gif-index="0"]')).toBeVisible(); + await page.click('[data-gif-index="0"]'); + + // toBeAttached rather than toBeVisible: the fixture GIF URL is not + // network-loaded in tests, so visibility would depend on an external fetch + await expect(await page.getByTestId('image-card-populated')).toBeAttached(); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Type caption for image (optional)
    +
    +
    + +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/markdown-card.test.js b/packages/koenig-lexical/test/e2e/cards/markdown-card.test.js deleted file mode 100644 index ba326769c8..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/markdown-card.test.js +++ /dev/null @@ -1,717 +0,0 @@ -import path from 'path'; -import { - assertHTML, - assertRootChildren, - createSnippet, - focusEditor, - html, - initialize, - insertCard, - selectBackwards -} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Click the markdown card and wait for CodeMirror to be focused -// (Chrome for Testing needs this before typing/shortcuts will register reliably) -async function focusMarkdownEditor(page) { - await page.click('[data-kg-card="markdown"]'); - await expect(page.locator('[data-kg-card="markdown"] .CodeMirror-focused')).toBeVisible(); -} - -async function pressMarkdownShortcut(page, key, modifier) { - const locator = '[data-kg-card="markdown"] [title^="Bold"]'; - const title = await page.locator(locator).getAttribute('title'); - - if (!title) { - throw new Error(`Unable to determine markdown shortcut modifier: missing title for locator "${locator}" while resolving toolbar shortcut intent.`); - } - - await page.keyboard.press(`${modifier || (title.includes('Ctrl-') ? 'Control' : 'Meta')}+${key}`); -} - -test.describe('Markdown card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized markdown card node', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'markdown', - version: 1, - markdown: '# This is a heading' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    -
    -

    This is a heading

    -
    -
    -
    -
    - `); - }); - - test('renders markdown card node', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - - await page.click('[data-kg-card-menu-item="Markdown"]'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test ('markdown card doesn\'t leave editing mode on double click inside', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - - await focusMarkdownEditor(page); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - await page.locator('.CodeMirror-line').dblclick(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('should open unsplash dialog on Cmd-Alt-O', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - - await pressMarkdownShortcut(page, 'Alt+O', 'Control'); - await page.waitForSelector('[data-kg-modal="unsplash"]'); - }); - - test('should toggle spellcheck on Cmd-Alt-S', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'markdown'}); - - await expect(page.locator('[title*="Spellcheck"]')).not.toBeNull(); - await pressMarkdownShortcut(page, 'Alt+S', 'Control'); - await expect(page.locator('[title*="Spellcheck"][class*="active"]')).toHaveCount(1); - }); - - test('should open image upload dialog on Cmd-Alt-I', async function () { - const fileChooserPromise = page.waitForEvent('filechooser'); - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'Alt+I', 'Control'); - await fileChooserPromise; - }); - - test('can display and close markdown help guide', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - - await page.click('a[title="Markdown Guide"]'); - await expect(await page.getByTestId('markdown-help-dialog')).toBeVisible(); - - await page.click('button[aria-label="Close dialog"]'); - await expect(await page.getByTestId('markdown-help-dialog')).not.toBeVisible(); - }); - - test('adds extra paragraph when markdown is inserted at end of document', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - - await expect(page.locator('[data-kg-card="markdown"][data-kg-card-editing="true"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('does not add extra paragraph when markdown is inserted mid-document', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('ArrowUp'); - await page.click('[data-kg-plus-button]'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - - await expect(page.locator('[data-kg-card="markdown"][data-kg-card-editing="true"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -

    Testing

    - `, {ignoreCardContents: true}); - }); - - test('can upload an image', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - await focusEditor(page); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await page.waitForSelector('[data-kg-card="markdown"] .editor-toolbar'); - await pressMarkdownShortcut(page, 'Alt+I', 'Control'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(filePath); - - // wait for progress bar to be shown and subsequently hidden - // TODO: these assertions cause a flaky test now that we've shortened the upload step timeouts, - // TODO: but we should find a way to re-enable them - // await page.waitForSelector('[data-testid="progress-bar"]'); - // await expect(await page.getByTestId('progress-bar')).not.toBeVisible(); - - // wait for image markdown to be inserted - await page.waitForSelector('[data-kg-card="markdown"] .cm-image'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '![large-image.png](blob:...)' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert bold text', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'B'); - await page.keyboard.type('bold text', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '**bold text**' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert text to bold', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('bold', {delay: 10}); - // select the text - await selectBackwards(page, 4); - // make it bold - await pressMarkdownShortcut(page, 'B'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '**bold**' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert italic text', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'I'); - await page.keyboard.type('italic text', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '*italic text*' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert text to italic', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('italic', {delay: 10}); - // select the text - await selectBackwards(page, 6); - // make it italic - await pressMarkdownShortcut(page, 'I'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '*italic*' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert strikethrough text', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'Alt+U'); - await page.keyboard.type('text', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '~~text~~' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert text to strikethrough', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('text', {delay: 10}); - // select the text - await selectBackwards(page, 4); - // make it strikethrough - await pressMarkdownShortcut(page, 'Alt+U'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '~~text~~' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert heading', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'H'); - await page.keyboard.type('Heading text', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '# Heading text' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert line to heading', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('Heading', {delay: 10}); - await pressMarkdownShortcut(page, 'H'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '# Heading' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert quote', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, '\''); - await page.keyboard.type('quote', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '> quote' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert line to quote', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('quote', {delay: 10}); - await pressMarkdownShortcut(page, '\''); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '> quote' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert an unordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'L'); - await page.keyboard.type('First list item', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '* First list item' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert line to unordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('A list item', {delay: 10}); - await pressMarkdownShortcut(page, 'L'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '* A list item' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert an ordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'Alt+L'); - // Wait for CodeMirror to insert the list prefix before typing - await expect(page.locator('[data-kg-card="markdown"] .CodeMirror-line')).toContainText('1.'); - await page.keyboard.type('First list item', {delay: 10}); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '1. First list item' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert line to ordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('A list item', {delay: 10}); - await pressMarkdownShortcut(page, 'Alt+L'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '1. A list item' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can insert a link', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await pressMarkdownShortcut(page, 'K'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '[](http://)' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can convert text to a link', async function () { - await focusEditor(page); - - await page.keyboard.type('/'); - await page.click('[data-kg-card-menu-item="Markdown"]'); - await focusMarkdownEditor(page); - await page.keyboard.type('link', {delay: 10}); - // select the text - await selectBackwards(page, 4); - // convert to link - await pressMarkdownShortcut(page, 'K'); - - await assertRootChildren(page, JSON.stringify([ - { - type: 'markdown', - version: 1, - markdown: '[link](http://)' - }, - { - children: [], - direction: null, - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ])); - }); - - test('can add snippet', async function () { - await focusEditor(page); - // insert new card - await insertCard(page, {cardName: 'markdown'}); - - // fill card - await expect(await page.locator('[data-kg-card="markdown"]')).toBeVisible(); - await focusMarkdownEditor(page); - await page.keyboard.type('snippet', {delay: 10}); - await page.keyboard.press('Escape'); - - // create snippet - await createSnippet(page); - - // Wait for snippet toolbar to close and card to be back in selected state - await expect(page.locator('[data-kg-card="markdown"][data-kg-card-selected="true"]')).toBeVisible(); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="markdown"]')).toHaveCount(2); - }); - - test('can undo/redo content in markdown editor', async function () { - await focusEditor(page); - // insert new card - await insertCard(page, {cardName: 'markdown'}); - - // fill card - await expect(await page.locator('[data-kg-card="markdown"]')).toBeVisible(); - await focusMarkdownEditor(page); - await page.keyboard.type('Here are some words', {delay: 10}); - await expect(page.getByText('Here are some words')).toBeVisible(); - await page.keyboard.press('Backspace'); - await expect(page.getByText('Here are some word')).toBeVisible(); - await pressMarkdownShortcut(page, 'z'); - await expect(page.getByText('Here are some words')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByText('Here are some words')).toBeVisible(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/markdown-card.test.ts b/packages/koenig-lexical/test/e2e/cards/markdown-card.test.ts new file mode 100644 index 0000000000..4330ca4f7c --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/markdown-card.test.ts @@ -0,0 +1,718 @@ +import path from 'path'; +import { + assertHTML, + assertRootChildren, + createSnippet, + focusEditor, + html, + initialize, + insertCard, + selectBackwards +} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Click the markdown card and wait for CodeMirror to be focused +// (Chrome for Testing needs this before typing/shortcuts will register reliably) +async function focusMarkdownEditor(page: Page) { + await page.click('[data-kg-card="markdown"]'); + await expect(page.locator('[data-kg-card="markdown"] .CodeMirror-focused')).toBeVisible(); +} + +async function pressMarkdownShortcut(page: Page, key: string, modifier?: string) { + const locator = '[data-kg-card="markdown"] [title^="Bold"]'; + const title = await page.locator(locator).getAttribute('title'); + + if (!title) { + throw new Error(`Unable to determine markdown shortcut modifier: missing title for locator "${locator}" while resolving toolbar shortcut intent.`); + } + + await page.keyboard.press(`${modifier || (title.includes('Ctrl-') ? 'Control' : 'Meta')}+${key}`); +} + +test.describe('Markdown card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized markdown card node', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'markdown', + version: 1, + markdown: '# This is a heading' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    +
    +

    This is a heading

    +
    +
    +
    +
    + `); + }); + + test('renders markdown card node', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + + await page.click('[data-kg-card-menu-item="Markdown"]'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test ('markdown card doesn\'t leave editing mode on double click inside', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + + await focusMarkdownEditor(page); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + await page.locator('.CodeMirror-line').dblclick(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('should open unsplash dialog on Cmd-Alt-O', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + + await pressMarkdownShortcut(page, 'Alt+O', 'Control'); + await page.waitForSelector('[data-kg-modal="unsplash"]'); + }); + + test('should toggle spellcheck on Cmd-Alt-S', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'markdown'}); + + await expect(page.locator('[title*="Spellcheck"]')).not.toBeNull(); + await pressMarkdownShortcut(page, 'Alt+S', 'Control'); + await expect(page.locator('[title*="Spellcheck"][class*="active"]')).toHaveCount(1); + }); + + test('should open image upload dialog on Cmd-Alt-I', async function () { + const fileChooserPromise = page.waitForEvent('filechooser'); + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'Alt+I', 'Control'); + await fileChooserPromise; + }); + + test('can display and close markdown help guide', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + + await page.click('a[title="Markdown Guide"]'); + await expect(await page.getByTestId('markdown-help-dialog')).toBeVisible(); + + await page.click('button[aria-label="Close dialog"]'); + await expect(await page.getByTestId('markdown-help-dialog')).not.toBeVisible(); + }); + + test('adds extra paragraph when markdown is inserted at end of document', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + + await expect(page.locator('[data-kg-card="markdown"][data-kg-card-editing="true"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('does not add extra paragraph when markdown is inserted mid-document', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('ArrowUp'); + await page.click('[data-kg-plus-button]'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + + await expect(page.locator('[data-kg-card="markdown"][data-kg-card-editing="true"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +

    Testing

    + `, {ignoreCardContents: true}); + }); + + test('can upload an image', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + await focusEditor(page); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await page.waitForSelector('[data-kg-card="markdown"] .editor-toolbar'); + await pressMarkdownShortcut(page, 'Alt+I', 'Control'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePath); + + // wait for progress bar to be shown and subsequently hidden + // TODO: these assertions cause a flaky test now that we've shortened the upload step timeouts, + // TODO: but we should find a way to re-enable them + // await page.waitForSelector('[data-testid="progress-bar"]'); + // await expect(await page.getByTestId('progress-bar')).not.toBeVisible(); + + // wait for image markdown to be inserted + await page.waitForSelector('[data-kg-card="markdown"] .cm-image'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '![large-image.png](blob:...)' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert bold text', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'B'); + await page.keyboard.type('bold text', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '**bold text**' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert text to bold', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('bold', {delay: 10}); + // select the text + await selectBackwards(page, 4); + // make it bold + await pressMarkdownShortcut(page, 'B'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '**bold**' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert italic text', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'I'); + await page.keyboard.type('italic text', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '*italic text*' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert text to italic', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('italic', {delay: 10}); + // select the text + await selectBackwards(page, 6); + // make it italic + await pressMarkdownShortcut(page, 'I'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '*italic*' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert strikethrough text', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'Alt+U'); + await page.keyboard.type('text', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '~~text~~' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert text to strikethrough', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('text', {delay: 10}); + // select the text + await selectBackwards(page, 4); + // make it strikethrough + await pressMarkdownShortcut(page, 'Alt+U'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '~~text~~' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert heading', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'H'); + await page.keyboard.type('Heading text', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '# Heading text' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert line to heading', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('Heading', {delay: 10}); + await pressMarkdownShortcut(page, 'H'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '# Heading' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert quote', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, '\''); + await page.keyboard.type('quote', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '> quote' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert line to quote', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('quote', {delay: 10}); + await pressMarkdownShortcut(page, '\''); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '> quote' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert an unordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'L'); + await page.keyboard.type('First list item', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '* First list item' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert line to unordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('A list item', {delay: 10}); + await pressMarkdownShortcut(page, 'L'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '* A list item' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert an ordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'Alt+L'); + // Wait for CodeMirror to insert the list prefix before typing + await expect(page.locator('[data-kg-card="markdown"] .CodeMirror-line')).toContainText('1.'); + await page.keyboard.type('First list item', {delay: 10}); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '1. First list item' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert line to ordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('A list item', {delay: 10}); + await pressMarkdownShortcut(page, 'Alt+L'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '1. A list item' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can insert a link', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await pressMarkdownShortcut(page, 'K'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '[](http://)' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can convert text to a link', async function () { + await focusEditor(page); + + await page.keyboard.type('/'); + await page.click('[data-kg-card-menu-item="Markdown"]'); + await focusMarkdownEditor(page); + await page.keyboard.type('link', {delay: 10}); + // select the text + await selectBackwards(page, 4); + // convert to link + await pressMarkdownShortcut(page, 'K'); + + await assertRootChildren(page, JSON.stringify([ + { + type: 'markdown', + version: 1, + markdown: '[link](http://)' + }, + { + children: [], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ])); + }); + + test('can add snippet', async function () { + await focusEditor(page); + // insert new card + await insertCard(page, {cardName: 'markdown'}); + + // fill card + await expect(await page.locator('[data-kg-card="markdown"]')).toBeVisible(); + await focusMarkdownEditor(page); + await page.keyboard.type('snippet', {delay: 10}); + await page.keyboard.press('Escape'); + + // create snippet + await createSnippet(page); + + // Wait for snippet toolbar to close and card to be back in selected state + await expect(page.locator('[data-kg-card="markdown"][data-kg-card-selected="true"]')).toBeVisible(); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="markdown"]')).toHaveCount(2); + }); + + test('can undo/redo content in markdown editor', async function () { + await focusEditor(page); + // insert new card + await insertCard(page, {cardName: 'markdown'}); + + // fill card + await expect(await page.locator('[data-kg-card="markdown"]')).toBeVisible(); + await focusMarkdownEditor(page); + await page.keyboard.type('Here are some words', {delay: 10}); + await expect(page.getByText('Here are some words')).toBeVisible(); + await page.keyboard.press('Backspace'); + await expect(page.getByText('Here are some word')).toBeVisible(); + await pressMarkdownShortcut(page, 'z'); + await expect(page.getByText('Here are some words')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Here are some words')).toBeVisible(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/paywall-card.test.js b/packages/koenig-lexical/test/e2e/cards/paywall-card.test.js deleted file mode 100644 index 8ba468e0bf..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/paywall-card.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -async function insertPaywallCard(page) { - await page.keyboard.type('/paywall'); - await page.waitForSelector('[data-kg-card-menu-item="Public preview"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); -} - -test.describe('Paywall card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized paywall card nodes', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [{ - type: 'paywall' - }], - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - - await assertHTML(page, html` -
    -
    -
    - Free public preview/Only visible to members -
    -
    -
    - `); - }); - - test('renders paywall card node from slash command', async function () { - await focusEditor(page); - await insertPaywallCard(page); - - await assertHTML(page, html` -
    -
    -
    - Free public preview/Only visible to members -
    -
    -
    -


    - `); - }); - - test('focuses on the next paragraph when rendered', async function () { - await focusEditor(page); - await insertPaywallCard(page); - - await page.keyboard.type('Next paragraph'); - - await assertHTML(page, html` -
    -
    -
    -
    -

    Next paragraph

    - `, {ignoreCardContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/paywall-card.test.ts b/packages/koenig-lexical/test/e2e/cards/paywall-card.test.ts new file mode 100644 index 0000000000..ac16cc7940 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/paywall-card.test.ts @@ -0,0 +1,83 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +async function insertPaywallCard(page: Page) { + await page.keyboard.type('/paywall'); + await page.waitForSelector('[data-kg-card-menu-item="Public preview"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); +} + +test.describe('Paywall card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized paywall card nodes', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [{ + type: 'paywall' + }], + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + + await assertHTML(page, html` +
    +
    +
    + Free public preview/Only visible to members +
    +
    +
    + `); + }); + + test('renders paywall card node from slash command', async function () { + await focusEditor(page); + await insertPaywallCard(page); + + await assertHTML(page, html` +
    +
    +
    + Free public preview/Only visible to members +
    +
    +
    +


    + `); + }); + + test('focuses on the next paragraph when rendered', async function () { + await focusEditor(page); + await insertPaywallCard(page); + + await page.keyboard.type('Next paragraph'); + + await assertHTML(page, html` +
    +
    +
    +
    +

    Next paragraph

    + `, {ignoreCardContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/product-card.test.js b/packages/koenig-lexical/test/e2e/cards/product-card.test.js deleted file mode 100644 index 66aaf5de4a..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/product-card.test.js +++ /dev/null @@ -1,715 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Product card', async () => { - const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized product card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'product', - productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', - productTitle: 'This is title', - productDescription: '

    Description

    ', - productUrl: 'https://google.com/', - productButton: 'Button', - productButtonEnabled: true, - productRatingEnabled: true, - productStarRating: 4 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    - Product thumbnail -
    -
    -
    -
    -
    -
    -

    - This is - title -

    -
    -
    -
    -
    -
    - - - - - -
    -
    -
    -
    -
    -
    -

    Description

    -
    -
    -
    -
    -
    - Button -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('renders product card node', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can upload image file', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - await uploadImg(page); - - // Image should be visible - const mediaPlaceholder = await page.getByTestId('media-placeholder'); - await expect(await page.getByTestId('product-card-image')).toBeVisible(); - await expect(mediaPlaceholder).toBeHidden(); - - // Can remove image - const replaceButton = page.getByTestId('replace-product-image'); - await replaceButton.click(); - await expect(await page.getByTestId('media-placeholder')).toBeVisible(); - }); - - test('can show errors for failed image upload', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - await uploadImg(page, 'large-image-fail.jpeg'); - - // Errors should be visible - await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); - }); - - test('can upload dropped image', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - // Placeholder should be visible - await expect(await page.getByTestId('media-placeholder')).toBeVisible(); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Image should be visible - await expect(await page.getByTestId('product-card-image')).toBeVisible(); - }); - - test('can show errors if was dropped a file with wrong extension', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); - - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - // Placeholder should be visible - await expect(await page.getByTestId('media-placeholder')).toBeVisible(); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image-fail.png', fileType: 'image/jpeg'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Errors should be visible - await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); - }); - - test('can show/hide rating starts if rating enabled/disabled', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - // Rating toggle should be visible and unchecked - const productRating = await page.getByTestId('product-rating-toggle'); - await expect(productRating).toBeVisible(); - await expect(await page.locator('[data-testid="product-rating-toggle"] input').isChecked()).toBeFalsy(); - - // Stars should be hidden - const productStars = await page.getByTestId('product-stars'); - await expect(productStars).toBeHidden(); - - // Stars should be visible after rating enabled - await productRating.check(); - await expect(productStars).toBeVisible(); - }); - - test('can show/hide button if button settings was enabled/disabled', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - // Button toggle should be visible and unchecked - const productButtonToggle = await page.getByTestId('product-button-toggle'); - await expect(productButtonToggle).toBeVisible(); - await expect(await page.locator('[data-testid="product-button-toggle"] input').isChecked()).toBeFalsy(); - - // Button should be hidden in card - const productButton = await page.getByTestId('product-button'); - await expect(productButton).toBeHidden(); - - // Button should be visible after button enabled in settings - await productButtonToggle.check(); - await expect(productButton).toBeVisible(); - - // Fill button text and url in settings - await page.getByTestId('product-button-text-input').fill('Button text'); - await page.getByTestId('product-button-url-input').fill('https://google.com/'); - - // Button should be filled and visible in card - const button = await page.getByTestId('product-button'); - await expect(button).toBeVisible(); - await expect(await page.getByTestId('product-button-span')).toContainText('Button text'); - await expect(await button.getAttribute('href')).toEqual('https://google.com/'); - }); - - test('can fill title and description', async () => { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - await page.keyboard.type('Title'); - - // Move to description - await page.keyboard.press('Enter'); - - await page.keyboard.type('Description'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -

    Title

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    Description

    -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('can add snippet', async function () { - await focusEditor(page); - // insert new card - await insertCard(page, {cardName: 'product'}); - - // fill card - await expect(await page.locator('[data-kg-card="product"]')).toBeVisible(); - await page.keyboard.type('snippet'); - await page.keyboard.press('Escape'); - - // wait for card to finish transitioning to display mode - await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); - await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - // create snippet - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="product"]')).toHaveCount(2); - }); - - test('renders product card toolbar', async () => { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - await page.keyboard.type('Title'); - - // Leave editing mode to display the toolbar - await page.keyboard.press('Escape'); - - // Check that the toolbar is displayed - await expect(page.locator('[data-kg-card-toolbar="product"]')).toBeVisible(); - - // Wait for card to finish transitioning to selected (non-editing) state - await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); - // Small wait for toolbar to stabilize after card state transition - await page.waitForTimeout(50); - - // Edit video card - await page.getByTestId('edit-product-card').click(); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can undo/redo without losing nested editor content', async () => { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - await page.keyboard.type('Test title'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Test description'); - // Exit card edit mode, then use Enter+Backspace×2 to delete so undo - // has a proper history entry. Direct Escape→Backspace doesn't create a - // main editor content update between card insertion and deletion, so the - // two operations merge in the undo history (known Lexical limitation with - // decorator nodes whose nested editors don't create main editor updates). - await page.keyboard.press('Escape'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd}+z`); - - // verify the card is restored and selected after undo - await expect(page.locator('[data-kg-card="product"]')).toBeVisible(); - await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - // verify the nested editor content is preserved - const titleEditor = page.locator('[data-kg-card="product"] [data-kg="editor"]').nth(0); - await expect(titleEditor).toContainText('Test title'); - const descEditor = page.locator('[data-kg-card="product"] [data-kg="editor"]').nth(1); - await expect(descEditor).toContainText('Test description'); - }); - test('can import serialized product card nodes with a br', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'product', - productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', - productTitle: 'This is title
    Second line', - productDescription: '

    Description
    Moar description

    ', - productUrl: 'https://google.com/', - productButton: 'Button', - productButtonEnabled: true, - productRatingEnabled: true, - productStarRating: 4 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    - Product thumbnail -
    -
    -
    -
    -
    -
    -

    - This is - title -
    - Second line -

    -
    -
    -
    -
    -
    - - - - - -
    -
    -
    -
    -
    -
    -

    - Description -
    - Moar description -

    -
    -
    -
    -
    -
    - Button -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - test('handles receiving titles wrapped in p', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'product', - productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', - productTitle: '

    This is title
    Second line

    ', - productDescription: '

    Description
    Moar description

    ', - productUrl: 'https://google.com/', - productButton: 'Button', - productButtonEnabled: true, - productRatingEnabled: true, - productStarRating: 4 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    - Product thumbnail -
    -
    -
    -
    -
    -
    -

    - This is - title -
    - Second line -

    -
    -
    -
    -
    -
    - - - - - -
    -
    -
    -
    -
    -
    -

    - Description -
    - Moar description -

    -
    -
    -
    -
    -
    - Button -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - test('can handle new shift-enter in title and description', async () => { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - await page.keyboard.type('Test title'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('Second line of title'); - - await page.keyboard.press('Enter'); - await page.keyboard.type('Test description'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('Second line of description'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -

    - Test title -
    - Second line of title -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    Test description -
    - Second line of description -

    -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); -}); - -async function uploadImg(page, src = 'large-image.png') { - // Placeholder should be visible - const mediaPlaceholder = await page.getByTestId('media-placeholder'); - await expect(mediaPlaceholder).toBeVisible(); - - // Upload image - const imagePath = path.relative(process.cwd(), __dirname + `/../fixtures/${src}`); - const fileChooserPromise = page.waitForEvent('filechooser'); - await mediaPlaceholder.click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([imagePath]); -} diff --git a/packages/koenig-lexical/test/e2e/cards/product-card.test.ts b/packages/koenig-lexical/test/e2e/cards/product-card.test.ts new file mode 100644 index 0000000000..b8dce63151 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/product-card.test.ts @@ -0,0 +1,716 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Product card', async () => { + const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized product card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'product', + productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', + productTitle: 'This is title', + productDescription: '

    Description

    ', + productUrl: 'https://google.com/', + productButton: 'Button', + productButtonEnabled: true, + productRatingEnabled: true, + productStarRating: 4 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    + Product thumbnail +
    +
    +
    +
    +
    +
    +

    + This is + title +

    +
    +
    +
    +
    +
    + + + + + +
    +
    +
    +
    +
    +
    +

    Description

    +
    +
    +
    +
    +
    + Button +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('renders product card node', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can upload image file', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + + // Image should be visible + const mediaPlaceholder = await page.getByTestId('media-placeholder'); + await expect(await page.getByTestId('product-card-image')).toBeVisible(); + await expect(mediaPlaceholder).toBeHidden(); + + // Can remove image + const replaceButton = page.getByTestId('replace-product-image'); + await replaceButton.click(); + await expect(await page.getByTestId('media-placeholder')).toBeVisible(); + }); + + test('can show errors for failed image upload', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page, 'large-image-fail.jpeg'); + + // Errors should be visible + await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); + }); + + test('can upload dropped image', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + // Placeholder should be visible + await expect(await page.getByTestId('media-placeholder')).toBeVisible(); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Image should be visible + await expect(await page.getByTestId('product-card-image')).toBeVisible(); + }); + + test('can show errors if was dropped a file with wrong extension', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); + + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + // Placeholder should be visible + await expect(await page.getByTestId('media-placeholder')).toBeVisible(); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image-fail.png', fileType: 'image/jpeg'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Errors should be visible + await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); + }); + + test('can show/hide rating starts if rating enabled/disabled', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + // Rating toggle should be visible and unchecked + const productRating = await page.getByTestId('product-rating-toggle'); + await expect(productRating).toBeVisible(); + await expect(await page.locator('[data-testid="product-rating-toggle"] input').isChecked()).toBeFalsy(); + + // Stars should be hidden + const productStars = await page.getByTestId('product-stars'); + await expect(productStars).toBeHidden(); + + // Stars should be visible after rating enabled + await productRating.check(); + await expect(productStars).toBeVisible(); + }); + + test('can show/hide button if button settings was enabled/disabled', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + // Button toggle should be visible and unchecked + const productButtonToggle = await page.getByTestId('product-button-toggle'); + await expect(productButtonToggle).toBeVisible(); + await expect(await page.locator('[data-testid="product-button-toggle"] input').isChecked()).toBeFalsy(); + + // Button should be hidden in card + const productButton = await page.getByTestId('product-button'); + await expect(productButton).toBeHidden(); + + // Button should be visible after button enabled in settings + await productButtonToggle.check(); + await expect(productButton).toBeVisible(); + + // Fill button text and url in settings + await page.getByTestId('product-button-text-input').fill('Button text'); + await page.getByTestId('product-button-url-input').fill('https://google.com/'); + + // Button should be filled and visible in card + const button = await page.getByTestId('product-button'); + await expect(button).toBeVisible(); + await expect(await page.getByTestId('product-button-span')).toContainText('Button text'); + await expect(await button.getAttribute('href')).toEqual('https://google.com/'); + }); + + test('can fill title and description', async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await page.keyboard.type('Title'); + + // Move to description + await page.keyboard.press('Enter'); + + await page.keyboard.type('Description'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    Title

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Description

    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('can add snippet', async function () { + await focusEditor(page); + // insert new card + await insertCard(page, {cardName: 'product'}); + + // fill card + await expect(await page.locator('[data-kg-card="product"]')).toBeVisible(); + await page.keyboard.type('snippet'); + await page.keyboard.press('Escape'); + + // wait for card to finish transitioning to display mode + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + // create snippet + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="product"]')).toHaveCount(2); + }); + + test('renders product card toolbar', async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await page.keyboard.type('Title'); + + // Leave editing mode to display the toolbar + await page.keyboard.press('Escape'); + + // Check that the toolbar is displayed + await expect(page.locator('[data-kg-card-toolbar="product"]')).toBeVisible(); + + // Wait for card to finish transitioning to selected (non-editing) state + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); + // Small wait for toolbar to stabilize after card state transition + await page.waitForTimeout(50); + + // Edit video card + await page.getByTestId('edit-product-card').click(); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can undo/redo without losing nested editor content', async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + await page.keyboard.type('Test title'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Test description'); + // Exit card edit mode, then use Enter+Backspace×2 to delete so undo + // has a proper history entry. Direct Escape→Backspace doesn't create a + // main editor content update between card insertion and deletion, so the + // two operations merge in the undo history (known Lexical limitation with + // decorator nodes whose nested editors don't create main editor updates). + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd}+z`); + + // verify the card is restored and selected after undo + await expect(page.locator('[data-kg-card="product"]')).toBeVisible(); + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + // verify the nested editor content is preserved + const titleEditor = page.locator('[data-kg-card="product"] [data-kg="editor"]').nth(0); + await expect(titleEditor).toContainText('Test title'); + const descEditor = page.locator('[data-kg-card="product"] [data-kg="editor"]').nth(1); + await expect(descEditor).toContainText('Test description'); + }); + test('can import serialized product card nodes with a br', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'product', + productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', + productTitle: 'This is title
    Second line', + productDescription: '

    Description
    Moar description

    ', + productUrl: 'https://google.com/', + productButton: 'Button', + productButtonEnabled: true, + productRatingEnabled: true, + productStarRating: 4 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    + Product thumbnail +
    +
    +
    +
    +
    +
    +

    + This is + title +
    + Second line +

    +
    +
    +
    +
    +
    + + + + + +
    +
    +
    +
    +
    +
    +

    + Description +
    + Moar description +

    +
    +
    +
    +
    +
    + Button +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + test('handles receiving titles wrapped in p', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'product', + productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', + productTitle: '

    This is title
    Second line

    ', + productDescription: '

    Description
    Moar description

    ', + productUrl: 'https://google.com/', + productButton: 'Button', + productButtonEnabled: true, + productRatingEnabled: true, + productStarRating: 4 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    + Product thumbnail +
    +
    +
    +
    +
    +
    +

    + This is + title +
    + Second line +

    +
    +
    +
    +
    +
    + + + + + +
    +
    +
    +
    +
    +
    +

    + Description +
    + Moar description +

    +
    +
    +
    +
    +
    + Button +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + test('can handle new shift-enter in title and description', async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + await page.keyboard.type('Test title'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('Second line of title'); + + await page.keyboard.press('Enter'); + await page.keyboard.type('Test description'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('Second line of description'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    + Test title +
    + Second line of title +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Test description +
    + Second line of description +

    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); +}); + +async function uploadImg(page: Page, src = 'large-image.png') { + // Placeholder should be visible + const mediaPlaceholder = await page.getByTestId('media-placeholder'); + await expect(mediaPlaceholder).toBeVisible(); + + // Upload image + const imagePath = path.relative(process.cwd(), __dirname + `/../fixtures/${src}`); + const fileChooserPromise = page.waitForEvent('filechooser'); + await mediaPlaceholder.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([imagePath]); +} diff --git a/packages/koenig-lexical/test/e2e/cards/signup-card.test.js b/packages/koenig-lexical/test/e2e/cards/signup-card.test.js deleted file mode 100644 index 491e38b2a9..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/signup-card.test.js +++ /dev/null @@ -1,773 +0,0 @@ -import path from 'path'; -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -import {selectCustomColor, selectTitledColor} from '../../utils/color-select-helper'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Signup card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized signup card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - alignment: 'left', - backgroundColor: 'accent', - backgroundImageSrc: '__GHOST_URL__/content/images/2023/05/fake-image.jpg', - buttonColor: '#ffffff', - buttonText: '', - buttonTextColor: '#000000', - disclaimer: 'Disclaimer', - header: 'Header', - labels: [], - layout: 'split', - subheader: 'Subheader', - textColor: '#FFFFFF', - type: 'signup', - swaped: false, - version: 1 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - Background image -
    -
    - - -
    -
    -
    -
    -
    -
    -

    Header

    -
    -
    -
    -
    -
    -
    -

    Subheader

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    Disclaimer

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - `); - }); - - test('renders signup card node', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can edit header', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const firstEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); - await expect(firstEditor).toHaveText('Sign up for Koenig Lexical'); - - await page.keyboard.type(', my friends'); - await expect(firstEditor).toHaveText('Sign up for Koenig Lexical, my friends'); - }); - - test('can edit subheader', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const secondEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); - await expect(secondEditor).toHaveText(`There's a whole lot to discover in this editor. Let us help you settle in.`); - - await page.keyboard.press('Enter'); - await page.keyboard.type(' Cool.'); - - await expect(secondEditor).toHaveText(`There's a whole lot to discover in this editor. Let us help you settle in. Cool.`); - }); - - test('can edit disclaimer', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const thirdEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(2); - await expect(thirdEditor).toHaveText('No spam. Unsubscribe anytime.'); - - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.type(' For real.'); - - await expect(thirdEditor).toHaveText('No spam. Unsubscribe anytime. For real.'); - }); - - test('header, subheader and disclaimer texts are prepopulated', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const firstEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); - const secondEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); - const thirdEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(2); - - await expect(firstEditor).toHaveText(/Sign up for Koenig Lexical/); - await expect(secondEditor).toHaveText(/There's a whole lot to discover in this editor. Let us help you settle in./); - await expect(thirdEditor).toHaveText(/No spam. Unsubscribe anytime./); - }); - - test('nested editors are hidden when not in edit mode', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const firstEditor = page.locator('[data-kg-card="signup"] .koenig-lexical').nth(0); - - for (let i = 0; i < 'Sign up for Koenig Lexical'.length; i++) { - await page.keyboard.press('Backspace'); - } - await page.keyboard.press('Escape'); - - await expect(firstEditor).toHaveClass(/hidden/); - }); - - test('can edit button text', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.click('[data-testid="signup-button-text"]'); - - // Default text - await expect(page.getByTestId('signup-card-button')).toHaveText('Subscribe'); - - await page.keyboard.type(' now'); - await expect(page.getByTestId('signup-card-button')).toHaveText('Subscribe now'); - }); - - test('can change the button background color and text color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.click('[data-testid="signup-button-color"] [data-testid="color-selector-button"]'); - - await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); - await page.click('[data-testid="settings-panel"]'); - - // Selected colour should be applied inline - await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await page.click('[data-testid="signup-button-color"] [data-testid="color-selector-button"]'); - - await selectCustomColor(page, '#f7f7f7', null); - await page.click('[data-testid="settings-panel"]'); - - await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('background-color', 'rgb(247, 247, 247)'); - await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); - }); - - test('can change the background color and text color', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); - - await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); - - await page.click('[data-testid="settings-panel"]'); - - const container = page.getByTestId('signup-card-container'); - await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); - await selectCustomColor(page, '#f7f7f7', null); - await page.click('[data-testid="settings-panel"]'); - await expect(container).toHaveCSS('background-color', 'rgb(247, 247, 247)'); - await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); - }); - - test('can change to grey, black and brand background colors', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); - - const container = page.getByTestId('signup-card-container'); - - await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); - await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); - - await selectTitledColor(page, 'Black', 'color-picker-toggle'); - - await expect(container).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - - await selectTitledColor(page, 'Brand color', 'color-picker-toggle'); - - await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 149)'); - await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); - }); - - test('can add and remove background image', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - const fileChooserPromise = page.waitForEvent('filechooser'); - - // await page.click('[data-testid="signup-background-image-toggle"]'); - await page.click('[data-testid="color-selector-button"]'); - await page.click('[data-testid="signup-background-image-toggle"]'); - await page.click('[data-testid="media-upload-placeholder"]'); - - // Set files - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - // Check if it is set as a background image - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - - // Check if it is also set as an image in the panel - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - - // Remove the image - await page.click('[data-testid="media-upload-remove"]'); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-placeholder"]')).toBeVisible(); - - // Add it again by clicking the placeholder - const fileChooserPromise2 = page.waitForEvent('filechooser'); - - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser2 = await fileChooserPromise2; - await fileChooser2.setFiles([filePath]); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - }); - - test('can switch between background image and color', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Choose an image - - const fileChooserPromise = page.waitForEvent('filechooser'); - - await page.click('[data-testid="color-selector-button"]'); - await page.click('[data-testid="signup-background-image-toggle"]'); - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - - // Switch to a color swatch - - await page.click('[data-testid="signup-background-color"] button[title="Black"]'); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - - // Switch back to the image - - await page.click('[data-testid="signup-background-image-toggle"]'); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); - await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - - // Open the color picker - - await page.click('[data-testid="signup-background-color"] [aria-label="Pick color"]'); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - }); - - test('can update the text color in split vs regular layout', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Text colour is updated based on the background colour - - // await page.click('[data-testid="signup-background-color"] button[title="Grey"]'); - await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); - - const container = page.getByTestId('signup-card-container'); - await selectTitledColor(page, 'Grey', 'color-picker-toggle'); - await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); - await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(240, 240, 240)'); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(0, 0, 0)'); - - // Text colour is updated based on the background image - - const fileChooserPromise = page.waitForEvent('filechooser'); - - // await page.click('[data-testid="signup-background-image-toggle"]'); - await page.click('[data-testid="color-selector-button"]'); - await page.click('[data-testid="signup-background-image-toggle"]'); - await page.click('[data-testid="media-upload-placeholder"]'); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(255, 255, 255)'); - - // // When switching to split layout, text colour is set based on the background colour - - await page.click('[data-testid="settings-panel"]'); - await page.locator('[data-testid="signup-layout-split"]').click(); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(240, 240, 240)'); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(0, 0, 0)'); - - // When switching back from split layout, text colour is set based on the background colour - - await page.locator('[data-testid="signup-layout-wide"]').click(); - - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); - await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(255, 255, 255)'); - }); - - test('can add and remove background image in split layout', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.locator('[data-testid="signup-layout-split"]').click(); - - await expect(page.locator('[data-testid="signup-background-image-toggle"]')).toHaveCount(0); - await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); - - await page.click('[data-testid="signup-card-container"] [data-testid="media-upload-placeholder"]'); - - // Set files - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - await expect(page.locator('[data-testid="signup-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - }); - - test('can add and remove labels', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.click('[data-testid="labels-dropdown"] input'); - - // Add existing label - await page.keyboard.type('Label 1'); - await page.click('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-item"]'); - - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(1); - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveText('Label 1'); - - // Add new label - await page.keyboard.type('Some new label'); - await page.keyboard.press('Enter'); - - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(2); - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]:nth-child(2)')).toHaveText('Some new label'); - - // Remove label with backspace - await page.keyboard.press('Backspace'); - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(1); - - // Remove label by clicking - await page.click('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]'); - await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(0); - }); - - test('changes the alignment options from the settings panel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Default: left alignment - const header = page.getByTestId('signup-header-editor'); - await expect(header).not.toHaveClass(/text-center/); - - // Change aligment to center - const alignmentCenter = page.locator('[data-testid="signup-alignment-center"]'); - await alignmentCenter.click(); - await expect(header).toHaveClass(/text-center/); - }); - - // TODO: fix and restore after the layout changes are finialized in the signup card - test.skip('changes the layout options from the settings panel', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Default: wise layout - const container = page.locator('[data-testid="signup-card-container"]'); - await expect(container).toHaveClass(/min-h-\[56vh\]/); - - // Change layout to regular - const layoutRegular = page.locator('[data-testid="signup-layout-regular"]'); - await layoutRegular.click(); - await expect(container).toHaveClass(/min-h-\[32vh\]/); - - // Change layout to full - const layoutFull = page.locator('[data-testid="signup-layout-full"]'); - await layoutFull.click(); - await expect(container).toHaveClass(/min-h-\[80vh\]/); - - // Change layout to split - const layoutSplit = page.locator('[data-testid="signup-layout-split"]'); - await layoutSplit.click(); - await expect(container).toHaveClass(/h-auto sm:h-\[80vh\]/); - }); - - test('keeps focus on previous editor when changing layout opts', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Start editing the header - await page.locator('[data-kg-card="signup"] [data-kg="editor"] [contenteditable]').nth(0).fill(''); - await page.keyboard.type('Hello '); - - // Change layout to regular - await page.locator('[data-testid="signup-layout-regular"]').click(); - - // Continue editing the header - await page.keyboard.type('world'); - - // Expect header to have 'Hello World' - const header = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); - await expect(header).toHaveText('Hello world'); - }); - - test('keeps focus on previous editor when changing alignment opts', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - // Start editing the subheader - await page.keyboard.press('Enter'); - await page.locator('[data-kg-card="signup"] [data-kg="editor"] [contenteditable]').nth(1).fill(''); - await page.keyboard.type('Hello '); - - // Change alignment to center - await page.locator('[data-testid="signup-alignment-center"]').click(); - - // Continue editing the subheader - await page.keyboard.type('world'); - - // Expect subheader to have 'Hello World' - const subheader = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); - await expect(subheader).toHaveText('Hello world'); - }); - - test('can swap split layout sides on image', async function () { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); - const fileChooserPromise = page.waitForEvent('filechooser'); - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - await page.locator('[data-testid="signup-layout-split"]').click(); - await expect(page.locator('[data-testid="signup-background-image-toggle"]')).toHaveCount(0); - await page.click('[data-testid="media-upload-placeholder"]'); - // Set files - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - await expect(page.locator('[data-testid="signup-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); - // Click swap - await page.click('[data-testid="signup-swapped"]'); - // Check the parent class name was updated - const swappedContainer = await page.locator('[data-testid="signup-card-content"]'); - await expect(swappedContainer).toHaveClass(/sm:flex-row-reverse/); - }); - test('can import when brs are present in the serialized content', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - alignment: 'left', - backgroundColor: 'accent', - backgroundImageSrc: '__GHOST_URL__/content/images/2023/05/fake-image.jpg', - buttonColor: '#ffffff', - buttonText: '', - buttonTextColor: '#000000', - disclaimer: 'Disclaimer
    Moar legal stuffz', - header: 'Header
    More header', - labels: [], - layout: 'split', - subheader: 'Subheader
    More subheader', - textColor: '#FFFFFF', - type: 'signup', - swaped: false, - version: 1 - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - await initialize({page, uri: `/#/?content=${contentParam}`}); - await assertHTML(page, html` -
    -
    -
    -
    -
    - Background image -
    -
    - - -
    -
    -
    -
    -
    -
    -

    Header -
    - More header -

    -
    -
    -
    -
    -
    -
    -

    Subheader -
    - More subheader -

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    Disclaimer -
    - Moar legal stuffz -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - `); - }); - test('can put a br anywhere', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('line two'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('line two'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('pickles'); - await page.keyboard.press('Escape'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -

    Sign up for Koenig Lexical -
    - line two -

    -
    -
    -
    -
    -
    -
    -

    There's a whole lot to discover in this editor. Let us help you settle in. -
    - line two -

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    No spam. Unsubscribe anytime. -
    - pickles -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - /*test('can undo/redo without losing nested editor content', async () => { - await focusEditor(page); - await insertCard(page, {cardName: 'signup'}); - - await page.keyboard.press('Shift+Tab'); - await page.keyboard.press('Shift+Tab'); - await page.keyboard.type('Header. '); - - await page.keyboard.press('Enter'); - await page.keyboard.type(' Subheader'); - - await page.keyboard.press('Enter'); - await page.keyboard.type(' Disclaimer'); - - await page.keyboard.press('Escape'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    - Header. Sign up for Koenig Lexical -

    -
    -
    -
    -
    -
    -
    -

    - There's a whole lot to discover in this editor. Let us help you settle in. Subheader -

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -

    - No spam. Unsubscribe anytime. Disclaimer -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - });*/ -}); diff --git a/packages/koenig-lexical/test/e2e/cards/signup-card.test.ts b/packages/koenig-lexical/test/e2e/cards/signup-card.test.ts new file mode 100644 index 0000000000..e52d6481fe --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/signup-card.test.ts @@ -0,0 +1,774 @@ +import path from 'path'; +import {assertHTML, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import {selectCustomColor, selectTitledColor} from '../../utils/color-select-helper'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Signup card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized signup card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + alignment: 'left', + backgroundColor: 'accent', + backgroundImageSrc: '__GHOST_URL__/content/images/2023/05/fake-image.jpg', + buttonColor: '#ffffff', + buttonText: '', + buttonTextColor: '#000000', + disclaimer: 'Disclaimer', + header: 'Header', + labels: [], + layout: 'split', + subheader: 'Subheader', + textColor: '#FFFFFF', + type: 'signup', + swaped: false, + version: 1 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + Background image +
    +
    + + +
    +
    +
    +
    +
    +
    +

    Header

    +
    +
    +
    +
    +
    +
    +

    Subheader

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +

    Disclaimer

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `); + }); + + test('renders signup card node', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can edit header', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const firstEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); + await expect(firstEditor).toHaveText('Sign up for Koenig Lexical'); + + await page.keyboard.type(', my friends'); + await expect(firstEditor).toHaveText('Sign up for Koenig Lexical, my friends'); + }); + + test('can edit subheader', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const secondEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); + await expect(secondEditor).toHaveText(`There's a whole lot to discover in this editor. Let us help you settle in.`); + + await page.keyboard.press('Enter'); + await page.keyboard.type(' Cool.'); + + await expect(secondEditor).toHaveText(`There's a whole lot to discover in this editor. Let us help you settle in. Cool.`); + }); + + test('can edit disclaimer', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const thirdEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(2); + await expect(thirdEditor).toHaveText('No spam. Unsubscribe anytime.'); + + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type(' For real.'); + + await expect(thirdEditor).toHaveText('No spam. Unsubscribe anytime. For real.'); + }); + + test('header, subheader and disclaimer texts are prepopulated', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const firstEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); + const secondEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); + const thirdEditor = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(2); + + await expect(firstEditor).toHaveText(/Sign up for Koenig Lexical/); + await expect(secondEditor).toHaveText(/There's a whole lot to discover in this editor. Let us help you settle in./); + await expect(thirdEditor).toHaveText(/No spam. Unsubscribe anytime./); + }); + + test('nested editors are hidden when not in edit mode', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const firstEditor = page.locator('[data-kg-card="signup"] .koenig-lexical').nth(0); + + for (let i = 0; i < 'Sign up for Koenig Lexical'.length; i++) { + await page.keyboard.press('Backspace'); + } + await page.keyboard.press('Escape'); + + await expect(firstEditor).toHaveClass(/hidden/); + }); + + test('can edit button text', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.click('[data-testid="signup-button-text"]'); + + // Default text + await expect(page.getByTestId('signup-card-button')).toHaveText('Subscribe'); + + await page.keyboard.type(' now'); + await expect(page.getByTestId('signup-card-button')).toHaveText('Subscribe now'); + }); + + test('can change the button background color and text color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.click('[data-testid="signup-button-color"] [data-testid="color-selector-button"]'); + + await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); + await page.click('[data-testid="settings-panel"]'); + + // Selected colour should be applied inline + await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await page.click('[data-testid="signup-button-color"] [data-testid="color-selector-button"]'); + + await selectCustomColor(page, '#f7f7f7', undefined); + await page.click('[data-testid="settings-panel"]'); + + await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('background-color', 'rgb(247, 247, 247)'); + await expect(page.locator('[data-testid="signup-card-button"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); + }); + + test('can change the background color and text color', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); + + await selectCustomColor(page, '#ff0000', 'color-picker-toggle'); + + await page.click('[data-testid="settings-panel"]'); + + const container = page.getByTestId('signup-card-container'); + await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); + await selectCustomColor(page, '#f7f7f7', undefined); + await page.click('[data-testid="settings-panel"]'); + await expect(container).toHaveCSS('background-color', 'rgb(247, 247, 247)'); + await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); + }); + + test('can change to grey, black and brand background colors', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); + + const container = page.getByTestId('signup-card-container'); + + await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); + await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); + + await selectTitledColor(page, 'Black', 'color-picker-toggle'); + + await expect(container).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + + await selectTitledColor(page, 'Brand color', 'color-picker-toggle'); + + await expect(container).toHaveCSS('background-color', 'rgb(255, 0, 149)'); + await expect(container).toHaveCSS('color', 'rgb(255, 255, 255)'); + }); + + test('can add and remove background image', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + // await page.click('[data-testid="signup-background-image-toggle"]'); + await page.click('[data-testid="color-selector-button"]'); + await page.click('[data-testid="signup-background-image-toggle"]'); + await page.click('[data-testid="media-upload-placeholder"]'); + + // Set files + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + // Check if it is set as a background image + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + + // Check if it is also set as an image in the panel + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + + // Remove the image + await page.click('[data-testid="media-upload-remove"]'); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-placeholder"]')).toBeVisible(); + + // Add it again by clicking the placeholder + const fileChooserPromise2 = page.waitForEvent('filechooser'); + + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser2 = await fileChooserPromise2; + await fileChooser2.setFiles([filePath]); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + }); + + test('can switch between background image and color', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Choose an image + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.click('[data-testid="color-selector-button"]'); + await page.click('[data-testid="signup-background-image-toggle"]'); + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + + // Switch to a color swatch + + await page.click('[data-testid="signup-background-color"] button[title="Black"]'); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + + // Switch back to the image + + await page.click('[data-testid="signup-background-image-toggle"]'); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-testid="media-upload-setting"]')).toBeVisible(); + await expect(page.locator('[data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + + // Open the color picker + + await page.click('[data-testid="signup-background-color"] [aria-label="Pick color"]'); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + }); + + test('can update the text color in split vs regular layout', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Text colour is updated based on the background colour + + // await page.click('[data-testid="signup-background-color"] button[title="Grey"]'); + await page.click('[data-testid="signup-background-color"] [data-testid="color-selector-button"]'); + + const container = page.getByTestId('signup-card-container'); + await selectTitledColor(page, 'Grey', 'color-picker-toggle'); + await expect(container).toHaveCSS('background-color', 'rgb(240, 240, 240)'); + await expect(container).toHaveCSS('color', 'rgb(0, 0, 0)'); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(240, 240, 240)'); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(0, 0, 0)'); + + // Text colour is updated based on the background image + + const fileChooserPromise = page.waitForEvent('filechooser'); + + // await page.click('[data-testid="signup-background-image-toggle"]'); + await page.click('[data-testid="color-selector-button"]'); + await page.click('[data-testid="signup-background-image-toggle"]'); + await page.click('[data-testid="media-upload-placeholder"]'); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(255, 255, 255)'); + + // // When switching to split layout, text colour is set based on the background colour + + await page.click('[data-testid="settings-panel"]'); + await page.locator('[data-testid="signup-layout-split"]').click(); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).not.toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-color', 'rgb(240, 240, 240)'); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(0, 0, 0)'); + + // When switching back from split layout, text colour is set based on the background colour + + await page.locator('[data-testid="signup-layout-wide"]').click(); + + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('background-image', /blob:/); + await expect(page.locator('[data-kg-card="signup"] > div:first-child')).toHaveCSS('color', 'rgb(255, 255, 255)'); + }); + + test('can add and remove background image in split layout', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.locator('[data-testid="signup-layout-split"]').click(); + + await expect(page.locator('[data-testid="signup-background-image-toggle"]')).toHaveCount(0); + await expect(page.locator('[data-testid="media-upload-setting"]')).not.toBeVisible(); + + await page.click('[data-testid="signup-card-container"] [data-testid="media-upload-placeholder"]'); + + // Set files + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + await expect(page.locator('[data-testid="signup-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + }); + + test('can add and remove labels', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.click('[data-testid="labels-dropdown"] input'); + + // Add existing label + await page.keyboard.type('Label 1'); + await page.click('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-item"]'); + + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(1); + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveText('Label 1'); + + // Add new label + await page.keyboard.type('Some new label'); + await page.keyboard.press('Enter'); + + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(2); + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]:nth-child(2)')).toHaveText('Some new label'); + + // Remove label with backspace + await page.keyboard.press('Backspace'); + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(1); + + // Remove label by clicking + await page.click('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]'); + await expect(page.locator('[data-testid="labels-dropdown"] [data-testid="multiselect-dropdown-selected"]')).toHaveCount(0); + }); + + test('changes the alignment options from the settings panel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Default: left alignment + const header = page.getByTestId('signup-header-editor'); + await expect(header).not.toHaveClass(/text-center/); + + // Change aligment to center + const alignmentCenter = page.locator('[data-testid="signup-alignment-center"]'); + await alignmentCenter.click(); + await expect(header).toHaveClass(/text-center/); + }); + + // TODO: fix and restore after the layout changes are finialized in the signup card + test.skip('changes the layout options from the settings panel', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Default: wise layout + const container = page.locator('[data-testid="signup-card-container"]'); + await expect(container).toHaveClass(/min-h-\[56vh\]/); + + // Change layout to regular + const layoutRegular = page.locator('[data-testid="signup-layout-regular"]'); + await layoutRegular.click(); + await expect(container).toHaveClass(/min-h-\[32vh\]/); + + // Change layout to full + const layoutFull = page.locator('[data-testid="signup-layout-full"]'); + await layoutFull.click(); + await expect(container).toHaveClass(/min-h-\[80vh\]/); + + // Change layout to split + const layoutSplit = page.locator('[data-testid="signup-layout-split"]'); + await layoutSplit.click(); + await expect(container).toHaveClass(/h-auto sm:h-\[80vh\]/); + }); + + test('keeps focus on previous editor when changing layout opts', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Start editing the header + await page.locator('[data-kg-card="signup"] [data-kg="editor"] [contenteditable]').nth(0).fill(''); + await page.keyboard.type('Hello '); + + // Change layout to regular + await page.locator('[data-testid="signup-layout-regular"]').click(); + + // Continue editing the header + await page.keyboard.type('world'); + + // Expect header to have 'Hello World' + const header = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(0); + await expect(header).toHaveText('Hello world'); + }); + + test('keeps focus on previous editor when changing alignment opts', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + // Start editing the subheader + await page.keyboard.press('Enter'); + await page.locator('[data-kg-card="signup"] [data-kg="editor"] [contenteditable]').nth(1).fill(''); + await page.keyboard.type('Hello '); + + // Change alignment to center + await page.locator('[data-testid="signup-alignment-center"]').click(); + + // Continue editing the subheader + await page.keyboard.type('world'); + + // Expect subheader to have 'Hello World' + const subheader = page.locator('[data-kg-card="signup"] [data-kg="editor"]').nth(1); + await expect(subheader).toHaveText('Hello world'); + }); + + test('can swap split layout sides on image', async function () { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/large-image.jpeg`); + const fileChooserPromise = page.waitForEvent('filechooser'); + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + await page.locator('[data-testid="signup-layout-split"]').click(); + await expect(page.locator('[data-testid="signup-background-image-toggle"]')).toHaveCount(0); + await page.click('[data-testid="media-upload-placeholder"]'); + // Set files + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + await expect(page.locator('[data-testid="signup-card-container"] [data-testid="media-upload-filled"] img')).toHaveAttribute('src', /blob:/); + // Click swap + await page.click('[data-testid="signup-swapped"]'); + // Check the parent class name was updated + const swappedContainer = await page.locator('[data-testid="signup-card-content"]'); + await expect(swappedContainer).toHaveClass(/sm:flex-row-reverse/); + }); + test('can import when brs are present in the serialized content', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + alignment: 'left', + backgroundColor: 'accent', + backgroundImageSrc: '__GHOST_URL__/content/images/2023/05/fake-image.jpg', + buttonColor: '#ffffff', + buttonText: '', + buttonTextColor: '#000000', + disclaimer: 'Disclaimer
    Moar legal stuffz', + header: 'Header
    More header', + labels: [], + layout: 'split', + subheader: 'Subheader
    More subheader', + textColor: '#FFFFFF', + type: 'signup', + swaped: false, + version: 1 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + await initialize({page, uri: `/#/?content=${contentParam}`}); + await assertHTML(page, html` +
    +
    +
    +
    +
    + Background image +
    +
    + + +
    +
    +
    +
    +
    +
    +

    Header +
    + More header +

    +
    +
    +
    +
    +
    +
    +

    Subheader +
    + More subheader +

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +

    Disclaimer +
    + Moar legal stuffz +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `); + }); + test('can put a br anywhere', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('line two'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('line two'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('pickles'); + await page.keyboard.press('Escape'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +

    Sign up for Koenig Lexical +
    + line two +

    +
    +
    +
    +
    +
    +
    +

    There's a whole lot to discover in this editor. Let us help you settle in. +
    + line two +

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +

    No spam. Unsubscribe anytime. +
    + pickles +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + /*test('can undo/redo without losing nested editor content', async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'signup'}); + + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.type('Header. '); + + await page.keyboard.press('Enter'); + await page.keyboard.type(' Subheader'); + + await page.keyboard.press('Enter'); + await page.keyboard.type(' Disclaimer'); + + await page.keyboard.press('Escape'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    + Header. Sign up for Koenig Lexical +

    +
    +
    +
    +
    +
    +
    +

    + There's a whole lot to discover in this editor. Let us help you settle in. Subheader +

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +

    + No spam. Unsubscribe anytime. Disclaimer +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + });*/ +}); diff --git a/packages/koenig-lexical/test/e2e/cards/toggle-card.test.js b/packages/koenig-lexical/test/e2e/cards/toggle-card.test.js deleted file mode 100644 index 9855c8249c..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/toggle-card.test.js +++ /dev/null @@ -1,286 +0,0 @@ -import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -async function insertToggleCard(page) { - await page.keyboard.type('/toggle'); - await page.waitForSelector('[data-kg-card-menu-item="Toggle"][data-kg-cardmenu-selected="true"]'); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="toggle"]'); -} - -test.describe('Toggle card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized toggle card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'toggle', - heading: 'Heading', // heading shouldn't have wrapper element like

    or

    - content: '

    Content

    ' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -

    Heading

    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -

    Content

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('renders toggle card node from slash command', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Toggle header
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -


    -
    -
    -
    Collapsible content
    -
    -
    -
    -
    -
    -


    - `, {ignoreInnerSVG: true}); - }); - - test('focuses on the heading input when rendered', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.type('Heading'); - - const heading = page.locator('.koenig-lexical-heading'); - await expect(heading).toContainText('Heading'); - }); - - test('focuses on the content input when "Enter" is pressed from the heading input', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.press('Enter'); - await page.keyboard.type('Content'); - }); - - test('focuses on the content input when "Tab" is pressed from the heading input', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.press('Tab'); - await page.keyboard.type('Content'); - }); - - test('focuses on the content input when "Arrow Down" is pressed from the heading input', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('Content'); - }); - - test('focuses on the heading input when "Arrow Up" is pressed from the content input', async function () { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('Content'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.type('Heading'); - - const heading = page.locator('.koenig-lexical-heading'); - await expect(heading).toContainText('Heading'); - }); - - test('renders in display mode when unfocused', async function () { - await focusEditor(page); - await insertToggleCard(page); - - // add some content to avoid auto-removal when leaving empty - await page.keyboard.type('Heading'); - - // Shift focus away from heading field - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(50); - - // Shift focus away from content field - await page.keyboard.press('ArrowDown'); - - const toggleCard = page.locator('[data-kg-card="toggle"]'); - await expect(toggleCard).toHaveAttribute('data-kg-card-editing', 'false'); - }); - - test('renders an action toolbar', async function () { - await focusEditor(page); - await insertToggleCard(page); - - // Add some content to avoid auto-removal - await page.keyboard.type('Heading'); - - // Shift focus away from toggle card - await page.keyboard.press('Escape'); - - const editButton = page.locator('[data-kg-card-toolbar="toggle"]'); - await expect(editButton).toBeVisible(); - }); - - test('is removed when left empty', async function () { - await focusEditor(page); - await insertToggleCard(page); - - // Shift focus away from heading field - await page.keyboard.press('ArrowDown'); - // Wait for focus change to register in Chrome for Testing - await page.waitForTimeout(50); - - // Shift focus away from content field - await page.keyboard.press('ArrowDown'); - - const toggleCard = page.locator('[data-kg-card="toggle"]'); - await expect(toggleCard).not.toBeVisible(); - }); - - test('can add snippet', async function () { - await focusEditor(page); - await insertToggleCard(page); - - // Add some content to avoid auto-removal - await page.keyboard.type('Heading'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="toggle"]')).toHaveCount(2); - }); - - test('can undo/redo without losing nested editor content', async () => { - await focusEditor(page); - await insertToggleCard(page); - - await page.keyboard.type('Test title'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Test description'); - // Exit card edit mode, then use Enter+Backspace×2 to delete so undo - // has a proper history entry. Direct Escape→Backspace doesn't create a - // main editor content update between card insertion and deletion, so the - // two operations merge in the undo history (known Lexical limitation with - // decorator nodes whose nested editors don't create main editor updates). - await page.keyboard.press('Escape'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - // verify the card is restored and selected after undo - await expect(page.locator('[data-kg-card="toggle"]')).toBeVisible(); - await expect(page.locator('[data-kg-card="toggle"]')).toHaveAttribute('data-kg-card-selected', 'true'); - - // verify content is preserved - const titleEditor = page.locator('[data-kg-card="toggle"] [data-kg="editor"]').nth(0); - await expect(titleEditor).toContainText('Test title'); - const contentEditor = page.locator('[data-kg-card="toggle"] [data-kg="editor"]').nth(1); - await expect(contentEditor).toContainText('Test description'); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/toggle-card.test.ts b/packages/koenig-lexical/test/e2e/cards/toggle-card.test.ts new file mode 100644 index 0000000000..1f265ad4e6 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/toggle-card.test.ts @@ -0,0 +1,287 @@ +import {assertHTML, createSnippet, ctrlOrCmd, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +async function insertToggleCard(page: Page) { + await page.keyboard.type('/toggle'); + await page.waitForSelector('[data-kg-card-menu-item="Toggle"][data-kg-cardmenu-selected="true"]'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="toggle"]'); +} + +test.describe('Toggle card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized toggle card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'toggle', + heading: 'Heading', // heading shouldn't have wrapper element like

    or

    + content: '

    Content

    ' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +

    Heading

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    Content

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('renders toggle card node from slash command', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Toggle header
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +


    +
    +
    +
    Collapsible content
    +
    +
    +
    +
    +
    +


    + `, {ignoreInnerSVG: true}); + }); + + test('focuses on the heading input when rendered', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.type('Heading'); + + const heading = page.locator('.koenig-lexical-heading'); + await expect(heading).toContainText('Heading'); + }); + + test('focuses on the content input when "Enter" is pressed from the heading input', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.press('Enter'); + await page.keyboard.type('Content'); + }); + + test('focuses on the content input when "Tab" is pressed from the heading input', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.press('Tab'); + await page.keyboard.type('Content'); + }); + + test('focuses on the content input when "Arrow Down" is pressed from the heading input', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Content'); + }); + + test('focuses on the heading input when "Arrow Up" is pressed from the content input', async function () { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Content'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.type('Heading'); + + const heading = page.locator('.koenig-lexical-heading'); + await expect(heading).toContainText('Heading'); + }); + + test('renders in display mode when unfocused', async function () { + await focusEditor(page); + await insertToggleCard(page); + + // add some content to avoid auto-removal when leaving empty + await page.keyboard.type('Heading'); + + // Shift focus away from heading field + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(50); + + // Shift focus away from content field + await page.keyboard.press('ArrowDown'); + + const toggleCard = page.locator('[data-kg-card="toggle"]'); + await expect(toggleCard).toHaveAttribute('data-kg-card-editing', 'false'); + }); + + test('renders an action toolbar', async function () { + await focusEditor(page); + await insertToggleCard(page); + + // Add some content to avoid auto-removal + await page.keyboard.type('Heading'); + + // Shift focus away from toggle card + await page.keyboard.press('Escape'); + + const editButton = page.locator('[data-kg-card-toolbar="toggle"]'); + await expect(editButton).toBeVisible(); + }); + + test('is removed when left empty', async function () { + await focusEditor(page); + await insertToggleCard(page); + + // Shift focus away from heading field + await page.keyboard.press('ArrowDown'); + // Wait for focus change to register in Chrome for Testing + await page.waitForTimeout(50); + + // Shift focus away from content field + await page.keyboard.press('ArrowDown'); + + const toggleCard = page.locator('[data-kg-card="toggle"]'); + await expect(toggleCard).not.toBeVisible(); + }); + + test('can add snippet', async function () { + await focusEditor(page); + await insertToggleCard(page); + + // Add some content to avoid auto-removal + await page.keyboard.type('Heading'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="toggle"]')).toHaveCount(2); + }); + + test('can undo/redo without losing nested editor content', async () => { + await focusEditor(page); + await insertToggleCard(page); + + await page.keyboard.type('Test title'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Test description'); + // Exit card edit mode, then use Enter+Backspace×2 to delete so undo + // has a proper history entry. Direct Escape→Backspace doesn't create a + // main editor content update between card insertion and deletion, so the + // two operations merge in the undo history (known Lexical limitation with + // decorator nodes whose nested editors don't create main editor updates). + await page.keyboard.press('Escape'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + // verify the card is restored and selected after undo + await expect(page.locator('[data-kg-card="toggle"]')).toBeVisible(); + await expect(page.locator('[data-kg-card="toggle"]')).toHaveAttribute('data-kg-card-selected', 'true'); + + // verify content is preserved + const titleEditor = page.locator('[data-kg-card="toggle"] [data-kg="editor"]').nth(0); + await expect(titleEditor).toContainText('Test title'); + const contentEditor = page.locator('[data-kg-card="toggle"] [data-kg="editor"]').nth(1); + await expect(contentEditor).toContainText('Test description'); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/transistor-card.test.js b/packages/koenig-lexical/test/e2e/cards/transistor-card.test.js deleted file mode 100644 index a7aee48be8..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/transistor-card.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Transistor Card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/?content=false&labs=transistor'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can insert card via slash command', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'transistor'}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can insert card via /podcast keyword', async function () { - await focusEditor(page); - await page.keyboard.type('/podcast'); - await expect(page.locator('[data-kg-card-menu-item="Transistor" i][data-kg-cardmenu-selected="true"]')).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); - }); - - test('renders placeholder by default', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'transistor'}); - - await expect(page.getByTestId('transistor-placeholder')).toBeVisible(); - }); - - test('has settings panel with Visibility tab', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'transistor'}); - - await expect(page.getByTestId('tab-visibility')).toBeVisible(); - // Design tab is hidden until color customization is fully implemented - await expect(page.getByTestId('tab-design')).not.toBeVisible(); - }); - - // TODO: Re-enable when design tab is implemented - // test('can change player color', async function () { - // await focusEditor(page); - // await insertCard(page, {cardName: 'transistor'}); - - // const playerColorSetting = page.getByTestId('transistor-accent-color'); - // await expect(playerColorSetting).toBeVisible(); - // }); - - // test('can change background color', async function () { - // await focusEditor(page); - // await insertCard(page, {cardName: 'transistor'}); - - // const backgroundColorSetting = page.getByTestId('transistor-background-color'); - // await expect(backgroundColorSetting).toBeVisible(); - // }); - - test('can access visibility settings', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'transistor'}); - - // Visibility is the default/only tab, so settings should be visible immediately - await expect(page.getByText('Free members').first()).toBeVisible(); - await expect(page.getByText('Paid members').first()).toBeVisible(); - }); - - test('does not show public visitors toggle in visibility', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'transistor'}); - - // Visibility is the default/only tab - await expect(page.getByText('Public visitors')).not.toBeVisible(); - }); - - test('can import serialized transistor card', async function () { - const serializedCard = { - type: 'transistor', - version: 1, - accentColor: '#8B5CF6', - backgroundColor: '#FFFFFF', - visibility: { - web: { - nonMember: false, - memberSegment: 'status:free,status:-free' - }, - email: { - memberSegment: 'status:free,status:-free' - } - } - }; - - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [serializedCard], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}&labs=transistor`}); - - await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/cards/transistor-card.test.ts b/packages/koenig-lexical/test/e2e/cards/transistor-card.test.ts new file mode 100644 index 0000000000..e5e5fe42d5 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/transistor-card.test.ts @@ -0,0 +1,124 @@ +import {assertHTML, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Transistor Card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false&labs=transistor'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can insert card via slash command', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'transistor'}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can insert card via /podcast keyword', async function () { + await focusEditor(page); + await page.keyboard.type('/podcast'); + await expect(page.locator('[data-kg-card-menu-item="Transistor" i][data-kg-cardmenu-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); + }); + + test('renders placeholder by default', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'transistor'}); + + await expect(page.getByTestId('transistor-placeholder')).toBeVisible(); + }); + + test('has settings panel with Visibility tab', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'transistor'}); + + await expect(page.getByTestId('tab-visibility')).toBeVisible(); + // Design tab is hidden until color customization is fully implemented + await expect(page.getByTestId('tab-design')).not.toBeVisible(); + }); + + // TODO: Re-enable when design tab is implemented + // test('can change player color', async function () { + // await focusEditor(page); + // await insertCard(page, {cardName: 'transistor'}); + + // const playerColorSetting = page.getByTestId('transistor-accent-color'); + // await expect(playerColorSetting).toBeVisible(); + // }); + + // test('can change background color', async function () { + // await focusEditor(page); + // await insertCard(page, {cardName: 'transistor'}); + + // const backgroundColorSetting = page.getByTestId('transistor-background-color'); + // await expect(backgroundColorSetting).toBeVisible(); + // }); + + test('can access visibility settings', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'transistor'}); + + // Visibility is the default/only tab, so settings should be visible immediately + await expect(page.getByText('Free members').first()).toBeVisible(); + await expect(page.getByText('Paid members').first()).toBeVisible(); + }); + + test('does not show public visitors toggle in visibility', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'transistor'}); + + // Visibility is the default/only tab + await expect(page.getByText('Public visitors')).not.toBeVisible(); + }); + + test('can import serialized transistor card', async function () { + const serializedCard = { + type: 'transistor', + version: 1, + accentColor: '#8B5CF6', + backgroundColor: '#FFFFFF', + visibility: { + web: { + nonMember: false, + memberSegment: 'status:free,status:-free' + }, + email: { + memberSegment: 'status:free,status:-free' + } + } + }; + + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [serializedCard], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}&labs=transistor`}); + + await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.js b/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.js deleted file mode 100644 index cf5990d99c..0000000000 --- a/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.js +++ /dev/null @@ -1,476 +0,0 @@ -import path from 'path'; -import { - assertHTML, - createDataTransfer, - createSnippet, - ctrlOrCmd, - focusEditor, - html, - initialize, - insertCard -} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Video card is tested in firefox -// Need to get video thumbnail before uploading on the server; for this purpose, convert video to blob https://github.com/TryGhost/Koenig/blob/a04c59c2d81ddc783869c47653aa9d7adf093629/packages/koenig-lexical/src/utils/extractVideoMetadata.js#L45 -// The problem is that Chromium can't read video src as blob -test.describe('Video card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can import serialized video card nodes', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [{ - type: 'video', - src: '/content/images/2022/11/koenig-lexical.jpg', - width: 100, - height: 100, - caption: 'This is a caption', - duration: 60, - thumbnailSrc: '/content/images/2022/12/koenig-lexical.png' - }], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - Video thumbnail -
    -
    - -
    -
    -
    - -
    - 0:00 - / - 1:00 -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - This is a caption -

    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('renders video card node', async function () { - const fileChooserPromise = page.waitForEvent('filechooser'); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - - await focusEditor(page); - await insertCard(page, {cardName: 'video'}); - const fileChooser = await fileChooserPromise; - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - // Close the fileChooser by selecting a file - // Without this line, fileChooser stays open for subsequent tests - await fileChooser.setFiles([filePath]); - }); - - test('can upload video file from slash menu', async function () { - const fileChooserPromise = page.waitForEvent('filechooser'); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - - await focusEditor(page); - - // Upload video file - await insertCard(page, {cardName: 'video'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); - - // Check that video file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:04'); - }); - - test('can upload video file from card menu', async function () { - await focusEditor(page); - await uploadVideo(page); - - // Check that video file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:04'); - }); - - test('can show errors for failed video upload', async function () { - await focusEditor(page); - await uploadVideo(page, 'video-fail.mp4'); - - // Errors should be visible - await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); - }); - - test('can manage custom thumbnail', async function () { - await focusEditor(page); - await uploadVideo(page); - - // Settings panel should be visible - await expect(await page.getByTestId('settings-panel')).toBeVisible(); - - // Custom thumbnail should be visible - const emptyThumbnail = await page.getByTestId('media-upload-placeholder'); - await expect(emptyThumbnail).toBeVisible(); - - // Upload thumbnail - const imagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const fileChooserPromise = page.waitForEvent('filechooser'); - emptyThumbnail.click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([imagePath]); - - // Thumbnail should be visible - await expect(await page.getByTestId('media-upload-filled')).toBeVisible(); - - // Can remove thumbnail - const replaceButton = page.getByTestId('media-upload-remove'); - await replaceButton.click(); - await expect(await page.getByTestId('media-upload-placeholder')).toBeVisible(); - }); - - test('can show errors for custom thumbnail', async function () { - await focusEditor(page); - await uploadVideo(page); - - // Settings panel should be visible - await expect(await page.getByTestId('settings-panel')).toBeVisible(); - - // Errors shouldn't be visible - await expect(page.getByTestId('media-placeholder-errors')).toBeHidden(); - - // Custom thumbnail should be visible - const emptyThumbnail = await page.getByTestId('media-upload-placeholder'); - - // Upload thumbnail - const imagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); - const fileChooserPromise = page.waitForEvent('filechooser'); - emptyThumbnail.click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([imagePath]); - - // Errors should be visible - await expect(await page.getByTestId('media-upload-errors')).toBeVisible(); - }); - - test('can upload dropped video', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - - // Open video card and dismiss files chooser to prepare card for video dropping - await insertCard(page, {cardName: 'video'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([]); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Check that video file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:04'); - }); - - test('can show errors if was dropped a file with wrong extension to video placeholder', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const fileChooserPromise = page.waitForEvent('filechooser'); - - await focusEditor(page); - - // Open video card and dismiss files chooser to prepare card for video dropping - await insertCard(page, {cardName: 'video'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([]); - - // Drop file - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); - await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Errors should be visible - await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); - }); - - test('can upload dropped custom thumbnail', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - await uploadVideo(page); - - // Wait for custom thumbnail - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - await page.getByTestId('media-upload-placeholder').dispatchEvent('dragover', {dataTransfer}); - - // Dragover text should be visible - await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); - - // Drop file - await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Thumbnail should be visible - await expect(await page.getByTestId('media-upload-filled')).toBeVisible(); - }); - - test('can show errors if was dropped a file with wrong extension to custom thumbnail', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - - await focusEditor(page); - await uploadVideo(page); - - // Wait for custom thumbnail - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - // Create and dispatch data transfer - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); - await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); - - // Errors should be visible - await expect(await page.getByTestId('media-upload-errors')).toBeVisible(); - }); - - test('renders video card toolbar', async function () { - await focusEditor(page); - - // Upload video - await uploadVideo(page); - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - // Leave editing mode to display the toolbar - await page.keyboard.press('Escape'); - - // Check that the toolbar is displayed - await expect(await page.locator('[data-kg-card-toolbar="video"]')).toBeVisible(); - }); - - test('video card toolbar has Edit button', async function () { - await focusEditor(page); - - // Upload video - await uploadVideo(page); - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - // Leave editing mode to display the toolbar - await page.keyboard.press('Escape'); - - // Check that the toolbar is displayed - await expect(await page.locator('[data-kg-card-toolbar="video"]')).toBeVisible(); - - // Edit video card - await page.waitForSelector('[data-testid="edit-video-card"]'); - await page.getByTestId('edit-video-card').click(); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('adds extra paragraph when video is inserted at end of document', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Video"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('does not add extra paragraph when video is inserted mid-document', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('ArrowUp'); - await page.click('[data-kg-plus-button]'); - - await Promise.all([ - page.waitForEvent('filechooser'), - page.click('[data-kg-card-menu-item="Video"]') - ]); - - await assertHTML(page, html` -
    -
    -
    -
    -

    Testing

    - `, {ignoreCardContents: true}); - }); - - test('can add snippet', async function () { - await focusEditor(page); - - // Upload video - await uploadVideo(page); - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - // create snippet - await page.keyboard.press('Escape'); - await createSnippet(page); - - // can insert card from snippet - await page.keyboard.press('Enter'); - await page.keyboard.type('/snippet'); - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="video"]')).toHaveCount(2); - }); - - test('can undo/redo without losing nested editor content', async () => { - await focusEditor(page); - // Upload video - await uploadVideo(page); - await page.waitForSelector('[data-testid="media-upload-placeholder"]'); - - await page.click('[data-testid="video-card-caption"]'); - await page.keyboard.type('Test caption'); - await page.keyboard.press('Escape'); - await page.keyboard.press('Backspace'); - await page.keyboard.press(`${ctrlOrCmd(page)}+z`); - - await assertHTML(page, html` -
    -
    -
    -
    -
    - Video thumbnail -
    -
    - -
    -
    -
    - -
    - 0:00 - / - 0:04 -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    - Test caption -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); -}); - -async function uploadVideo(page, fileName = 'video.mp4') { - const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); - - const fileChooserPromise = page.waitForEvent('filechooser'); - await insertCard(page, {cardName: 'video'}); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles([filePath]); -} diff --git a/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.ts b/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.ts new file mode 100644 index 0000000000..49f1b97dfe --- /dev/null +++ b/packages/koenig-lexical/test/e2e/cards/video-card.firefox.test.ts @@ -0,0 +1,477 @@ +import path from 'path'; +import { + assertHTML, + createDataTransfer, + createSnippet, + ctrlOrCmd, + focusEditor, + html, + initialize, + insertCard +} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Video card is tested in firefox +// Need to get video thumbnail before uploading on the server; for this purpose, convert video to blob https://github.com/TryGhost/Koenig/blob/a04c59c2d81ddc783869c47653aa9d7adf093629/packages/koenig-lexical/src/utils/extractVideoMetadata.js#L45 +// The problem is that Chromium can't read video src as blob +test.describe('Video card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can import serialized video card nodes', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'video', + src: '/content/images/2022/11/koenig-lexical.jpg', + width: 100, + height: 100, + caption: 'This is a caption', + duration: 60, + thumbnailSrc: '/content/images/2022/12/koenig-lexical.png' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + Video thumbnail +
    +
    + +
    +
    +
    + +
    + 0:00 + / + 1:00 +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + This is a caption +

    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('renders video card node', async function () { + const fileChooserPromise = page.waitForEvent('filechooser'); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + + await focusEditor(page); + await insertCard(page, {cardName: 'video'}); + const fileChooser = await fileChooserPromise; + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + // Close the fileChooser by selecting a file + // Without this line, fileChooser stays open for subsequent tests + await fileChooser.setFiles([filePath]); + }); + + test('can upload video file from slash menu', async function () { + const fileChooserPromise = page.waitForEvent('filechooser'); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + + await focusEditor(page); + + // Upload video file + await insertCard(page, {cardName: 'video'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); + + // Check that video file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:04'); + }); + + test('can upload video file from card menu', async function () { + await focusEditor(page); + await uploadVideo(page); + + // Check that video file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:04'); + }); + + test('can show errors for failed video upload', async function () { + await focusEditor(page); + await uploadVideo(page, 'video-fail.mp4'); + + // Errors should be visible + await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); + }); + + test('can manage custom thumbnail', async function () { + await focusEditor(page); + await uploadVideo(page); + + // Settings panel should be visible + await expect(await page.getByTestId('settings-panel')).toBeVisible(); + + // Custom thumbnail should be visible + const emptyThumbnail = await page.getByTestId('media-upload-placeholder'); + await expect(emptyThumbnail).toBeVisible(); + + // Upload thumbnail + const imagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const fileChooserPromise = page.waitForEvent('filechooser'); + emptyThumbnail.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([imagePath]); + + // Thumbnail should be visible + await expect(await page.getByTestId('media-upload-filled')).toBeVisible(); + + // Can remove thumbnail + const replaceButton = page.getByTestId('media-upload-remove'); + await replaceButton.click(); + await expect(await page.getByTestId('media-upload-placeholder')).toBeVisible(); + }); + + test('can show errors for custom thumbnail', async function () { + await focusEditor(page); + await uploadVideo(page); + + // Settings panel should be visible + await expect(await page.getByTestId('settings-panel')).toBeVisible(); + + // Errors shouldn't be visible + await expect(page.getByTestId('media-placeholder-errors')).toBeHidden(); + + // Custom thumbnail should be visible + const emptyThumbnail = await page.getByTestId('media-upload-placeholder'); + + // Upload thumbnail + const imagePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image-fail.jpeg'); + const fileChooserPromise = page.waitForEvent('filechooser'); + emptyThumbnail.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([imagePath]); + + // Errors should be visible + await expect(await page.getByTestId('media-upload-errors')).toBeVisible(); + }); + + test('can upload dropped video', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + + // Open video card and dismiss files chooser to prepare card for video dropping + await insertCard(page, {cardName: 'video'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([]); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Check that video file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:04'); + }); + + test('can show errors if was dropped a file with wrong extension to video placeholder', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const fileChooserPromise = page.waitForEvent('filechooser'); + + await focusEditor(page); + + // Open video card and dismiss files chooser to prepare card for video dropping + await insertCard(page, {cardName: 'video'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([]); + + // Drop file + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('media-placeholder').dispatchEvent('dragover', {dataTransfer}); + await page.getByTestId('media-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Errors should be visible + await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); + }); + + test('can upload dropped custom thumbnail', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + await uploadVideo(page); + + // Wait for custom thumbnail + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + await page.getByTestId('media-upload-placeholder').dispatchEvent('dragover', {dataTransfer}); + + // Dragover text should be visible + await expect(await page.locator('[data-kg-card-drag-text="true"]')).toBeVisible(); + + // Drop file + await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Thumbnail should be visible + await expect(await page.getByTestId('media-upload-filled')).toBeVisible(); + }); + + test('can show errors if was dropped a file with wrong extension to custom thumbnail', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + + await focusEditor(page); + await uploadVideo(page); + + // Wait for custom thumbnail + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + // Create and dispatch data transfer + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); + await page.getByTestId('media-upload-placeholder').dispatchEvent('drop', {dataTransfer}); + + // Errors should be visible + await expect(await page.getByTestId('media-upload-errors')).toBeVisible(); + }); + + test('renders video card toolbar', async function () { + await focusEditor(page); + + // Upload video + await uploadVideo(page); + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + // Leave editing mode to display the toolbar + await page.keyboard.press('Escape'); + + // Check that the toolbar is displayed + await expect(await page.locator('[data-kg-card-toolbar="video"]')).toBeVisible(); + }); + + test('video card toolbar has Edit button', async function () { + await focusEditor(page); + + // Upload video + await uploadVideo(page); + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + // Leave editing mode to display the toolbar + await page.keyboard.press('Escape'); + + // Check that the toolbar is displayed + await expect(await page.locator('[data-kg-card-toolbar="video"]')).toBeVisible(); + + // Edit video card + await page.waitForSelector('[data-testid="edit-video-card"]'); + await page.getByTestId('edit-video-card').click(); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('adds extra paragraph when video is inserted at end of document', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Video"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('does not add extra paragraph when video is inserted mid-document', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('ArrowUp'); + await page.click('[data-kg-plus-button]'); + + await Promise.all([ + page.waitForEvent('filechooser'), + page.click('[data-kg-card-menu-item="Video"]') + ]); + + await assertHTML(page, html` +
    +
    +
    +
    +

    Testing

    + `, {ignoreCardContents: true}); + }); + + test('can add snippet', async function () { + await focusEditor(page); + + // Upload video + await uploadVideo(page); + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + // create snippet + await page.keyboard.press('Escape'); + await createSnippet(page); + + // can insert card from snippet + await page.keyboard.press('Enter'); + await page.keyboard.type('/snippet'); + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'snippet'})).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="video"]')).toHaveCount(2); + }); + + test('can undo/redo without losing nested editor content', async () => { + await focusEditor(page); + // Upload video + await uploadVideo(page); + await page.waitForSelector('[data-testid="media-upload-placeholder"]'); + + await page.click('[data-testid="video-card-caption"]'); + await page.keyboard.type('Test caption'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Backspace'); + await page.keyboard.press(`${ctrlOrCmd(page)}+z`); + + await assertHTML(page, html` +
    +
    +
    +
    +
    + Video thumbnail +
    +
    + +
    +
    +
    + +
    + 0:00 + / + 0:04 +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + Test caption +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); +}); + +async function uploadVideo(page: Page, fileName = 'video.mp4') { + const filePath = path.relative(process.cwd(), __dirname + `/../fixtures/${fileName}`); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await insertCard(page, {cardName: 'video'}); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([filePath]); +} diff --git a/packages/koenig-lexical/test/e2e/content-visibility.test.js b/packages/koenig-lexical/test/e2e/content-visibility.test.js deleted file mode 100644 index 66b530191a..0000000000 --- a/packages/koenig-lexical/test/e2e/content-visibility.test.js +++ /dev/null @@ -1,185 +0,0 @@ -import {expect, test} from '@playwright/test'; -import {focusEditor,initialize, insertCard} from '../utils/e2e'; - -test.describe('Content Visibility', async () => { - let page; - async function insertHtmlCard() { - await focusEditor(page); - await insertCard(page, {cardName: 'html'}); - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - await page.keyboard.type('Testing'); - // exit editing mode - use Escape instead of Meta+Enter for cross-platform compatibility - await page.keyboard.press('Escape'); - await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-editing', 'false'); - await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-selected', 'true'); - return page.locator('[data-kg-card="html"]'); - } - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('HTML card', async function () { - test.beforeEach(async () => { - await initialize({page, uri: '/#/?content=false'}); - }); - - test('toolbar shows edit icon', async function () { - await insertHtmlCard(); - - await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-selected', 'true'); - await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-editing', 'false'); - await expect(page.locator('[data-kg-card-toolbar="html"]')).toBeVisible(); - await expect(page.locator('[data-kg-card-toolbar="html"] [data-testid="edit-html"]')).toBeVisible(); - }); - - test('toolbar does not show settings panel by default on click', async function () { - const card = await insertHtmlCard(); - await card.getByTestId('edit-html').click(); - await expect(card.getByTestId('settings-panel')).not.toBeVisible(); - }); - - test('clicking on edit button transitions card into edit mode', async function () { - const card = await insertHtmlCard(); - await card.getByTestId('edit-html').click(); - - await expect(card).toHaveAttribute('data-kg-card-editing', 'true'); - }); - - test('visibility settings defaults to show on email and web and all members', async function () { - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - - await expect(card.getByTestId('visibility-message')).not.toBeVisible(); - - await expect(card.getByTestId('visibility-toggle-web-nonMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-web-paidMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); - await expect(card.getByTestId('visibility-toggle-email-paidMembers')).toBeChecked(); - }); - - test('can toggle visibility settings ', async function () { - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - await expect(card.getByTestId('visibility-toggle-web-nonMembers')).not.toBeChecked(); - await card.getByTestId('visibility-toggle-web-freeMembers').click(); - await expect(card.getByTestId('visibility-toggle-web-freeMembers')).not.toBeChecked(); - await card.getByTestId('visibility-toggle-web-paidMembers').click(); - await expect(card.getByTestId('visibility-toggle-web-paidMembers')).not.toBeChecked(); - await card.getByTestId('visibility-toggle-email-freeMembers').click(); - await expect(card.getByTestId('visibility-toggle-email-freeMembers')).not.toBeChecked(); - await card.getByTestId('visibility-toggle-email-paidMembers').click(); - await expect(card.getByTestId('visibility-toggle-email-paidMembers')).not.toBeChecked(); - - // change from the beta - visibility message is no longer shown - await expect(card.getByTestId('visibility-message')).not.toBeVisible(); - }); - - test('toggling settings in visibility panel does not trigger edit mode', async function () { - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); - }); - - test('visibility icon is shown when visibility changes from shown-to-all', async function () { - const card = await insertHtmlCard(); - - await expect(page.getByTestId('visibility-indicator')).not.toBeVisible(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - - await expect(page.getByTestId('visibility-indicator')).toBeVisible(); - }); - - test('paid member visibility settings hidden when stripe is not enabled', async function () { - await initialize({page, uri: '/#/?content=false&stripe=false'}); - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - - await expect(card.getByTestId('visibility-toggle-web-paidMembers')).not.toBeVisible(); - await expect(card.getByTestId('visibility-toggle-email-paidMembers')).not.toBeVisible(); - }); - - test('visibility indicator can toggle visibility settings panel', async function () { - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - - await page.getByTestId('post-title').click(); - await page.getByTestId('visibility-indicator').click(); - - await expect(card.getByTestId('settings-panel')).toBeVisible(); - }); - - test('clicking show visibility in toolbar does not trigger edit mode', async function () { - const card = await insertHtmlCard(); - - await page.getByTestId('show-visibility').click(); - await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); - }); - - test('clicking visibility indicator does not trigger edit mode', async function () { - const card = await insertHtmlCard(); - - await card.getByTestId('show-visibility').click(); - await card.getByTestId('tab-visibility').click(); - - await card.getByTestId('visibility-toggle-web-nonMembers').click(); - - await page.getByTestId('post-title').click(); - - await page.getByTestId('visibility-indicator').click(); - await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); - }); - }); - - test.describe('Edge cases', async function () { - test.beforeEach(async () => { - await initialize({page, uri: '/#/?content=false'}); - }); - // We need to ensure that when we used the visibility indicator to toggle the visibility settings and then - // switch to a different card type, the visibility settings state is reset so that you don't have visibility settings - // to be visible when it was not explicitly set. - test('Visibility Settings Card state are reset when switching between different card types', async function () { - // Set up HTML card with visibility settings - const htmlCard = await insertHtmlCard(); - await htmlCard.getByTestId('show-visibility').click(); - await htmlCard.getByTestId('tab-visibility').click(); - await htmlCard.getByTestId('visibility-toggle-web-nonMembers').click(); - - // Add CTA card and configure its visibility - await page.keyboard.press('Enter'); - const ctaCard = await insertCard(page, {cardName: 'call-to-action'}); - await page.click('[data-testid="cta-card-content-editor"]'); - await page.keyboard.type('This is a new CTA Card.'); - - await ctaCard.getByTestId('tab-visibility').click(); - await ctaCard.getByTestId('visibility-toggle-web-nonMembers').click(); - await page.click('body'); - // Verify visibility indicator works for HTML card - await page.getByTestId('visibility-indicator').first().click(); - await expect(htmlCard.getByTestId('settings-panel')).toBeVisible(); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/content-visibility.test.ts b/packages/koenig-lexical/test/e2e/content-visibility.test.ts new file mode 100644 index 0000000000..c391d3c21f --- /dev/null +++ b/packages/koenig-lexical/test/e2e/content-visibility.test.ts @@ -0,0 +1,186 @@ +import {expect, test} from '@playwright/test'; +import {focusEditor,initialize, insertCard} from '../utils/e2e'; +import type {Page} from '@playwright/test'; + +test.describe('Content Visibility', async () => { + let page: Page; + async function insertHtmlCard() { + await focusEditor(page); + await insertCard(page, {cardName: 'html'}); + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + await page.keyboard.type('Testing'); + // exit editing mode - use Escape instead of Meta+Enter for cross-platform compatibility + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-selected', 'true'); + return page.locator('[data-kg-card="html"]'); + } + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('HTML card', async function () { + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false'}); + }); + + test('toolbar shows edit icon', async function () { + await insertHtmlCard(); + + await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-selected', 'true'); + await expect(page.locator('[data-kg-card="html"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.locator('[data-kg-card-toolbar="html"]')).toBeVisible(); + await expect(page.locator('[data-kg-card-toolbar="html"] [data-testid="edit-html"]')).toBeVisible(); + }); + + test('toolbar does not show settings panel by default on click', async function () { + const card = await insertHtmlCard(); + await card.getByTestId('edit-html').click(); + await expect(card.getByTestId('settings-panel')).not.toBeVisible(); + }); + + test('clicking on edit button transitions card into edit mode', async function () { + const card = await insertHtmlCard(); + await card.getByTestId('edit-html').click(); + + await expect(card).toHaveAttribute('data-kg-card-editing', 'true'); + }); + + test('visibility settings defaults to show on email and web and all members', async function () { + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + + await expect(card.getByTestId('visibility-message')).not.toBeVisible(); + + await expect(card.getByTestId('visibility-toggle-web-nonMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-web-freeMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-web-paidMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-email-freeMembers')).toBeChecked(); + await expect(card.getByTestId('visibility-toggle-email-paidMembers')).toBeChecked(); + }); + + test('can toggle visibility settings ', async function () { + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + await expect(card.getByTestId('visibility-toggle-web-nonMembers')).not.toBeChecked(); + await card.getByTestId('visibility-toggle-web-freeMembers').click(); + await expect(card.getByTestId('visibility-toggle-web-freeMembers')).not.toBeChecked(); + await card.getByTestId('visibility-toggle-web-paidMembers').click(); + await expect(card.getByTestId('visibility-toggle-web-paidMembers')).not.toBeChecked(); + await card.getByTestId('visibility-toggle-email-freeMembers').click(); + await expect(card.getByTestId('visibility-toggle-email-freeMembers')).not.toBeChecked(); + await card.getByTestId('visibility-toggle-email-paidMembers').click(); + await expect(card.getByTestId('visibility-toggle-email-paidMembers')).not.toBeChecked(); + + // change from the beta - visibility message is no longer shown + await expect(card.getByTestId('visibility-message')).not.toBeVisible(); + }); + + test('toggling settings in visibility panel does not trigger edit mode', async function () { + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); + }); + + test('visibility icon is shown when visibility changes from shown-to-all', async function () { + const card = await insertHtmlCard(); + + await expect(page.getByTestId('visibility-indicator')).not.toBeVisible(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + + await expect(page.getByTestId('visibility-indicator')).toBeVisible(); + }); + + test('paid member visibility settings hidden when stripe is not enabled', async function () { + await initialize({page, uri: '/#/?content=false&stripe=false'}); + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + + await expect(card.getByTestId('visibility-toggle-web-paidMembers')).not.toBeVisible(); + await expect(card.getByTestId('visibility-toggle-email-paidMembers')).not.toBeVisible(); + }); + + test('visibility indicator can toggle visibility settings panel', async function () { + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + + await page.getByTestId('post-title').click(); + await page.getByTestId('visibility-indicator').click(); + + await expect(card.getByTestId('settings-panel')).toBeVisible(); + }); + + test('clicking show visibility in toolbar does not trigger edit mode', async function () { + const card = await insertHtmlCard(); + + await page.getByTestId('show-visibility').click(); + await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); + }); + + test('clicking visibility indicator does not trigger edit mode', async function () { + const card = await insertHtmlCard(); + + await card.getByTestId('show-visibility').click(); + await card.getByTestId('tab-visibility').click(); + + await card.getByTestId('visibility-toggle-web-nonMembers').click(); + + await page.getByTestId('post-title').click(); + + await page.getByTestId('visibility-indicator').click(); + await expect(card).toHaveAttribute('data-kg-card-editing', 'false'); + }); + }); + + test.describe('Edge cases', async function () { + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false'}); + }); + // We need to ensure that when we used the visibility indicator to toggle the visibility settings and then + // switch to a different card type, the visibility settings state is reset so that you don't have visibility settings + // to be visible when it was not explicitly set. + test('Visibility Settings Card state are reset when switching between different card types', async function () { + // Set up HTML card with visibility settings + const htmlCard = await insertHtmlCard(); + await htmlCard.getByTestId('show-visibility').click(); + await htmlCard.getByTestId('tab-visibility').click(); + await htmlCard.getByTestId('visibility-toggle-web-nonMembers').click(); + + // Add CTA card and configure its visibility + await page.keyboard.press('Enter'); + const ctaCard = await insertCard(page, {cardName: 'call-to-action'}); + await page.click('[data-testid="cta-card-content-editor"]'); + await page.keyboard.type('This is a new CTA Card.'); + + await ctaCard.getByTestId('tab-visibility').click(); + await ctaCard.getByTestId('visibility-toggle-web-nonMembers').click(); + await page.click('body'); + // Verify visibility indicator works for HTML card + await page.getByTestId('visibility-indicator').first().click(); + await expect(htmlCard.getByTestId('settings-panel')).toBeVisible(); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/editors/basic-editor.test.js b/packages/koenig-lexical/test/e2e/editors/basic-editor.test.js deleted file mode 100644 index 284b1a6982..0000000000 --- a/packages/koenig-lexical/test/e2e/editors/basic-editor.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, selectBackwards} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Koening Editor with basic nodes', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/basic?content=false'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('caret does not appear on empty editor', async function () { - await focusEditor(page); - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('can add basic text', async function () { - await focusEditor(page); - - await page.keyboard.type('Hello World'); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('can add more than 1 paragraphs by typing manually', async function () { - await focusEditor(page); - - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await page.keyboard.type('This is second para'); - - await assertHTML(page, html` -

    Hello World

    -

    This is second para

    - `); - }); - - test('ignores hr card shortcut', async function () { - await focusEditor(page); - - await page.keyboard.type('---'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    ---

    -


    - `); - }); - - test('ignores code block card shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - - await assertHTML(page, html` -

    \`\`\`javascript

    - `); - }); - - test('ignores slash menu on blank paragraph', async function () { - await focusEditor(page); - await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); - await page.keyboard.type('/'); - await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); - }); - - test.describe('Floating format toolbar', async () => { - test('appears on text selection', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - await selectBackwards(page, 'for selection'.length); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - }); - - test('does not has heading buttons', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - await selectBackwards(page, 'for selection'.length); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - - const boldButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="bold"] button`; - expect(await page.locator(boldButtonSelector)).not.toBeNull(); - - const h2ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await expect(await page.locator(h2ButtonSelector)).toHaveCount(0); - - const h3ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; - await expect(await page.locator(h3ButtonSelector)).toHaveCount(0); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/editors/basic-editor.test.ts b/packages/koenig-lexical/test/e2e/editors/basic-editor.test.ts new file mode 100644 index 0000000000..948c6ea9e0 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/editors/basic-editor.test.ts @@ -0,0 +1,108 @@ +import {assertHTML, focusEditor, html, initialize, selectBackwards} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Koening Editor with basic nodes', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/basic?content=false'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('caret does not appear on empty editor', async function () { + await focusEditor(page); + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('can add basic text', async function () { + await focusEditor(page); + + await page.keyboard.type('Hello World'); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('can add more than 1 paragraphs by typing manually', async function () { + await focusEditor(page); + + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('This is second para'); + + await assertHTML(page, html` +

    Hello World

    +

    This is second para

    + `); + }); + + test('ignores hr card shortcut', async function () { + await focusEditor(page); + + await page.keyboard.type('---'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    ---

    +


    + `); + }); + + test('ignores code block card shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + + await assertHTML(page, html` +

    \`\`\`javascript

    + `); + }); + + test('ignores slash menu on blank paragraph', async function () { + await focusEditor(page); + await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); + await page.keyboard.type('/'); + await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); + }); + + test.describe('Floating format toolbar', async () => { + test('appears on text selection', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + await selectBackwards(page, 'for selection'.length); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + }); + + test('does not has heading buttons', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + await selectBackwards(page, 'for selection'.length); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + + const boldButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="bold"] button`; + expect(await page.locator(boldButtonSelector)).not.toBeNull(); + + const h2ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await expect(await page.locator(h2ButtonSelector)).toHaveCount(0); + + const h3ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; + await expect(await page.locator(h3ButtonSelector)).toHaveCount(0); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js b/packages/koenig-lexical/test/e2e/editors/email-editor.test.js deleted file mode 100644 index e8a73b777e..0000000000 --- a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js +++ /dev/null @@ -1,385 +0,0 @@ -import path from 'path'; -import {assertHTML, focusEditor, html, initialize, insertCard, pasteText, selectBackwards} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const visibleEmailMenuItems = [ - 'Image', - 'Unsplash', - 'GIF', - 'Bookmark', - 'Button', - 'Callout', - 'Call to action', - 'Email call to action', - 'HTML', - 'Product', - 'Divider', - 'YouTube' -]; - -const unavailableEmailMenuItems = [ - 'Audio', - 'Gallery', - 'Video', - 'File', - 'Markdown', - 'Header', - 'Public preview', - 'Toggle', - 'Signup', - 'Email content' -]; - -const smokeTestInsertions = [ - {shortcut: 'button', menuItem: 'Button', selector: '[data-kg-card="button"]'}, - {shortcut: 'callout', menuItem: 'Callout', selector: '[data-kg-card="callout"]'}, - {shortcut: 'html', menuItem: 'HTML', selector: '[data-kg-card="html"]'}, - {shortcut: 'divider', menuItem: 'Divider', selector: '[data-kg-card="horizontalrule"]'}, - {shortcut: 'email-cta', menuItem: 'Email call to action', selector: '[data-kg-card="email-cta"]'} -]; - -async function insertCardFromMenu(page, {shortcut, menuItem, selector}) { - await focusEditor(page); - await page.keyboard.type(`/${shortcut}`); - await expect(page.locator(`[data-kg-card-menu-item="${menuItem}" i]`)).toBeVisible(); - await page.locator(`[data-kg-card-menu-item="${menuItem}" i]`).click(); - await expect(page.locator(selector)).toBeVisible(); -} - -test.describe('Koenig Editor with email template nodes', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/email?content=false'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('Basic functionality', function () { - test('can navigate to email editor', async function () { - await focusEditor(page); - await expect(page.locator('[data-kg="editor"]')).toBeVisible(); - }); - - test('shows correct placeholder text', async function () { - await expect(page.locator('text=Begin writing your email...')).toBeVisible(); - }); - - test('renders email header with From and Subject fields', async function () { - await expect(page.locator('text=From:')).toBeVisible(); - await expect(page.locator('text=Ghost ')).toBeVisible(); - await expect(page.locator('text=Subject:')).toBeVisible(); - await expect(page.locator('text=Welcome to Ghost')).toBeVisible(); - }); - - test('title is hidden', async function () { - await expect(page.locator('[data-testid="post-title"]')).toHaveCount(0); - }); - }); - - test.describe('Supported features', function () { - test('can add basic text', async function () { - await focusEditor(page); - await page.keyboard.type('Hello World'); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('can add multiple paragraphs', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Second paragraph'); - - await assertHTML(page, html` -

    First paragraph

    -

    Second paragraph

    - `); - }); - - test('can create headings with ## shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('## Heading 2'); - - await assertHTML(page, html` -

    Heading 2

    - `); - }); - - test('can create unordered lists with - shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('- List item'); - - await assertHTML(page, html` -
      -
    • List item
    • -
    - `); - }); - - test('can create ordered lists with 1. shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('1. List item'); - - await assertHTML(page, html` -
      -
    1. List item
    2. -
    - `); - }); - - test('can create horizontal rules with --- shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true}); - }); - - test('list backspace at start converts to paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('- Item'); - // Move to start of line - await page.keyboard.press('Home'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    Item

    - `); - }); - - test('can create blockquote with > shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('> This is a quote'); - - await assertHTML(page, html` -
    This is a quote
    - `); - }); - - test('pasting URL on blank paragraph creates an embed or bookmark card', async function () { - await focusEditor(page); - await pasteText(page, 'https://ghost.org/'); - - const embedCard = page.getByTestId('embed-iframe'); - const bookmarkCard = page.getByTestId('bookmark-container'); - - await expect(embedCard.or(bookmarkCard)).toBeVisible(); - }); - - test('bookmark card fetches metadata from URL input', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'bookmark'}); - - const urlInput = page.getByTestId('bookmark-url'); - await expect(urlInput).toBeVisible(); - await urlInput.fill('https://ghost.org/'); - await urlInput.press('Enter'); - - await expect(page.getByTestId('bookmark-title')).toContainText('Ghost'); - }); - - test('image card hides width controls when only one width is configured', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - await insertCard(page, {cardName: 'image'}); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.click('button[name="placeholder-button"]') - ]); - await fileChooser.setFiles([filePath]); - - await expect(page.getByTestId('image-card-populated')).toBeVisible(); - await expect(page.getByTestId('progress-bar')).toBeHidden(); - - await page.click('[data-kg-card="image"]'); - await expect(page.locator('[data-kg-card-toolbar="image"]')).toBeVisible(); - - await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Wide width"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Full width"]')).toHaveCount(0); - }); - }); - - test.describe('Unsupported features', function () { - test('code block shortcut does NOT create code block', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - - // Should remain as plain text, not a code block - await assertHTML(page, html` -

    \`\`\`javascript

    - `); - }); - }); - - test.describe('Card menu', function () { - test('slash menu is available', async function () { - await focusEditor(page); - await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0); - await page.keyboard.type('/'); - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - }); - - test('shows the supported email card menu items', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - - for (const label of visibleEmailMenuItems) { - await expect(page.locator(`[data-kg-card-menu-item="${label}"]`)).toBeVisible(); - } - - for (const label of unavailableEmailMenuItems) { - await expect(page.locator(`[data-kg-card-menu-item="${label}"]`)).toHaveCount(0); - } - }); - - test('only shows YouTube embed option', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - - // Should show YouTube - await expect(page.locator('[data-kg-card-menu-item="YouTube"]')).toBeVisible(); - - // Should NOT show other embed types - await expect(page.locator('[data-kg-card-menu-item="Other..."]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-menu-item="Vimeo"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-menu-item="SoundCloud"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-menu-item="Spotify"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-menu-item="CodePen"]')).toHaveCount(0); - await expect(page.locator('[data-kg-card-menu-item="X (formerly Twitter)"]')).toHaveCount(0); - }); - - test('can insert YouTube embed via slash menu', async function () { - await focusEditor(page); - await page.keyboard.type('/youtube'); - await expect(page.locator('[data-kg-card-menu-item="YouTube"][data-kg-cardmenu-selected="true"]')).toBeVisible(); - await page.keyboard.press('Enter'); - - await expect(page.locator('[data-kg-card="embed"]')).toBeVisible(); - }); - - for (const {shortcut, menuItem, selector} of smokeTestInsertions) { - test(`can insert ${menuItem} via slash menu`, async function () { - await insertCardFromMenu(page, {shortcut, menuItem, selector}); - }); - } - - test('can insert call to action card via slash menu', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'call-to-action'}); - - await expect(page.locator('[data-kg-card="call-to-action"]')).toBeVisible(); - }); - - test('can insert product card via slash menu', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'product'}); - - await expect(page.locator('[data-kg-card="product"]')).toBeVisible(); - }); - - test('can insert transistor card via slash menu when feature flag is enabled', async function () { - await initialize({page, uri: '/#/email?content=false&labs=transistor'}); - await focusEditor(page); - await page.keyboard.type('/transistor'); - await expect(page.locator('[data-kg-card-menu-item="Transistor" i]')).toBeVisible(); - await page.locator('[data-kg-card-menu-item="Transistor" i]').click(); - await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); - }); - - test('plus button is shown', async function () { - await focusEditor(page); - await expect(page.locator('[data-kg-plus-button]')).toBeVisible(); - }); - }); - - test.describe('Floating format toolbar', function () { - test('appears on text selection', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - // Select text - await selectBackwards(page, 'for selection'.length); - - await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - }); - - test('has heading buttons', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - // Select text - await selectBackwards(page, 'for selection'.length); - - await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - - // Email editor should have heading buttons (unlike basic/minimal) - const h2ButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button'; - await expect(page.locator(h2ButtonSelector)).toBeVisible(); - }); - - test('has quote button', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - // Select text - await selectBackwards(page, 'for selection'.length); - - await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - - const quoteButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="quote"] button'; - await expect(page.locator(quoteButtonSelector)).toBeVisible(); - }); - - test('has link button', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - // Select text - await selectBackwards(page, 'for selection'.length); - - await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - - const linkButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button'; - await expect(page.locator(linkButtonSelector)).toBeVisible(); - }); - - test('has snippet button', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - // Select text - await selectBackwards(page, 'for selection'.length); - - await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - - const snippetButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="snippet"] button'; - await expect(page.locator(snippetButtonSelector)).toBeVisible(); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/editors/email-editor.test.ts b/packages/koenig-lexical/test/e2e/editors/email-editor.test.ts new file mode 100644 index 0000000000..762bee0d52 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/editors/email-editor.test.ts @@ -0,0 +1,386 @@ +import path from 'path'; +import {assertHTML, focusEditor, html, initialize, insertCard, pasteText, selectBackwards} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const visibleEmailMenuItems = [ + 'Image', + 'Unsplash', + 'GIF', + 'Bookmark', + 'Button', + 'Callout', + 'Call to action', + 'Email call to action', + 'HTML', + 'Product', + 'Divider', + 'YouTube' +]; + +const unavailableEmailMenuItems = [ + 'Audio', + 'Gallery', + 'Video', + 'File', + 'Markdown', + 'Header', + 'Public preview', + 'Toggle', + 'Signup', + 'Email content' +]; + +const smokeTestInsertions = [ + {shortcut: 'button', menuItem: 'Button', selector: '[data-kg-card="button"]'}, + {shortcut: 'callout', menuItem: 'Callout', selector: '[data-kg-card="callout"]'}, + {shortcut: 'html', menuItem: 'HTML', selector: '[data-kg-card="html"]'}, + {shortcut: 'divider', menuItem: 'Divider', selector: '[data-kg-card="horizontalrule"]'}, + {shortcut: 'email-cta', menuItem: 'Email call to action', selector: '[data-kg-card="email-cta"]'} +]; + +async function insertCardFromMenu(page: Page, {shortcut, menuItem, selector}: {shortcut: string; menuItem: string; selector: string}) { + await focusEditor(page); + await page.keyboard.type(`/${shortcut}`); + await expect(page.locator(`[data-kg-card-menu-item="${menuItem}" i]`)).toBeVisible(); + await page.locator(`[data-kg-card-menu-item="${menuItem}" i]`).click(); + await expect(page.locator(selector)).toBeVisible(); +} + +test.describe('Koenig Editor with email template nodes', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/email?content=false'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('Basic functionality', function () { + test('can navigate to email editor', async function () { + await focusEditor(page); + await expect(page.locator('[data-kg="editor"]')).toBeVisible(); + }); + + test('shows correct placeholder text', async function () { + await expect(page.locator('text=Begin writing your email...')).toBeVisible(); + }); + + test('renders email header with From and Subject fields', async function () { + await expect(page.locator('text=From:')).toBeVisible(); + await expect(page.locator('text=Ghost ')).toBeVisible(); + await expect(page.locator('text=Subject:')).toBeVisible(); + await expect(page.locator('text=Welcome to Ghost')).toBeVisible(); + }); + + test('title is hidden', async function () { + await expect(page.locator('[data-testid="post-title"]')).toHaveCount(0); + }); + }); + + test.describe('Supported features', function () { + test('can add basic text', async function () { + await focusEditor(page); + await page.keyboard.type('Hello World'); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('can add multiple paragraphs', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second paragraph'); + + await assertHTML(page, html` +

    First paragraph

    +

    Second paragraph

    + `); + }); + + test('can create headings with ## shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('## Heading 2'); + + await assertHTML(page, html` +

    Heading 2

    + `); + }); + + test('can create unordered lists with - shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('- List item'); + + await assertHTML(page, html` +
      +
    • List item
    • +
    + `); + }); + + test('can create ordered lists with 1. shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('1. List item'); + + await assertHTML(page, html` +
      +
    1. List item
    2. +
    + `); + }); + + test('can create horizontal rules with --- shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true}); + }); + + test('list backspace at start converts to paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('- Item'); + // Move to start of line + await page.keyboard.press('Home'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    Item

    + `); + }); + + test('can create blockquote with > shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('> This is a quote'); + + await assertHTML(page, html` +
    This is a quote
    + `); + }); + + test('pasting URL on blank paragraph creates an embed or bookmark card', async function () { + await focusEditor(page); + await pasteText(page, 'https://ghost.org/'); + + const embedCard = page.getByTestId('embed-iframe'); + const bookmarkCard = page.getByTestId('bookmark-container'); + + await expect(embedCard.or(bookmarkCard)).toBeVisible(); + }); + + test('bookmark card fetches metadata from URL input', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + + const urlInput = page.getByTestId('bookmark-url'); + await expect(urlInput).toBeVisible(); + await urlInput.fill('https://ghost.org/'); + await urlInput.press('Enter'); + + await expect(page.getByTestId('bookmark-title')).toContainText('Ghost'); + }); + + test('image card hides width controls when only one width is configured', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + await insertCard(page, {cardName: 'image'}); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.click('button[name="placeholder-button"]') + ]); + await fileChooser.setFiles([filePath]); + + await expect(page.getByTestId('image-card-populated')).toBeVisible(); + await expect(page.getByTestId('progress-bar')).toBeHidden(); + + await page.click('[data-kg-card="image"]'); + await expect(page.locator('[data-kg-card-toolbar="image"]')).toBeVisible(); + + await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Regular width"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Wide width"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-toolbar="image"] button[aria-label="Full width"]')).toHaveCount(0); + }); + }); + + test.describe('Unsupported features', function () { + test('code block shortcut does NOT create code block', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + + // Should remain as plain text, not a code block + await assertHTML(page, html` +

    \`\`\`javascript

    + `); + }); + }); + + test.describe('Card menu', function () { + test('slash menu is available', async function () { + await focusEditor(page); + await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0); + await page.keyboard.type('/'); + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + }); + + test('shows the supported email card menu items', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + + for (const label of visibleEmailMenuItems) { + await expect(page.locator(`[data-kg-card-menu-item="${label}"]`)).toBeVisible(); + } + + for (const label of unavailableEmailMenuItems) { + await expect(page.locator(`[data-kg-card-menu-item="${label}"]`)).toHaveCount(0); + } + }); + + test('only shows YouTube embed option', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + + // Should show YouTube + await expect(page.locator('[data-kg-card-menu-item="YouTube"]')).toBeVisible(); + + // Should NOT show other embed types + await expect(page.locator('[data-kg-card-menu-item="Other..."]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-menu-item="Vimeo"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-menu-item="SoundCloud"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-menu-item="Spotify"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-menu-item="CodePen"]')).toHaveCount(0); + await expect(page.locator('[data-kg-card-menu-item="X (formerly Twitter)"]')).toHaveCount(0); + }); + + test('can insert YouTube embed via slash menu', async function () { + await focusEditor(page); + await page.keyboard.type('/youtube'); + await expect(page.locator('[data-kg-card-menu-item="YouTube"][data-kg-cardmenu-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); + + await expect(page.locator('[data-kg-card="embed"]')).toBeVisible(); + }); + + for (const {shortcut, menuItem, selector} of smokeTestInsertions) { + test(`can insert ${menuItem} via slash menu`, async function () { + await insertCardFromMenu(page, {shortcut, menuItem, selector}); + }); + } + + test('can insert call to action card via slash menu', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'call-to-action'}); + + await expect(page.locator('[data-kg-card="call-to-action"]')).toBeVisible(); + }); + + test('can insert product card via slash menu', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + + await expect(page.locator('[data-kg-card="product"]')).toBeVisible(); + }); + + test('can insert transistor card via slash menu when feature flag is enabled', async function () { + await initialize({page, uri: '/#/email?content=false&labs=transistor'}); + await focusEditor(page); + await page.keyboard.type('/transistor'); + await expect(page.locator('[data-kg-card-menu-item="Transistor" i]')).toBeVisible(); + await page.locator('[data-kg-card-menu-item="Transistor" i]').click(); + await expect(page.locator('[data-kg-card="transistor"]')).toBeVisible(); + }); + + test('plus button is shown', async function () { + await focusEditor(page); + await expect(page.locator('[data-kg-plus-button]')).toBeVisible(); + }); + }); + + test.describe('Floating format toolbar', function () { + test('appears on text selection', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + // Select text + await selectBackwards(page, 'for selection'.length); + + await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + }); + + test('has heading buttons', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + // Select text + await selectBackwards(page, 'for selection'.length); + + await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + + // Email editor should have heading buttons (unlike basic/minimal) + const h2ButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button'; + await expect(page.locator(h2ButtonSelector)).toBeVisible(); + }); + + test('has quote button', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + // Select text + await selectBackwards(page, 'for selection'.length); + + await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + + const quoteButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="quote"] button'; + await expect(page.locator(quoteButtonSelector)).toBeVisible(); + }); + + test('has link button', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + // Select text + await selectBackwards(page, 'for selection'.length); + + await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + + const linkButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button'; + await expect(page.locator(linkButtonSelector)).toBeVisible(); + }); + + test('has snippet button', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + // Select text + await selectBackwards(page, 'for selection'.length); + + await expect(page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + + const snippetButtonSelector = '[data-kg-floating-toolbar] [data-kg-toolbar-button="snippet"] button'; + await expect(page.locator(snippetButtonSelector)).toBeVisible(); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.js b/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.js deleted file mode 100644 index 0cfcd2cce0..0000000000 --- a/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, selectBackwards} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Koening Editor with minimal nodes', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/minimal?content=false'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can add basic text', async function () { - await focusEditor(page); - - await page.keyboard.type('Hello World'); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('restricts to single paragraph by typing manually', async function () { - await focusEditor(page); - - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await page.keyboard.type('This is second para'); - - await assertHTML(page, html` -

    Hello WorldThis is second para

    - `); - }); - - test('ignores hr card shortcut', async function () { - await focusEditor(page); - - await page.keyboard.type('---'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    ---

    - `); - }); - - test('ignores code block card shortcut', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - - await assertHTML(page, html` -

    \`\`\`javascript

    - `); - }); - - test('ignores slash menu on blank paragraph', async function () { - await focusEditor(page); - await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); - await page.keyboard.type('/'); - await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); - }); - - test.describe('Floating format toolbar', async () => { - test('appears on text selection', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - await selectBackwards(page, 'for selection'.length); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - }); - - test('does not has heading buttons', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - await selectBackwards(page, 'for selection'.length); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - - const boldButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="bold"] button`; - expect(await page.locator(boldButtonSelector)).not.toBeNull(); - - const h2ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await expect(await page.locator(h2ButtonSelector)).toHaveCount(0); - - const h3ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; - await expect(await page.locator(h3ButtonSelector)).toHaveCount(0); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.ts b/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.ts new file mode 100644 index 0000000000..c300fabefe --- /dev/null +++ b/packages/koenig-lexical/test/e2e/editors/minimal-editor.test.ts @@ -0,0 +1,101 @@ +import {assertHTML, focusEditor, html, initialize, selectBackwards} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Koening Editor with minimal nodes', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/minimal?content=false'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can add basic text', async function () { + await focusEditor(page); + + await page.keyboard.type('Hello World'); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('restricts to single paragraph by typing manually', async function () { + await focusEditor(page); + + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('This is second para'); + + await assertHTML(page, html` +

    Hello WorldThis is second para

    + `); + }); + + test('ignores hr card shortcut', async function () { + await focusEditor(page); + + await page.keyboard.type('---'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    ---

    + `); + }); + + test('ignores code block card shortcut', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + + await assertHTML(page, html` +

    \`\`\`javascript

    + `); + }); + + test('ignores slash menu on blank paragraph', async function () { + await focusEditor(page); + await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); + await page.keyboard.type('/'); + await expect(await page.locator('[data-kg-slash-menu]')).toHaveCount(0); + }); + + test.describe('Floating format toolbar', async () => { + test('appears on text selection', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + await selectBackwards(page, 'for selection'.length); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + }); + + test('does not has heading buttons', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + await selectBackwards(page, 'for selection'.length); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + + const boldButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="bold"] button`; + expect(await page.locator(boldButtonSelector)).not.toBeNull(); + + const h2ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await expect(await page.locator(h2ButtonSelector)).toHaveCount(0); + + const h3ButtonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; + await expect(await page.locator(h3ButtonSelector)).toHaveCount(0); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/floating-toolbar.test.js b/packages/koenig-lexical/test/e2e/floating-toolbar.test.js deleted file mode 100644 index e90e855301..0000000000 --- a/packages/koenig-lexical/test/e2e/floating-toolbar.test.js +++ /dev/null @@ -1,364 +0,0 @@ -import path from 'path'; -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, selectBackwards} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Floating format toolbar', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('appears on text selection', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - - await selectBackwards(page, 'for selection'.length); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - }); - - test('appears on paragraph selection', async function () { - await focusEditor(page); - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - await test.step('Insert paragraphs', async () => { - await page.keyboard.type('paragraph for selection'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('paragraph for selection'); - await page.keyboard.press('Shift+Enter'); - await page.keyboard.type('paragraph for selection'); - }); - - await test.step('Move cursor to the end of first paragraph', async () => { - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - }); - - await test.step('Select paragraphs', async () => { - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Shift'); - }); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toBeVisible(); - }); - - test('disappears on selection removal', async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - await selectBackwards(page, 1); - - expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); - - await page.keyboard.press('ArrowRight'); - - await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); - }); - - test.describe('buttons', function () { - const BASIC_TOGGLES = [{ - button: 'bold', - html: html` -

    - text for - selection -

    - ` - }, { - button: 'italic', - html: html` -

    - text for - selection -

    - ` - }, { - button: 'h2', - html: html` -

    text for selection

    - ` - }, { - button: 'h3', - html: html` -

    text for selection

    - ` - }]; - - BASIC_TOGGLES.forEach((testCase) => { - test(`toggles ${testCase.button}`, async function () { - await focusEditor(page); - await page.keyboard.type('text for selection'); - - await assertHTML(page, html`

    text for selection

    `); - - await selectBackwards(page, 'selection'.length); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="${testCase.button}"] button`; - await page.click(buttonSelector); - - await assertHTML(page, testCase.html); - - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); - - await page.click(buttonSelector); - - await assertHTML(page, html`

    text for selection

    `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); - }); - }); - - test('toggles h4 to h2', async function () { - await focusEditor(page); - await page.keyboard.type('#### header for selection'); - - await assertHTML(page, html`

    header for selection

    `); - - await selectBackwards(page, 'selection'.length); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -

    header for selection

    - `); - - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); - - await page.click(buttonSelector); - - await assertHTML(page, html`

    header for selection

    `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); - }); - - test('toggles h4 to h3', async function () { - await focusEditor(page); - await page.keyboard.type('#### header for selection'); - - await assertHTML(page, html`

    header for selection

    `); - - await selectBackwards(page, 'selection'.length); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -

    header for selection

    - `); - - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); - - await page.click(buttonSelector); - - await assertHTML(page, html`

    header for selection

    `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); - }); - - test('cycles through quote styles', async function () { - await focusEditor(page); - await page.keyboard.type('quote text'); - - await selectBackwards(page, 1); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="quote"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -
    - quote text -
    - `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); - - await page.click(buttonSelector); - - await assertHTML(page, html` - - `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); - - await page.click(buttonSelector); - - await assertHTML(page, html`

    quote text

    `); - expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); - }); - - test('can create link (with search)', async function () { - await focusEditor(page); - await page.keyboard.type('link'); - - await assertHTML(page, html` -

    - link -

    - `); - - await selectBackwards(page, 4); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button`; - - // Add the link - await page.click(buttonSelector); - await expect(page.getByTestId('link-input')).toBeVisible(); - await expect(page.getByTestId('link-input')).toBeFocused(); - await page.keyboard.type('https://ghost.org/', {delay: 10}); - await page.keyboard.press('Enter'); - await expect(page.locator('[data-kg-floating-toolbar]')).not.toBeVisible(); - - await assertHTML(page, html` -

    - - link - -

    - `); - - // TODO: assert link is not selected - - // Edit the link - await selectBackwards(page, 4); - await page.click(buttonSelector); - await expect(page.getByTestId('link-input')).toHaveValue('https://ghost.org/'); - }); - - test('can create link (without search)', async function () { - await initialize({page, uri: '/#/?content=false&searchLinks=false'}); - await focusEditor(page); - await page.keyboard.type('link'); - - await assertHTML(page, html` -

    - link -

    - `); - - await selectBackwards(page, 4); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button`; - await page.click(buttonSelector); - await page.waitForSelector('[data-testid="link-input"]'); - await page.getByTestId('link-input').fill('https://ghost.org/'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    - - link - -

    - `); - - await selectBackwards(page, 4); - await page.click(buttonSelector); - await page.waitForSelector('[data-testid="link-input"]'); - await page.getByTestId('link-input').fill(''); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    link

    - `); - }); - }); - - test.describe('with cards', function () { - test('toggles all text when text+hr cards are selected', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'divider'}); - await page.keyboard.type('Second paragraph'); - await page.keyboard.press(`${ctrlOrCmd(page)}+A`); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -

    First paragraph

    -
    -
    -
    -
    -

    Second paragraph

    - `, {ignoreCardContents: true}); - }); - - test('toggles all text when text+image cards are selected', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); - - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'image'}) - ]); - await fileChooser.setFiles([filePath]); - - await expect(page.getByTestId('image-card-populated')).toBeVisible(); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('Second paragraph'); - await page.keyboard.press(`${ctrlOrCmd(page)}+A`); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -

    First paragraph

    -
    -
    -
    -
    -

    Second paragraph

    - `, {ignoreCardContents: true}); - }); - - test('toggles all text when text+audio cards are selected', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); - - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await insertCard(page, {cardName: 'audio'}) - ]); - await fileChooser.setFiles([filePath]); - - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('Second paragraph'); - await page.keyboard.press(`${ctrlOrCmd(page)}+A`); - - const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; - await page.click(buttonSelector); - - await assertHTML(page, html` -

    First paragraph

    -
    -
    -
    -
    -

    Second paragraph

    - `, {ignoreCardContents: true}); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/floating-toolbar.test.ts b/packages/koenig-lexical/test/e2e/floating-toolbar.test.ts new file mode 100644 index 0000000000..3b457e460b --- /dev/null +++ b/packages/koenig-lexical/test/e2e/floating-toolbar.test.ts @@ -0,0 +1,365 @@ +import path from 'path'; +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, selectBackwards} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Floating format toolbar', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('appears on text selection', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + + await selectBackwards(page, 'for selection'.length); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + }); + + test('appears on paragraph selection', async function () { + await focusEditor(page); + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + await test.step('Insert paragraphs', async () => { + await page.keyboard.type('paragraph for selection'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('paragraph for selection'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('paragraph for selection'); + }); + + await test.step('Move cursor to the end of first paragraph', async () => { + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + }); + + await test.step('Select paragraphs', async () => { + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + }); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toBeVisible(); + }); + + test('disappears on selection removal', async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + await selectBackwards(page, 1); + + expect(await page.locator('[data-kg-floating-toolbar]')).not.toBeNull(); + + await page.keyboard.press('ArrowRight'); + + await expect(await page.locator('[data-kg-floating-toolbar]')).toHaveCount(0); + }); + + test.describe('buttons', function () { + const BASIC_TOGGLES = [{ + button: 'bold', + html: html` +

    + text for + selection +

    + ` + }, { + button: 'italic', + html: html` +

    + text for + selection +

    + ` + }, { + button: 'h2', + html: html` +

    text for selection

    + ` + }, { + button: 'h3', + html: html` +

    text for selection

    + ` + }]; + + BASIC_TOGGLES.forEach((testCase) => { + test(`toggles ${testCase.button}`, async function () { + await focusEditor(page); + await page.keyboard.type('text for selection'); + + await assertHTML(page, html`

    text for selection

    `); + + await selectBackwards(page, 'selection'.length); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="${testCase.button}"] button`; + await page.click(buttonSelector); + + await assertHTML(page, testCase.html); + + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); + + await page.click(buttonSelector); + + await assertHTML(page, html`

    text for selection

    `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); + }); + }); + + test('toggles h4 to h2', async function () { + await focusEditor(page); + await page.keyboard.type('#### header for selection'); + + await assertHTML(page, html`

    header for selection

    `); + + await selectBackwards(page, 'selection'.length); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +

    header for selection

    + `); + + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); + + await page.click(buttonSelector); + + await assertHTML(page, html`

    header for selection

    `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); + }); + + test('toggles h4 to h3', async function () { + await focusEditor(page); + await page.keyboard.type('#### header for selection'); + + await assertHTML(page, html`

    header for selection

    `); + + await selectBackwards(page, 'selection'.length); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h3"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +

    header for selection

    + `); + + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); + + await page.click(buttonSelector); + + await assertHTML(page, html`

    header for selection

    `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); + }); + + test('cycles through quote styles', async function () { + await focusEditor(page); + await page.keyboard.type('quote text'); + + await selectBackwards(page, 1); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="quote"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +
    + quote text +
    + `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); + + await page.click(buttonSelector); + + await assertHTML(page, html` + + `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('true'); + + await page.click(buttonSelector); + + await assertHTML(page, html`

    quote text

    `); + expect(await page.$eval(buttonSelector, e => e.dataset.kgActive)).toEqual('false'); + }); + + test('can create link (with search)', async function () { + await focusEditor(page); + await page.keyboard.type('link'); + + await assertHTML(page, html` +

    + link +

    + `); + + await selectBackwards(page, 4); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button`; + + // Add the link + await page.click(buttonSelector); + await expect(page.getByTestId('link-input')).toBeVisible(); + await expect(page.getByTestId('link-input')).toBeFocused(); + await page.keyboard.type('https://ghost.org/', {delay: 10}); + await page.keyboard.press('Enter'); + await expect(page.locator('[data-kg-floating-toolbar]')).not.toBeVisible(); + + await assertHTML(page, html` +

    + + link + +

    + `); + + // TODO: assert link is not selected + + // Edit the link + await selectBackwards(page, 4); + await page.click(buttonSelector); + await expect(page.getByTestId('link-input')).toHaveValue('https://ghost.org/'); + }); + + test('can create link (without search)', async function () { + await initialize({page, uri: '/#/?content=false&searchLinks=false'}); + await focusEditor(page); + await page.keyboard.type('link'); + + await assertHTML(page, html` +

    + link +

    + `); + + await selectBackwards(page, 4); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="link"] button`; + await page.click(buttonSelector); + await page.waitForSelector('[data-testid="link-input"]'); + await page.getByTestId('link-input').fill('https://ghost.org/'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    + + link + +

    + `); + + await selectBackwards(page, 4); + await page.click(buttonSelector); + await page.waitForSelector('[data-testid="link-input"]'); + await page.getByTestId('link-input').fill(''); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    link

    + `); + }); + }); + + test.describe('with cards', function () { + test('toggles all text when text+hr cards are selected', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'divider'}); + await page.keyboard.type('Second paragraph'); + await page.keyboard.press(`${ctrlOrCmd(page)}+A`); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +

    First paragraph

    +
    +
    +
    +
    +

    Second paragraph

    + `, {ignoreCardContents: true}); + }); + + test('toggles all text when text+image cards are selected', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); + + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'image'}) + ]); + await fileChooser.setFiles([filePath]); + + await expect(page.getByTestId('image-card-populated')).toBeVisible(); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Second paragraph'); + await page.keyboard.press(`${ctrlOrCmd(page)}+A`); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +

    First paragraph

    +
    +
    +
    +
    +

    Second paragraph

    + `, {ignoreCardContents: true}); + }); + + test('toggles all text when text+audio cards are selected', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); + + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await insertCard(page, {cardName: 'audio'}) + ]); + await fileChooser.setFiles([filePath]); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Second paragraph'); + await page.keyboard.press(`${ctrlOrCmd(page)}+A`); + + const buttonSelector = `[data-kg-floating-toolbar] [data-kg-toolbar-button="h2"] button`; + await page.click(buttonSelector); + + await assertHTML(page, html` +

    First paragraph

    +
    +
    +
    +
    +

    Second paragraph

    + `, {ignoreCardContents: true}); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/format-shortcuts.test.js b/packages/koenig-lexical/test/e2e/format-shortcuts.test.js deleted file mode 100644 index 74d7e87a2e..0000000000 --- a/packages/koenig-lexical/test/e2e/format-shortcuts.test.js +++ /dev/null @@ -1,219 +0,0 @@ -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, selectBackwards} from '../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Editor keyboard shortcuts', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('text formatting shortcuts', function () { - test('bold', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd(page)}+B`); - - await assertHTML(page, html`

    test

    `); - }); - - test('italic', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd(page)}+I`); - - await assertHTML(page, html`

    test

    `); - }); - - test('strikethrough', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd(page)}+Alt+U`); - - await assertHTML(page, html`

    test

    `); - }); - - test('link', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd(page)}+K`); - - await page.waitForSelector('[data-testid="link-input"]'); - await page.getByTestId('link-input').fill('https://example.com'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    - - test - -

    `); - }); - - test('inline code', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`Control+Shift+K`); - - await assertHTML(page, html`

    test

    `); - }); - - test('highlight', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await selectBackwards(page, 4); - - await page.keyboard.press(`${ctrlOrCmd(page)}+Alt+H`); - - await assertHTML(page, html`

    test

    `); - }); - }); - - test('quotes', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - // paragraph -> blockquote - await page.keyboard.press('Control+q'); - - await assertHTML(page, html` -
    - test -
    - `); - - // blockquote -> aside - await page.keyboard.press('Control+q'); - - await assertHTML(page, html` - - `); - - // aside -> paragraph - await page.keyboard.press('Control+q'); - - await assertHTML(page, html` -

    test

    - `); - }); - - test('specific heading', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - - await page.keyboard.press(`Control+Alt+1`); - - await assertHTML(page, html` -

    test

    - `); - - await page.keyboard.press(`Control+Alt+2`); - - await assertHTML(page, html` -

    test

    - `); - - await page.keyboard.press(`Control+Alt+3`); - - await assertHTML(page, html` -

    test

    - `); - - await page.keyboard.press(`Control+Alt+4`); - - await assertHTML(page, html` -

    test

    - `); - - await page.keyboard.press(`Control+Alt+5`); - - await assertHTML(page, html` -
    test
    - `); - - await page.keyboard.press(`Control+Alt+6`); - - await assertHTML(page, html` -
    test
    - `); - - // higher levels are ignored - await page.keyboard.press(`Control+Alt+7`); - - await assertHTML(page, html` -
    test
    - `); - }); - - test('unordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - await page.keyboard.press('Control+l'); - - await assertHTML(page, html` -
      -
    • test
    • -
    - `); - - await page.keyboard.press('Control+l'); - - await assertHTML(page, html` -

    test

    - `); - }); - - test('ordered list', async function () { - await focusEditor(page); - - await page.keyboard.type('test'); - await page.keyboard.press('Control+Alt+l'); - - await assertHTML(page, html` -
      -
    1. test
    2. -
    - `); - - await page.keyboard.press('Control+l'); - - await assertHTML(page, html` -

    test

    - `); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/format-shortcuts.test.ts b/packages/koenig-lexical/test/e2e/format-shortcuts.test.ts new file mode 100644 index 0000000000..66fe0eb300 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/format-shortcuts.test.ts @@ -0,0 +1,220 @@ +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, selectBackwards} from '../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Editor keyboard shortcuts', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('text formatting shortcuts', function () { + test('bold', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd(page)}+B`); + + await assertHTML(page, html`

    test

    `); + }); + + test('italic', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd(page)}+I`); + + await assertHTML(page, html`

    test

    `); + }); + + test('strikethrough', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd(page)}+Alt+U`); + + await assertHTML(page, html`

    test

    `); + }); + + test('link', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd(page)}+K`); + + await page.waitForSelector('[data-testid="link-input"]'); + await page.getByTestId('link-input').fill('https://example.com'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    + + test + +

    `); + }); + + test('inline code', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`Control+Shift+K`); + + await assertHTML(page, html`

    test

    `); + }); + + test('highlight', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await selectBackwards(page, 4); + + await page.keyboard.press(`${ctrlOrCmd(page)}+Alt+H`); + + await assertHTML(page, html`

    test

    `); + }); + }); + + test('quotes', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + // paragraph -> blockquote + await page.keyboard.press('Control+q'); + + await assertHTML(page, html` +
    + test +
    + `); + + // blockquote -> aside + await page.keyboard.press('Control+q'); + + await assertHTML(page, html` + + `); + + // aside -> paragraph + await page.keyboard.press('Control+q'); + + await assertHTML(page, html` +

    test

    + `); + }); + + test('specific heading', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + + await page.keyboard.press(`Control+Alt+1`); + + await assertHTML(page, html` +

    test

    + `); + + await page.keyboard.press(`Control+Alt+2`); + + await assertHTML(page, html` +

    test

    + `); + + await page.keyboard.press(`Control+Alt+3`); + + await assertHTML(page, html` +

    test

    + `); + + await page.keyboard.press(`Control+Alt+4`); + + await assertHTML(page, html` +

    test

    + `); + + await page.keyboard.press(`Control+Alt+5`); + + await assertHTML(page, html` +
    test
    + `); + + await page.keyboard.press(`Control+Alt+6`); + + await assertHTML(page, html` +
    test
    + `); + + // higher levels are ignored + await page.keyboard.press(`Control+Alt+7`); + + await assertHTML(page, html` +
    test
    + `); + }); + + test('unordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + await page.keyboard.press('Control+l'); + + await assertHTML(page, html` +
      +
    • test
    • +
    + `); + + await page.keyboard.press('Control+l'); + + await assertHTML(page, html` +

    test

    + `); + }); + + test('ordered list', async function () { + await focusEditor(page); + + await page.keyboard.type('test'); + await page.keyboard.press('Control+Alt+l'); + + await assertHTML(page, html` +
      +
    1. test
    2. +
    + `); + + await page.keyboard.press('Control+l'); + + await assertHTML(page, html` +

    test

    + `); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/linking.test.js b/packages/koenig-lexical/test/e2e/linking.test.js deleted file mode 100644 index 1ae9ad78f6..0000000000 --- a/packages/koenig-lexical/test/e2e/linking.test.js +++ /dev/null @@ -1,210 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, pasteText} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Linking', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - // searchLinks (and therefore internal linking) is provided by default, - // can be turned off with /#/?searchLinks=false - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('with toolbar', function () { - test.fixme('can type custom link', async function () {}); - test.fixme('can paste custom link', async function () {}); - test.fixme('can insert a default link', async function () {}); - test.fixme('can insert a searched link', async function () {}); - test.fixme('can edit a link', async function () {}); - test.fixme('can remove a link', async function () {}); - }); - - test.describe('with @-link', function () { - test('displays default links with no query', async function () { - await focusEditor(page); - await page.keyboard.type('@'); - - await assertHTML(page, html` -

    - - - - - -

    - `); - - await assertHTML(page, html` -
    -
    -
    -
      -
    • Latest posts
    • -
    • - Remote Work's Impact on Job Markets and Employment - - - 8 May 2024 - -
    • -
    • - Robotics Renaissance: How Automation is Transforming Industries -
    • -
    • - Biodiversity Conservation in Fragile Ecosystems -
    • -
    • - Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment -
    • -
    -
    -
    -
    - `, {selector: '[data-testid="at-link-popup"]'}); - }); - - test('can search for links', async function () { - await focusEditor(page); - await page.keyboard.type('@'); - await page.keyboard.type('Emo'); - - await assertHTML(page, html` -

    - - - - Emo - -

    - `); - - // wait for search to complete - await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); - - await assertHTML(page, html` -
    -
    -
    -
      -
    • Posts
    • -
    • - Emoji autocomplete ✨ -
    • -
    -
    -
    -
    - `, {selector: '[data-testid="at-link-popup"]'}); - }); - - test('has custom no result options', async function () { - await focusEditor(page); - await page.keyboard.type('@'); - await page.keyboard.type('Unknown page'); - - await expect(page.locator('[data-testid="at-link-popup"]')).toContainText('No results found'); - - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    @Unknown page

    - `); - }); - - test('removes at-linking when backspacing', async function () { - await focusEditor(page); - await page.keyboard.type('@'); - await page.keyboard.type('AB'); - - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - // we should now have an empty input field with placeholder text - await assertHTML(page, html` -

    - - - - - -

    - `); - - // small wait for DOM to settle before backspace removes the at-link - await page.waitForTimeout(50); - await page.keyboard.press('Backspace'); - - // it should now remove the at-linking entirely leaving only an @ - await assertHTML(page, html` -

    @

    - `); - }); - - test('creates a bookmark when at-linking from a line', async function () { - await focusEditor(page); - - await page.keyboard.type('@'); - await page.keyboard.type('Emo'); - await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); - await page.keyboard.press('Enter'); - // now wait till data-testid="bookmark-container" appears - await page.waitForSelector('[data-testid="bookmark-container"]'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    Ghost: The Creator Economy Platform
    -
    - The former of the two songs addresses the issue of negative rumors - in a relationship, while the latter, with a more upbeat pulse, is a - classic club track; the single is highlighted by a hyped bridge. -
    -
    - - Ghost - The Professional Publishing Platform - Author McAuthory -
    -
    -
    -
    -
    -
    -
    -
    -
    - `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); - }); - - test('can paste into at-link node', async function () { - await focusEditor(page); - await page.keyboard.type('@'); - await pasteText(page, 'https://ghost.org'); - await expect(page.getByTestId('at-link-results')).toBeVisible(); - - await assertHTML(page, html` -

    - - - - https://ghost.org - -

    - `); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/linking.test.ts b/packages/koenig-lexical/test/e2e/linking.test.ts new file mode 100644 index 0000000000..7b2005db6a --- /dev/null +++ b/packages/koenig-lexical/test/e2e/linking.test.ts @@ -0,0 +1,211 @@ +import {assertHTML, focusEditor, html, initialize, pasteText} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Linking', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + // searchLinks (and therefore internal linking) is provided by default, + // can be turned off with /#/?searchLinks=false + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('with toolbar', function () { + test.fixme('can type custom link', async function () {}); + test.fixme('can paste custom link', async function () {}); + test.fixme('can insert a default link', async function () {}); + test.fixme('can insert a searched link', async function () {}); + test.fixme('can edit a link', async function () {}); + test.fixme('can remove a link', async function () {}); + }); + + test.describe('with @-link', function () { + test('displays default links with no query', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + + await assertHTML(page, html` +

    + + + + + +

    + `); + + await assertHTML(page, html` +
    +
    +
    +
      +
    • Latest posts
    • +
    • + Remote Work's Impact on Job Markets and Employment + + + 8 May 2024 + +
    • +
    • + Robotics Renaissance: How Automation is Transforming Industries +
    • +
    • + Biodiversity Conservation in Fragile Ecosystems +
    • +
    • + Unveiling the Crisis of Plastic Pollution: Analyzing Its Profound Impact on the Environment +
    • +
    +
    +
    +
    + `, {selector: '[data-testid="at-link-popup"]'}); + }); + + test('can search for links', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + await page.keyboard.type('Emo'); + + await assertHTML(page, html` +

    + + + + Emo + +

    + `); + + // wait for search to complete + await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); + + await assertHTML(page, html` +
    +
    +
    +
      +
    • Posts
    • +
    • + Emoji autocomplete ✨ +
    • +
    +
    +
    +
    + `, {selector: '[data-testid="at-link-popup"]'}); + }); + + test('has custom no result options', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + await page.keyboard.type('Unknown page'); + + await expect(page.locator('[data-testid="at-link-popup"]')).toContainText('No results found'); + + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    @Unknown page

    + `); + }); + + test('removes at-linking when backspacing', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + await page.keyboard.type('AB'); + + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + // we should now have an empty input field with placeholder text + await assertHTML(page, html` +

    + + + + + +

    + `); + + // small wait for DOM to settle before backspace removes the at-link + await page.waitForTimeout(50); + await page.keyboard.press('Backspace'); + + // it should now remove the at-linking entirely leaving only an @ + await assertHTML(page, html` +

    @

    + `); + }); + + test('creates a bookmark when at-linking from a line', async function () { + await focusEditor(page); + + await page.keyboard.type('@'); + await page.keyboard.type('Emo'); + await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); + await page.keyboard.press('Enter'); + // now wait till data-testid="bookmark-container" appears + await page.waitForSelector('[data-testid="bookmark-container"]'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    Ghost: The Creator Economy Platform
    +
    + The former of the two songs addresses the issue of negative rumors + in a relationship, while the latter, with a more upbeat pulse, is a + classic club track; the single is highlighted by a hyped bridge. +
    +
    + + Ghost - The Professional Publishing Platform + Author McAuthory +
    +
    +
    +
    +
    +
    +
    +
    +
    + `, {ignoreCardToolbarContents: true, ignoreInnerSVG: true}); + }); + + test('can paste into at-link node', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + await pasteText(page, 'https://ghost.org'); + await expect(page.getByTestId('at-link-results')).toBeVisible(); + + await assertHTML(page, html` +

    + + + + https://ghost.org + +

    + `); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/list-behaviour.test.js b/packages/koenig-lexical/test/e2e/list-behaviour.test.js deleted file mode 100644 index eb827d32de..0000000000 --- a/packages/koenig-lexical/test/e2e/list-behaviour.test.js +++ /dev/null @@ -1,320 +0,0 @@ -import {assertHTML, assertSelection, focusEditor, html, initialize} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('List behaviour', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('BACKSPACE', function () { - test('at beginning of populated list item after paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('Paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('- first li'); - await page.keyboard.press('Enter'); - await page.keyboard.type('second li'); - - // sanity check - contents are as we expect - await assertHTML(page, html` -

    Paragraph

    -
      -
    • first li
    • -
    • second li
    • -
    - `); - - await page.keyboard.press('ArrowUp'); - for (let i = 0; i < 'first li'.length; i++) { - await page.keyboard.press('ArrowLeft'); - } - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - // sanity check - cursor is at beginning of list - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1,0,0,0], - focusOffset: 0, - focusPath: [1,0,0,0] - }); - - // should convert list item to a paragraph - await page.keyboard.press('Backspace'); - - // first list item converted to a paragraph - await assertHTML(page, html` -

    Paragraph

    -

    first li

    -
      -
    • second li
    • -
    - `); - - // selection is at beginning of li->p paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1,0,0], - focusOffset: 0, - focusPath: [1,0,0] - }); - - // pressing again reverts to default Lexical behaviour of smushing paragraphs - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    Paragraphfirst li

    -
      -
    • second li
    • -
    - `); - }); - - test('at beginning of populated list after card', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - await page.keyboard.type('- first li'); - await page.keyboard.press('Enter'); - await page.keyboard.type('second li'); - - // sanity check - contents are as we expect - await assertHTML(page, html` -
    -

    -
    -
      -
    • first li
    • -
    • second li
    • -
    - `); - - await page.keyboard.press('ArrowUp'); - for (let i = 0; i < 'first li'.length; i++) { - await page.keyboard.press('ArrowLeft'); - } - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - // sanity check - cursor is at beginning of list - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1,0,0,0], - focusOffset: 0, - focusPath: [1,0,0,0] - }); - - // should convert list item to a paragraph - await page.keyboard.press('Backspace'); - - // first list item converted to a paragraph - await assertHTML(page, html` -
    -

    -
    -

    first li

    -
      -
    • second li
    • -
    - `); - }); - - test('at beginning of populated list-item mid list', async function () { - await focusEditor(page); - await page.keyboard.type('- first li'); - await page.keyboard.press('Enter'); - await page.keyboard.type('second li'); - await page.keyboard.press('Enter'); - await page.keyboard.type('third li'); - - for (let i = 0; i < 'third li'.length; i++) { - await page.keyboard.press('ArrowLeft'); - } - await page.keyboard.press('ArrowUp'); - // Wait for selection to be registered in Chrome for Testing - await page.waitForTimeout(50); - - // sanity check - cursor is at beginning of second list item - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0,1,0,0], - focusOffset: 0, - focusPath: [0,1,0,0] - }); - - // should split list converting second li to paragraph - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
      -
    • first li
    • -
    -

    second li

    -
      -
    • third li
    • -
    - `); - - // selection is at beginning of the converted paragraph - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1,0,0], - focusOffset: 0, - focusPath: [1,0,0] - }); - }); - - test('on empty list item after paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('- '); - - // sanity check - await assertHTML(page, html` -

    First paragraph

    -
      -

    • -
    - `); - - // should convert list to a paragraph - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -

    First paragraph

    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - - test('on empty list item at end of list', async function () { - await focusEditor(page); - await page.keyboard.type('- first li'); - await page.keyboard.press('Enter'); - - // should convert last list item to empty paragraph - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
      -
    • first li
    • -
    -


    - `); - }); - }); - - test.describe('TAB', function () { - test('indents on tab', async function () { - await focusEditor(page); - await page.keyboard.type('*'); - await page.keyboard.press('Space'); - await page.keyboard.type('list item'); - await page.keyboard.press('Tab'); - - await assertHTML(page, html` -
      -
    • -
        -
      • - list item -
      • -
      -
    • -
    - `); - }); - - test('dedents on shift+tab as long as indent is > 0', async function () { - await focusEditor(page); - await page.keyboard.type('*'); - await page.keyboard.press('Space'); - await page.keyboard.type('list item'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Shift+Tab'); - - await assertHTML(page, html` -
      -
    • -
        -
      • - list item -
      • -
      -
    • -
    - `); - }); - - test('shift+tab moves to header if indent is 0', async function () { - await focusEditor(page); - await page.keyboard.type('*'); - await page.keyboard.press('Space'); - await page.keyboard.type('list item'); - await page.keyboard.press('Shift+Tab'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - }); - - test.describe('Merging', function () { - test('merges two ULs after deleting a separating paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('- one'); - await page.keyboard.press('Enter'); - await page.keyboard.type('two'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
      -
    • one
    • -
    • two
    • -
    - `); - }); - - test('does not merge two lists of different types after deleting separating paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('- ul one'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.type('1. ol one'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); - - await assertHTML(page, html` -
      -
    • ul one
    • -
    -
      -
    1. ol one
    2. -
    - `); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/list-behaviour.test.ts b/packages/koenig-lexical/test/e2e/list-behaviour.test.ts new file mode 100644 index 0000000000..cadd5998ab --- /dev/null +++ b/packages/koenig-lexical/test/e2e/list-behaviour.test.ts @@ -0,0 +1,321 @@ +import {assertHTML, assertSelection, focusEditor, html, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('List behaviour', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('BACKSPACE', function () { + test('at beginning of populated list item after paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('Paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('- first li'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second li'); + + // sanity check - contents are as we expect + await assertHTML(page, html` +

    Paragraph

    +
      +
    • first li
    • +
    • second li
    • +
    + `); + + await page.keyboard.press('ArrowUp'); + for (let i = 0; i < 'first li'.length; i++) { + await page.keyboard.press('ArrowLeft'); + } + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + // sanity check - cursor is at beginning of list + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1,0,0,0], + focusOffset: 0, + focusPath: [1,0,0,0] + }); + + // should convert list item to a paragraph + await page.keyboard.press('Backspace'); + + // first list item converted to a paragraph + await assertHTML(page, html` +

    Paragraph

    +

    first li

    +
      +
    • second li
    • +
    + `); + + // selection is at beginning of li->p paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1,0,0], + focusOffset: 0, + focusPath: [1,0,0] + }); + + // pressing again reverts to default Lexical behaviour of smushing paragraphs + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    Paragraphfirst li

    +
      +
    • second li
    • +
    + `); + }); + + test('at beginning of populated list after card', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + await page.keyboard.type('- first li'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second li'); + + // sanity check - contents are as we expect + await assertHTML(page, html` +
    +

    +
    +
      +
    • first li
    • +
    • second li
    • +
    + `); + + await page.keyboard.press('ArrowUp'); + for (let i = 0; i < 'first li'.length; i++) { + await page.keyboard.press('ArrowLeft'); + } + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + // sanity check - cursor is at beginning of list + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1,0,0,0], + focusOffset: 0, + focusPath: [1,0,0,0] + }); + + // should convert list item to a paragraph + await page.keyboard.press('Backspace'); + + // first list item converted to a paragraph + await assertHTML(page, html` +
    +

    +
    +

    first li

    +
      +
    • second li
    • +
    + `); + }); + + test('at beginning of populated list-item mid list', async function () { + await focusEditor(page); + await page.keyboard.type('- first li'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second li'); + await page.keyboard.press('Enter'); + await page.keyboard.type('third li'); + + for (let i = 0; i < 'third li'.length; i++) { + await page.keyboard.press('ArrowLeft'); + } + await page.keyboard.press('ArrowUp'); + // Wait for selection to be registered in Chrome for Testing + await page.waitForTimeout(50); + + // sanity check - cursor is at beginning of second list item + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0,1,0,0], + focusOffset: 0, + focusPath: [0,1,0,0] + }); + + // should split list converting second li to paragraph + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
      +
    • first li
    • +
    +

    second li

    +
      +
    • third li
    • +
    + `); + + // selection is at beginning of the converted paragraph + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1,0,0], + focusOffset: 0, + focusPath: [1,0,0] + }); + }); + + test('on empty list item after paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('- '); + + // sanity check + await assertHTML(page, html` +

    First paragraph

    +
      +

    • +
    + `); + + // should convert list to a paragraph + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +

    First paragraph

    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + + test('on empty list item at end of list', async function () { + await focusEditor(page); + await page.keyboard.type('- first li'); + await page.keyboard.press('Enter'); + + // should convert last list item to empty paragraph + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
      +
    • first li
    • +
    +


    + `); + }); + }); + + test.describe('TAB', function () { + test('indents on tab', async function () { + await focusEditor(page); + await page.keyboard.type('*'); + await page.keyboard.press('Space'); + await page.keyboard.type('list item'); + await page.keyboard.press('Tab'); + + await assertHTML(page, html` +
      +
    • +
        +
      • + list item +
      • +
      +
    • +
    + `); + }); + + test('dedents on shift+tab as long as indent is > 0', async function () { + await focusEditor(page); + await page.keyboard.type('*'); + await page.keyboard.press('Space'); + await page.keyboard.type('list item'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + + await assertHTML(page, html` +
      +
    • +
        +
      • + list item +
      • +
      +
    • +
    + `); + }); + + test('shift+tab moves to header if indent is 0', async function () { + await focusEditor(page); + await page.keyboard.type('*'); + await page.keyboard.press('Space'); + await page.keyboard.type('list item'); + await page.keyboard.press('Shift+Tab'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + }); + + test.describe('Merging', function () { + test('merges two ULs after deleting a separating paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('- one'); + await page.keyboard.press('Enter'); + await page.keyboard.type('two'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
      +
    • one
    • +
    • two
    • +
    + `); + }); + + test('does not merge two lists of different types after deleting separating paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('- ul one'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type('1. ol one'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + + await assertHTML(page, html` +
      +
    • ul one
    • +
    +
      +
    1. ol one
    2. +
    + `); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.js b/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.js deleted file mode 100644 index 0a71a6e2f2..0000000000 --- a/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.js +++ /dev/null @@ -1,167 +0,0 @@ -// TODO: Switch to mocked API. Currently uses real Unsplash API so the asserted test data isn't stable -import {expect, test} from '@playwright/test'; -import {focusEditor, initialize} from '../../utils/e2e'; - -test.describe('Modals', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.skip('can open selector', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - }); - - test.skip('renders (empty) image card under container', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await expect(page.locator('[data-kg-card="image"]')).toBeVisible(); - }); - - test.skip('can close selector', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.click('[data-kg-modal-close-button]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); - }); - - test.skip('empty image card removed when closing model', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.click('[data-kg-modal-close-button]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); - await expect(page.locator('[data-kg-card="image"]')).not.toBeVisible(); - }); - - test.skip('renders unsplash gallery', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - }); - - test.skip('can select / zoom image', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - await page.click('[data-kg-unsplash-gallery-item]'); - expect(await page.locator('[data-kg-unsplash-zoomed]')).not.toBeNull(); - }); - - test.skip('can download image', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - await page.click('[data-kg-unsplash-gallery-item]'); - const [download] = await Promise.all([ - page.waitForEvent('download'), - page.click('[data-kg-button="unsplash-download"]') - ]); - expect(download.suggestedFilename()).toContain('unsplash.jpg'); - }); - - test.skip('can click like button, opens in new tab', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - await page.click('[data-kg-unsplash-gallery-item]'); - await expect(page.locator('[data-kg-button="unsplash-like"]')).toBeVisible(); - const [newPage] = await Promise.all([ - page.waitForEvent('popup'), - page.click('[data-kg-button="unsplash-like"]') - ]); - expect(newPage.url()).toContain('unsplash.com'); - }); - - test.skip('can search for photos', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - await expect(page.locator('[data-kg-unsplash-search]')).toBeVisible(); - const searchTerm = 'kitten'; - await page.click('[data-kg-unsplash-search]'); - await page.type('[data-kg-unsplash-search]', searchTerm); - // wait 5 seconds for search results - await page.waitForTimeout(5000); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - const images = await page.$$('img[data-kg-unsplash-gallery-img]'); - const altTexts = await Promise.all(images.map(img => img.getAttribute('alt'))); - expect(altTexts.some(alt => alt.includes(searchTerm))).toBe(true); - }); - - test.skip('closes a zoomed image when searching', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery]'); - await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - await page.click('[data-kg-unsplash-gallery-item]'); - await expect(page.locator('[data-kg-unsplash-zoomed]')).toBeVisible(); - await expect(page.locator('[data-kg-unsplash-search]')).toBeVisible(); - const searchTerm = 'kitten'; - await page.click('[data-kg-unsplash-search]'); - await page.type('[data-kg-unsplash-search]', searchTerm); - // wait 5 seconds for search results - await page.waitForTimeout(5000); - await page.waitForSelector('[data-kg-unsplash-gallery-item]'); - await expect(page.locator('[data-kg-unsplash-zoomed]')).not.toBeVisible(); - }); - - test('can close modal with escape key', async () => { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await page.click('button[data-kg-card-menu-item="Unsplash"]'); - await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); - }); - - //test.fixme('can infinite scroll'); -}); diff --git a/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.ts b/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.ts new file mode 100644 index 0000000000..ee7eeb9b9b --- /dev/null +++ b/packages/koenig-lexical/test/e2e/modals/UnsplashSelector.test.ts @@ -0,0 +1,168 @@ +// TODO: Switch to mocked API. Currently uses real Unsplash API so the asserted test data isn't stable +import {expect, test} from '@playwright/test'; +import {focusEditor, initialize} from '../../utils/e2e'; +import type {Page} from '@playwright/test'; + +test.describe('Modals', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.skip('can open selector', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + }); + + test.skip('renders (empty) image card under container', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await expect(page.locator('[data-kg-card="image"]')).toBeVisible(); + }); + + test.skip('can close selector', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.click('[data-kg-modal-close-button]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); + }); + + test.skip('empty image card removed when closing model', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.click('[data-kg-modal-close-button]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); + await expect(page.locator('[data-kg-card="image"]')).not.toBeVisible(); + }); + + test.skip('renders unsplash gallery', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + }); + + test.skip('can select / zoom image', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + await page.click('[data-kg-unsplash-gallery-item]'); + expect(await page.locator('[data-kg-unsplash-zoomed]')).not.toBeNull(); + }); + + test.skip('can download image', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + await page.click('[data-kg-unsplash-gallery-item]'); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('[data-kg-button="unsplash-download"]') + ]); + expect(download.suggestedFilename()).toContain('unsplash.jpg'); + }); + + test.skip('can click like button, opens in new tab', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + await page.click('[data-kg-unsplash-gallery-item]'); + await expect(page.locator('[data-kg-button="unsplash-like"]')).toBeVisible(); + const [newPage] = await Promise.all([ + page.waitForEvent('popup'), + page.click('[data-kg-button="unsplash-like"]') + ]); + expect(newPage.url()).toContain('unsplash.com'); + }); + + test.skip('can search for photos', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + await expect(page.locator('[data-kg-unsplash-search]')).toBeVisible(); + const searchTerm = 'kitten'; + await page.click('[data-kg-unsplash-search]'); + await page.type('[data-kg-unsplash-search]', searchTerm); + // wait 5 seconds for search results + await page.waitForTimeout(5000); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + const images = await page.$$('img[data-kg-unsplash-gallery-img]'); + const altTexts = await Promise.all(images.map(img => img.getAttribute('alt'))); + expect(altTexts.some(alt => alt!.includes(searchTerm))).toBe(true); + }); + + test.skip('closes a zoomed image when searching', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await expect(page.locator('[data-kg-plus-menu]')).toBeVisible(); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery]'); + await expect(page.locator('[data-kg-unsplash-gallery]')).toBeVisible(); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + await page.click('[data-kg-unsplash-gallery-item]'); + await expect(page.locator('[data-kg-unsplash-zoomed]')).toBeVisible(); + await expect(page.locator('[data-kg-unsplash-search]')).toBeVisible(); + const searchTerm = 'kitten'; + await page.click('[data-kg-unsplash-search]'); + await page.type('[data-kg-unsplash-search]', searchTerm); + // wait 5 seconds for search results + await page.waitForTimeout(5000); + await page.waitForSelector('[data-kg-unsplash-gallery-item]'); + await expect(page.locator('[data-kg-unsplash-zoomed]')).not.toBeVisible(); + }); + + test('can close modal with escape key', async () => { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await page.click('button[data-kg-card-menu-item="Unsplash"]'); + await expect(page.locator('[data-kg-modal="unsplash"]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-modal="unsplash"]')).not.toBeVisible(); + }); + + //test.fixme('can infinite scroll'); +}); diff --git a/packages/koenig-lexical/test/e2e/node-transforms.test.js b/packages/koenig-lexical/test/e2e/node-transforms.test.js deleted file mode 100644 index 259dcdbcd3..0000000000 --- a/packages/koenig-lexical/test/e2e/node-transforms.test.js +++ /dev/null @@ -1,146 +0,0 @@ -import {assertHTML, html, initialize} from '../utils/e2e'; -// import {calloutColorPicker} from '../../../src/components/ui/cards/CalloutCardx'; -import {test} from '@playwright/test'; - -test.describe('Node transforms', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('nested elements in paragraph nodes 1', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [ - { - children: [ - { - children: [ - { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Hello Fintech Friends,', - type: 'text', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }, - { - type: 'horizontalrule', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - await assertHTML(page, html` -

    -

    Hello Fintech Friends,

    -
    -
    -
    -
    -

    - `, {ignoreCardContents: true}); - }); - - test('nested elements in paragraph nodes 2', async function () { - await page.evaluate(() => { - const serializedState = JSON.stringify({ - root: { - children: [ - { - children: [ - { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Hello Fintech Friends,', - type: 'text', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }, - { - children: [ - { - children: [], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }, - { - type: 'horizontalrule', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'root', - version: 1 - } - }); - const editor = window.lexicalEditor; - const editorState = editor.parseEditorState(serializedState); - editor.setEditorState(editorState); - }); - await assertHTML(page, html` -

    Hello Fintech Friends,

    -

    -


    -
    -
    -
    -
    -

    - `, {ignoreCardContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/node-transforms.test.ts b/packages/koenig-lexical/test/e2e/node-transforms.test.ts new file mode 100644 index 0000000000..18a55b6708 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/node-transforms.test.ts @@ -0,0 +1,147 @@ +import {assertHTML, html, initialize} from '../utils/e2e'; +// import {calloutColorPicker} from '../../../src/components/ui/cards/CalloutCardx'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Node transforms', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('nested elements in paragraph nodes 1', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [ + { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Hello Fintech Friends,', + type: 'text', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }, + { + type: 'horizontalrule', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + await assertHTML(page, html` +

    +

    Hello Fintech Friends,

    +
    +
    +
    +
    +

    + `, {ignoreCardContents: true}); + }); + + test('nested elements in paragraph nodes 2', async function () { + await page.evaluate(() => { + const serializedState = JSON.stringify({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Hello Fintech Friends,', + type: 'text', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }, + { + children: [ + { + children: [], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }, + { + type: 'horizontalrule', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const editor = window.lexicalEditor; + const editorState = editor.parseEditorState(serializedState); + editor.setEditorState(editorState); + }); + await assertHTML(page, html` +

    Hello Fintech Friends,

    +

    +


    +
    +
    +
    +
    +

    + `, {ignoreCardContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/paste-behaviour.test.js b/packages/koenig-lexical/test/e2e/paste-behaviour.test.js deleted file mode 100644 index df320d6268..0000000000 --- a/packages/koenig-lexical/test/e2e/paste-behaviour.test.js +++ /dev/null @@ -1,622 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, paste, pasteFiles, pasteFilesWithText, pasteHtml, pasteText, selectBackwards} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Paste behaviour', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('Text', function () { - test('converts line breaks to paragraphs', async function () { - await focusEditor(page); - await pasteText(page, 'One\n\nTwo\n\nThree'); - await assertHTML(page, html` -

    One

    -

    Two

    -

    Three

    - `); - }); - }); - - test.describe('URLs', function () { - test('pasted at start of populated paragraph creates a link', async function () { - await focusEditor(page); - await page.keyboard.type('1 2'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Space'); - - await pasteText(page, 'https://ghost.org'); - - await assertHTML(page, html` -

    - - - https://ghost.org - - 1 2 -

    - `); - }); - - test('pasted mid populated paragraph creates a link', async function () { - await focusEditor(page); - await page.keyboard.type('1 2'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('Space'); - - await pasteText(page, 'https://ghost.org'); - - await assertHTML(page, html` -

    - 1 - - https://ghost.org - - 2 -

    - `); - }); - - test('pasted at end of populated paragraph creates a link', async function () { - await focusEditor(page); - await page.keyboard.type('1 2 '); - await pasteText(page, 'https://ghost.org'); - - await assertHTML(page, html` -

    - 1 2 - - https://ghost.org - -

    - `); - }); - - test('pasted on selected text converts to link', async function () { - await focusEditor(page); - await page.keyboard.type('1 test'); - await selectBackwards(page, 4); - await pasteText(page, 'https://ghost.org'); - - await assertHTML(page, html` -

    - 1 - - test - -

    - `); - }); - - test('pasted on selected text containing formats converts to link', async function () { - await focusEditor(page); - await page.keyboard.type('Text with '); - await page.keyboard.press(`${ctrlOrCmd(page)}+B`); - await page.keyboard.type('bold'); - await page.keyboard.press(`${ctrlOrCmd(page)}+B`); - await page.keyboard.type(' and '); - await page.keyboard.press(`${ctrlOrCmd(page)}+I`); - await page.keyboard.type('italic'); - await page.keyboard.press(`${ctrlOrCmd(page)}+I`); - await page.keyboard.type(' text.'); - - await assertHTML(page, html` -

    - Text with - bold - and - italic - text. -

    - `); - - await page.keyboard.press(`${ctrlOrCmd(page)}+A`); - await pasteText(page, 'https://ghost.org'); - - await assertHTML(page, html` -

    - - Text with - bold - and - italic - text. - -

    - `); - }); - - test('pasted on selected text within a nested editor converts to link', async function () { - await focusEditor(page); - await page.keyboard.type('/callout', {delay: 10}); - await page.keyboard.press('Enter'); - await page.keyboard.type('1 test'); - await selectBackwards(page, 4); - await pasteText(page, 'https://ghost.org'); - await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); // exit edit mode - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -

    - 1 - - test - -

    -
    -
    -
    -
    -
    -
    -
      -
    • - -
      Edit
      -
    • -
    • -
    • - -
      Save as snippet
      -
    • -
    -
    -
    -
    -


    - `); - }); - - test('pasted on blank paragraph creates embed/bookmark', async function () { - await focusEditor(page); - await pasteText(page, 'https://ghost.org/'); - await expect(page.getByTestId('embed-url-loading-container')).toBeVisible(); - await expect(page.getByTestId('embed-url-loading-container')).toBeHidden(); - await expect(page.getByTestId('embed-iframe')).toBeVisible(); - }); - - test('pasted on blank paragraph with shift creates a link', async function () { - await focusEditor(page); - await page.keyboard.down('Shift'); - await pasteText(page, 'https://ghost.org/'); - await page.keyboard.up('Shift'); - - await assertHTML(page, html` -

    - - https://ghost.org/ - -

    - `); - }); - - test('pasted on a card shortcut avoids conversion', async function () { - await focusEditor(page); - await page.keyboard.type('/embed '); - await pasteText(page, 'https://ghost.org/'); - - await assertHTML(page, html` -

    - /embed https://ghost.org/ -

    - `); - - // wait for React to flush slash menu state update with commandParams - await page.waitForTimeout(50); - await page.keyboard.press('Enter'); - - // loading container may be too transient to catch, go straight to end state - await expect(page.getByTestId('embed-iframe')).toBeVisible({timeout: 30000}); - }); - }); - - test.describe('Styles', function () { - test('text alignment styles are stripped from paragraphs on paste', async function () { - await focusEditor(page); - await pasteHtml(page, '

    Testing

    '); - - await assertHTML(page, html` -

    - Testing -

    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - - test('text alignment styles are stripped from headings on paste', async function () { - await focusEditor(page); - await pasteHtml(page, '

    Testing

    '); - - await assertHTML(page, html` -

    Testing

    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - - test('text alignment styles are stripped from quotes on paste', async function () { - await focusEditor(page); - await pasteHtml(page, '
    Testing
    '); - - await assertHTML(page, html` -
    Testing
    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - - test('text alignment styles are not copied over for lists on paste', async function () { - await focusEditor(page); - await pasteHtml(page, '
    • Testing
    '); - - await assertHTML(page, html` -
      -
    • Testing
    • -
    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - - test('text format styles are not copied over on paste', async function () { - await focusEditor(page); - await pasteHtml(page, '

    Testing

    '); - - await assertHTML(page, html` -

    - Testing -

    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - }); - - test.describe('Office.com Word', function () { - test('supports basic text formatting', async function () { - const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/office-com-text-formats.html', 'utf8'); - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - Testing - bold - - italic - underline - - strikethrough - - subscript - supscript - - link - -   -

    -

    - Bold+italic+underline -   -

    -

    - - Bold+italic+link - -   -

    -

    - highlight -   -

    - `, {ignoreClasses: false, ignoreInlineStyles: false}); - }); - - test('supports headings', async function () { - const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/office-com-headings.html', 'utf8'); - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    Heading one 

    -

    Heading two 

    -

    Heading three 

    -

    Heading four 

    - `); - }); - }); - - test.describe('Google Docs', function () { - test('ignores line breaks between paragraphs', async function () { - const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/google-docs-empty-paragraphs.html', 'utf8'); - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - - Start of the article. Here is the 1st paragraph, followed by two line breaks then a paragraph. - -

    -

    - - Here is the 2nd paragraph, followed by a line break then a heading. - -

    -

    Heading 1

    -

    - - Here is the 3rd paragraph, with a line break before and a line break after, followed by a list. - -

    -
      -
    • -
      - List item 1 -
      - -
    • -
    • -
      - List item 2 -
      - -
    • -
    -

    - - Here is the 4th paragraph, with a line break before and a divider after. - -

    -
    -
    -
    -
    -
    -

    - - Here is the 5th paragraph. End of the article. - -

    -


    - `); - }); - }); - - test.describe('Invalid nesting', function () { - // if we have inline elements converting to block elements such as Google Docs - // spans converting to headings then we need to make sure we don't end up with - // invalid nesting in the editor - - test('paragraphs containing Google Docs heading span at start', async function () { - const copiedHtml = `

    Nested heading Text after

    `; - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - Nested heading -

    -

    - Text after -

    - `); - }); - - test('paragraphs containing Google Docs heading span at end', async function () { - const copiedHtml = `

    Paragraph with elementsNested heading

    `; - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - Paragraph - with - - elements -

    -

    - Nested heading -

    - `); - }); - - test('paragraphs containing Google Docs heading span in middle', async function () { - const copiedHtml = `

    Text before Nested heading Text after

    `; - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - Text before -

    -

    - Nested heading -

    -

    - Text after -

    - `); - }); - - test('headings containing Google Docs title span', async function () { - const copiedHtml = `

    Normal H2 Nested Google heading

    `; - - await focusEditor(page); - await pasteHtml(page, copiedHtml); - - await assertHTML(page, html` -

    - Normal H2 -

    -

    - Nested Google heading -

    - `); - }); - }); - - test.describe('Inside cards', function () { - test('pasting inside HTML card CodeMirror editor works', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'html'}); - - await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); - - await paste(page, { - 'text/plain': 'ignore default Lexical behaviour', - 'text/html': '
    ignore default Lexical behaviour
    ' - }); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    ignore default Lexical behaviour
    -
    - - -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: false, ignoreCardSettings: true}); - }); - }); - - test.describe('Files', function () { - test('pastes an .png file as Image card', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); - - await focusEditor(page); - await pasteFiles(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - - const imageCard = await page.locator('[data-kg-card="image"]'); - await expect(imageCard).toHaveCount(1); - }); - - test('pastes an .jpeg file as Image card', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.jpeg'); - - await focusEditor(page); - await pasteFiles(page, [{filePath, fileName: 'large-image.jpeg', fileType: 'image/jpeg'}]); - - const imageCard = await page.locator('[data-kg-card="image"]'); - await expect(imageCard).toHaveCount(1); - }); - - test('pastes an .mp4 file as Video card', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/video.mp4'); - - await focusEditor(page); - await pasteFiles(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); - - const videoCard = await page.locator('[data-kg-card="video"]'); - await expect(videoCard).toHaveCount(1); - }); - - test('does not paste an image file if there is text/html content in the clipboard', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); - const files = [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]; - const textHtml = {'text/html': '

    Some text

    '}; - - await focusEditor(page); - await pasteFilesWithText(page, files, textHtml); - - const text = await page.locator('p').filter({hasText: 'Some text'}); - expect(text).not.toBeNull(); - - const imageCard = await page.locator('[data-kg-card="image"]'); - await expect(imageCard).toHaveCount(0); - }); - - // By default, Lexical dispatches the file paste command (DRAG_DROP_PASTE) only if there is no text content in the clipboard - // We override this behaviour in KoenigBehaviourPlugin > PASTE_COMMAND, to support copy/pasting files from e.g. Slack - test('pastes a image file if the clipboard contains a single image file and text/html with a tag', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); - const files = [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]; - const textHtml = {'text/html': ''}; - - await focusEditor(page); - await pasteFilesWithText(page, files, textHtml); - - const imageCard = await page.locator('[data-kg-card="image"]'); - const imgSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); - - await expect(imageCard).toHaveCount(1); - - // Check that the image src is not coming from the text/html content - expect(imgSrc).not.toContain('https://files.slack.com/foo-bar'); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/paste-behaviour.test.ts b/packages/koenig-lexical/test/e2e/paste-behaviour.test.ts new file mode 100644 index 0000000000..5e14a8066c --- /dev/null +++ b/packages/koenig-lexical/test/e2e/paste-behaviour.test.ts @@ -0,0 +1,623 @@ +import fs from 'fs'; +import path from 'path'; +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, paste, pasteFiles, pasteFilesWithText, pasteHtml, pasteText, selectBackwards} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Paste behaviour', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('Text', function () { + test('converts line breaks to paragraphs', async function () { + await focusEditor(page); + await pasteText(page, 'One\n\nTwo\n\nThree'); + await assertHTML(page, html` +

    One

    +

    Two

    +

    Three

    + `); + }); + }); + + test.describe('URLs', function () { + test('pasted at start of populated paragraph creates a link', async function () { + await focusEditor(page); + await page.keyboard.type('1 2'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Space'); + + await pasteText(page, 'https://ghost.org'); + + await assertHTML(page, html` +

    + + + https://ghost.org + + 1 2 +

    + `); + }); + + test('pasted mid populated paragraph creates a link', async function () { + await focusEditor(page); + await page.keyboard.type('1 2'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Space'); + + await pasteText(page, 'https://ghost.org'); + + await assertHTML(page, html` +

    + 1 + + https://ghost.org + + 2 +

    + `); + }); + + test('pasted at end of populated paragraph creates a link', async function () { + await focusEditor(page); + await page.keyboard.type('1 2 '); + await pasteText(page, 'https://ghost.org'); + + await assertHTML(page, html` +

    + 1 2 + + https://ghost.org + +

    + `); + }); + + test('pasted on selected text converts to link', async function () { + await focusEditor(page); + await page.keyboard.type('1 test'); + await selectBackwards(page, 4); + await pasteText(page, 'https://ghost.org'); + + await assertHTML(page, html` +

    + 1 + + test + +

    + `); + }); + + test('pasted on selected text containing formats converts to link', async function () { + await focusEditor(page); + await page.keyboard.type('Text with '); + await page.keyboard.press(`${ctrlOrCmd(page)}+B`); + await page.keyboard.type('bold'); + await page.keyboard.press(`${ctrlOrCmd(page)}+B`); + await page.keyboard.type(' and '); + await page.keyboard.press(`${ctrlOrCmd(page)}+I`); + await page.keyboard.type('italic'); + await page.keyboard.press(`${ctrlOrCmd(page)}+I`); + await page.keyboard.type(' text.'); + + await assertHTML(page, html` +

    + Text with + bold + and + italic + text. +

    + `); + + await page.keyboard.press(`${ctrlOrCmd(page)}+A`); + await pasteText(page, 'https://ghost.org'); + + await assertHTML(page, html` +

    + + Text with + bold + and + italic + text. + +

    + `); + }); + + test('pasted on selected text within a nested editor converts to link', async function () { + await focusEditor(page); + await page.keyboard.type('/callout', {delay: 10}); + await page.keyboard.press('Enter'); + await page.keyboard.type('1 test'); + await selectBackwards(page, 4); + await pasteText(page, 'https://ghost.org'); + await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); // exit edit mode + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +

    + 1 + + test + +

    +
    +
    +
    +
    +
    +
    +
      +
    • + +
      Edit
      +
    • +
    • +
    • + +
      Save as snippet
      +
    • +
    +
    +
    +
    +


    + `); + }); + + test('pasted on blank paragraph creates embed/bookmark', async function () { + await focusEditor(page); + await pasteText(page, 'https://ghost.org/'); + await expect(page.getByTestId('embed-url-loading-container')).toBeVisible(); + await expect(page.getByTestId('embed-url-loading-container')).toBeHidden(); + await expect(page.getByTestId('embed-iframe')).toBeVisible(); + }); + + test('pasted on blank paragraph with shift creates a link', async function () { + await focusEditor(page); + await page.keyboard.down('Shift'); + await pasteText(page, 'https://ghost.org/'); + await page.keyboard.up('Shift'); + + await assertHTML(page, html` +

    + + https://ghost.org/ + +

    + `); + }); + + test('pasted on a card shortcut avoids conversion', async function () { + await focusEditor(page); + await page.keyboard.type('/embed '); + await pasteText(page, 'https://ghost.org/'); + + await assertHTML(page, html` +

    + /embed https://ghost.org/ +

    + `); + + // wait for React to flush slash menu state update with commandParams + await page.waitForTimeout(50); + await page.keyboard.press('Enter'); + + // loading container may be too transient to catch, go straight to end state + await expect(page.getByTestId('embed-iframe')).toBeVisible({timeout: 30000}); + }); + }); + + test.describe('Styles', function () { + test('text alignment styles are stripped from paragraphs on paste', async function () { + await focusEditor(page); + await pasteHtml(page, '

    Testing

    '); + + await assertHTML(page, html` +

    + Testing +

    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + + test('text alignment styles are stripped from headings on paste', async function () { + await focusEditor(page); + await pasteHtml(page, '

    Testing

    '); + + await assertHTML(page, html` +

    Testing

    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + + test('text alignment styles are stripped from quotes on paste', async function () { + await focusEditor(page); + await pasteHtml(page, '
    Testing
    '); + + await assertHTML(page, html` +
    Testing
    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + + test('text alignment styles are not copied over for lists on paste', async function () { + await focusEditor(page); + await pasteHtml(page, '
    • Testing
    '); + + await assertHTML(page, html` +
      +
    • Testing
    • +
    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + + test('text format styles are not copied over on paste', async function () { + await focusEditor(page); + await pasteHtml(page, '

    Testing

    '); + + await assertHTML(page, html` +

    + Testing +

    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + }); + + test.describe('Office.com Word', function () { + test('supports basic text formatting', async function () { + const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/office-com-text-formats.html', 'utf8'); + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + Testing + bold + + italic + underline + + strikethrough + + subscript + supscript + + link + +   +

    +

    + Bold+italic+underline +   +

    +

    + + Bold+italic+link + +   +

    +

    + highlight +   +

    + `, {ignoreClasses: false, ignoreInlineStyles: false}); + }); + + test('supports headings', async function () { + const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/office-com-headings.html', 'utf8'); + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    Heading one 

    +

    Heading two 

    +

    Heading three 

    +

    Heading four 

    + `); + }); + }); + + test.describe('Google Docs', function () { + test('ignores line breaks between paragraphs', async function () { + const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/google-docs-empty-paragraphs.html', 'utf8'); + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + + Start of the article. Here is the 1st paragraph, followed by two line breaks then a paragraph. + +

    +

    + + Here is the 2nd paragraph, followed by a line break then a heading. + +

    +

    Heading 1

    +

    + + Here is the 3rd paragraph, with a line break before and a line break after, followed by a list. + +

    +
      +
    • +
      + List item 1 +
      + +
    • +
    • +
      + List item 2 +
      + +
    • +
    +

    + + Here is the 4th paragraph, with a line break before and a divider after. + +

    +
    +
    +
    +
    +
    +

    + + Here is the 5th paragraph. End of the article. + +

    +


    + `); + }); + }); + + test.describe('Invalid nesting', function () { + // if we have inline elements converting to block elements such as Google Docs + // spans converting to headings then we need to make sure we don't end up with + // invalid nesting in the editor + + test('paragraphs containing Google Docs heading span at start', async function () { + const copiedHtml = `

    Nested heading Text after

    `; + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + Nested heading +

    +

    + Text after +

    + `); + }); + + test('paragraphs containing Google Docs heading span at end', async function () { + const copiedHtml = `

    Paragraph with elementsNested heading

    `; + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + Paragraph + with + + elements +

    +

    + Nested heading +

    + `); + }); + + test('paragraphs containing Google Docs heading span in middle', async function () { + const copiedHtml = `

    Text before Nested heading Text after

    `; + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + Text before +

    +

    + Nested heading +

    +

    + Text after +

    + `); + }); + + test('headings containing Google Docs title span', async function () { + const copiedHtml = `

    Normal H2 Nested Google heading

    `; + + await focusEditor(page); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +

    + Normal H2 +

    +

    + Nested Google heading +

    + `); + }); + }); + + test.describe('Inside cards', function () { + test('pasting inside HTML card CodeMirror editor works', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'html'}); + + await expect(page.locator('.cm-content[contenteditable="true"]')).toBeVisible(); + + await paste(page, { + 'text/plain': 'ignore default Lexical behaviour', + 'text/html': '
    ignore default Lexical behaviour
    ' + }); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    ignore default Lexical behaviour
    +
    + + +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: false, ignoreCardSettings: true}); + }); + }); + + test.describe('Files', function () { + test('pastes an .png file as Image card', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); + + await focusEditor(page); + await pasteFiles(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + + const imageCard = await page.locator('[data-kg-card="image"]'); + await expect(imageCard).toHaveCount(1); + }); + + test('pastes an .jpeg file as Image card', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.jpeg'); + + await focusEditor(page); + await pasteFiles(page, [{filePath, fileName: 'large-image.jpeg', fileType: 'image/jpeg'}]); + + const imageCard = await page.locator('[data-kg-card="image"]'); + await expect(imageCard).toHaveCount(1); + }); + + test('pastes an .mp4 file as Video card', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/video.mp4'); + + await focusEditor(page); + await pasteFiles(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); + + const videoCard = await page.locator('[data-kg-card="video"]'); + await expect(videoCard).toHaveCount(1); + }); + + test('does not paste an image file if there is text/html content in the clipboard', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); + const files = [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]; + const textHtml = {'text/html': '

    Some text

    '}; + + await focusEditor(page); + await pasteFilesWithText(page, files, textHtml); + + const text = await page.locator('p').filter({hasText: 'Some text'}); + expect(text).not.toBeNull(); + + const imageCard = await page.locator('[data-kg-card="image"]'); + await expect(imageCard).toHaveCount(0); + }); + + // By default, Lexical dispatches the file paste command (DRAG_DROP_PASTE) only if there is no text content in the clipboard + // We override this behaviour in KoenigBehaviourPlugin > PASTE_COMMAND, to support copy/pasting files from e.g. Slack + test('pastes a image file if the clipboard contains a single image file and text/html with a tag', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/fixtures/large-image.png'); + const files = [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]; + const textHtml = {'text/html': ''}; + + await focusEditor(page); + await pasteFilesWithText(page, files, textHtml); + + const imageCard = await page.locator('[data-kg-card="image"]'); + const imgSrc = await page.locator('[data-kg-card="image"] img').getAttribute('src'); + + await expect(imageCard).toHaveCount(1); + + // Check that the image src is not coming from the text/html content + expect(imgSrc).not.toContain('https://files.slack.com/foo-bar'); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.js b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.js deleted file mode 100644 index 78c8342384..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Video card is tested in firefox -// Need to get video thumbnail before uploading on the server; for this purpose, convert video to blob https://github.com/TryGhost/Koenig/blob/a04c59c2d81ddc783869c47653aa9d7adf093629/packages/koenig-lexical/src/utils/extractVideoMetadata.js#L45 -// The problem is that Chromium can't read video src as blob -test.describe('Drag Drop Paste Plugin Firefox', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can drag and drop a video file on the editor', async function () { - await focusEditor(page); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - // Check that video file was uploaded - await expect(await page.getByTestId('media-duration')).toContainText('0:04'); - }); - - test('can drag and drop multiple video files on the editor', async function () { - await focusEditor(page); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - const dataTransfer = await createDataTransfer(page, [ - {filePath, fileName: 'video-1.mp4', fileType: 'video/mp4'}, - {filePath: filePath2, fileName: 'video-2.mp4', fileType: 'video/mp4'} - ]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - // wait for card visibility - await expect(await page.getByTestId('media-duration')).toHaveCount(2); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true, ignoreInnerSVG: false}); - }); - - test('can drag and drop multiple different types of files on the editor', async function () { - await focusEditor(page); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - const filePath3 = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); - const dataTransfer = await createDataTransfer(page, [ - {filePath, fileName: 'large-image.png', fileType: 'image/png'}, - {filePath: filePath2, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}, - {filePath: filePath3, fileName: 'video.mp4', fileType: 'video/mp4'} - ]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - // Wait for uploads to complete - await expect(await page.locator('input[value="Audio sample"]')).toBeVisible(); - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - await expect(await page.locator('[data-testid="video-card-populated"] [data-testid="media-duration"]')).toContainText('0:04'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true, ignoreInnerSVG: false}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.ts b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.ts new file mode 100644 index 0000000000..e306539182 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.firefox.test.ts @@ -0,0 +1,103 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Video card is tested in firefox +// Need to get video thumbnail before uploading on the server; for this purpose, convert video to blob https://github.com/TryGhost/Koenig/blob/a04c59c2d81ddc783869c47653aa9d7adf093629/packages/koenig-lexical/src/utils/extractVideoMetadata.js#L45 +// The problem is that Chromium can't read video src as blob +test.describe('Drag Drop Paste Plugin Firefox', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can drag and drop a video file on the editor', async function () { + await focusEditor(page); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'video.mp4', fileType: 'video/mp4'}]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + // Check that video file was uploaded + await expect(await page.getByTestId('media-duration')).toContainText('0:04'); + }); + + test('can drag and drop multiple video files on the editor', async function () { + await focusEditor(page); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + const dataTransfer = await createDataTransfer(page, [ + {filePath, fileName: 'video-1.mp4', fileType: 'video/mp4'}, + {filePath: filePath2, fileName: 'video-2.mp4', fileType: 'video/mp4'} + ]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + // wait for card visibility + await expect(await page.getByTestId('media-duration')).toHaveCount(2); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true, ignoreInnerSVG: false}); + }); + + test('can drag and drop multiple different types of files on the editor', async function () { + await focusEditor(page); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + const filePath3 = path.relative(process.cwd(), __dirname + '/../fixtures/video.mp4'); + const dataTransfer = await createDataTransfer(page, [ + {filePath, fileName: 'large-image.png', fileType: 'image/png'}, + {filePath: filePath2, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}, + {filePath: filePath3, fileName: 'video.mp4', fileType: 'video/mp4'} + ]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + // Wait for uploads to complete + await expect(await page.locator('input[value="Audio sample"]')).toBeVisible(); + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + await expect(await page.locator('[data-testid="video-card-populated"] [data-testid="media-duration"]')).toContainText('0:04'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true, ignoreInnerSVG: false}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.js deleted file mode 100644 index f0ff928190..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import path from 'path'; -import {assertHTML, createDataTransfer, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Drag Drop Paste Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can drag and drop an image on the editor', async function () { - await focusEditor(page); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - // wait for card visibility - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    - -
    - -
    - - -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreCardContents: true}); - }); - - test('can drag and drop multiple images on the editor', async function () { - await focusEditor(page); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); - const dataTransfer = await createDataTransfer(page, [ - {filePath, fileName: 'large-image.png', fileType: 'image/png'}, - {filePath: filePath2, fileName: 'large-image.jpeg', fileType: 'image/jpeg'} - ]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - // wait for card visibility - await expect(await page.getByTestId('image-card-populated')).toHaveCount(2); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardToolbarContents: true, ignoreCardContents: true}); - }); - - test('can drag and drop an audio file on the editor', async function () { - await focusEditor(page); - - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - await assertHTML(page, html` -
    -
    - -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('can drag and drop multiple audio files on the editor', async function () { - await focusEditor(page); - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); - - const dataTransfer = await createDataTransfer(page, [ - {filePath, fileName: 'audio-sample-1.mp3', fileType: 'audio/mp3'}, - {filePath: filePath2, fileName: 'audio-sample-2.mp3', fileType: 'audio/mp3'} - ]); - - await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); - await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); - - await expect(await page.locator('input[value="Audio sample 1"]')).toBeVisible(); - await expect(await page.locator('input[value="Audio sample 2"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    - -
    -
    -


    - `, {ignoreCardContents: true, ignoreInnerSVG: false}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.ts new file mode 100644 index 0000000000..245dd82f4a --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/DragDropPastePlugin.test.ts @@ -0,0 +1,131 @@ +import path from 'path'; +import {assertHTML, createDataTransfer, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Drag Drop Paste Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can drag and drop an image on the editor', async function () { + await focusEditor(page); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'large-image.png', fileType: 'image/png'}]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + // wait for card visibility + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    + +
    + +
    + + +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreCardContents: true}); + }); + + test('can drag and drop multiple images on the editor', async function () { + await focusEditor(page); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.jpeg'); + const dataTransfer = await createDataTransfer(page, [ + {filePath, fileName: 'large-image.png', fileType: 'image/png'}, + {filePath: filePath2, fileName: 'large-image.jpeg', fileType: 'image/jpeg'} + ]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + // wait for card visibility + await expect(await page.getByTestId('image-card-populated')).toHaveCount(2); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardToolbarContents: true, ignoreCardContents: true}); + }); + + test('can drag and drop an audio file on the editor', async function () { + await focusEditor(page); + + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + const dataTransfer = await createDataTransfer(page, [{filePath, fileName: 'audio-sample.mp3', fileType: 'audio/mp3'}]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + await assertHTML(page, html` +
    +
    + +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('can drag and drop multiple audio files on the editor', async function () { + await focusEditor(page); + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + const filePath2 = path.relative(process.cwd(), __dirname + '/../fixtures/audio-sample.mp3'); + + const dataTransfer = await createDataTransfer(page, [ + {filePath, fileName: 'audio-sample-1.mp3', fileType: 'audio/mp3'}, + {filePath: filePath2, fileName: 'audio-sample-2.mp3', fileType: 'audio/mp3'} + ]); + + await page.locator('.kg-prose').dispatchEvent('dragenter', {dataTransfer}); + await page.locator('.kg-prose').dispatchEvent('drop', {dataTransfer}); + + await expect(await page.locator('input[value="Audio sample 1"]')).toBeVisible(); + await expect(await page.locator('input[value="Audio sample 2"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    + +
    +
    +


    + `, {ignoreCardContents: true, ignoreInnerSVG: false}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.js deleted file mode 100644 index 12c1994b6d..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.js +++ /dev/null @@ -1,260 +0,0 @@ -import path from 'path'; -import {assertHTML, dragMouse, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Drag Drop Reorder Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can drag and drop a card between two other nodes', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - - await page.keyboard.type('/image'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.keyboard.press('Enter') - ]); - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); - await page.keyboard.press('ArrowDown'); - - await insertDivider(page); - - await page.keyboard.type('This is some text'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    - `, {ignoreCardContents: true}); - - const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); - // :not(figure p) avoids the p element that is the nested editor for the image card caption - const paragraphBBox = await page.locator('p:not(figure p)').boundingBox(); - - await dragMouse(page, imageBBox, paragraphBBox, 'start', 'start', true, 100, 100); - - // Click on the paragraph to deselect the card after drop - // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) - await page.click('p:not(figure p)'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    - `, {ignoreCardContents: true}); - }); - - test('can drag and drop a card at the top of the editor', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - - await insertDivider(page); - - await page.keyboard.type('This is some text'); - await page.keyboard.press('Enter'); - - await page.keyboard.type('/image'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.keyboard.press('Enter') - ]); - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); - await page.keyboard.press('ArrowDown'); - - await assertHTML(page, html` -
    -
    -
    -

    This is some text

    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); - const dividerBBox = await page.locator('hr').boundingBox(); - - await dragMouse(page, imageBBox, dividerBBox, 'start', 'start', true, 100, 100); - - // Click on the paragraph to deselect the card after drop - // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) - await page.click('p:not(figure p)'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    -


    - `, {ignoreCardContents: true}); - }); - - test('can drag and drop a card at the bottom of the editor', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - - await page.keyboard.type('/image'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.keyboard.press('Enter') - ]); - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); - await page.keyboard.press('ArrowDown'); - - await insertDivider(page); - - await page.keyboard.type('This is some text', {delay: 100}); // type slower to imitate user - await expect(await page.getByText('This is some text')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    - `, {ignoreCardContents: true}); - - const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); - - await twoPhaseDragToBottom(page, imageBBox); - await page.waitForTimeout(100); - await page.mouse.up(); - - // Click on the paragraph to deselect the card after drop - // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) - await page.click('p:not(figure p)'); - - await assertHTML(page, html` -
    -
    -
    -

    This is some text

    -
    -
    -
    - `, {ignoreCardContents: true}); - }); - - test('can display placeholder element while hovering between nodes', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - - await page.keyboard.type('/image'); - await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); - - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.keyboard.press('Enter') - ]); - await fileChooser.setFiles([filePath]); - - await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); - await page.keyboard.press('ArrowDown'); - - await insertDivider(page); - - await page.keyboard.type('This is some text'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    - `, {ignoreCardContents: true}); - - const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); - - await twoPhaseDragToBottom(page, imageBBox); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -

    This is some text

    - `, {ignoreCardContents: true}); - - const indicator = await page.locator('#koenig-drag-drop-indicator'); - await expect(await indicator).toBeVisible(); - - // Release the mouse to clean up drag state - await page.mouse.up(); - }); -}); - -async function insertDivider(page) { - await insertCard(page, {cardName: 'divider'}); -} - -// Two-phase drag: move partway first, wait for caption and CSS transitions -// to settle, then measure paragraph's actual position and move into its -// bottom half. A single fast drag races against the 250ms CSS transition -// that shifts the paragraph during drag. Leaves the mouse held down so -// the caller can mouse.up() (for drop tests) or assert mid-drag state. -async function twoPhaseDragToBottom(page, imageBBox) { - await page.mouse.move(imageBBox.x, imageBBox.y); - await page.mouse.down(); - - // Move past the HR card to trigger the drop indicator and transforms - const hrBBox = await page.locator('hr').boundingBox(); - await page.mouse.move(imageBBox.x, hrBBox.y + hrBBox.height, {steps: 50}); - - // Wait for caption appearance and CSS transitions to settle - await page.waitForTimeout(300); - - // Measure paragraph's actual visual position (includes caption shift + transform) - // :not(figure p) avoids the p element that is the nested editor for the image card caption - const shiftedParagraphBBox = await page.locator('p:not(figure p)').boundingBox(); - const targetY = shiftedParagraphBBox.y + shiftedParagraphBBox.height * 0.75; - await page.mouse.move(imageBBox.x, targetY, {steps: 10}); -} diff --git a/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.ts new file mode 100644 index 0000000000..d281f6a316 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/DragDropReorderPlugin.test.ts @@ -0,0 +1,262 @@ +import path from 'path'; +import {assertHTML, dragMouse, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import type {BoundingBox} from '../../utils/e2e'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Drag Drop Reorder Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can drag and drop a card between two other nodes', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + + await page.keyboard.type('/image'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.keyboard.press('Enter') + ]); + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); + await page.keyboard.press('ArrowDown'); + + await insertDivider(page); + + await page.keyboard.type('This is some text'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    + `, {ignoreCardContents: true}); + + const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); + // :not(figure p) avoids the p element that is the nested editor for the image card caption + const paragraphBBox = await page.locator('p:not(figure p)').boundingBox(); + + await dragMouse(page, imageBBox, paragraphBBox, 'start', 'start', true, 100, 100); + + // Click on the paragraph to deselect the card after drop + // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) + await page.click('p:not(figure p)'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    + `, {ignoreCardContents: true}); + }); + + test('can drag and drop a card at the top of the editor', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + + await insertDivider(page); + + await page.keyboard.type('This is some text'); + await page.keyboard.press('Enter'); + + await page.keyboard.type('/image'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.keyboard.press('Enter') + ]); + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); + await page.keyboard.press('ArrowDown'); + + await assertHTML(page, html` +
    +
    +
    +

    This is some text

    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); + const dividerBBox = await page.locator('hr').boundingBox(); + + await dragMouse(page, imageBBox, dividerBBox, 'start', 'start', true, 100, 100); + + // Click on the paragraph to deselect the card after drop + // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) + await page.click('p:not(figure p)'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    +


    + `, {ignoreCardContents: true}); + }); + + test('can drag and drop a card at the bottom of the editor', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + + await page.keyboard.type('/image'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.keyboard.press('Enter') + ]); + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); + await page.keyboard.press('ArrowDown'); + + await insertDivider(page); + + await page.keyboard.type('This is some text', {delay: 100}); // type slower to imitate user + await expect(await page.getByText('This is some text')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    + `, {ignoreCardContents: true}); + + const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); + + await twoPhaseDragToBottom(page, imageBBox!); + await page.waitForTimeout(100); + await page.mouse.up(); + + // Click on the paragraph to deselect the card after drop + // (Chrome for Testing keeps the card selected after drag & drop unlike old Chromium) + await page.click('p:not(figure p)'); + + await assertHTML(page, html` +
    +
    +
    +

    This is some text

    +
    +
    +
    + `, {ignoreCardContents: true}); + }); + + test('can display placeholder element while hovering between nodes', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + + await page.keyboard.type('/image'); + await page.waitForSelector('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + await page.keyboard.press('Enter') + ]); + await fileChooser.setFiles([filePath]); + + await page.waitForSelector('[data-kg-card="image"] [data-testid="image-card-populated"]'); + await page.keyboard.press('ArrowDown'); + + await insertDivider(page); + + await page.keyboard.type('This is some text'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    + `, {ignoreCardContents: true}); + + const imageBBox = await page.locator('[data-kg-card="image"]').boundingBox(); + + await twoPhaseDragToBottom(page, imageBBox!); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +

    This is some text

    + `, {ignoreCardContents: true}); + + const indicator = await page.locator('#koenig-drag-drop-indicator'); + await expect(await indicator).toBeVisible(); + + // Release the mouse to clean up drag state + await page.mouse.up(); + }); +}); + +async function insertDivider(page: Page) { + await insertCard(page, {cardName: 'divider'}); +} + +// Two-phase drag: move partway first, wait for caption and CSS transitions +// to settle, then measure paragraph's actual position and move into its +// bottom half. A single fast drag races against the 250ms CSS transition +// that shifts the paragraph during drag. Leaves the mouse held down so +// the caller can mouse.up() (for drop tests) or assert mid-drag state. +async function twoPhaseDragToBottom(page: Page, imageBBox: BoundingBox) { + await page.mouse.move(imageBBox.x, imageBBox.y); + await page.mouse.down(); + + // Move past the HR card to trigger the drop indicator and transforms + const hrBBox = (await page.locator('hr').boundingBox())!; + await page.mouse.move(imageBBox.x, hrBBox.y + hrBBox.height, {steps: 50}); + + // Wait for caption appearance and CSS transitions to settle + await page.waitForTimeout(300); + + // Measure paragraph's actual visual position (includes caption shift + transform) + // :not(figure p) avoids the p element that is the nested editor for the image card caption + const shiftedParagraphBBox = (await page.locator('p:not(figure p)').boundingBox())!; + const targetY = shiftedParagraphBBox.y + shiftedParagraphBBox.height * 0.75; + await page.mouse.move(imageBBox.x, targetY, {steps: 10}); +} diff --git a/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.js deleted file mode 100644 index 8ce6396480..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.js +++ /dev/null @@ -1,316 +0,0 @@ -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Emoji Picker Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('displays an emoji menu when typing : followed by a character', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - }); - - test('hides emoji menu when typing a space after the colon', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await page.keyboard.press('Space'); - await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); - }); - - test('hides the emoji menu when pressing escape', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); - }); - - test('can use the arrow keys to navigate the emoji menu', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'false'); - - await page.keyboard.press('ArrowDown'); - await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'false'); - await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'true'); - - await page.keyboard.press('ArrowUp'); - await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'false'); - }); - - test('can use the enter key to select an emoji', async function () { - await focusEditor(page); - - await page.keyboard.type(':+1', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - - // small wait for emoji picker to fully process typed characters - await page.waitForTimeout(50); - await page.keyboard.press('Enter'); - - await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); - await assertHTML(page, '

    👍

    '); - }); - - test('filters the emoji menu when typing', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await expect(page.getByTestId('emoji-option-0')).toHaveText('🦖t-rex'); - await expect(page.getByTestId('emoji-option-1')).toHaveText('🏓table_tennis_paddle_and_ball'); - - await page.keyboard.type('a'); - await expect(page.getByTestId('emoji-option-0')).toHaveText('🏓table_tennis_paddle_and_ball'); - await expect(page.getByTestId('emoji-option-1')).toHaveText('🌮taco'); - - await page.keyboard.type('c'); - await expect(page.getByTestId('emoji-option-0')).toHaveText('🌮taco'); - await expect(page.getByTestId('emoji-option-1')).not.toBeVisible(); - - // small wait for emoji picker to fully process typed characters - await page.waitForTimeout(50); - - await page.keyboard.press('Enter'); - await assertHTML(page, '

    🌮

    '); - }); - - test('can use the mouse to select an emoji', async function () { - await focusEditor(page); - - await page.keyboard.type(':t'); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - - await page.click('[data-testid="emoji-option-2"]'); - - await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); - await assertHTML(page, '

    🌮

    '); - }); - - test('can use punctuation', async function () { - await focusEditor(page); - - await page.keyboard.type(':t-rex', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - - // small wait for emoji picker to fully process typed characters - await page.waitForTimeout(50); - await page.keyboard.press('Enter'); - await assertHTML(page, '

    🦖

    '); - }); - - test('can put emojis back to back without spaces', async function () { - await focusEditor(page); - - await page.keyboard.type(':tac', {delay: 10}); - await page.keyboard.press('Enter'); - await page.keyboard.type(':tac', {delay: 10}); - await page.keyboard.press('Enter'); - await page.keyboard.type('s for all', {delay: 10}); - - await assertHTML(page, '

    🌮🌮s for all

    '); - }); - - test('emojis retain text formatting on menu insert', async function () { - await focusEditor(page); - await page.keyboard.press('Control+Alt+H'); - await page.keyboard.type('Test :taco', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await page.keyboard.press('Enter'); - - await assertHTML(page, '

    Test 🌮

    '); - }); - - test('emojis retain text formatting on : completion', async function () { - await focusEditor(page); - await page.keyboard.press('Control+Alt+H'); - await page.keyboard.type('Test :heart', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - await page.keyboard.type(':', {delay: 10}); - - await assertHTML(page, '

    Test ❤️

    '); - }); - - test('can handle :, with no search matches', async function () { - await focusEditor(page); - await page.keyboard.type(':,', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); - // can continue typing (previous bug crashed editor) - await page.keyboard.type(' testing'); - - await assertHTML(page, html` -

    :, testing

    - `); - }); - - test(`can use emojis in nested editors`, async function () { - await focusEditor(page); - - await insertCard(page, {cardName: 'callout'}); - - await page.keyboard.type(':tac', {delay: 10}); - await expect(page.getByTestId('emoji-menu')).toBeVisible(); - // small wait for emoji picker to fully process typed characters - await page.waitForTimeout(50); - await page.keyboard.press('Enter'); - await page.keyboard.type('s for all', {delay: 10}); - - await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); // exit edit mode - - await assertHTML(page, ` -
    -
    -
    -
    -
    -
    -
    -

    🌮s for all

    -
    -
    -
    -
    -
    -
    -
      -
    • - -
      Edit
      -
    • -
    • -
    • - -
      Save as snippet
      -
    • -
    -
    -
    -
    -


    - `); - }); - - // not sure why this test is flaky on CI... - test.skip('can use emojis in captions', async function () { - await focusEditor(page); - - await page.keyboard.type('```js ', {delay: 10}); - await page.keyboard.type(`sample code`, {delay: 10}); - await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); - await page.keyboard.type('enjoy :ta', {delay: 10}); - await page.keyboard.press('ArrowDown'); // make sure we test arrow key use - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Enter'); // make sure we test enter key use - await page.keyboard.type('s for all', {delay: 10}); - - await assertHTML(page, ` -
    -
    -
    -
    sample code
    -
    js
    -
    -
    -
    -
    -
    -
    -

    - enjoy 🌮s for all -

    -
    -
    -
    -
    -
    -
    -
      -
    • - -
      Edit
      -
    • -
    • -
    • - -
      Save as snippet
      -
    • -
    -
    -
    -
    - `); - }); - - test.describe('Completion matching', async function () { - test('can insert emojis by providing the whole :shortcode:', async function () { - await focusEditor(page); - - await page.keyboard.type(':taco:'); - await assertHTML(page, '

    🌮

    '); - }); - - test('a whole :shortcode: with no emojis matches inserts nothing', async function () { - await focusEditor(page); - - await page.keyboard.type(':tac:'); - await assertHTML(page, '

    :tac:

    '); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.ts new file mode 100644 index 0000000000..8063411c82 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/EmojiPickerPlugin.test.ts @@ -0,0 +1,317 @@ +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Emoji Picker Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('displays an emoji menu when typing : followed by a character', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + }); + + test('hides emoji menu when typing a space after the colon', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await page.keyboard.press('Space'); + await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); + }); + + test('hides the emoji menu when pressing escape', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); + }); + + test('can use the arrow keys to navigate the emoji menu', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'false'); + + await page.keyboard.press('ArrowDown'); + await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'false'); + await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'true'); + + await page.keyboard.press('ArrowUp'); + await expect(page.getByTestId('emoji-option-0')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('emoji-option-1')).toHaveAttribute('aria-selected', 'false'); + }); + + test('can use the enter key to select an emoji', async function () { + await focusEditor(page); + + await page.keyboard.type(':+1', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + + // small wait for emoji picker to fully process typed characters + await page.waitForTimeout(50); + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); + await assertHTML(page, '

    👍

    '); + }); + + test('filters the emoji menu when typing', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await expect(page.getByTestId('emoji-option-0')).toHaveText('🦖t-rex'); + await expect(page.getByTestId('emoji-option-1')).toHaveText('🏓table_tennis_paddle_and_ball'); + + await page.keyboard.type('a'); + await expect(page.getByTestId('emoji-option-0')).toHaveText('🏓table_tennis_paddle_and_ball'); + await expect(page.getByTestId('emoji-option-1')).toHaveText('🌮taco'); + + await page.keyboard.type('c'); + await expect(page.getByTestId('emoji-option-0')).toHaveText('🌮taco'); + await expect(page.getByTestId('emoji-option-1')).not.toBeVisible(); + + // small wait for emoji picker to fully process typed characters + await page.waitForTimeout(50); + + await page.keyboard.press('Enter'); + await assertHTML(page, '

    🌮

    '); + }); + + test('can use the mouse to select an emoji', async function () { + await focusEditor(page); + + await page.keyboard.type(':t'); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + + await page.click('[data-testid="emoji-option-2"]'); + + await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); + await assertHTML(page, '

    🌮

    '); + }); + + test('can use punctuation', async function () { + await focusEditor(page); + + await page.keyboard.type(':t-rex', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + + // small wait for emoji picker to fully process typed characters + await page.waitForTimeout(50); + await page.keyboard.press('Enter'); + await assertHTML(page, '

    🦖

    '); + }); + + test('can put emojis back to back without spaces', async function () { + await focusEditor(page); + + await page.keyboard.type(':tac', {delay: 10}); + await page.keyboard.press('Enter'); + await page.keyboard.type(':tac', {delay: 10}); + await page.keyboard.press('Enter'); + await page.keyboard.type('s for all', {delay: 10}); + + await assertHTML(page, '

    🌮🌮s for all

    '); + }); + + test('emojis retain text formatting on menu insert', async function () { + await focusEditor(page); + await page.keyboard.press('Control+Alt+H'); + await page.keyboard.type('Test :taco', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await page.keyboard.press('Enter'); + + await assertHTML(page, '

    Test 🌮

    '); + }); + + test('emojis retain text formatting on : completion', async function () { + await focusEditor(page); + await page.keyboard.press('Control+Alt+H'); + await page.keyboard.type('Test :heart', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + await page.keyboard.type(':', {delay: 10}); + + await assertHTML(page, '

    Test ❤️

    '); + }); + + test('can handle :, with no search matches', async function () { + await focusEditor(page); + await page.keyboard.type(':,', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).not.toBeVisible(); + // can continue typing (previous bug crashed editor) + await page.keyboard.type(' testing'); + + await assertHTML(page, html` +

    :, testing

    + `); + }); + + test(`can use emojis in nested editors`, async function () { + await focusEditor(page); + + await insertCard(page, {cardName: 'callout'}); + + await page.keyboard.type(':tac', {delay: 10}); + await expect(page.getByTestId('emoji-menu')).toBeVisible(); + // small wait for emoji picker to fully process typed characters + await page.waitForTimeout(50); + await page.keyboard.press('Enter'); + await page.keyboard.type('s for all', {delay: 10}); + + await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); // exit edit mode + + await assertHTML(page, ` +
    +
    +
    +
    +
    +
    +
    +

    🌮s for all

    +
    +
    +
    +
    +
    +
    +
      +
    • + +
      Edit
      +
    • +
    • +
    • + +
      Save as snippet
      +
    • +
    +
    +
    +
    +


    + `); + }); + + // not sure why this test is flaky on CI... + test.skip('can use emojis in captions', async function () { + await focusEditor(page); + + await page.keyboard.type('```js ', {delay: 10}); + await page.keyboard.type(`sample code`, {delay: 10}); + await page.keyboard.press(`${ctrlOrCmd(page)}+Enter`); + await page.keyboard.type('enjoy :ta', {delay: 10}); + await page.keyboard.press('ArrowDown'); // make sure we test arrow key use + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); // make sure we test enter key use + await page.keyboard.type('s for all', {delay: 10}); + + await assertHTML(page, ` +
    +
    +
    +
    sample code
    +
    js
    +
    +
    +
    +
    +
    +
    +

    + enjoy 🌮s for all +

    +
    +
    +
    +
    +
    +
    +
      +
    • + +
      Edit
      +
    • +
    • +
    • + +
      Save as snippet
      +
    • +
    +
    +
    +
    + `); + }); + + test.describe('Completion matching', async function () { + test('can insert emojis by providing the whole :shortcode:', async function () { + await focusEditor(page); + + await page.keyboard.type(':taco:'); + await assertHTML(page, '

    🌮

    '); + }); + + test('a whole :shortcode: with no emojis matches inserts nothing', async function () { + await focusEditor(page); + + await page.keyboard.type(':tac:'); + await assertHTML(page, '

    :tac:

    '); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.js deleted file mode 100644 index 203a8ce5d3..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Html Output Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page, uri: '/#/html-output'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can render html to editor', async function () { - await focusEditor(page); - - await assertHTML(page, html` -

    - check - - ghost.org/changelog/markdown/ - -

    - `); - }); - - test('can parse editor state to html', async function () { - const ctrl = isMac() ? 'Meta' : 'Control'; - await focusEditor(page); - - // check that default content renders to html - await expect(await page.getByTestId('html-output').textContent()).toEqual('

    check ghost.org/changelog/markdown/

    '); - - // remove content - await page.keyboard.press(`${ctrl}+KeyA`); - await page.keyboard.press(`Delete`); - - await assertHTML(page, html` -


    - `); - - // paste link - await pasteText(page, 'ghost.org/changelog/markdown/', 'text/html'); - - // check that link pasted successfully - await assertHTML(page, html` -

    - - ghost.org/changelog/markdown/ - -

    - `); - - // check that link renders to html - await expect(await page.getByTestId('html-output').textContent()).toEqual('

    ghost.org/changelog/markdown/

    '); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.ts new file mode 100644 index 0000000000..68512fd6f3 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/HtmlOutputPlugin.test.ts @@ -0,0 +1,63 @@ +import {assertHTML, focusEditor, html, initialize, isMac, pasteHtml} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Html Output Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/html-output'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can render html to editor', async function () { + await focusEditor(page); + + await assertHTML(page, html` +

    + check + + ghost.org/changelog/markdown/ + +

    + `); + }); + + test('can parse editor state to html', async function () { + const ctrl = isMac() ? 'Meta' : 'Control'; + await focusEditor(page); + + // check that default content renders to html + await expect(await page.getByTestId('html-output').textContent()).toEqual('

    check ghost.org/changelog/markdown/

    '); + + // remove content + await page.keyboard.press(`${ctrl}+KeyA`); + await page.keyboard.press(`Delete`); + + await assertHTML(page, html` +


    + `); + + // paste link + await pasteHtml(page, 'ghost.org/changelog/markdown/'); + + // check that link pasted successfully + await assertHTML(page, html` +

    + + ghost.org/changelog/markdown/ + +

    + `); + + // check that link renders to html + await expect(await page.getByTestId('html-output').textContent()).toEqual('

    ghost.org/changelog/markdown/

    '); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.js deleted file mode 100644 index 23f100c71d..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import {expect, test} from '@playwright/test'; -import {focusEditor, initialize} from '../../utils/e2e'; - -test.describe('Snippet Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - // Set localStorage to enable snippets - const defaultSnippets = [ - { - name: 'planes', - value: '{"namespace":"KoenigEditor","nodes":[{"type":"image","version":1,"src":"https://images.unsplash.com/photo-1556388158-158ea5ccacbd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDl8fGFpcmNyYWZ0fGVufDB8fHx8MTcxNTc1OTIxNXww&ixlib=rb-4.0.3&q=80&w=2000","width":5046,"height":3364,"title":"","alt":"white biplane","caption":"Photo by Pascal Meier / Unsplash","cardWidth":"regular","href":""},{"type":"image","version":1,"src":"https://images.unsplash.com/photo-1556388158-158ea5ccacbd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDl8fGFpcmNyYWZ0fGVufDB8fHx8MTcxNTc1OTIxNXww&ixlib=rb-4.0.3&q=80&w=2000","width":5046,"height":3364,"title":"","alt":"white biplane","caption":"Photo by Pascal Meier / Unsplash","cardWidth":"regular","href":""}]}' - } - ]; - - await page.evaluate((snippets) => { - localStorage.setItem('snippets', JSON.stringify(snippets)); - }, defaultSnippets); - - await page.reload(); // Ensure the page reloads to pick up the new localStorage values - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('Can Insert a snippet with multiple nodes', async function () { - await focusEditor(page); - await page.keyboard.type('/snippet'); - // Wait for snippet to appear in slash menu before pressing Enter - await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'planes'})).toBeVisible(); - await page.keyboard.press('Enter'); - await page.waitForSelector('[data-kg-card="image"]'); - expect(await page.$('[data-kg-card="image"]')).not.toBeNull(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.ts new file mode 100644 index 0000000000..d84dd531c4 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/KoenigSnippetPlugin.test.ts @@ -0,0 +1,42 @@ +import {expect, test} from '@playwright/test'; +import {focusEditor, initialize} from '../../utils/e2e'; +import type {Page} from '@playwright/test'; + +test.describe('Snippet Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + // Set localStorage to enable snippets + const defaultSnippets = [ + { + name: 'planes', + value: '{"namespace":"KoenigEditor","nodes":[{"type":"image","version":1,"src":"https://images.unsplash.com/photo-1556388158-158ea5ccacbd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDl8fGFpcmNyYWZ0fGVufDB8fHx8MTcxNTc1OTIxNXww&ixlib=rb-4.0.3&q=80&w=2000","width":5046,"height":3364,"title":"","alt":"white biplane","caption":"Photo by Pascal Meier / Unsplash","cardWidth":"regular","href":""},{"type":"image","version":1,"src":"https://images.unsplash.com/photo-1556388158-158ea5ccacbd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDl8fGFpcmNyYWZ0fGVufDB8fHx8MTcxNTc1OTIxNXww&ixlib=rb-4.0.3&q=80&w=2000","width":5046,"height":3364,"title":"","alt":"white biplane","caption":"Photo by Pascal Meier / Unsplash","cardWidth":"regular","href":""}]}' + } + ]; + + await page.evaluate((snippets) => { + localStorage.setItem('snippets', JSON.stringify(snippets)); + }, defaultSnippets); + + await page.reload(); // Ensure the page reloads to pick up the new localStorage values + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('Can Insert a snippet with multiple nodes', async function () { + await focusEditor(page); + await page.keyboard.type('/snippet'); + // Wait for snippet to appear in slash menu before pressing Enter + await expect(page.locator('[data-kg-cardmenu-selected="true"]').filter({hasText: 'planes'})).toBeVisible(); + await page.keyboard.press('Enter'); + await page.waitForSelector('[data-kg-card="image"]'); + expect(await page.$('[data-kg-card="image"]')).not.toBeNull(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.js deleted file mode 100644 index 4f5b358ffb..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.js +++ /dev/null @@ -1,143 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('ReplacementStringsPlugin', async function () { - test.describe('In email editor (ExtendedTextNode)', function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - // full editor doesn't use the plugin directly; it's part of specific email cards - await initialize({page, uri: '/#/email?content=false'}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('formats {first_name} as code', async function () { - await focusEditor(page); - await page.keyboard.type('Hello {first_name}!'); - - await assertHTML(page, html` -

    - Hello - {first_name} - ! -

    - `); - }); - - test('formats {first_name, "fallback"} as code', async function () { - await focusEditor(page); - await page.keyboard.type('Hello {first_name, "there"}!'); - - await assertHTML(page, html` -

    - Hello - {first_name, "there"} - ! -

    - `); - }); - - test('formats {email} as code', async function () { - await focusEditor(page); - await page.keyboard.type('Your email is {email}'); - - await assertHTML(page, html` -

    - Your email is - {email} -

    - `); - }); - - test('handles multiple replacement strings in same paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('Hi {first_name}, your email is {email}'); - - await assertHTML(page, html` -

    - Hi - {first_name} - , your email is - {email} -

    - `); - }); - - test('formats replacement string at start of paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('{first_name} is here'); - - await assertHTML(page, html` -

    - {first_name} - is here -

    - `); - }); - - test('formats replacement string at end of paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('Name: {first_name}'); - - await assertHTML(page, html` -

    - Name: - {first_name} -

    - `); - }); - - test('formats standalone replacement string', async function () { - await focusEditor(page); - await page.keyboard.type('{first_name}'); - - await assertHTML(page, html` -

    - {first_name} -

    - `); - }); - - test('handles adjacent replacement strings', async function () { - await focusEditor(page); - await page.keyboard.type('{first_name}{last_name}'); - - await assertHTML(page, html` -

    - {first_name}{last_name} -

    - `); - }); - - test('does not format incomplete braces', async function () { - await focusEditor(page); - await page.keyboard.type('This {is incomplete'); - - await assertHTML(page, html` -

    - This {is incomplete -

    - `); - }); - - test('formats empty braces as replacement string', async function () { - await focusEditor(page); - await page.keyboard.type('Empty {} braces'); - - await assertHTML(page, html` -

    - Empty - {} - braces -

    - `); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.ts new file mode 100644 index 0000000000..68f5332756 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/ReplacementStringsPlugin.test.ts @@ -0,0 +1,144 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('ReplacementStringsPlugin', async function () { + test.describe('In email editor (ExtendedTextNode)', function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + // full editor doesn't use the plugin directly; it's part of specific email cards + await initialize({page, uri: '/#/email?content=false'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('formats {first_name} as code', async function () { + await focusEditor(page); + await page.keyboard.type('Hello {first_name}!'); + + await assertHTML(page, html` +

    + Hello + {first_name} + ! +

    + `); + }); + + test('formats {first_name, "fallback"} as code', async function () { + await focusEditor(page); + await page.keyboard.type('Hello {first_name, "there"}!'); + + await assertHTML(page, html` +

    + Hello + {first_name, "there"} + ! +

    + `); + }); + + test('formats {email} as code', async function () { + await focusEditor(page); + await page.keyboard.type('Your email is {email}'); + + await assertHTML(page, html` +

    + Your email is + {email} +

    + `); + }); + + test('handles multiple replacement strings in same paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('Hi {first_name}, your email is {email}'); + + await assertHTML(page, html` +

    + Hi + {first_name} + , your email is + {email} +

    + `); + }); + + test('formats replacement string at start of paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('{first_name} is here'); + + await assertHTML(page, html` +

    + {first_name} + is here +

    + `); + }); + + test('formats replacement string at end of paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('Name: {first_name}'); + + await assertHTML(page, html` +

    + Name: + {first_name} +

    + `); + }); + + test('formats standalone replacement string', async function () { + await focusEditor(page); + await page.keyboard.type('{first_name}'); + + await assertHTML(page, html` +

    + {first_name} +

    + `); + }); + + test('handles adjacent replacement strings', async function () { + await focusEditor(page); + await page.keyboard.type('{first_name}{last_name}'); + + await assertHTML(page, html` +

    + {first_name}{last_name} +

    + `); + }); + + test('does not format incomplete braces', async function () { + await focusEditor(page); + await page.keyboard.type('This {is incomplete'); + + await assertHTML(page, html` +

    + This {is incomplete +

    + `); + }); + + test('formats empty braces as replacement string', async function () { + await focusEditor(page); + await page.keyboard.type('Empty {} braces'); + + await assertHTML(page, html` +

    + Empty + {} + braces +

    + `); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.js deleted file mode 100644 index 384f85935a..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.js +++ /dev/null @@ -1,144 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, pasteHtml, pasteLexical, pasteText, selectBackwards} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Restrict Content Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('restricted content editor accepts input', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - - await page.keyboard.type('Hello World'); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('can not add more than specified number of paragraphs by typing manually', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('can not add more than specified number of paragraphs by pasting plain text', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - - await pasteText(page, 'Hello world \n Hello world'); - - await assertHTML(page, html` -

    Hello world Hello world

    - `); - }); - - test('can not add more than specified number of paragraphs by pasting HTML', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - - await pasteHtml(page, html`

    Hello world

    Hello world

    `); - - await assertHTML(page, html` -

    Hello world

    - `); - }); - - test('can not add more than specified number of paragraphs by pasting Lexical', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - - const content = {namespace: 'KoenigEditor',nodes: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the first line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 0,type: 'listitem',version: 1,value: 1},{children: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the second line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 1,type: 'listitem',version: 1,value: 1},{children: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the third line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 2,type: 'listitem',version: 1,value: 1}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'}],direction: null,format: '',indent: 1,type: 'listitem',version: 1,value: 2}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'}],direction: 'ltr',format: '',indent: 0,type: 'listitem',version: 1,value: 2}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'},{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'Here is a paragraph',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 0,type: 'paragraph',version: 1}]}; - - await pasteLexical(page, JSON.stringify(content)); - - await assertHTML(page, html` -

    This is the first line

    - `); - }); - - test('can not add more than specified number of paragraphs when paragraphs > 1', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=3'}); - - await focusEditor(page); - - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await page.keyboard.type('Hello World'); - - await assertHTML(page, html` -

    Hello World

    -

    Hello World

    -

    Hello WorldHello World

    - `); - }); - - test('formats in paragraphs are preserved', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - await pasteHtml(page, '

    Hello World

    Extra

    '); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('formats in first list item are preserved when converting to paragraph', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - await pasteHtml(page, '
    • Hello World
    • Extra
    '); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('headings are converted to paragraphs', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - await pasteHtml(page, '

    Hello World

    '); - - await assertHTML(page, html` -

    Hello World

    - `); - }); - - test('pasting over a selection does not bypass the restriction', async function () { - await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); - - await focusEditor(page); - await page.keyboard.type('Test'); - await selectBackwards(page, 4); - - await pasteHtml(page, '

    Hello World

    Extra

    '); - - await assertHTML(page, html` -

    Hello World

    - `); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.ts new file mode 100644 index 0000000000..71b8cef1d2 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/RestrictContentPlugin.test.ts @@ -0,0 +1,145 @@ +import {assertHTML, focusEditor, html, initialize, pasteHtml, pasteLexical, pasteText, selectBackwards} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Restrict Content Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('restricted content editor accepts input', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + + await page.keyboard.type('Hello World'); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('can not add more than specified number of paragraphs by typing manually', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('can not add more than specified number of paragraphs by pasting plain text', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + + await pasteText(page, 'Hello world \n Hello world'); + + await assertHTML(page, html` +

    Hello world Hello world

    + `); + }); + + test('can not add more than specified number of paragraphs by pasting HTML', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + + await pasteHtml(page, html`

    Hello world

    Hello world

    `); + + await assertHTML(page, html` +

    Hello world

    + `); + }); + + test('can not add more than specified number of paragraphs by pasting Lexical', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + + const content = {namespace: 'KoenigEditor',nodes: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the first line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 0,type: 'listitem',version: 1,value: 1},{children: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the second line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 1,type: 'listitem',version: 1,value: 1},{children: [{children: [{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'This is the third line',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 2,type: 'listitem',version: 1,value: 1}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'}],direction: null,format: '',indent: 1,type: 'listitem',version: 1,value: 2}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'}],direction: 'ltr',format: '',indent: 0,type: 'listitem',version: 1,value: 2}],direction: 'ltr',format: '',indent: 0,type: 'list',version: 1,listType: 'bullet',start: 1,tag: 'ul'},{children: [{detail: 0,format: 0,mode: 'normal',style: '',text: 'Here is a paragraph',type: 'text',version: 1}],direction: 'ltr',format: '',indent: 0,type: 'paragraph',version: 1}]}; + + await pasteLexical(page, JSON.stringify(content)); + + await assertHTML(page, html` +

    This is the first line

    + `); + }); + + test('can not add more than specified number of paragraphs when paragraphs > 1', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=3'}); + + await focusEditor(page); + + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Hello World'); + + await assertHTML(page, html` +

    Hello World

    +

    Hello World

    +

    Hello WorldHello World

    + `); + }); + + test('formats in paragraphs are preserved', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + await pasteHtml(page, '

    Hello World

    Extra

    '); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('formats in first list item are preserved when converting to paragraph', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + await pasteHtml(page, '
    • Hello World
    • Extra
    '); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('headings are converted to paragraphs', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + await pasteHtml(page, '

    Hello World

    '); + + await assertHTML(page, html` +

    Hello World

    + `); + }); + + test('pasting over a selection does not bypass the restriction', async function () { + await initialize({page, force: true, uri: '/#/contentrestricted?paragraphs=1'}); + + await focusEditor(page); + await page.keyboard.type('Test'); + await selectBackwards(page, 4); + + await pasteHtml(page, '

    Hello World

    Extra

    '); + + await assertHTML(page, html` +

    Hello World

    + `); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js deleted file mode 100644 index 134c6efa24..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js +++ /dev/null @@ -1,165 +0,0 @@ -import {assertHTML, assertSelection, focusEditor, html, initialize} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('TK Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('highlights TK nodes', async function () { - test('highlights a TK node when TK is typed in text', async function () { - await focusEditor(page); - await page.keyboard.type('TK'); - await expect(page.getByRole('paragraph').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK node when TK is typed in text (case insensitive)', async function () { - await focusEditor(page); - await page.keyboard.type('tk'); - await expect(page.getByRole('paragraph').getByText('tk')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK when surrounded by symbols', async function () { - await focusEditor(page); - await page.keyboard.type('[TK],'); - await expect(page.getByRole('paragraph').getByText('[TK],')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK when TK is repeated', async function () { - await focusEditor(page); - await page.keyboard.type('TKTK'); - await expect(page.getByRole('paragraph').getByText('TKTK')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK when TK is repeated (case insensitive)', async function () { - await focusEditor(page); - await page.keyboard.type('TkTk'); - await expect(page.getByRole('paragraph').getByText('TkTk')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('does not highlight TK when surrounded by letters', async function () { - await focusEditor(page); - await page.keyboard.type('TKtest'); - await expect(page.getByRole('paragraph').getByText('TKtest')).not.toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK node when TK is typed in a heading', async function () { - await focusEditor(page); - await page.keyboard.type('# TK'); - await expect(page.getByRole('heading').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('highlights a TK node when TK is typed in a list item', async function () { - await focusEditor(page); - await page.keyboard.type('- TK'); - await expect(page.getByRole('listitem').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); - }); - - test('changes highlight when TK indicator is hovered', async function () { - await focusEditor(page); - await page.keyboard.type('TK'); - await page.getByTestId('tk-indicator').hover(); - await expect(page.getByRole('paragraph').getByText('TK')).toHaveClass('bg-lime-500 dark:bg-lime-800 py-1'); - }); - - test('highlights TK nodes following invalid TK text', async function () { - await focusEditor(page); - await page.keyboard.type('TKs and TK and [TK]'); - await expect(page.locator('[data-kg-tk="true"]')).toHaveCount(2); - }); - - test('highlights TK when preceded or follow by emdash', async function () { - await focusEditor(page); - await page.keyboard.type('First---TK Second---TK---Third TK---Last'); - - await assertHTML(page, html` -

    - First - —TK - Second - —TK— - Third - TK— - Last -

    - `); - }); - }); - - test.describe('indicators', async function () { - test('creates a TK indicator for each TK node', async function () { - await focusEditor(page); - await page.keyboard.type('TK and TK and TK'); - await expect(page.getByTestId('tk-indicator')).toBeVisible(); - }); - - test('creates a TK indicator for each parent element with a TK', async function () { - await focusEditor(page); - - await page.keyboard.type('TK and some text'); - await page.keyboard.press('Enter'); - await page.keyboard.type('TK and some text'); - await page.keyboard.press('Enter'); - await page.keyboard.type('TK and some text'); - - await expect(page.getByTestId('tk-indicator')).toHaveCount(3); - }); - - test('clicking the indicator selects the first TK node in the parent', async function () { - await focusEditor(page); - await page.keyboard.type('TK and TK and TK'); - - await page.evaluate(() => window.getSelection().toString()).then((selection) => { - expect(selection).toEqual(''); - }); - - await page.getByTestId('tk-indicator').click(); - - await page.evaluate(() => window.getSelection().toString()).then((selection) => { - expect(selection).toEqual('TK'); - }); - }); - - test('continuing to click the indicator cycles through the TK nodes in the parent', async function () { - await focusEditor(page); - await page.keyboard.type('TK and TK and TK'); - await page.getByTestId('tk-indicator').click(); - - // piece 2 in the array is the child node - await assertSelection(page, { - anchorPath: [0, 0, 0], - anchorOffset: 0, - focusPath: [0, 0, 0], - focusOffset: 2 - }); - - await page.getByTestId('tk-indicator').click(); - - await assertSelection(page, { - anchorPath: [0, 2, 0], - anchorOffset: 0, - focusPath: [0, 2, 0], - focusOffset: 2 - }); - - await page.getByTestId('tk-indicator').click(); - - await assertSelection(page, { - anchorPath: [0, 4, 0], - anchorOffset: 0, - focusPath: [0, 4, 0], - focusOffset: 2 - }); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.ts new file mode 100644 index 0000000000..1750828cd2 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.ts @@ -0,0 +1,166 @@ +import {assertHTML, assertSelection, focusEditor, html, initialize} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('TK Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('highlights TK nodes', async function () { + test('highlights a TK node when TK is typed in text', async function () { + await focusEditor(page); + await page.keyboard.type('TK'); + await expect(page.getByRole('paragraph').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK node when TK is typed in text (case insensitive)', async function () { + await focusEditor(page); + await page.keyboard.type('tk'); + await expect(page.getByRole('paragraph').getByText('tk')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK when surrounded by symbols', async function () { + await focusEditor(page); + await page.keyboard.type('[TK],'); + await expect(page.getByRole('paragraph').getByText('[TK],')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK when TK is repeated', async function () { + await focusEditor(page); + await page.keyboard.type('TKTK'); + await expect(page.getByRole('paragraph').getByText('TKTK')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK when TK is repeated (case insensitive)', async function () { + await focusEditor(page); + await page.keyboard.type('TkTk'); + await expect(page.getByRole('paragraph').getByText('TkTk')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('does not highlight TK when surrounded by letters', async function () { + await focusEditor(page); + await page.keyboard.type('TKtest'); + await expect(page.getByRole('paragraph').getByText('TKtest')).not.toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK node when TK is typed in a heading', async function () { + await focusEditor(page); + await page.keyboard.type('# TK'); + await expect(page.getByRole('heading').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('highlights a TK node when TK is typed in a list item', async function () { + await focusEditor(page); + await page.keyboard.type('- TK'); + await expect(page.getByRole('listitem').getByText('TK')).toHaveAttribute('data-kg-tk', 'true'); + }); + + test('changes highlight when TK indicator is hovered', async function () { + await focusEditor(page); + await page.keyboard.type('TK'); + await page.getByTestId('tk-indicator').hover(); + await expect(page.getByRole('paragraph').getByText('TK')).toHaveClass('bg-lime-500 dark:bg-lime-800 py-1'); + }); + + test('highlights TK nodes following invalid TK text', async function () { + await focusEditor(page); + await page.keyboard.type('TKs and TK and [TK]'); + await expect(page.locator('[data-kg-tk="true"]')).toHaveCount(2); + }); + + test('highlights TK when preceded or follow by emdash', async function () { + await focusEditor(page); + await page.keyboard.type('First---TK Second---TK---Third TK---Last'); + + await assertHTML(page, html` +

    + First + —TK + Second + —TK— + Third + TK— + Last +

    + `); + }); + }); + + test.describe('indicators', async function () { + test('creates a TK indicator for each TK node', async function () { + await focusEditor(page); + await page.keyboard.type('TK and TK and TK'); + await expect(page.getByTestId('tk-indicator')).toBeVisible(); + }); + + test('creates a TK indicator for each parent element with a TK', async function () { + await focusEditor(page); + + await page.keyboard.type('TK and some text'); + await page.keyboard.press('Enter'); + await page.keyboard.type('TK and some text'); + await page.keyboard.press('Enter'); + await page.keyboard.type('TK and some text'); + + await expect(page.getByTestId('tk-indicator')).toHaveCount(3); + }); + + test('clicking the indicator selects the first TK node in the parent', async function () { + await focusEditor(page); + await page.keyboard.type('TK and TK and TK'); + + await page.evaluate(() => window.getSelection()!.toString()).then((selection) => { + expect(selection).toEqual(''); + }); + + await page.getByTestId('tk-indicator').click(); + + await page.evaluate(() => window.getSelection()!.toString()).then((selection) => { + expect(selection).toEqual('TK'); + }); + }); + + test('continuing to click the indicator cycles through the TK nodes in the parent', async function () { + await focusEditor(page); + await page.keyboard.type('TK and TK and TK'); + await page.getByTestId('tk-indicator').click(); + + // piece 2 in the array is the child node + await assertSelection(page, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [0, 0, 0], + focusOffset: 2 + }); + + await page.getByTestId('tk-indicator').click(); + + await assertSelection(page, { + anchorPath: [0, 2, 0], + anchorOffset: 0, + focusPath: [0, 2, 0], + focusOffset: 2 + }); + + await page.getByTestId('tk-indicator').click(); + + await assertSelection(page, { + anchorPath: [0, 4, 0], + anchorOffset: 0, + focusPath: [0, 4, 0], + focusOffset: 2 + }); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.js deleted file mode 100644 index 69f09affd0..0000000000 --- a/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import {expect, test} from '@playwright/test'; -import {focusEditor, initialize, insertCard} from '../../utils/e2e'; - -test.describe('Word Count Plugin', async function () { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('counts words in editor', async function () { - await focusEditor(page); - await expect(page.getByTestId('word-count')).toHaveText('0'); - await page.keyboard.type('Hello World'); - await expect(page.getByTestId('word-count')).toHaveText('2'); - }); - - test('counts words in nested editors', async function () { - await focusEditor(page); - await page.keyboard.type('Hello World'); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'callout'}); - await page.keyboard.type('Nested content'); - await expect(page.getByTestId('word-count')).toHaveText('4'); - }); - - test('counts words immediately after initialization', async function () { - const contentParam = encodeURIComponent(JSON.stringify({ - root: { - children: [ - { - children: [ - { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Hello word', - type: 'text', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - } - ], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - })); - - await initialize({page, uri: `/#/?content=${contentParam}`}); - - await expect(page.getByTestId('word-count')).toHaveText('2'); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.ts b/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.ts new file mode 100644 index 0000000000..d19ae35349 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/WordCountPlugin.test.ts @@ -0,0 +1,71 @@ +import {expect, test} from '@playwright/test'; +import {focusEditor, initialize, insertCard} from '../../utils/e2e'; +import type {Page} from '@playwright/test'; + +test.describe('Word Count Plugin', async function () { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('counts words in editor', async function () { + await focusEditor(page); + await expect(page.getByTestId('word-count')).toHaveText('0'); + await page.keyboard.type('Hello World'); + await expect(page.getByTestId('word-count')).toHaveText('2'); + }); + + test('counts words in nested editors', async function () { + await focusEditor(page); + await page.keyboard.type('Hello World'); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'callout'}); + await page.keyboard.type('Nested content'); + await expect(page.getByTestId('word-count')).toHaveText('4'); + }); + + test('counts words immediately after initialization', async function () { + const contentParam = encodeURIComponent(JSON.stringify({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Hello word', + type: 'text', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + + await initialize({page, uri: `/#/?content=${contentParam}`}); + + await expect(page.getByTestId('word-count')).toHaveText('2'); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/plus-button.test.js b/packages/koenig-lexical/test/e2e/plus-button.test.js deleted file mode 100644 index 4d4c642ec3..0000000000 --- a/packages/koenig-lexical/test/e2e/plus-button.test.js +++ /dev/null @@ -1,400 +0,0 @@ -import {assertHTML, assertPosition, assertSelection, focusEditor, html, initialize, insertCard} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Plus button', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('with caret', function () { - test('appears on empty editor', async function () { - await focusEditor(page); - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - }); - - test('moves when selection moves between empty paragraphs', async function () { - await focusEditor(page); - - // expect button to be positioned for first paragraph - const firstPara = await page.locator('[data-lexical-editor] > p'); - const firstParaRect = await firstPara.boundingBox(); - await assertPosition(page, '[data-kg-plus-button]', {y: firstParaRect.y}, {threshold: 5}); - - await page.keyboard.press('Enter'); - - // expect button to be positioned for second paragraph - const secondPara = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); - const secondParaRect = await secondPara.boundingBox(); - await assertPosition(page, '[data-kg-plus-button]', {y: secondParaRect.y}, {threshold: 5}); - - await page.keyboard.press('ArrowUp'); - // wait for selection change to be processed and plus button position to update - await page.waitForTimeout(50); - - // expect button to be positioned for first paragraph - await assertPosition(page, '[data-kg-plus-button]', {y: firstParaRect.y}, {threshold: 5}); - }); - - test('disappears when starting to type', async function () { - await focusEditor(page); - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - await page.keyboard.type('t'); - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('does not appear on list sections', async function () { - await focusEditor(page); - await page.keyboard.type('- '); - - // sanity checks for expected editor state - await assertHTML(page, html` -
      -

    • -
    - `); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0], - focusOffset: 0, - focusPath: [0, 0] - }); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('is shown after deleting all paragraph contents', async function () { - await focusEditor(page); - await page.keyboard.type('t'); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - - await page.keyboard.press('Backspace'); - await page.waitForSelector('p > br', {state: 'attached'}); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - }); - }); - - test.describe('with mouse movement', async function () { - test('appears over blank paragraphs', async function () { - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - - const pHandle = await page.locator('[data-lexical-editor] > p'); - await pHandle.hover(); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - }); - - test('moves when mouse moves', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - - const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(2); - const firstPHandleBox = await firstPHandle.boundingBox(); - await firstPHandle.hover(); - - await assertPosition(page, '[data-kg-plus-button]', {y: firstPHandleBox.y}, {threshold: 5}); - - const secondPHandle = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); - const secondPHandleBox = await secondPHandle.boundingBox(); - await secondPHandle.hover(); - - await assertPosition(page, '[data-kg-plus-button]', {y: secondPHandleBox.y}, {threshold: 5}); - }); - - test('does not appear over populated paragraphs', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - - const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(0); - await firstPHandle.hover(); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - const secondPHandle = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); - await secondPHandle.hover(); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('does not appear over list sections', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - await page.keyboard.type('- '); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - - const pHandle = await page.locator('[data-lexical-editor] > p'); - await pHandle.hover(); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - const listHandle = await page.locator('[data-lexical-editor] li'); - await listHandle.hover(); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('disappears from hovered p when typing on focused p', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - - const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(0); - await firstPHandle.hover(); - - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - await page.keyboard.type('T'); - - await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); - }); - - test('returns to caret position when over non-empty element', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - await page.keyboard.press('Enter'); - - const pHandle1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); - const pHandle2 = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); - const pHandle3 = await page.locator('[data-lexical-editor] > p:nth-of-type(3)'); - - const pHandle1Box = await pHandle1.boundingBox(); - const pHandle3Box = await pHandle3.boundingBox(); - - await assertPosition(page, '[data-kg-plus-button]', {y: pHandle3Box.y}, {threshold: 5}); - - await pHandle1.hover(); - - await assertPosition(page, '[data-kg-plus-button]', {y: pHandle1Box.y}, {threshold: 5}); - - await pHandle2.hover(); - - await assertPosition(page, '[data-kg-plus-button]', {y: pHandle3Box.y}, {threshold: 5}); - }); - - test('does not appear over an empty paragraph in a card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'callout'}); - - await expect(page.locator('[data-kg-plus-button]')).not.toBeVisible(); - - await page.locator('[data-kg-card="callout"] [data-lexical-editor] p').hover(); - - await expect(page.locator('[data-kg-plus-button]')).not.toBeVisible(); - }); - }); - - test.describe('menu', function () { - test('opens on button click', async function () { - await focusEditor(page); - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - await page.click('[data-kg-plus-button]'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - }); - - test('closes on click outside', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - await page.click('.koenig-lexical'); - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - }); - - test('does not close on click inside', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await page.click('[data-kg-plus-menu] [role="separator"] > span'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - }); - - test('closes on escape', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - await page.keyboard.press('Escape'); - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - }); - - test('does not move on empty p mouseover when open', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - - const p1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); - const p3 = await page.locator('[data-lexical-editor] > p:nth-of-type(3)'); - const p3Box = await p3.boundingBox(); - - await assertPosition(page, '[data-kg-plus-button]', {y: p3Box.y}, {threshold: 5}); - - await page.click('[data-kg-plus-button]'); - - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - await assertPosition(page, '[data-kg-plus-menu]', {y: p3Box.y}, {threshold: 5}); - - await p1.hover(); - - await assertPosition(page, '[data-kg-plus-button]', {y: p3Box.y}, {threshold: 5}); - await assertPosition(page, '[data-kg-plus-menu]', {y: p3Box.y}, {threshold: 5}); - }); - - test('moves cursor when opening', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - - const p1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); - await p1.hover(); - await page.click('[data-kg-plus-button]'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - }); - - test('closes when typing', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - - await page.keyboard.type('Test'); - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - expect(await page.$eval('[data-lexical-editor] > p', p => p.innerText)) - .toBe('Test'); - }); - - test('closes and moves focus on up/down', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.click('[data-kg-plus-button]'); - expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - - await page.keyboard.press('ArrowUp'); - // Wait for plus button to reposition after cursor move - await page.waitForTimeout(50); - - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - const p1 = await page.locator('[data-lexical-editor] > p').first(); - const p1Box = await p1.boundingBox(); - await assertPosition(page, '[data-kg-plus-button]', {y: p1Box.y}, {threshold: 5}); - }); - - test('inserts card and closes menu when card item clicked', async function () { - await focusEditor(page); - await page.click('[data-kg-plus-button]'); - await page.click('[data-kg-card-menu-item="Divider"]'); - - await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - }); - - test('deselects a selected card when plus button is clicked', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.click('[data-kg-card="horizontalrule"]'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - - const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); - await pHandle.hover(); - await page.click('[data-kg-plus-button]'); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); - - test('exits a card\'s edit mode when plus button is clicked', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.type('``` '); - await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); - await page.keyboard.type('# Test'); - - const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); - await pHandle.hover(); - await page.click('[data-kg-plus-button]'); - await page.waitForTimeout(200); - await expect(page.locator('[data-kg-card="codeblock"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/plus-button.test.ts b/packages/koenig-lexical/test/e2e/plus-button.test.ts new file mode 100644 index 0000000000..c5645f696e --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plus-button.test.ts @@ -0,0 +1,401 @@ +import {assertHTML, assertPosition, assertSelection, focusEditor, html, initialize, insertCard} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Plus button', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('with caret', function () { + test('appears on empty editor', async function () { + await focusEditor(page); + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + }); + + test('moves when selection moves between empty paragraphs', async function () { + await focusEditor(page); + + // expect button to be positioned for first paragraph + const firstPara = await page.locator('[data-lexical-editor] > p'); + const firstParaRect = await firstPara.boundingBox(); + await assertPosition(page, '[data-kg-plus-button]', {y: firstParaRect!.y}, {threshold: 5}); + + await page.keyboard.press('Enter'); + + // expect button to be positioned for second paragraph + const secondPara = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); + const secondParaRect = await secondPara.boundingBox(); + await assertPosition(page, '[data-kg-plus-button]', {y: secondParaRect!.y}, {threshold: 5}); + + await page.keyboard.press('ArrowUp'); + // wait for selection change to be processed and plus button position to update + await page.waitForTimeout(50); + + // expect button to be positioned for first paragraph + await assertPosition(page, '[data-kg-plus-button]', {y: firstParaRect!.y}, {threshold: 5}); + }); + + test('disappears when starting to type', async function () { + await focusEditor(page); + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + await page.keyboard.type('t'); + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('does not appear on list sections', async function () { + await focusEditor(page); + await page.keyboard.type('- '); + + // sanity checks for expected editor state + await assertHTML(page, html` +
      +

    • +
    + `); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0], + focusOffset: 0, + focusPath: [0, 0] + }); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('is shown after deleting all paragraph contents', async function () { + await focusEditor(page); + await page.keyboard.type('t'); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + + await page.keyboard.press('Backspace'); + await page.waitForSelector('p > br', {state: 'attached'}); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + }); + }); + + test.describe('with mouse movement', async function () { + test('appears over blank paragraphs', async function () { + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + + const pHandle = await page.locator('[data-lexical-editor] > p'); + await pHandle.hover(); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + }); + + test('moves when mouse moves', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + + const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(2); + const firstPHandleBox = await firstPHandle.boundingBox(); + await firstPHandle.hover(); + + await assertPosition(page, '[data-kg-plus-button]', {y: firstPHandleBox!.y}, {threshold: 5}); + + const secondPHandle = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); + const secondPHandleBox = await secondPHandle.boundingBox(); + await secondPHandle.hover(); + + await assertPosition(page, '[data-kg-plus-button]', {y: secondPHandleBox!.y}, {threshold: 5}); + }); + + test('does not appear over populated paragraphs', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + + const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(0); + await firstPHandle.hover(); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + const secondPHandle = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); + await secondPHandle.hover(); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('does not appear over list sections', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + await page.keyboard.type('- '); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + + const pHandle = await page.locator('[data-lexical-editor] > p'); + await pHandle.hover(); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + const listHandle = await page.locator('[data-lexical-editor] li'); + await listHandle.hover(); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('disappears from hovered p when typing on focused p', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + + const firstPHandle = await page.locator('[data-lexical-editor] > p').nth(0); + await firstPHandle.hover(); + + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + await page.keyboard.type('T'); + + await expect(await page.locator('[data-kg-plus-button]')).toHaveCount(0); + }); + + test('returns to caret position when over non-empty element', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + await page.keyboard.press('Enter'); + + const pHandle1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); + const pHandle2 = await page.locator('[data-lexical-editor] > p:nth-of-type(2)'); + const pHandle3 = await page.locator('[data-lexical-editor] > p:nth-of-type(3)'); + + const pHandle1Box = await pHandle1.boundingBox(); + const pHandle3Box = await pHandle3.boundingBox(); + + await assertPosition(page, '[data-kg-plus-button]', {y: pHandle3Box!.y}, {threshold: 5}); + + await pHandle1.hover(); + + await assertPosition(page, '[data-kg-plus-button]', {y: pHandle1Box!.y}, {threshold: 5}); + + await pHandle2.hover(); + + await assertPosition(page, '[data-kg-plus-button]', {y: pHandle3Box!.y}, {threshold: 5}); + }); + + test('does not appear over an empty paragraph in a card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'callout'}); + + await expect(page.locator('[data-kg-plus-button]')).not.toBeVisible(); + + await page.locator('[data-kg-card="callout"] [data-lexical-editor] p').hover(); + + await expect(page.locator('[data-kg-plus-button]')).not.toBeVisible(); + }); + }); + + test.describe('menu', function () { + test('opens on button click', async function () { + await focusEditor(page); + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + await page.click('[data-kg-plus-button]'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + }); + + test('closes on click outside', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + await page.click('.koenig-lexical'); + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + }); + + test('does not close on click inside', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await page.click('[data-kg-plus-menu] [role="separator"] > span'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + }); + + test('closes on escape', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + await page.keyboard.press('Escape'); + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + }); + + test('does not move on empty p mouseover when open', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + + const p1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); + const p3 = await page.locator('[data-lexical-editor] > p:nth-of-type(3)'); + const p3Box = await p3.boundingBox(); + + await assertPosition(page, '[data-kg-plus-button]', {y: p3Box!.y}, {threshold: 5}); + + await page.click('[data-kg-plus-button]'); + + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + await assertPosition(page, '[data-kg-plus-menu]', {y: p3Box!.y}, {threshold: 5}); + + await p1.hover(); + + await assertPosition(page, '[data-kg-plus-button]', {y: p3Box!.y}, {threshold: 5}); + await assertPosition(page, '[data-kg-plus-menu]', {y: p3Box!.y}, {threshold: 5}); + }); + + test('moves cursor when opening', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + + const p1 = await page.locator('[data-lexical-editor] > p:nth-of-type(1)'); + await p1.hover(); + await page.click('[data-kg-plus-button]'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + }); + + test('closes when typing', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + + await page.keyboard.type('Test'); + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + expect(await page.$eval('[data-lexical-editor] > p', p => (p as HTMLElement).innerText)) + .toBe('Test'); + }); + + test('closes and moves focus on up/down', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.click('[data-kg-plus-button]'); + expect(await page.locator('[data-kg-plus-menu]')).not.toBeNull(); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + + await page.keyboard.press('ArrowUp'); + // Wait for plus button to reposition after cursor move + await page.waitForTimeout(50); + + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + expect(await page.locator('[data-kg-plus-button]')).not.toBeNull(); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + const p1 = await page.locator('[data-lexical-editor] > p').first(); + const p1Box = await p1.boundingBox(); + await assertPosition(page, '[data-kg-plus-button]', {y: p1Box!.y}, {threshold: 5}); + }); + + test('inserts card and closes menu when card item clicked', async function () { + await focusEditor(page); + await page.click('[data-kg-plus-button]'); + await page.click('[data-kg-card-menu-item="Divider"]'); + + await expect(await page.locator('[data-kg-plus-menu]')).toHaveCount(0); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + }); + + test('deselects a selected card when plus button is clicked', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.click('[data-kg-card="horizontalrule"]'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + + const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); + await pHandle.hover(); + await page.click('[data-kg-plus-button]'); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); + + test('exits a card\'s edit mode when plus button is clicked', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.type('``` '); + await page.waitForSelector('[data-kg-card="codeblock"] .cm-editor'); + await page.keyboard.type('# Test'); + + const pHandle = await page.locator('[data-lexical-editor] > p').nth(0); + await pHandle.hover(); + await page.click('[data-kg-plus-button]'); + await page.waitForTimeout(200); + await expect(page.locator('[data-kg-card="codeblock"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/selection.test.js b/packages/koenig-lexical/test/e2e/selection.test.js deleted file mode 100644 index 09e2bb506e..0000000000 --- a/packages/koenig-lexical/test/e2e/selection.test.js +++ /dev/null @@ -1,132 +0,0 @@ -import {assertHTML, assertSelection, ctrlOrCmd, dragMouse, focusEditor, html, initialize} from '../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Selection behaviour', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can create range selection covering a card', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second paragraph'); - - const firstPBoundingBox = await page.locator('p').nth(0).boundingBox(); - const secondPBoundingBox = await page.locator('p').nth(1).boundingBox(); - - await dragMouse(page, firstPBoundingBox, secondPBoundingBox, 'start', 'end'); - - // make sure we're waiting for any card behaviours to finish - await page.waitForTimeout(100); - - await assertSelection(page, { - anchorPath: [0, 0, 0], - anchorOffset: 0, - focusPath: [2, 0, 0], - focusOffset: 16 - }); - }); - - test('cards do not show as selected in range selections', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second paragraph'); - - const firstPBoundingBox = await page.locator('p').nth(0).boundingBox(); - const secondPBoundingBox = await page.locator('p').nth(1).boundingBox(); - - await dragMouse(page, firstPBoundingBox, secondPBoundingBox, 'start', 'end'); - - await assertHTML(page, html` -

    First paragraph

    -
    -
    -
    -
    -
    -

    Second paragraph

    - `); - }); - - test.describe('select all - cmd + a', () => { - test('works with first and end nodes being paragraphs', async function () { - await focusEditor(page); - await page.keyboard.type('First paragraph'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.type('Second paragraph'); - - const modifier = ctrlOrCmd(page); - await page.keyboard.down(modifier); - await page.keyboard.press('a'); - await page.keyboard.up(modifier); - - await assertSelection(page, { - anchorPath: [0, 0, 0], - anchorOffset: 0, - focusPath: [2, 0, 0], - focusOffset: 16 - }); - }); - - test('works with first and end nodes being empty paragraphs', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - - const modifier = ctrlOrCmd(page); - await page.keyboard.down(modifier); - await page.keyboard.press('a'); - await page.keyboard.up(modifier); - - await assertSelection(page, { - anchorPath: [0], - anchorOffset: 0, - focusPath: [2], - focusOffset: 0 - }); - }); - - // // not sure why this is returning 0 for the focus offset.. this test DOES work, but offset should be 3 - // // TODO: may be related to why we don't see text selection while first and last nodes are cards/decorators? - // // if we spy on window.selection() we can see that the selection is correct (offset = 3), just not in the test - // test.only('works with first and end nodes being cards', async function () { - // await focusEditor(page); - // await page.keyboard.type('``` '); - // await page.keyboard.type('Some code'); - // await page.keyboard.press('Meta+Enter'); - - // await page.keyboard.type('Some text'); - // await page.keyboard.press('Enter'); - - // await page.keyboard.type('``` '); - // await page.keyboard.type('Some code'); - // await page.keyboard.press('Meta+Enter'); - - // await page.keyboard.down('Meta'); - // await page.keyboard.press('a'); - // await page.keyboard.up('Meta'); - - // await assertSelection(page, { - // anchorPath: [], - // anchorOffset: 0, - // focusPath: [], - // focusOffset: 0 - // }); - // }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/selection.test.ts b/packages/koenig-lexical/test/e2e/selection.test.ts new file mode 100644 index 0000000000..561a99c574 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/selection.test.ts @@ -0,0 +1,133 @@ +import {assertHTML, assertSelection, ctrlOrCmd, dragMouse, focusEditor, html, initialize} from '../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Selection behaviour', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can create range selection covering a card', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second paragraph'); + + const firstPBoundingBox = await page.locator('p').nth(0).boundingBox(); + const secondPBoundingBox = await page.locator('p').nth(1).boundingBox(); + + await dragMouse(page, firstPBoundingBox!, secondPBoundingBox!, 'start', 'end'); + + // make sure we're waiting for any card behaviours to finish + await page.waitForTimeout(100); + + await assertSelection(page, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [2, 0, 0], + focusOffset: 16 + }); + }); + + test('cards do not show as selected in range selections', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second paragraph'); + + const firstPBoundingBox = await page.locator('p').nth(0).boundingBox(); + const secondPBoundingBox = await page.locator('p').nth(1).boundingBox(); + + await dragMouse(page, firstPBoundingBox!, secondPBoundingBox!, 'start', 'end'); + + await assertHTML(page, html` +

    First paragraph

    +
    +
    +
    +
    +
    +

    Second paragraph

    + `); + }); + + test.describe('select all - cmd + a', () => { + test('works with first and end nodes being paragraphs', async function () { + await focusEditor(page); + await page.keyboard.type('First paragraph'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.type('Second paragraph'); + + const modifier = ctrlOrCmd(page); + await page.keyboard.down(modifier); + await page.keyboard.press('a'); + await page.keyboard.up(modifier); + + await assertSelection(page, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [2, 0, 0], + focusOffset: 16 + }); + }); + + test('works with first and end nodes being empty paragraphs', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + + const modifier = ctrlOrCmd(page); + await page.keyboard.down(modifier); + await page.keyboard.press('a'); + await page.keyboard.up(modifier); + + await assertSelection(page, { + anchorPath: [0], + anchorOffset: 0, + focusPath: [2], + focusOffset: 0 + }); + }); + + // // not sure why this is returning 0 for the focus offset.. this test DOES work, but offset should be 3 + // // TODO: may be related to why we don't see text selection while first and last nodes are cards/decorators? + // // if we spy on window.selection() we can see that the selection is correct (offset = 3), just not in the test + // test.only('works with first and end nodes being cards', async function () { + // await focusEditor(page); + // await page.keyboard.type('``` '); + // await page.keyboard.type('Some code'); + // await page.keyboard.press('Meta+Enter'); + + // await page.keyboard.type('Some text'); + // await page.keyboard.press('Enter'); + + // await page.keyboard.type('``` '); + // await page.keyboard.type('Some code'); + // await page.keyboard.press('Meta+Enter'); + + // await page.keyboard.down('Meta'); + // await page.keyboard.press('a'); + // await page.keyboard.up('Meta'); + + // await assertSelection(page, { + // anchorPath: [], + // anchorOffset: 0, + // focusPath: [], + // focusOffset: 0 + // }); + // }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/slash-menu.test.js b/packages/koenig-lexical/test/e2e/slash-menu.test.js deleted file mode 100644 index 29dadac10c..0000000000 --- a/packages/koenig-lexical/test/e2e/slash-menu.test.js +++ /dev/null @@ -1,352 +0,0 @@ -import {assertHTML, assertSelection, focusEditor, html, initialize, insertCard} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Slash menu', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('open/close', function () { - test('opens with / on blank paragraph', async function () { - await focusEditor(page); - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - await page.keyboard.type('/'); - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - }); - - test('opens with / on paragraph that is entirely selected', async function () { - await focusEditor(page); - await page.keyboard.type('testing'); - - const paragraph = await page.locator('[data-lexical-editor] > p'); - await paragraph.click({clickCount: 3}); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 7, - focusPath: [0, 0, 0] - }); - - await page.keyboard.type('/'); - - // sanity check that text was fully selected + replaced - await assertHTML(page, html`

    /

    `); - - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - }); - - test('opens with / + SHIFT', async function () { - await focusEditor(page); - await page.keyboard.down('Shift'); - await page.keyboard.type('/'); - await page.keyboard.up('Shift'); - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - }); - - test('does not open with / on populated paragraph', async function () { - await focusEditor(page); - await page.keyboard.type('testing'); - await page.keyboard.type('/'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - - await page.keyboard.press('Backspace'); - for (let i = 0; i < 'testing'.length; i++) { - await page.keyboard.press('ArrowLeft'); - } - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 0, 0], - focusOffset: 0, - focusPath: [0, 0, 0] - }); - - await page.keyboard.type('/'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - }); - - test('closes when / deleted', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - - await page.keyboard.press('Backspace'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - }); - - test('closes on Escape', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('Escape'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - - await assertSelection(page, { - anchorOffset: 1, - anchorPath: [0, 0, 0], - focusOffset: 1, - focusPath: [0, 0, 0] - }); - }); - - test('closes on click outside menu', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('body'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - }); - - test('does not close on click inside menu', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.click('[data-kg-slash-menu] [role="separator"] > span'); // better selector for menu headings? - - await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); - }); - - test('does not re-open when cursor placed back on /', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('/'); - await page.click('body'); - await page.click('[data-lexical-editor] > p:nth-of-type(2)'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - - // TODO: this fails in CI but passes locally - // await assertSelection(page, { - // anchorOffset: 1, - // anchorPath: [1, 0, 0], - // focusOffset: 1, - // focusPath: [1, 0, 0] - // }); - - // Temp workaround for above to ensure the focus is in the right place - await page.keyboard.type('words'); - await assertHTML(page, html` -


    -

    /words

    - `); - }); - }); - - test.describe('filtering', function () { - test('matches text after /', async function () { - await focusEditor(page); - await page.keyboard.type('/img'); - - const menuItems = page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems).toHaveCount(1); - - await expect(menuItems.first()).toContainText('Image'); - }); - - test('shows no menu with no matches', async function () { - await focusEditor(page); - await page.keyboard.type('/unknown'); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - }); - }); - - test.describe('selection', function () { - test('first item is selected when opening', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); - }); - - test('DOWN selects next item', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('ArrowDown'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); - await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - }); - - test('RIGHT selects next item', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('ArrowRight'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); - await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - }); - - test('UP selects previous item', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowUp'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); - }); - - test('LEFT selects previous time', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowLeft'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); - }); - - test('first item is selected after changing query', async function () { - await focusEditor(page); - await page.keyboard.type('/'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.type('hr'); - - const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); - await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); - }); - }); - - test.describe('insertion', function () { - test('ENTER inserts card', async function () { - await focusEditor(page); - await insertCard(page, {cardName: 'divider'}); - - await assertHTML(page, html` -
    -

    -
    -


    - `); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1], - focusOffset: 0, - focusPath: [1] - }); - - await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); - }); - - test('has correct order when inserting after text', async function () { - await focusEditor(page); - await page.keyboard.type('Testing'); - await page.keyboard.press('Enter'); - await insertCard(page, {cardName: 'divider'}); - - await assertHTML(page, html` -

    Testing

    -
    -

    -
    -


    - `); - - // HR card puts focus on paragraph after insert - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [2], - focusOffset: 0, - focusPath: [2] - }); - }); - - test('has correct order when inserting after a card', async function () { - await focusEditor(page); - await page.keyboard.type('/hr'); - await page.waitForSelector('li:first-child > [data-kg-card-menu-item="Divider"]'); - await page.keyboard.press('Enter'); - await page.keyboard.type('/img'); - await page.waitForSelector('li:first-child > [data-kg-card-menu-item="Image"]'); - await page.keyboard.press('Enter'); - - // image card retains focus after insert - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - - test('uses query params', async function () { - await focusEditor(page); - await page.keyboard.type('/image https://example.com/image.jpg'); - await expect(await page.locator('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]')).toBeVisible(); - await page.keyboard.press('Enter'); - await expect(await page.locator('[data-kg-card="image"]')).toBeVisible(); - - await assertHTML(page, html` -
    -
    -
    -


    - `, {ignoreCardContents: true}); - - expect(await page.evaluate(() => { - return document.querySelector('[data-kg-card="image"] img').src; - })).toEqual('https://example.com/image.jpg'); - }); - - test('can insert card at beginning of document before text', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - // todo: flaky test, added delay for slower typing to imitate user behaviour - // need to add retry instead of delay after migration to playwright if the problem persists - await page.keyboard.type('Testing',{delay: 100}); - await page.keyboard.press('ArrowUp', {delay: 100}); - await insertCard(page, {cardName: 'callout'}); - - await assertHTML(page, html` -
    -
    -
    -

    Testing

    - `, {ignoreCardContents: true}); - }); - - test('can insert card at beginning of document before card', async function () { - await focusEditor(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await insertCard(page, {cardName: 'callout'}); - - await assertHTML(page, html` -
    -
    -
    -
    -
    -
    -


    - `, {ignoreCardContents: true}); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/slash-menu.test.ts b/packages/koenig-lexical/test/e2e/slash-menu.test.ts new file mode 100644 index 0000000000..beaa6bb857 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/slash-menu.test.ts @@ -0,0 +1,353 @@ +import {assertHTML, assertSelection, focusEditor, html, initialize, insertCard} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Slash menu', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('open/close', function () { + test('opens with / on blank paragraph', async function () { + await focusEditor(page); + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + await page.keyboard.type('/'); + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + }); + + test('opens with / on paragraph that is entirely selected', async function () { + await focusEditor(page); + await page.keyboard.type('testing'); + + const paragraph = await page.locator('[data-lexical-editor] > p'); + await paragraph.click({clickCount: 3}); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 7, + focusPath: [0, 0, 0] + }); + + await page.keyboard.type('/'); + + // sanity check that text was fully selected + replaced + await assertHTML(page, html`

    /

    `); + + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + }); + + test('opens with / + SHIFT', async function () { + await focusEditor(page); + await page.keyboard.down('Shift'); + await page.keyboard.type('/'); + await page.keyboard.up('Shift'); + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + }); + + test('does not open with / on populated paragraph', async function () { + await focusEditor(page); + await page.keyboard.type('testing'); + await page.keyboard.type('/'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + + await page.keyboard.press('Backspace'); + for (let i = 0; i < 'testing'.length; i++) { + await page.keyboard.press('ArrowLeft'); + } + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0] + }); + + await page.keyboard.type('/'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + }); + + test('closes when / deleted', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + + await page.keyboard.press('Backspace'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + }); + + test('closes on Escape', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('Escape'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + + await assertSelection(page, { + anchorOffset: 1, + anchorPath: [0, 0, 0], + focusOffset: 1, + focusPath: [0, 0, 0] + }); + }); + + test('closes on click outside menu', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('body'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + }); + + test('does not close on click inside menu', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.click('[data-kg-slash-menu] [role="separator"] > span'); // better selector for menu headings? + + await expect(page.locator('[data-kg-slash-menu]')).toBeVisible(); + }); + + test('does not re-open when cursor placed back on /', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('/'); + await page.click('body'); + await page.click('[data-lexical-editor] > p:nth-of-type(2)'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + + // TODO: this fails in CI but passes locally + // await assertSelection(page, { + // anchorOffset: 1, + // anchorPath: [1, 0, 0], + // focusOffset: 1, + // focusPath: [1, 0, 0] + // }); + + // Temp workaround for above to ensure the focus is in the right place + await page.keyboard.type('words'); + await assertHTML(page, html` +


    +

    /words

    + `); + }); + }); + + test.describe('filtering', function () { + test('matches text after /', async function () { + await focusEditor(page); + await page.keyboard.type('/img'); + + const menuItems = page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems).toHaveCount(1); + + await expect(menuItems.first()).toContainText('Image'); + }); + + test('shows no menu with no matches', async function () { + await focusEditor(page); + await page.keyboard.type('/unknown'); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + }); + }); + + test.describe('selection', function () { + test('first item is selected when opening', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); + }); + + test('DOWN selects next item', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('ArrowDown'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); + await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + }); + + test('RIGHT selects next item', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('ArrowRight'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); + await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + }); + + test('UP selects previous item', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowUp'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); + }); + + test('LEFT selects previous time', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowLeft'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + await expect(menuItems.nth(1)).toHaveAttribute('data-kg-cardmenu-selected', 'false'); + }); + + test('first item is selected after changing query', async function () { + await focusEditor(page); + await page.keyboard.type('/'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('hr'); + + const menuItems = await page.locator('[data-kg-slash-menu] [role="menuitem"]'); + await expect(menuItems.nth(0)).toHaveAttribute('data-kg-cardmenu-selected', 'true'); + }); + }); + + test.describe('insertion', function () { + test('ENTER inserts card', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'divider'}); + + await assertHTML(page, html` +
    +

    +
    +


    + `); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1] + }); + + await expect(page.locator('[data-kg-slash-menu]')).not.toBeVisible(); + }); + + test('has correct order when inserting after text', async function () { + await focusEditor(page); + await page.keyboard.type('Testing'); + await page.keyboard.press('Enter'); + await insertCard(page, {cardName: 'divider'}); + + await assertHTML(page, html` +

    Testing

    +
    +

    +
    +


    + `); + + // HR card puts focus on paragraph after insert + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [2], + focusOffset: 0, + focusPath: [2] + }); + }); + + test('has correct order when inserting after a card', async function () { + await focusEditor(page); + await page.keyboard.type('/hr'); + await page.waitForSelector('li:first-child > [data-kg-card-menu-item="Divider"]'); + await page.keyboard.press('Enter'); + await page.keyboard.type('/img'); + await page.waitForSelector('li:first-child > [data-kg-card-menu-item="Image"]'); + await page.keyboard.press('Enter'); + + // image card retains focus after insert + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + + test('uses query params', async function () { + await focusEditor(page); + await page.keyboard.type('/image https://example.com/image.jpg'); + await expect(await page.locator('[data-kg-card-menu-item="Image"][data-kg-cardmenu-selected="true"]')).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(await page.locator('[data-kg-card="image"]')).toBeVisible(); + + await assertHTML(page, html` +
    +
    +
    +


    + `, {ignoreCardContents: true}); + + expect(await page.evaluate(() => { + return (document.querySelector('[data-kg-card="image"] img') as HTMLImageElement).src; + })).toEqual('https://example.com/image.jpg'); + }); + + test('can insert card at beginning of document before text', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + // todo: flaky test, added delay for slower typing to imitate user behaviour + // need to add retry instead of delay after migration to playwright if the problem persists + await page.keyboard.type('Testing',{delay: 100}); + await page.keyboard.press('ArrowUp', {delay: 100}); + await insertCard(page, {cardName: 'callout'}); + + await assertHTML(page, html` +
    +
    +
    +

    Testing

    + `, {ignoreCardContents: true}); + }); + + test('can insert card at beginning of document before card', async function () { + await focusEditor(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await insertCard(page, {cardName: 'callout'}); + + await assertHTML(page, html` +
    +
    +
    +
    +
    +
    +


    + `, {ignoreCardContents: true}); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.js b/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.js deleted file mode 100644 index 5dbaa91ae0..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Renders code block node', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('renders code block node in edit mode', async function () { - await focusEditor(page); - await page.keyboard.type('```javascript '); - await assertHTML(page, html` -
    -
    -
    -
    - `, {ignoreCardContents: true}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.ts new file mode 100644 index 0000000000..7473b2a5ab --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/code-block.test.ts @@ -0,0 +1,30 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Renders code block node', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('renders code block node in edit mode', async function () { + await focusEditor(page); + await page.keyboard.type('```javascript '); + await assertHTML(page, html` +
    +
    +
    +
    + `, {ignoreCardContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.js b/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.js deleted file mode 100644 index a735055b0e..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Renders horizontal line rule', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('emdash', () => { - test('renders with text and space on either side', async function () { - await focusEditor(page); - await page.keyboard.type('text--- '); - await assertHTML(page, html`

    text—

    `); - }); - - test('renders with text and text on either side', async function () { - await focusEditor(page); - await page.keyboard.type('text---text'); - await assertHTML(page, html`

    text—text

    `); - }); - - test('renders with space and space on either side', async function () { - await focusEditor(page); - await page.keyboard.type('text --- '); - await assertHTML(page, html`

    text —

    `); - }); - - test('renders in the middle of text', async function () { - await focusEditor(page); - await page.keyboard.type('texttext'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.type('---'); - - await assertHTML(page, html`

    text—text

    `); - }); - }); - - test.describe('endash', () => { - test('renders with text and space on either side', async function () { - await focusEditor(page); - await page.keyboard.type('text-- '); - await assertHTML(page, html`

    text–

    `); - }); - - test('renders with space and space on either side', async function () { - await focusEditor(page); - await page.keyboard.type('text -- '); - await assertHTML(page, html`

    text –

    `); - }); - - test('does render in the middle of text with space', async function () { - await focusEditor(page); - await page.keyboard.type('texttext'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.type('-- '); - await assertHTML(page, html`

    text– text

    `); - }); - - test('does not render in the middle of text without space', async function () { - await focusEditor(page); - await page.keyboard.type('texttext'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.type('--'); - await assertHTML(page, html`

    text--text

    `); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.ts new file mode 100644 index 0000000000..a8dda8a724 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/emdash-endash.test.ts @@ -0,0 +1,87 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Renders horizontal line rule', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('emdash', () => { + test('renders with text and space on either side', async function () { + await focusEditor(page); + await page.keyboard.type('text--- '); + await assertHTML(page, html`

    text—

    `); + }); + + test('renders with text and text on either side', async function () { + await focusEditor(page); + await page.keyboard.type('text---text'); + await assertHTML(page, html`

    text—text

    `); + }); + + test('renders with space and space on either side', async function () { + await focusEditor(page); + await page.keyboard.type('text --- '); + await assertHTML(page, html`

    text —

    `); + }); + + test('renders in the middle of text', async function () { + await focusEditor(page); + await page.keyboard.type('texttext'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('---'); + + await assertHTML(page, html`

    text—text

    `); + }); + }); + + test.describe('endash', () => { + test('renders endash with text and space on either side', async function () { + await focusEditor(page); + await page.keyboard.type('text-- '); + await assertHTML(page, html`

    text–

    `); + }); + + test('renders endash with space and space on either side', async function () { + await focusEditor(page); + await page.keyboard.type('text -- '); + await assertHTML(page, html`

    text –

    `); + }); + + test('does render in the middle of text with space', async function () { + await focusEditor(page); + await page.keyboard.type('texttext'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('-- '); + await assertHTML(page, html`

    text– text

    `); + }); + + test('does not render in the middle of text without space', async function () { + await focusEditor(page); + await page.keyboard.type('texttext'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('--'); + await assertHTML(page, html`

    text--text

    `); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/headings.test.js b/packages/koenig-lexical/test/e2e/text-transforms/headings.test.js deleted file mode 100644 index d5f00d654c..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/headings.test.js +++ /dev/null @@ -1,219 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Text transforms > Headings', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('on blank paragraph', function () { - const BASIC_TRANSFORMS = [{ - text: '# ', - html: html`


    ` - }, { - text: '# Test', - html: html`

    Test

    ` - }, { - text: '## Test', - html: html`

    Test

    ` - }, { - text: '### Test', - html: html`

    Test

    ` - }, { - text: '#### Test', - html: html`

    Test

    ` - }, { - text: '##### Test', - html: html`
    Test
    ` - }, { - text: '###### Test', - html: html`
    Test
    ` - }, { - text: '####### Test', - html: html`

    ####### Test

    ` - }]; - - BASIC_TRANSFORMS.forEach((testCase) => { - test(`${testCase.text} -> heading`, async function () { - await focusEditor(page); - await page.keyboard.type(testCase.text); - await assertHTML(page, testCase.html); - }); - }); - }); - - test.describe('on paragraph with text', function () { - test('"# " before plain text converts to heading', async function () { - await focusEditor(page); - await page.keyboard.type('existing text'); - await assertHTML(page, html`

    existing text

    `); - - // move caret to beginning of line via mouse - const pHandle = await page.locator('div[contenteditable="true"] p'); - const pBox = await pHandle.boundingBox(); - await page.mouse.click(pBox.x + 1, pBox.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html`

    existing text

    `); - }); - - test('"# " before formatted text converts to heading', async function () { - await focusEditor(page); - await page.keyboard.type('existing **formatted** text'); - await assertHTML(page, html` -

    - existing - formatted - text -

    - `); - - // move caret to beginning of line - const pHandle = await page.locator('div[contenteditable="true"] p'); - const pBox = await pHandle.boundingBox(); - await page.mouse.click(pBox.x + 1, pBox.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html` -

    - existing - formatted - text -

    - `); - }); - }); - - test.describe('on existing heading', function () { - test('"# " before existing h1 text removes "# "', async function () { - await focusEditor(page); - await page.keyboard.type('# existing h1'); - await assertHTML(page, html` -

    - existing h1 -

    - `); - - // move caret to beginning of line - const h1Handle = await page.locator('div[contenteditable="true"] h1'); - const h1Box = await h1Handle.boundingBox(); - await page.mouse.click(h1Box.x + 1, h1Box.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html` -

    - existing h1 -

    - `); - }); - - test('"# " before existing h2 text converts to h1', async function () { - await focusEditor(page); - await page.keyboard.type('## existing h2'); - await assertHTML(page, html` -

    - existing h2 -

    - `); - - // move caret to beginning of line - const h2Handle = await page.locator('div[contenteditable="true"] h2'); - const h2Box = await h2Handle.boundingBox(); - await page.mouse.click(h2Box.x + 1, h2Box.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html` -

    - existing h2 -

    - `); - }); - - test('"##" before existing h1 text converts to h2', async function () { - await focusEditor(page); - await page.keyboard.type('# existing h1'); - await assertHTML(page, html` -

    - existing h1 -

    - `); - - // move caret to beginning of line - const h1Handle = await page.locator('div[contenteditable="true"] h1'); - const h1Box = await h1Handle.boundingBox(); - await page.mouse.click(h1Box.x + 1, h1Box.y + 5); - - // type `## ` at beginning to convert to heading - await page.keyboard.type('## '); - await assertHTML(page, html` -

    - existing h1 -

    - `); - }); - }); - - test.describe('on lists', function () { - // TODO: core lexical behaviour differs from our mobiledoc editor here - test.skip('"# " before list item converts list to h1', async function () { - await focusEditor(page); - await page.keyboard.type('- list item'); - await assertHTML(page, html` -
      -
    • list item
    • -
    - `); - - // move caret to beginning of list item - const liHandle = await page.locator('div[contenteditable="true"] li'); - const liBox = await liHandle.boundingBox(); - await page.mouse.click(liBox.x + 1, liBox.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html` -

    - list item -

    - `); - }); - }); - - test.describe('on quotes', function () { - test('"# " at beginning of blockquote converts to h1', async function () { - await focusEditor(page); - await page.keyboard.type('> '); - await assertHTML(page, html` -

    - `); - - // move caret to beginning of quote - const bqHandle = await page.locator('div[contenteditable="true"] blockquote'); - const bqBox = await bqHandle.boundingBox(); - await page.mouse.click(bqBox.x + 1, bqBox.y + 5); - - // type `# ` at beginning to convert to heading - await page.keyboard.type('# '); - await assertHTML(page, html` -


    - `); - }); - - // TODO: add aside node support - //test.fixme('aside #\\s -> h1'); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/headings.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/headings.test.ts new file mode 100644 index 0000000000..8e727dca77 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/headings.test.ts @@ -0,0 +1,220 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Text transforms > Headings', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('on blank paragraph', function () { + const BASIC_TRANSFORMS = [{ + text: '# ', + html: html`


    ` + }, { + text: '# Test', + html: html`

    Test

    ` + }, { + text: '## Test', + html: html`

    Test

    ` + }, { + text: '### Test', + html: html`

    Test

    ` + }, { + text: '#### Test', + html: html`

    Test

    ` + }, { + text: '##### Test', + html: html`
    Test
    ` + }, { + text: '###### Test', + html: html`
    Test
    ` + }, { + text: '####### Test', + html: html`

    ####### Test

    ` + }]; + + BASIC_TRANSFORMS.forEach((testCase) => { + test(`${testCase.text} -> heading`, async function () { + await focusEditor(page); + await page.keyboard.type(testCase.text); + await assertHTML(page, testCase.html); + }); + }); + }); + + test.describe('on paragraph with text', function () { + test('"# " before plain text converts to heading', async function () { + await focusEditor(page); + await page.keyboard.type('existing text'); + await assertHTML(page, html`

    existing text

    `); + + // move caret to beginning of line via mouse + const pHandle = await page.locator('div[contenteditable="true"] p'); + const pBox = await pHandle.boundingBox(); + await page.mouse.click(pBox!.x + 1, pBox!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html`

    existing text

    `); + }); + + test('"# " before formatted text converts to heading', async function () { + await focusEditor(page); + await page.keyboard.type('existing **formatted** text'); + await assertHTML(page, html` +

    + existing + formatted + text +

    + `); + + // move caret to beginning of line + const pHandle = await page.locator('div[contenteditable="true"] p'); + const pBox = await pHandle.boundingBox(); + await page.mouse.click(pBox!.x + 1, pBox!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html` +

    + existing + formatted + text +

    + `); + }); + }); + + test.describe('on existing heading', function () { + test('"# " before existing h1 text removes "# "', async function () { + await focusEditor(page); + await page.keyboard.type('# existing h1'); + await assertHTML(page, html` +

    + existing h1 +

    + `); + + // move caret to beginning of line + const h1Handle = await page.locator('div[contenteditable="true"] h1'); + const h1Box = await h1Handle.boundingBox(); + await page.mouse.click(h1Box!.x + 1, h1Box!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html` +

    + existing h1 +

    + `); + }); + + test('"# " before existing h2 text converts to h1', async function () { + await focusEditor(page); + await page.keyboard.type('## existing h2'); + await assertHTML(page, html` +

    + existing h2 +

    + `); + + // move caret to beginning of line + const h2Handle = await page.locator('div[contenteditable="true"] h2'); + const h2Box = await h2Handle.boundingBox(); + await page.mouse.click(h2Box!.x + 1, h2Box!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html` +

    + existing h2 +

    + `); + }); + + test('"##" before existing h1 text converts to h2', async function () { + await focusEditor(page); + await page.keyboard.type('# existing h1'); + await assertHTML(page, html` +

    + existing h1 +

    + `); + + // move caret to beginning of line + const h1Handle = await page.locator('div[contenteditable="true"] h1'); + const h1Box = await h1Handle.boundingBox(); + await page.mouse.click(h1Box!.x + 1, h1Box!.y + 5); + + // type `## ` at beginning to convert to heading + await page.keyboard.type('## '); + await assertHTML(page, html` +

    + existing h1 +

    + `); + }); + }); + + test.describe('on lists', function () { + // TODO: core lexical behaviour differs from our mobiledoc editor here + test.skip('"# " before list item converts list to h1', async function () { + await focusEditor(page); + await page.keyboard.type('- list item'); + await assertHTML(page, html` +
      +
    • list item
    • +
    + `); + + // move caret to beginning of list item + const liHandle = await page.locator('div[contenteditable="true"] li'); + const liBox = await liHandle.boundingBox(); + await page.mouse.click(liBox!.x + 1, liBox!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html` +

    + list item +

    + `); + }); + }); + + test.describe('on quotes', function () { + test('"# " at beginning of blockquote converts to h1', async function () { + await focusEditor(page); + await page.keyboard.type('> '); + await assertHTML(page, html` +

    + `); + + // move caret to beginning of quote + const bqHandle = await page.locator('div[contenteditable="true"] blockquote'); + const bqBox = await bqHandle.boundingBox(); + await page.mouse.click(bqBox!.x + 1, bqBox!.y + 5); + + // type `# ` at beginning to convert to heading + await page.keyboard.type('# '); + await assertHTML(page, html` +


    + `); + }); + + // TODO: add aside node support + //test.fixme('aside #\\s -> h1'); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.js b/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.js deleted file mode 100644 index 3d83de7d72..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Renders horizontal line rule', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('renders horizontal line rule', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await assertHTML(page, html` -
    -
    -
    -
    -
    -


    - `); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.ts new file mode 100644 index 0000000000..e24700d76b --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/horizontal-line-rule.test.ts @@ -0,0 +1,32 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Renders horizontal line rule', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('renders horizontal line rule', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await assertHTML(page, html` +
    +
    +
    +
    +
    +


    + `); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/links.test.js b/packages/koenig-lexical/test/e2e/text-transforms/links.test.js deleted file mode 100644 index 5d522024c4..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/links.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, pasteText, selectBackwards} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Links', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('converts selected text to link on url paste', async function () { - await focusEditor(page); - await page.keyboard.type('link'); - await selectBackwards(page, 4); - await pasteText(page, 'https://koenig.ghost.org'); - await assertHTML(page, html` -

    - - link - -

    - `); - }); - - test('does not convert text to link if pasting a non-url', async function () { - await focusEditor(page); - await page.keyboard.type('link'); - await selectBackwards(page, 4); - await pasteText(page, 'Hello Koenig'); - await assertHTML(page, html` -

    - Hello Koenig -

    - `); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/links.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/links.test.ts new file mode 100644 index 0000000000..b7fb56406d --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/links.test.ts @@ -0,0 +1,45 @@ +import {assertHTML, focusEditor, html, initialize, pasteText, selectBackwards} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Links', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('converts selected text to link on url paste', async function () { + await focusEditor(page); + await page.keyboard.type('link'); + await selectBackwards(page, 4); + await pasteText(page, 'https://koenig.ghost.org'); + await assertHTML(page, html` +

    + + link + +

    + `); + }); + + test('does not convert text to link if pasting a non-url', async function () { + await focusEditor(page); + await page.keyboard.type('link'); + await selectBackwards(page, 4); + await pasteText(page, 'Hello Koenig'); + await assertHTML(page, html` +

    + Hello Koenig +

    + `); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.js b/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.js deleted file mode 100644 index b835b45fa5..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.js +++ /dev/null @@ -1,166 +0,0 @@ -import {assertHTML, focusEditor, html, initialize, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; - -const HEADLINE_TRANSFORMS = [{ - text: '# ', - html: html`


    ` -}, { - text: '# Test', - html: html`

    Test

    ` -}, { - text: '## Test', - html: html`

    Test

    ` -}, { - text: '### Test', - html: html`

    Test

    ` -}, { - text: '#### Test', - html: html`

    Test

    ` -}, { - text: '##### Test', - html: html`
    Test
    ` -}, { - text: '###### Test', - html: html`
    Test
    ` -}, { - text: '####### Test', - html: html`

    ####### Test

    ` -}]; - -const EMPHASIS_TRANSFORMS = [{ - text: '**This is bold text**', - html: html`

    This is bold text

    ` -}, { - text: '__This is bold text__', - html: html`

    This is bold text

    ` -}, { - text: '*This is italic text*', - html: html`

    This is italic text

    ` -}, { - text: '_This is italic text_', - html: html`

    This is italic text

    ` -}, { - text: '~~Strikethrough~~', - html: html`

    Strikethrough

    ` -}]; - -const SPECIAL_MARKUP_TRANSFORMS = [{ - text: '~~strikethrough~~', - html: html`

    strikethrough

    ` -}, { - text: '`code`', - html: html`

    code

    ` -}, { - text: '^superscript^', - html: html`

    superscript

    ` -}, { - text: '~subscript~', - html: html`

    subscript

    ` -}]; - -test.describe('Markdown', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('converts markdown img to html', async function () { - await focusEditor(page); - await pasteText(page, '![Image](https://octodex.github.com/images/minion.png)'); - - await expect(await page.getByTestId('image-card-populated')).toBeVisible(); - await expect(await page.locator('img')).toHaveAttribute('src', 'https://octodex.github.com/images/minion.png'); - }); - - test('converts markdown link to html', async function () { - await focusEditor(page); - await pasteText(page, '[link](https://ghost.org/)'); - - await expect(await page.locator('a[href="https://ghost.org/"]')).toBeVisible(); - }); - - test('converts to code card', async function () { - await focusEditor(page); - await pasteText(page, ` - // Some comments - line 1 of code - line 2 of code - line 3 of code - `); - - await expect(await page.locator('[data-kg-card="codeblock"]')).toBeVisible(); - }); - - test('converts --- to hr', async function () { - await focusEditor(page); - await pasteText(page, '---'); - - await expect(await page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); - }); - - test.describe('converts ## to headlines', async function () { - HEADLINE_TRANSFORMS.forEach((testCase) => { - test(`${testCase.text} -> heading`, async function () { - await focusEditor(page); - await pasteText(page, testCase.text); - await assertHTML(page, testCase.html); - }); - }); - }); - - test.describe('converts emphasis to html', async function () { - EMPHASIS_TRANSFORMS.forEach((testCase) => { - test(`${testCase.text}`, async function () { - await focusEditor(page); - await pasteText(page, testCase.text); - await assertHTML(page, testCase.html); - }); - }); - }); - - test.describe('backspace undoes special markdown', async function () { - SPECIAL_MARKUP_TRANSFORMS.forEach((testCase) => { - test(`${testCase.text}`, async function () { - await focusEditor(page); - await pasteText(page, testCase.text); - await assertHTML(page, testCase.html); - await page.keyboard.press('Backspace'); - await assertHTML(page, html`

    ${testCase.text.slice(0,-1)}

    `); - }); - }); - }); - - test('does not convert markdown to html if pasting with shift', async function () { - await focusEditor(page); - await page.keyboard.down('Shift'); - await pasteText(page, ` - [link](https://ghost.org/) - - --- - - You will like those - projects! - - ![Image](https://octodex.github.com/images/minion.png) - `); - await expect(await page.getByTestId('image-card-populated')).toHaveCount(0); - await expect(await page.locator('a[href="https://ghost.org/"]')).toHaveCount(0); - await expect(await page.locator('[data-kg-card="horizontalrule"]')).toHaveCount(0); - }); - - test('converts table to html card', async function () { - await focusEditor(page); - await pasteText(page, '
    MonthSavings
    January$100
    February$80
    '); - - await expect(page.locator('[data-kg-card="html"]')).toBeVisible(); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.ts new file mode 100644 index 0000000000..9896d9d691 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/markdown.test.ts @@ -0,0 +1,167 @@ +import {assertHTML, focusEditor, html, initialize, pasteText} from '../../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +const HEADLINE_TRANSFORMS = [{ + text: '# ', + html: html`


    ` +}, { + text: '# Test', + html: html`

    Test

    ` +}, { + text: '## Test', + html: html`

    Test

    ` +}, { + text: '### Test', + html: html`

    Test

    ` +}, { + text: '#### Test', + html: html`

    Test

    ` +}, { + text: '##### Test', + html: html`
    Test
    ` +}, { + text: '###### Test', + html: html`
    Test
    ` +}, { + text: '####### Test', + html: html`

    ####### Test

    ` +}]; + +const EMPHASIS_TRANSFORMS = [{ + text: '**This is bold text**', + html: html`

    This is bold text

    ` +}, { + text: '__This is bold text__', + html: html`

    This is bold text

    ` +}, { + text: '*This is italic text*', + html: html`

    This is italic text

    ` +}, { + text: '_This is italic text_', + html: html`

    This is italic text

    ` +}, { + text: '~~Strikethrough~~', + html: html`

    Strikethrough

    ` +}]; + +const SPECIAL_MARKUP_TRANSFORMS = [{ + text: '~~strikethrough~~', + html: html`

    strikethrough

    ` +}, { + text: '`code`', + html: html`

    code

    ` +}, { + text: '^superscript^', + html: html`

    superscript

    ` +}, { + text: '~subscript~', + html: html`

    subscript

    ` +}]; + +test.describe('Markdown', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('converts markdown img to html', async function () { + await focusEditor(page); + await pasteText(page, '![Image](https://octodex.github.com/images/minion.png)'); + + await expect(await page.getByTestId('image-card-populated')).toBeVisible(); + await expect(await page.locator('img')).toHaveAttribute('src', 'https://octodex.github.com/images/minion.png'); + }); + + test('converts markdown link to html', async function () { + await focusEditor(page); + await pasteText(page, '[link](https://ghost.org/)'); + + await expect(await page.locator('a[href="https://ghost.org/"]')).toBeVisible(); + }); + + test('converts to code card', async function () { + await focusEditor(page); + await pasteText(page, ` + // Some comments + line 1 of code + line 2 of code + line 3 of code + `); + + await expect(await page.locator('[data-kg-card="codeblock"]')).toBeVisible(); + }); + + test('converts --- to hr', async function () { + await focusEditor(page); + await pasteText(page, '---'); + + await expect(await page.locator('[data-kg-card="horizontalrule"]')).toBeVisible(); + }); + + test.describe('converts ## to headlines', async function () { + HEADLINE_TRANSFORMS.forEach((testCase) => { + test(`${testCase.text} -> heading`, async function () { + await focusEditor(page); + await pasteText(page, testCase.text); + await assertHTML(page, testCase.html); + }); + }); + }); + + test.describe('converts emphasis to html', async function () { + EMPHASIS_TRANSFORMS.forEach((testCase) => { + test(`${testCase.text}`, async function () { + await focusEditor(page); + await pasteText(page, testCase.text); + await assertHTML(page, testCase.html); + }); + }); + }); + + test.describe('backspace undoes special markdown', async function () { + SPECIAL_MARKUP_TRANSFORMS.forEach((testCase) => { + test(`${testCase.text}`, async function () { + await focusEditor(page); + await pasteText(page, testCase.text); + await assertHTML(page, testCase.html); + await page.keyboard.press('Backspace'); + await assertHTML(page, html`

    ${testCase.text.slice(0,-1)}

    `); + }); + }); + }); + + test('does not convert markdown to html if pasting with shift', async function () { + await focusEditor(page); + await page.keyboard.down('Shift'); + await pasteText(page, ` + [link](https://ghost.org/) + + --- + + You will like those + projects! + + ![Image](https://octodex.github.com/images/minion.png) + `); + await expect(await page.getByTestId('image-card-populated')).toHaveCount(0); + await expect(await page.locator('a[href="https://ghost.org/"]')).toHaveCount(0); + await expect(await page.locator('[data-kg-card="horizontalrule"]')).toHaveCount(0); + }); + + test('converts table to html card', async function () { + await focusEditor(page); + await pasteText(page, '
    MonthSavings
    January$100
    February$80
    '); + + await expect(page.locator('[data-kg-card="html"]')).toBeVisible(); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.js b/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.js deleted file mode 100644 index 87ffa977b5..0000000000 --- a/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; -import {test} from '@playwright/test'; - -test.describe('Renders paywall card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('renders paywall card', async function () { - await focusEditor(page); - await page.keyboard.type('==='); - await assertHTML(page, html` -
    -
    -
    - Free public preview - - / - - Only visible to members -
    -
    -
    -


    - `); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.ts b/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.ts new file mode 100644 index 0000000000..b79e7a871b --- /dev/null +++ b/packages/koenig-lexical/test/e2e/text-transforms/paywall.test.ts @@ -0,0 +1,38 @@ +import {assertHTML, focusEditor, html, initialize} from '../../utils/e2e'; +import {test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Renders paywall card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('renders paywall card', async function () { + await focusEditor(page); + await page.keyboard.type('==='); + await assertHTML(page, html` +
    +
    +
    + Free public preview + + / + + Only visible to members +
    +
    +
    +


    + `); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/title-behaviour.test.js b/packages/koenig-lexical/test/e2e/title-behaviour.test.js deleted file mode 100644 index 62c35d1541..0000000000 --- a/packages/koenig-lexical/test/e2e/title-behaviour.test.js +++ /dev/null @@ -1,427 +0,0 @@ -import {assertHTML, assertSelection, focusEditor, html, initialize} from '../utils/e2e'; -import {expect, test} from '@playwright/test'; - -test.describe('Title behaviour (ExternalControlPlugin)', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test.describe('in title', function () { - test.describe('ENTER', function () { - test('moves cursor to blank editor', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.press('Enter'); - - // selection is on editor - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - // no extra paragraph created - await assertHTML(page, html` -


    - `); - }); - - test('adds paragraph and moves cursor to populated editor', async function () { - await focusEditor(page); - await page.keyboard.type('Populated editor'); - - await page.getByTestId('post-title').click(); - await page.keyboard.press('Enter'); - - // selection is at start of editor - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - // extra paragraph inserted - await assertHTML(page, html` -


    -

    Populated editor

    - `); - }); - }); - - test.describe('TAB', function () { - test('moves cursor to blank editor', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.press('Tab'); - - // selection is on editor - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - // no extra paragraph created - await assertHTML(page, html` -


    - `); - }); - }); - - test.describe('ARROW RIGHT', function () { - test('moves cursor to editor when title is blank', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.press('ArrowRight'); - - // selection is on editor - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - // no extra paragraph created - await assertHTML(page, html` -


    - `); - }); - - test('moves cursor to editor when cursor at end of title', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.type('Populated title'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowRight'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - - await page.keyboard.press('ArrowRight'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - }); - - test.describe('ARROW DOWN', function () { - test('moves cursor to editor when title is blank', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.press('ArrowDown'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - }); - - test('moves cursor to editor when cursor at end of title', async function () { - await page.getByTestId('post-title').click(); - await page.keyboard.type('Populated title'); - await page.keyboard.press('ArrowLeft'); - // moves cursor to end - await page.keyboard.press('ArrowDown'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - - // moves cursor to editor - await page.keyboard.press('ArrowDown'); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0], - focusOffset: 0, - focusPath: [0] - }); - - titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - - test('selects card if that is first section in doc', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - - await page.getByTestId('post-title').click(); - await page.keyboard.press('ArrowDown'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - - // card is selected - await assertHTML(page, html` -
    -

    -
    -


    - `); - - // editor has focus so it's possible to continue typing - await page.keyboard.press('Enter'); - await page.keyboard.type('Testing'); - - await assertHTML(page, html` -
    -

    -
    -

    Testing

    -


    - `); - }); - }); - }); - - test.describe('in editor', function () { - test.describe('ARROW UP', function () { - test('at start of paragraph doc moves cursor to title', async function () { - await focusEditor(page); - await page.keyboard.press('ArrowUp'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('at start of list at top of doc moves to title', async function () { - await focusEditor(page); - await page.keyboard.type('- '); - await page.keyboard.press('ArrowUp'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('with selected card at start of doc moves to title', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - - // card is not selected - await assertHTML(page, html` -
    -

    -
    -


    - `); - }); - - test('with non-collapsed selection at start of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('Test'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - - await assertSelection(page, { - anchorOffset: 4, - anchorPath: [0,0,0], - focusOffset: 0, - focusPath: [0,0,0] - }); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - - await page.keyboard.press('ArrowUp'); - - titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0,0,0], - focusOffset: 0, - focusPath: [0,0,0] - }); - }); - - test('at middle of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('One'); - await page.keyboard.press('Enter'); - await page.keyboard.press('ArrowUp'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - - test('with selected card in middle of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('One'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - }); - - test.describe('ARROW LEFT', function () { - test('at start of paragraph doc moves cursor to title', async function () { - await focusEditor(page); - await page.keyboard.press('ArrowLeft'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('at start of list at top of doc moves to title', async function () { - await focusEditor(page); - await page.keyboard.type('- '); - await page.keyboard.press('ArrowLeft'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('with selected card at start of doc moves to title', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowLeft'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - - // card is not selected - await assertHTML(page, html` -
    -

    -
    -


    - `); - }); - - test('with non-collapsed selection at start of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('Test'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - - await assertSelection(page, { - anchorOffset: 4, - anchorPath: [0,0,0], - focusOffset: 0, - focusPath: [0,0,0] - }); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - - await page.keyboard.press('ArrowLeft'); - - titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0,0,0], - focusOffset: 0, - focusPath: [0,0,0] - }); - }); - - test('at middle of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('One'); - await page.keyboard.press('Enter'); - await page.keyboard.press('ArrowLeft'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - - test('with selected card in middle of doc does not move to title', async function () { - await focusEditor(page); - await page.keyboard.type('One'); - await page.keyboard.press('Enter'); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowLeft'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(false); - }); - }); - - test.describe('SHIFT+TAB', function () { - test('moves cursor to title when not dedenting', async function () { - await focusEditor(page); - await page.keyboard.type('Test'); - await page.keyboard.press('Shift+Tab'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('moves cursor to title when card is selected', async function () { - await focusEditor(page); - await page.keyboard.type('---'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Shift+Tab'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - - test('moves cursor to title when a range is selected with no indents', async function () { - await focusEditor(page); - await page.keyboard.type('Test'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+ArrowLeft'); - await page.keyboard.press('Shift+Tab'); - - const title = page.getByTestId('post-title'); - let titleHasFocus = await title.evaluate(node => document.activeElement === node); - expect(titleHasFocus).toEqual(true); - }); - }); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/title-behaviour.test.ts b/packages/koenig-lexical/test/e2e/title-behaviour.test.ts new file mode 100644 index 0000000000..94b1e749fb --- /dev/null +++ b/packages/koenig-lexical/test/e2e/title-behaviour.test.ts @@ -0,0 +1,428 @@ +import {assertHTML, assertSelection, focusEditor, html, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +test.describe('Title behaviour (ExternalControlPlugin)', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('in title', function () { + test.describe('ENTER', function () { + test('moves cursor to blank editor', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.press('Enter'); + + // selection is on editor + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + // no extra paragraph created + await assertHTML(page, html` +


    + `); + }); + + test('adds paragraph and moves cursor to populated editor', async function () { + await focusEditor(page); + await page.keyboard.type('Populated editor'); + + await page.getByTestId('post-title').click(); + await page.keyboard.press('Enter'); + + // selection is at start of editor + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + // extra paragraph inserted + await assertHTML(page, html` +


    +

    Populated editor

    + `); + }); + }); + + test.describe('TAB', function () { + test('moves cursor to blank editor via tab', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.press('Tab'); + + // selection is on editor + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + // no extra paragraph created + await assertHTML(page, html` +


    + `); + }); + }); + + test.describe('ARROW RIGHT', function () { + test('moves cursor to editor when title is blank', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.press('ArrowRight'); + + // selection is on editor + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + // no extra paragraph created + await assertHTML(page, html` +


    + `); + }); + + test('moves cursor to editor when cursor at end of title', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.type('Populated title'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); + + const title = page.getByTestId('post-title'); + let titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + + await page.keyboard.press('ArrowRight'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + }); + + test.describe('ARROW DOWN', function () { + test('moves cursor to editor via arrow down when title is blank', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.press('ArrowDown'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + }); + + test('moves cursor to editor via arrow down when cursor at end of title', async function () { + await page.getByTestId('post-title').click(); + await page.keyboard.type('Populated title'); + await page.keyboard.press('ArrowLeft'); + // moves cursor to end + await page.keyboard.press('ArrowDown'); + + const title = page.getByTestId('post-title'); + let titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + + // moves cursor to editor + await page.keyboard.press('ArrowDown'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0] + }); + + titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + + test('selects card if that is first section in doc', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + + await page.getByTestId('post-title').click(); + await page.keyboard.press('ArrowDown'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + + // card is selected + await assertHTML(page, html` +
    +

    +
    +


    + `); + + // editor has focus so it's possible to continue typing + await page.keyboard.press('Enter'); + await page.keyboard.type('Testing'); + + await assertHTML(page, html` +
    +

    +
    +

    Testing

    +


    + `); + }); + }); + }); + + test.describe('in editor', function () { + test.describe('ARROW UP', function () { + test('at start of paragraph doc moves cursor to title', async function () { + await focusEditor(page); + await page.keyboard.press('ArrowUp'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('at start of list at top of doc moves to title', async function () { + await focusEditor(page); + await page.keyboard.type('- '); + await page.keyboard.press('ArrowUp'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('with selected card at start of doc moves to title', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + + // card is not selected + await assertHTML(page, html` +
    +

    +
    +


    + `); + }); + + test('with non-collapsed selection at start of doc does not move to title', async function () { + await focusEditor(page); + await page.keyboard.type('Test'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + + await assertSelection(page, { + anchorOffset: 4, + anchorPath: [0,0,0], + focusOffset: 0, + focusPath: [0,0,0] + }); + + const title = page.getByTestId('post-title'); + let titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + + await page.keyboard.press('ArrowUp'); + + titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0,0,0], + focusOffset: 0, + focusPath: [0,0,0] + }); + }); + + test('at middle of doc does not move to title', async function () { + await focusEditor(page); + await page.keyboard.type('One'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowUp'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + + test('with selected card in middle of doc does not move to title', async function () { + await focusEditor(page); + await page.keyboard.type('One'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + }); + + test.describe('ARROW LEFT', function () { + test('at start of paragraph doc moves cursor to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.press('ArrowLeft'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('at start of list at top of doc moves to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.type('- '); + await page.keyboard.press('ArrowLeft'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('with selected card at start of doc moves to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowLeft'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + + // card is not selected + await assertHTML(page, html` +
    +

    +
    +


    + `); + }); + + test('with non-collapsed selection at start of doc does not move to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.type('Test'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + + await assertSelection(page, { + anchorOffset: 4, + anchorPath: [0,0,0], + focusOffset: 0, + focusPath: [0,0,0] + }); + + const title = page.getByTestId('post-title'); + let titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + + await page.keyboard.press('ArrowLeft'); + + titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0,0,0], + focusOffset: 0, + focusPath: [0,0,0] + }); + }); + + test('at middle of doc does not move to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.type('One'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowLeft'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + + test('with selected card in middle of doc does not move to title via arrow left', async function () { + await focusEditor(page); + await page.keyboard.type('One'); + await page.keyboard.press('Enter'); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowLeft'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(false); + }); + }); + + test.describe('SHIFT+TAB', function () { + test('moves cursor to title when not dedenting', async function () { + await focusEditor(page); + await page.keyboard.type('Test'); + await page.keyboard.press('Shift+Tab'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('moves cursor to title when card is selected', async function () { + await focusEditor(page); + await page.keyboard.type('---'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Shift+Tab'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + + test('moves cursor to title when a range is selected with no indents', async function () { + await focusEditor(page); + await page.keyboard.type('Test'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+ArrowLeft'); + await page.keyboard.press('Shift+Tab'); + + const title = page.getByTestId('post-title'); + const titleHasFocus = await title.evaluate(node => document.activeElement === node); + expect(titleHasFocus).toEqual(true); + }); + }); + }); +}); diff --git a/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.js b/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.js deleted file mode 100644 index 47e8fe68b2..0000000000 --- a/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import path from 'path'; -import {expect, test} from '@playwright/test'; -import {fileURLToPath} from 'url'; -import {focusEditor, initialize, insertCard} from '../../utils/e2e'; -import {getImageDimensions} from '../../../src/utils/getImageDimensions'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -test.describe('Image card', async () => { - let page; - - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - }); - - test.beforeEach(async () => { - await initialize({page}); - }); - - test.afterAll(async () => { - await page.close(); - }); - - test('can get image height and width', async function () { - const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); - - await focusEditor(page); - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - insertCard(page, {cardName: 'image'}) - ]); - await fileChooser.setFiles([filePath]); - - const imageCard = await page.locator('[data-kg-card="image"]'); - expect(imageCard).not.toBeNull(); - - const image = await page.locator('img'); - expect(image).not.toBeNull(); - - const url = await image.getAttribute('src'); - - const getImageDimensionsStr = getImageDimensions.toString(); - const command = `(${getImageDimensionsStr})('${url}')`; - const dimensions = await page.evaluate(command); - - expect(dimensions).toEqual({width: 248, height: 248}); - }); -}); diff --git a/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.ts b/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.ts new file mode 100644 index 0000000000..7e34888a3c --- /dev/null +++ b/packages/koenig-lexical/test/e2e/utils/getImageDimensions.test.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; +import {focusEditor, initialize, insertCard} from '../../utils/e2e'; +import {getImageDimensions} from '../../../src/utils/getImageDimensions'; +import type {Page} from '@playwright/test'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('Image card', async () => { + let page: Page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('can get image height and width', async function () { + const filePath = path.relative(process.cwd(), __dirname + '/../fixtures/large-image.png'); + + await focusEditor(page); + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + insertCard(page, {cardName: 'image'}) + ]); + await fileChooser.setFiles([filePath]); + + const imageCard = await page.locator('[data-kg-card="image"]'); + expect(imageCard).not.toBeNull(); + + const image = await page.locator('img'); + expect(image).not.toBeNull(); + + const url = await image.getAttribute('src'); + + const getImageDimensionsStr = getImageDimensions.toString(); + const command = `(${getImageDimensionsStr})('${url}')`; + const dimensions = await page.evaluate(command); + + expect(dimensions).toEqual({width: 248, height: 248}); + }); +}); diff --git a/packages/koenig-lexical/test/test-setup.js b/packages/koenig-lexical/test/test-setup.ts similarity index 100% rename from packages/koenig-lexical/test/test-setup.js rename to packages/koenig-lexical/test/test-setup.ts diff --git a/packages/koenig-lexical/test/types.d.ts b/packages/koenig-lexical/test/types.d.ts new file mode 100644 index 0000000000..b2b6ff0c58 --- /dev/null +++ b/packages/koenig-lexical/test/types.d.ts @@ -0,0 +1,19 @@ +interface KoenigTestEditor { + blur(): void; + setEditorState(state: unknown): void; + getEditorState(): {toJSON(): unknown}; + parseEditorState(state: unknown): unknown; + update(fn: () => void): void; + dispatchCommand(command: unknown, payload: unknown): void; +} + +declare interface Window { + lexicalEditor: KoenigTestEditor; + originalEditorState: unknown; + navigate: (path: string) => void; +} + +declare module 'fs-extra' { + import fs from 'fs'; + export = fs; +} diff --git a/packages/koenig-lexical/test/unit/EmailEditor.test.js b/packages/koenig-lexical/test/unit/EmailEditor.test.js deleted file mode 100644 index 5b6dfb1022..0000000000 --- a/packages/koenig-lexical/test/unit/EmailEditor.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import {VISIBILITY_SETTINGS} from '../../src/utils/visibility'; -import {expect} from 'vitest'; -import {getEmailEditorCardConfig} from '../../src/components/EmailEditor'; - -describe('getEmailEditorCardConfig', function () { - it('defaults to EMAIL_ONLY visibility when no cardConfig is passed', function () { - const result = getEmailEditorCardConfig(); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('defaults to EMAIL_ONLY visibility when cardConfig has no visibilitySettings', function () { - const result = getEmailEditorCardConfig({stripeEnabled: true}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('allows NONE visibility to be passed through', function () { - const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.NONE}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.NONE); - }); - - it('allows EMAIL_ONLY visibility to be passed through', function () { - const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.EMAIL_ONLY}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('falls back to EMAIL_ONLY for WEB_AND_EMAIL', function () { - const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.WEB_AND_EMAIL}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('falls back to EMAIL_ONLY for WEB_ONLY', function () { - const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.WEB_ONLY}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('falls back to EMAIL_ONLY for invalid values', function () { - const result = getEmailEditorCardConfig({visibilitySettings: 'garbage'}); - expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); - }); - - it('restricts image widths to regular only', function () { - const result = getEmailEditorCardConfig({image: {allowedWidths: ['wide', 'full']}}); - expect(result.image.allowedWidths).toEqual(['regular']); - }); - - it('preserves other cardConfig properties', function () { - const result = getEmailEditorCardConfig({stripeEnabled: true, foo: 'bar'}); - expect(result.stripeEnabled).toBe(true); - expect(result.foo).toBe('bar'); - }); - - it('sets editorType to email', function () { - const result = getEmailEditorCardConfig(); - expect(result.editorType).toBe('email'); - }); - - it('always sets editorType to email even if cardConfig has a different editorType', function () { - const result = getEmailEditorCardConfig({editorType: 'full'}); - expect(result.editorType).toBe('email'); - }); -}); diff --git a/packages/koenig-lexical/test/unit/EmailEditor.test.ts b/packages/koenig-lexical/test/unit/EmailEditor.test.ts new file mode 100644 index 0000000000..4efa0bfc89 --- /dev/null +++ b/packages/koenig-lexical/test/unit/EmailEditor.test.ts @@ -0,0 +1,61 @@ +import {VISIBILITY_SETTINGS} from '../../src/utils/visibility'; +import {expect} from 'vitest'; +import {getEmailEditorCardConfig} from '../../src/components/EmailEditor'; + +describe('getEmailEditorCardConfig', function () { + it('defaults to EMAIL_ONLY visibility when no cardConfig is passed', function () { + const result = getEmailEditorCardConfig(); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('defaults to EMAIL_ONLY visibility when cardConfig has no visibilitySettings', function () { + const result = getEmailEditorCardConfig({stripeEnabled: true}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('allows NONE visibility to be passed through', function () { + const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.NONE}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.NONE); + }); + + it('allows EMAIL_ONLY visibility to be passed through', function () { + const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.EMAIL_ONLY}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('falls back to EMAIL_ONLY for WEB_AND_EMAIL', function () { + const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.WEB_AND_EMAIL}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('falls back to EMAIL_ONLY for WEB_ONLY', function () { + const result = getEmailEditorCardConfig({visibilitySettings: VISIBILITY_SETTINGS.WEB_ONLY}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('falls back to EMAIL_ONLY for invalid values', function () { + const result = getEmailEditorCardConfig({visibilitySettings: 'garbage'}); + expect(result.visibilitySettings).toBe(VISIBILITY_SETTINGS.EMAIL_ONLY); + }); + + it('restricts image widths to regular only', function () { + const result = getEmailEditorCardConfig({image: {allowedWidths: ['wide', 'full']}}); + expect(result.image?.allowedWidths).toEqual(['regular']); + }); + + it('preserves other cardConfig properties', function () { + const result = getEmailEditorCardConfig({stripeEnabled: true, foo: 'bar'}); + expect(result.stripeEnabled).toBe(true); + expect(result.foo).toBe('bar'); + }); + + it('sets editorType to email', function () { + const result = getEmailEditorCardConfig(); + expect(result.editorType).toBe('email'); + }); + + it('always sets editorType to email even if cardConfig has a different editorType', function () { + const result = getEmailEditorCardConfig({editorType: 'full'}); + expect(result.editorType).toBe('email'); + }); +}); diff --git a/packages/koenig-lexical/test/unit/KoenigComposer.test.jsx b/packages/koenig-lexical/test/unit/KoenigComposer.test.tsx similarity index 100% rename from packages/koenig-lexical/test/unit/KoenigComposer.test.jsx rename to packages/koenig-lexical/test/unit/KoenigComposer.test.tsx diff --git a/packages/koenig-lexical/test/unit/build-output.test.js b/packages/koenig-lexical/test/unit/build-output.test.ts similarity index 100% rename from packages/koenig-lexical/test/unit/build-output.test.js rename to packages/koenig-lexical/test/unit/build-output.test.ts diff --git a/packages/koenig-lexical/test/unit/buildCardMenu.test.js b/packages/koenig-lexical/test/unit/buildCardMenu.test.js deleted file mode 100644 index 31fa310e48..0000000000 --- a/packages/koenig-lexical/test/unit/buildCardMenu.test.js +++ /dev/null @@ -1,545 +0,0 @@ -import {buildCardMenu} from '../../src/utils/buildCardMenu'; -import {describe, expect, it} from 'vitest'; - -const Icon = () => {}; - -describe('buildCardMenu', function () { - it('adds to Primary section by default', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one' - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two' - }}] - ]; - - const cardMenu = buildCardMenu(nodes); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - nodeType: 'one' - }, - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - nodeType: 'two' - } - ]] - ])); - - expect(cardMenu.maxItemIndex).to.equal(1); - }); - - it('can add cards to other headers', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one' - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - section: 'Secondary', - Icon, - insertCommand: 'insert_card_two' - }}] - ]; - - const cardMenu = buildCardMenu(nodes); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - nodeType: 'one' - } - ]], - ['Secondary', [ - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - nodeType: 'two', - section: 'Secondary' - } - ]] - ])); - - expect(cardMenu.maxItemIndex).to.equal(1); - }); - - it('can add multiple items for a single card', async function () { - const nodes = [ - ['one', {kgMenu: [{ - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one' - }, { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two' - }]}] - ]; - - const cardMenu = buildCardMenu(nodes); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - nodeType: 'one' - }, - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - nodeType: 'one' - } - ]] - ])); - }); - - describe('filtering', function () { - it('adds all items for blank query', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: ''}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'], - nodeType: 'one' - }, - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'], - nodeType: 'two' - } - ]] - ])); - }); - - it('matches start of strings', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 't'}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'], - nodeType: 'two' - } - ]] - ])); - - expect(cardMenu.maxItemIndex).to.equal(0); - }); - - it('can match against multiple strings', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two', 'multiple'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 'mul'}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two', 'multiple'], - nodeType: 'two' - } - ]] - ])); - - expect(cardMenu.maxItemIndex).to.equal(0); - }); - - it('filters all sections', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - section: 'Secondary', - Icon, - insertCommand: 'insert_card_two', - matches: ['two', 'multiple'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 'mul'}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Secondary', [ - { - label: 'Two', - desc: 'Card test two', - section: 'Secondary', - Icon, - insertCommand: 'insert_card_two', - matches: ['two', 'multiple'], - nodeType: 'two' - } - ]] - ])); - }); - - it('returns empty menu with no matches', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - section: 'Secondary', - Icon, - insertCommand: 'insert_card_two', - matches: ['two', 'multiple'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 'unknown'}); - - expect(cardMenu.menu).deep.equal(new Map()); - expect(cardMenu.maxItemIndex).to.equal(-1); - }); - - it('is case-insensitive', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: ['one'] - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'] - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 'Tw'}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: ['two'], - nodeType: 'two' - } - ]] - ])); - }); - - it('can pass function to matches', async function () { - const matchFn = (query, label) => label.includes(query); - const nodes = [ - ['one', {kgMenu: { - label: 'One wow', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: matchFn - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - matches: matchFn - }}] - ]; - - const cardMenu = buildCardMenu(nodes, {query: 'wow'}); - - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One wow', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - matches: matchFn, - nodeType: 'one' - } - ]] - ])); - }); - - it('can filter snippets', async function () { - const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; - const cardMenu = buildCardMenu([], {query: 'snip', config: {snippets, deleteSnippet: () => {}}}); - - expect(cardMenu.menu).toEqual(new Map([ - ['Snippets', [ - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'One snippet' - }, - label: 'One snippet', - matches: expect.any(Function), - onRemove: expect.any(Function), - section: 'Snippets', - type: 'snippet' - }, - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'Two snippet' - }, - label: 'Two snippet', - matches: expect.any(Function), - onRemove: expect.any(Function), - section: 'Snippets', - type: 'snippet' - } - ]] - ])); - }); - - it(`doesn't show delete option if createSnippet is not defined`, async function () { - const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; - const cardMenu = buildCardMenu([], {query: 'snippets', config: {snippets}}); - expect(cardMenu.menu).toEqual(new Map([ - ['Snippets', [ - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'One snippet' - }, - label: 'One snippet', - matches: expect.any(Function), - section: 'Snippets', - type: 'snippet' - }, - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'Two snippet' - }, - label: 'Two snippet', - matches: expect.any(Function), - section: 'Snippets', - type: 'snippet' - } - ]] - ])); - }); - - it('returns empty value if no snippet matches ', async function () { - const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; - const cardMenu = buildCardMenu([], {query: 'sniptr', config: {snippets}}); - expect(cardMenu.menu).deep.equal(new Map()); - }); - - it('shows all snippets when typing /snippets', async function () { - const snippets = [{name: 'Test1'}, {name: 'Test2'}]; - const cardMenu = buildCardMenu([], {query: 'snippets', config: {snippets, deleteSnippet: () => {}}}); - - expect(cardMenu.menu).toEqual(new Map([ - ['Snippets', [ - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'Test1' - }, - label: 'Test1', - matches: expect.any(Function), - onRemove: expect.any(Function), - section: 'Snippets', - type: 'snippet' - }, - { - Icon: expect.any(Function), - insertCommand: {}, - insertParams: { - name: 'Test2' - }, - label: 'Test2', - matches: expect.any(Function), - onRemove: expect.any(Function), - section: 'Snippets', - type: 'snippet' - } - ]] - ])); - }); - - it('can filter based on the post type', async function () { - const config = {post: {displayName: 'post'}}; - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - postType: 'page' - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two' - }}]]; - const cardMenu = buildCardMenu(nodes, {config}); - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - nodeType: 'two' - } - ]] - ])); - }); - - it('does not filter on post type if missing in config', async function () { - const nodes = [ - ['one', {kgMenu: { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - postType: 'page' - }}], - ['two', {kgMenu: { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two' - }}]]; - const cardMenu = buildCardMenu(nodes); - expect(cardMenu.menu).deep.equal(new Map([ - ['Primary', [ - { - label: 'One', - desc: 'Card test one', - Icon, - insertCommand: 'insert_card_one', - nodeType: 'one', - postType: 'page' - }, - { - label: 'Two', - desc: 'Card test two', - Icon, - insertCommand: 'insert_card_two', - nodeType: 'two' - } - ]] - ])); - }); - }); -}); diff --git a/packages/koenig-lexical/test/unit/buildCardMenu.test.ts b/packages/koenig-lexical/test/unit/buildCardMenu.test.ts new file mode 100644 index 0000000000..956e3820d6 --- /dev/null +++ b/packages/koenig-lexical/test/unit/buildCardMenu.test.ts @@ -0,0 +1,547 @@ +import {type CardMenuItem, buildCardMenu} from '../../src/utils/buildCardMenu'; +import {describe, expect, it} from 'vitest'; + +type CardMenuNodes = Parameters[0]; + +const Icon: CardMenuItem['Icon'] = () => null; + +describe('buildCardMenu', function () { + it('adds to Primary section by default', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one' + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two' + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + nodeType: 'one' + }, + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + nodeType: 'two' + } + ]] + ])); + + expect(cardMenu.maxItemIndex).to.equal(1); + }); + + it('can add cards to other headers', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one' + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + section: 'Secondary', + Icon, + insertCommand: 'insert_card_two' + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + nodeType: 'one' + } + ]], + ['Secondary', [ + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + nodeType: 'two', + section: 'Secondary' + } + ]] + ])); + + expect(cardMenu.maxItemIndex).to.equal(1); + }); + + it('can add multiple items for a single card', async function () { + const nodes = [ + ['one', {kgMenu: [{ + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one' + }, { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two' + }]}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + nodeType: 'one' + }, + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + nodeType: 'one' + } + ]] + ])); + }); + + describe('filtering', function () { + it('adds all items for blank query', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: ''}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'], + nodeType: 'one' + }, + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'], + nodeType: 'two' + } + ]] + ])); + }); + + it('matches start of strings', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 't'}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'], + nodeType: 'two' + } + ]] + ])); + + expect(cardMenu.maxItemIndex).to.equal(0); + }); + + it('can match against multiple strings', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two', 'multiple'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 'mul'}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two', 'multiple'], + nodeType: 'two' + } + ]] + ])); + + expect(cardMenu.maxItemIndex).to.equal(0); + }); + + it('filters all sections', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + section: 'Secondary', + Icon, + insertCommand: 'insert_card_two', + matches: ['two', 'multiple'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 'mul'}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Secondary', [ + { + label: 'Two', + desc: 'Card test two', + section: 'Secondary', + Icon, + insertCommand: 'insert_card_two', + matches: ['two', 'multiple'], + nodeType: 'two' + } + ]] + ])); + }); + + it('returns empty menu with no matches', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + section: 'Secondary', + Icon, + insertCommand: 'insert_card_two', + matches: ['two', 'multiple'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 'unknown'}); + + expect(cardMenu.menu).deep.equal(new Map()); + expect(cardMenu.maxItemIndex).to.equal(-1); + }); + + it('is case-insensitive', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: ['one'] + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'] + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 'Tw'}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: ['two'], + nodeType: 'two' + } + ]] + ])); + }); + + it('can pass function to matches', async function () { + const matchFn = (query: string, label: string) => label.includes(query); + const nodes = [ + ['one', {kgMenu: { + label: 'One wow', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: matchFn + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + matches: matchFn + }}] + ]; + + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {query: 'wow'}); + + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One wow', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + matches: matchFn, + nodeType: 'one' + } + ]] + ])); + }); + + it('can filter snippets', async function () { + const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; + const cardMenu = buildCardMenu([], {query: 'snip', config: {snippets, deleteSnippet: () => {}}}); + + expect(cardMenu.menu).toEqual(new Map([ + ['Snippets', [ + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'One snippet' + }, + label: 'One snippet', + matches: expect.any(Function), + onRemove: expect.any(Function), + section: 'Snippets', + type: 'snippet' + }, + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'Two snippet' + }, + label: 'Two snippet', + matches: expect.any(Function), + onRemove: expect.any(Function), + section: 'Snippets', + type: 'snippet' + } + ]] + ])); + }); + + it(`doesn't show delete option if createSnippet is not defined`, async function () { + const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; + const cardMenu = buildCardMenu([], {query: 'snippets', config: {snippets}}); + expect(cardMenu.menu).toEqual(new Map([ + ['Snippets', [ + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'One snippet' + }, + label: 'One snippet', + matches: expect.any(Function), + section: 'Snippets', + type: 'snippet' + }, + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'Two snippet' + }, + label: 'Two snippet', + matches: expect.any(Function), + section: 'Snippets', + type: 'snippet' + } + ]] + ])); + }); + + it('returns empty value if no snippet matches ', async function () { + const snippets = [{name: 'One snippet'}, {name: 'Two snippet'}]; + const cardMenu = buildCardMenu([], {query: 'sniptr', config: {snippets}}); + expect(cardMenu.menu).deep.equal(new Map()); + }); + + it('shows all snippets when typing /snippets', async function () { + const snippets = [{name: 'Test1'}, {name: 'Test2'}]; + const cardMenu = buildCardMenu([], {query: 'snippets', config: {snippets, deleteSnippet: () => {}}}); + + expect(cardMenu.menu).toEqual(new Map([ + ['Snippets', [ + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'Test1' + }, + label: 'Test1', + matches: expect.any(Function), + onRemove: expect.any(Function), + section: 'Snippets', + type: 'snippet' + }, + { + Icon: expect.any(Function), + insertCommand: {}, + insertParams: { + name: 'Test2' + }, + label: 'Test2', + matches: expect.any(Function), + onRemove: expect.any(Function), + section: 'Snippets', + type: 'snippet' + } + ]] + ])); + }); + + it('can filter based on the post type', async function () { + const config = {post: {displayName: 'post'}}; + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + postType: 'page' + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two' + }}]]; + const cardMenu = buildCardMenu(nodes as CardMenuNodes, {config}); + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + nodeType: 'two' + } + ]] + ])); + }); + + it('does not filter on post type if missing in config', async function () { + const nodes = [ + ['one', {kgMenu: { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + postType: 'page' + }}], + ['two', {kgMenu: { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two' + }}]]; + const cardMenu = buildCardMenu(nodes as CardMenuNodes); + expect(cardMenu.menu).deep.equal(new Map([ + ['Primary', [ + { + label: 'One', + desc: 'Card test one', + Icon, + insertCommand: 'insert_card_one', + nodeType: 'one', + postType: 'page' + }, + { + label: 'Two', + desc: 'Card test two', + Icon, + insertCommand: 'insert_card_two', + nodeType: 'two' + } + ]] + ])); + }); + }); +}); diff --git a/packages/koenig-lexical/test/unit/headerCardv2.test.js b/packages/koenig-lexical/test/unit/headerCardv2.test.js deleted file mode 100644 index 08fa964ef5..0000000000 --- a/packages/koenig-lexical/test/unit/headerCardv2.test.js +++ /dev/null @@ -1,87 +0,0 @@ -import {$createHeaderNode, HeaderNode} from '../../src/nodes/HeaderNode'; -const {createHeadlessEditor} = require('@lexical/headless'); - -const editorNodes = [HeaderNode]; - -describe('HeaderNode v2', function () { - let editor; - let dataset; - - const editorTest = testFn => function () { - let resolve, reject; - const promise = new Promise((resolve_, reject_) => { - resolve = resolve_; - reject = reject_; - }); - - editor.update(() => { - try { - testFn(); - resolve(); - } catch (error) { - reject(error); - } - }); - - return promise; - }; - - beforeEach(function () { - editor = createHeadlessEditor({nodes: editorNodes}); - - dataset = { - type: 'header', - version: 2, - size: 'small', - style: 'dark', - buttonEnabled: false, - buttonUrl: '', - buttonText: '', - header: 'Hello header
    On two lines, even.', - subheader: '

    Subheadings are awesome
    I like them a lot.

    ', - backgroundImageSrc: '', - accentColor: '#ff0095', - alignment: 'center', - backgroundColor: '#000000', - backgroundImageWidth: null, - backgroundImageHeight: null, - backgroundSize: 'cover', - textColor: '#FFFFFF', - buttonColor: '#ffffff', - buttonTextColor: '#000000', - layout: 'full', - swapped: false - }; - }); - - describe('Content load and export testing', function () { - it('handles titles with extra br', editorTest(function () { - dataset.header = 'Product title!
    Hello part 2'; - const headerNode = $createHeaderNode(dataset); - const json = headerNode.exportJSON(); - const heading = json.header; - expect(heading).toEqual('Product title!
    Hello part 2'); - })); - it('loads and unwraps headers when wrapped with p', editorTest(function () { - dataset.header = '

    Product title!
    Hello part 2

    '; - const headerNode = $createHeaderNode(dataset); - const json = headerNode.exportJSON(); - const heading = json.header; - expect(heading).toEqual('Product title!
    Hello part 2'); - })); - it('allows br tags in subheaders', editorTest(function () { - dataset.subheader = 'Product title!
    Hello part 2'; - const headerNode = $createHeaderNode(dataset); - const json = headerNode.exportJSON(); - const subheading = json.subheader; - expect(subheading).toEqual('Product title!
    Hello part 2'); - })); - it('can handle subheaders that are wrapped in p tags', editorTest(function () { - dataset.subheader = '

    Product title!
    Hello part 2

    '; - const headerNode = $createHeaderNode(dataset); - const json = headerNode.exportJSON(); - const subheading = json.subheader; - expect(subheading).toEqual('Product title!
    Hello part 2'); - })); - }); -}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/headerCardv2.test.ts b/packages/koenig-lexical/test/unit/headerCardv2.test.ts new file mode 100644 index 0000000000..8508739bfc --- /dev/null +++ b/packages/koenig-lexical/test/unit/headerCardv2.test.ts @@ -0,0 +1,88 @@ +import {$createHeaderNode, HeaderNode} from '../../src/nodes/HeaderNode'; +import {createHeadlessEditor} from '@lexical/headless'; +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +const editorNodes = [HeaderNode] as unknown as Klass[]; + +describe('HeaderNode v2', function () { + let editor: LexicalEditor; + let dataset: Record; + + const editorTest = (testFn: () => void) => function () { + let resolve: (value?: unknown) => void, reject: (reason?: unknown) => void; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + + editor.update(() => { + try { + testFn(); + resolve(); + } catch (error) { + reject(error); + } + }); + + return promise; + }; + + beforeEach(function () { + editor = createHeadlessEditor({nodes: editorNodes}); + + dataset = { + type: 'header', + version: 2, + size: 'small', + style: 'dark', + buttonEnabled: false, + buttonUrl: '', + buttonText: '', + header: 'Hello header
    On two lines, even.', + subheader: '

    Subheadings are awesome
    I like them a lot.

    ', + backgroundImageSrc: '', + accentColor: '#ff0095', + alignment: 'center', + backgroundColor: '#000000', + backgroundImageWidth: null, + backgroundImageHeight: null, + backgroundSize: 'cover', + textColor: '#FFFFFF', + buttonColor: '#ffffff', + buttonTextColor: '#000000', + layout: 'full', + swapped: false + }; + }); + + describe('Content load and export testing', function () { + it('handles titles with extra br', editorTest(function () { + dataset.header = 'Product title!
    Hello part 2'; + const headerNode = $createHeaderNode(dataset); + const json = headerNode.exportJSON(); + const heading = json.header; + expect(heading).toEqual('Product title!
    Hello part 2'); + })); + it('loads and unwraps headers when wrapped with p', editorTest(function () { + dataset.header = '

    Product title!
    Hello part 2

    '; + const headerNode = $createHeaderNode(dataset); + const json = headerNode.exportJSON(); + const heading = json.header; + expect(heading).toEqual('Product title!
    Hello part 2'); + })); + it('allows br tags in subheaders', editorTest(function () { + dataset.subheader = 'Product title!
    Hello part 2'; + const headerNode = $createHeaderNode(dataset); + const json = headerNode.exportJSON(); + const subheading = json.subheader; + expect(subheading).toEqual('Product title!
    Hello part 2'); + })); + it('can handle subheaders that are wrapped in p tags', editorTest(function () { + dataset.subheader = '

    Product title!
    Hello part 2

    '; + const headerNode = $createHeaderNode(dataset); + const json = headerNode.exportJSON(); + const subheading = json.subheader; + expect(subheading).toEqual('Product title!
    Hello part 2'); + })); + }); +}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.js b/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.js deleted file mode 100644 index 8b102b4b9b..0000000000 --- a/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.js +++ /dev/null @@ -1,223 +0,0 @@ -import * as visibilityUtils from '../../../src/utils/visibility'; -import {$getNodeByKey} from 'lexical'; -import {VISIBILITY_SETTINGS} from '../../../src/utils/visibility'; -import {act, renderHook} from '@testing-library/react'; -import {expect, vi} from 'vitest'; -import {useVisibilityToggle} from '../../../src/hooks/useVisibilityToggle'; - -const lexicalMocks = vi.hoisted(() => ({ - $getNodeByKey: vi.fn() -})); - -vi.mock(import('lexical'), async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - $getNodeByKey: lexicalMocks.$getNodeByKey - }; -}); - -describe('useVisibilityToggle', () => { - let editor; - let node; - let cardConfig; - - const DEFAULT_VISIBILITY = { - web: { - nonMember: true, - memberSegment: 'status:free,status:-free' - }, - email: { - memberSegment: 'status:free,status:-free' - } - }; - - beforeEach(() => { - node = { - visibility: DEFAULT_VISIBILITY, - getIsVisibilityActive: vi.fn(() => true) - }; - - editor = { - update: vi.fn(callback => callback()), - getEditorState: vi.fn(() => ({ - read: vi.fn(callback => callback()) - })) - }; - - $getNodeByKey.mockReturnValue(node); - - cardConfig = { - stripeEnabled: true - }; - }); - - afterEach(() => { - vi.restoreAllMocks(); - lexicalMocks.$getNodeByKey.mockReset(); - }); - - function callHook(visibility = DEFAULT_VISIBILITY, {stripeEnabled = true} = {}) { - node.visibility = visibility; - cardConfig.stripeEnabled = stripeEnabled; - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_AND_EMAIL; - - return renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - } - - it('calls getVisibilityOptions with correct arguments', () => { - vi.spyOn(visibilityUtils, 'getVisibilityOptions'); - const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; - callHook(visibility); - expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: true, showEmail: true}); - }); - - it('calls getVisibilityOptions with showWeb only when visibilitySettings is "web only"', () => { - vi.spyOn(visibilityUtils, 'getVisibilityOptions'); - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; - const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; - node.visibility = visibility; - renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: true, showEmail: false}); - }); - - it('calls getVisibilityOptions with showEmail only when visibilitySettings is "email only"', () => { - vi.spyOn(visibilityUtils, 'getVisibilityOptions'); - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; - const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; - node.visibility = visibility; - renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: false, showEmail: true}); - }); - - it('returns correct visibilityOptions', () => { - const {result} = callHook({web: {nonMember: true, memberSegment: 'status:free'}, email: {memberSegment: 'status:free'}}); - const {visibilityOptions} = result.current; - - expect(visibilityOptions).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: false} - ] - }, - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: false} - ] - } - ]); - }); - - it('returns working toggleVisibility function', () => { - const {result} = callHook(); - const {toggleVisibility} = result.current; - - act(() => toggleVisibility('web', 'nonMembers', false)); - expect(node.visibility.web.nonMember).toBe(false); - - act(() => toggleVisibility('web', 'paidMembers', false)); - expect(node.visibility.web.memberSegment).toBe('status:free'); - }); - - it('returns only web options when email visibility is disabled in cardConfig', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.visibilityOptions).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); - - it('returns only email options when web visibility is disabled in cardConfig', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.visibilityOptions).toEqual([ - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); - - it('returns isVisibilityEnabled as false when visibilitySettings is "none"', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.NONE; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.isVisibilityEnabled).toBe(false); - expect(result.current.visibilityOptions).toEqual([]); - }); - - it('returns isVisibilityEnabled as true when visibilitySettings is "web and email"', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_AND_EMAIL; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.isVisibilityEnabled).toBe(true); - }); - - it('returns isVisibilityEnabled as true when visibilitySettings is "web only"', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.isVisibilityEnabled).toBe(true); - }); - - it('returns isVisibilityEnabled as true when visibilitySettings is "email only"', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - - expect(result.current.isVisibilityEnabled).toBe(true); - }); - - it('safely no-ops when toggling a hidden email group with "web only" setting', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - const beforeVisibility = structuredClone(node.visibility); - - act(() => result.current.toggleVisibility('email', 'freeMembers', false)); - - expect(node.visibility).toEqual(beforeVisibility); - }); - - it('safely no-ops when toggling a hidden web group with "email only" setting', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - const beforeVisibility = structuredClone(node.visibility); - - act(() => result.current.toggleVisibility('web', 'nonMembers', false)); - - expect(node.visibility).toEqual(beforeVisibility); - }); - - it('safely no-ops when toggling any group with "none" setting', () => { - cardConfig.visibilitySettings = VISIBILITY_SETTINGS.NONE; - const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); - const beforeVisibility = structuredClone(node.visibility); - - act(() => result.current.toggleVisibility('web', 'nonMembers', false)); - expect(node.visibility).toEqual(beforeVisibility); - - act(() => result.current.toggleVisibility('email', 'freeMembers', false)); - expect(node.visibility).toEqual(beforeVisibility); - }); -}); diff --git a/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.ts b/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.ts new file mode 100644 index 0000000000..ace61e16fb --- /dev/null +++ b/packages/koenig-lexical/test/unit/hooks/useVisibilityToggle.test.ts @@ -0,0 +1,224 @@ +import * as visibilityUtils from '../../../src/utils/visibility'; +import {$getNodeByKey} from 'lexical'; +import {VISIBILITY_SETTINGS} from '../../../src/utils/visibility'; +import {act, renderHook} from '@testing-library/react'; +import {expect, vi} from 'vitest'; +import {useVisibilityToggle} from '../../../src/hooks/useVisibilityToggle'; +import type {LexicalEditor} from 'lexical'; + +const lexicalMocks = vi.hoisted(() => ({ + $getNodeByKey: vi.fn() +})); + +vi.mock(import('lexical'), async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + $getNodeByKey: lexicalMocks.$getNodeByKey + }; +}); + +describe('useVisibilityToggle', () => { + let editor: LexicalEditor; + let node: {visibility: typeof DEFAULT_VISIBILITY; getIsVisibilityActive: ReturnType}; + let cardConfig: {stripeEnabled: boolean; visibilitySettings?: string}; + + const DEFAULT_VISIBILITY = { + web: { + nonMember: true, + memberSegment: 'status:free,status:-free' + }, + email: { + memberSegment: 'status:free,status:-free' + } + }; + + beforeEach(() => { + node = { + visibility: DEFAULT_VISIBILITY, + getIsVisibilityActive: vi.fn(() => true) + }; + + editor = { + update: vi.fn(callback => callback()), + getEditorState: vi.fn(() => ({ + read: vi.fn(callback => callback()) + })) + } as unknown as LexicalEditor; + + vi.mocked($getNodeByKey).mockReturnValue(node as unknown as ReturnType); + + cardConfig = { + stripeEnabled: true + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + lexicalMocks.$getNodeByKey.mockReset(); + }); + + function callHook(visibility = DEFAULT_VISIBILITY, {stripeEnabled = true} = {}) { + node.visibility = visibility; + cardConfig.stripeEnabled = stripeEnabled; + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_AND_EMAIL; + + return renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + } + + it('calls getVisibilityOptions with correct arguments', () => { + vi.spyOn(visibilityUtils, 'getVisibilityOptions'); + const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; + callHook(visibility); + expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: true, showEmail: true}); + }); + + it('calls getVisibilityOptions with showWeb only when visibilitySettings is "web only"', () => { + vi.spyOn(visibilityUtils, 'getVisibilityOptions'); + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; + const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; + node.visibility = visibility; + renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: true, showEmail: false}); + }); + + it('calls getVisibilityOptions with showEmail only when visibilitySettings is "email only"', () => { + vi.spyOn(visibilityUtils, 'getVisibilityOptions'); + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; + const visibility = {web: {nonMember: false, memberSegment: ''}, email: {memberSegment: 'status:free,status:-free'}}; + node.visibility = visibility; + renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + expect(visibilityUtils.getVisibilityOptions).toHaveBeenCalledWith(visibility, {isStripeEnabled: true, showWeb: false, showEmail: true}); + }); + + it('returns correct visibilityOptions', () => { + const {result} = callHook({web: {nonMember: true, memberSegment: 'status:free'}, email: {memberSegment: 'status:free'}}); + const {visibilityOptions} = result.current; + + expect(visibilityOptions).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: false} + ] + }, + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: false} + ] + } + ]); + }); + + it('returns working toggleVisibility function', () => { + const {result} = callHook(); + const {toggleVisibility} = result.current; + + act(() => toggleVisibility('web', 'nonMembers', false)); + expect(node.visibility.web.nonMember).toBe(false); + + act(() => toggleVisibility('web', 'paidMembers', false)); + expect(node.visibility.web.memberSegment).toBe('status:free'); + }); + + it('returns only web options when email visibility is disabled in cardConfig', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.visibilityOptions).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); + + it('returns only email options when web visibility is disabled in cardConfig', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.visibilityOptions).toEqual([ + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); + + it('returns isVisibilityEnabled as false when visibilitySettings is "none"', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.NONE; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.isVisibilityEnabled).toBe(false); + expect(result.current.visibilityOptions).toEqual([]); + }); + + it('returns isVisibilityEnabled as true when visibilitySettings is "web and email"', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_AND_EMAIL; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.isVisibilityEnabled).toBe(true); + }); + + it('returns isVisibilityEnabled as true when visibilitySettings is "web only"', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.isVisibilityEnabled).toBe(true); + }); + + it('returns isVisibilityEnabled as true when visibilitySettings is "email only"', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + + expect(result.current.isVisibilityEnabled).toBe(true); + }); + + it('safely no-ops when toggling a hidden email group with "web only" setting', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.WEB_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + const beforeVisibility = structuredClone(node.visibility); + + act(() => result.current.toggleVisibility('email', 'freeMembers', false)); + + expect(node.visibility).toEqual(beforeVisibility); + }); + + it('safely no-ops when toggling a hidden web group with "email only" setting', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.EMAIL_ONLY; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + const beforeVisibility = structuredClone(node.visibility); + + act(() => result.current.toggleVisibility('web', 'nonMembers', false)); + + expect(node.visibility).toEqual(beforeVisibility); + }); + + it('safely no-ops when toggling any group with "none" setting', () => { + cardConfig.visibilitySettings = VISIBILITY_SETTINGS.NONE; + const {result} = renderHook(() => useVisibilityToggle(editor, 'testKey', cardConfig)); + const beforeVisibility = structuredClone(node.visibility); + + act(() => result.current.toggleVisibility('web', 'nonMembers', false)); + expect(node.visibility).toEqual(beforeVisibility); + + act(() => result.current.toggleVisibility('email', 'freeMembers', false)); + expect(node.visibility).toEqual(beforeVisibility); + }); +}); diff --git a/packages/koenig-lexical/test/unit/productCard.test.js b/packages/koenig-lexical/test/unit/productCard.test.js deleted file mode 100644 index 02d18f9e20..0000000000 --- a/packages/koenig-lexical/test/unit/productCard.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import {$createProductNode, ProductNode} from '../../src/nodes/ProductNode'; -const {createHeadlessEditor} = require('@lexical/headless'); - -const editorNodes = [ProductNode]; - -describe('ProductNode', function () { - let editor; - let dataset; - - const editorTest = testFn => function () { - let resolve, reject; - const promise = new Promise((resolve_, reject_) => { - resolve = resolve_; - reject = reject_; - }); - - editor.update(() => { - try { - testFn(); - resolve(); - } catch (error) { - reject(error); - } - }); - - return promise; - }; - - beforeEach(function () { - editor = createHeadlessEditor({nodes: editorNodes}); - - dataset = { - productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', - productImageWidth: 200, - productImageHeight: 100, - productTitle: 'This is a title', - productDescription: 'This is a description', - productRatingEnabled: true, - productButtonEnabled: true, - productButton: 'Button text', - productUrl: 'https://google.com/' - }; - }); - - describe('load and export testing', function () { - it('handles titles with extra br', editorTest(function () { - dataset.productTitle = 'Product title!
    Hello part 2'; - const productNode = $createProductNode(dataset); - const json = productNode.exportJSON(); - const title = json.productTitle; - expect(title).toEqual('Product title!
    Hello part 2'); - })); - it('loads and unwraps titles when wrapped with p', editorTest(function () { - dataset.productTitle = '

    Product title!
    Hello part 2

    '; - const productNode = $createProductNode(dataset); - const json = productNode.exportJSON(); - const title = json.productTitle; - expect(title).toEqual('Product title!
    Hello part 2'); - })); - it('combines adjacent spans', editorTest(function () { - dataset.productTitle = 'Product title! Hello part 2'; - const productNode = $createProductNode(dataset); - const json = productNode.exportJSON(); - const title = json.productTitle; - expect(title).toEqual('Product title! Hello part 2'); - })); - it('handles italics correctly', editorTest(function () { - dataset.productTitle = `Hello title land baaaabeee.`; - const productNode = $createProductNode(dataset); - const json = productNode.exportJSON(); - const title = json.productTitle; - expect(title).toEqual('Hello title land baaaabeee.'); - })); - }); -}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/productCard.test.ts b/packages/koenig-lexical/test/unit/productCard.test.ts new file mode 100644 index 0000000000..6731762ebb --- /dev/null +++ b/packages/koenig-lexical/test/unit/productCard.test.ts @@ -0,0 +1,76 @@ +import {$createProductNode, ProductNode} from '../../src/nodes/ProductNode'; +import {createHeadlessEditor} from '@lexical/headless'; +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +const editorNodes = [ProductNode] as unknown as Klass[]; + +describe('ProductNode', function () { + let editor: LexicalEditor; + let dataset: Record; + + const editorTest = (testFn: () => void) => function () { + let resolve: (value?: unknown) => void, reject: (reason?: unknown) => void; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + + editor.update(() => { + try { + testFn(); + resolve(); + } catch (error) { + reject(error); + } + }); + + return promise; + }; + + beforeEach(function () { + editor = createHeadlessEditor({nodes: editorNodes}); + + dataset = { + productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', + productImageWidth: 200, + productImageHeight: 100, + productTitle: 'This is a title', + productDescription: 'This is a description', + productRatingEnabled: true, + productButtonEnabled: true, + productButton: 'Button text', + productUrl: 'https://google.com/' + }; + }); + + describe('load and export testing', function () { + it('handles titles with extra br', editorTest(function () { + dataset.productTitle = 'Product title!
    Hello part 2'; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + const title = json.productTitle; + expect(title).toEqual('Product title!
    Hello part 2'); + })); + it('loads and unwraps titles when wrapped with p', editorTest(function () { + dataset.productTitle = '

    Product title!
    Hello part 2

    '; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + const title = json.productTitle; + expect(title).toEqual('Product title!
    Hello part 2'); + })); + it('combines adjacent spans', editorTest(function () { + dataset.productTitle = 'Product title! Hello part 2'; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + const title = json.productTitle; + expect(title).toEqual('Product title! Hello part 2'); + })); + it('handles italics correctly', editorTest(function () { + dataset.productTitle = `Hello title land baaaabeee.`; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + const title = json.productTitle; + expect(title).toEqual('Hello title land baaaabeee.'); + })); + }); +}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/signupCard.test.js b/packages/koenig-lexical/test/unit/signupCard.test.js deleted file mode 100644 index d2a546bd03..0000000000 --- a/packages/koenig-lexical/test/unit/signupCard.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import {$createSignupNode, SignupNode} from '../../src/nodes/SignupNode'; -const {createHeadlessEditor} = require('@lexical/headless'); - -const editorNodes = [SignupNode]; - -describe('SignupNode', function () { - let editor; - let dataset; - - const editorTest = testFn => function () { - let resolve, reject; - const promise = new Promise((resolve_, reject_) => { - resolve = resolve_; - reject = reject_; - }); - - editor.update(() => { - try { - testFn(); - resolve(); - } catch (error) { - reject(error); - } - }); - - return promise; - }; - - beforeEach(function () { - editor = createHeadlessEditor({nodes: editorNodes}); - dataset = { - type: 'signup', - version: 1, - alignment: 'left', - backgroundColor: '#F0F0F0', - backgroundImageSrc: '', - backgroundSize: 'cover', - textColor: '#000000', - buttonColor: 'accent', - buttonTextColor: '#FFFFFF', - buttonText: 'Subscribe', - disclaimer: 'No spam. Unsubscribe anytime.', - header: 'Sign up for Koenig Lexical', - labels: [], - layout: 'wide', - subheader: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', - successMessage: 'Email sent! Check your inbox to complete your signup.', - swapped: false - }; - }); - - describe('Content load and export testing', function () { - it('handles "normal" content', editorTest(function () { - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - expect(json.header).toEqual('Sign up for Koenig Lexical'); - expect(json.subheader).toEqual('There\'s a whole lot to discover in this editor. Let us help you settle in.'); - expect(json.disclaimer).toEqual('No spam. Unsubscribe anytime.'); - })); - it('handles headers with extra br', editorTest(function () { - dataset.header = 'Sign up for
    Koenig Lexical'; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const header = json.header; - expect(header).toEqual('Sign up for
    Koenig Lexical'); - })); - it('loads and unwraps headers when wrapped with p', editorTest(function () { - dataset.header = '

    Sign up for
    Koenig Lexical

    '; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const header = json.header; - expect(header).toEqual('Sign up for
    Koenig Lexical'); - })); - it('allows br tags in subheaders', editorTest(function () { - dataset.subheader = 'Product title!
    Hello part 2'; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const subheader = json.subheader; - expect(subheader).toEqual('Product title!
    Hello part 2'); - })); - it('can handle subheaders that are wrapped in p tags', editorTest(function () { - dataset.subheader = '

    Product title!
    Hello part 2

    '; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const subheader = json.subheader; - expect(subheader).toEqual('Product title!
    Hello part 2'); - })); - it('handles disclaimers with extra br', editorTest(function () { - dataset.disclaimer = 'No spam.
    Unsubscribe anytime.'; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const disclaimer = json.disclaimer; - expect(disclaimer).toEqual('No spam.
    Unsubscribe anytime.'); - })); - it('loads and unwraps disclaimers when wrapped with p', editorTest(function () { - dataset.disclaimer = '

    No spam.
    Unsubscribe anytime.

    '; - const signupNode = $createSignupNode(dataset); - const json = signupNode.exportJSON(); - const disclaimer = json.disclaimer; - expect(disclaimer).toEqual('No spam.
    Unsubscribe anytime.'); - })); - }); -}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/signupCard.test.ts b/packages/koenig-lexical/test/unit/signupCard.test.ts new file mode 100644 index 0000000000..0da7dc19a5 --- /dev/null +++ b/packages/koenig-lexical/test/unit/signupCard.test.ts @@ -0,0 +1,104 @@ +import {$createSignupNode, SignupNode} from '../../src/nodes/SignupNode'; +import {createHeadlessEditor} from '@lexical/headless'; +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +const editorNodes = [SignupNode] as unknown as Klass[]; + +describe('SignupNode', function () { + let editor: LexicalEditor; + let dataset: Record; + + const editorTest = (testFn: () => void) => function () { + let resolve: (value?: unknown) => void, reject: (reason?: unknown) => void; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + + editor.update(() => { + try { + testFn(); + resolve(); + } catch (error) { + reject(error); + } + }); + + return promise; + }; + + beforeEach(function () { + editor = createHeadlessEditor({nodes: editorNodes}); + dataset = { + type: 'signup', + version: 1, + alignment: 'left', + backgroundColor: '#F0F0F0', + backgroundImageSrc: '', + backgroundSize: 'cover', + textColor: '#000000', + buttonColor: 'accent', + buttonTextColor: '#FFFFFF', + buttonText: 'Subscribe', + disclaimer: 'No spam. Unsubscribe anytime.', + header: 'Sign up for Koenig Lexical', + labels: [], + layout: 'wide', + subheader: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', + successMessage: 'Email sent! Check your inbox to complete your signup.', + swapped: false + }; + }); + + describe('Content load and export testing', function () { + it('handles "normal" content', editorTest(function () { + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + expect(json.header).toEqual('Sign up for Koenig Lexical'); + expect(json.subheader).toEqual('There\'s a whole lot to discover in this editor. Let us help you settle in.'); + expect(json.disclaimer).toEqual('No spam. Unsubscribe anytime.'); + })); + it('handles headers with extra br', editorTest(function () { + dataset.header = 'Sign up for
    Koenig Lexical'; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const header = json.header; + expect(header).toEqual('Sign up for
    Koenig Lexical'); + })); + it('loads and unwraps headers when wrapped with p', editorTest(function () { + dataset.header = '

    Sign up for
    Koenig Lexical

    '; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const header = json.header; + expect(header).toEqual('Sign up for
    Koenig Lexical'); + })); + it('allows br tags in subheaders', editorTest(function () { + dataset.subheader = 'Product title!
    Hello part 2'; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const subheader = json.subheader; + expect(subheader).toEqual('Product title!
    Hello part 2'); + })); + it('can handle subheaders that are wrapped in p tags', editorTest(function () { + dataset.subheader = '

    Product title!
    Hello part 2

    '; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const subheader = json.subheader; + expect(subheader).toEqual('Product title!
    Hello part 2'); + })); + it('handles disclaimers with extra br', editorTest(function () { + dataset.disclaimer = 'No spam.
    Unsubscribe anytime.'; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const disclaimer = json.disclaimer; + expect(disclaimer).toEqual('No spam.
    Unsubscribe anytime.'); + })); + it('loads and unwraps disclaimers when wrapped with p', editorTest(function () { + dataset.disclaimer = '

    No spam.
    Unsubscribe anytime.

    '; + const signupNode = $createSignupNode(dataset); + const json = signupNode.exportJSON(); + const disclaimer = json.disclaimer; + expect(disclaimer).toEqual('No spam.
    Unsubscribe anytime.'); + })); + }); +}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/toggleCard.test.js b/packages/koenig-lexical/test/unit/toggleCard.test.js deleted file mode 100644 index e1fa6b7fb1..0000000000 --- a/packages/koenig-lexical/test/unit/toggleCard.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import {$createToggleNode, ToggleNode} from '../../src/nodes/ToggleNode'; -const {createHeadlessEditor} = require('@lexical/headless'); - -const editorNodes = [ToggleNode]; - -describe('ToggleNode', function () { - let editor; - let dataset; - - const editorTest = testFn => function () { - let resolve, reject; - const promise = new Promise((resolve_, reject_) => { - resolve = resolve_; - reject = reject_; - }); - - editor.update(() => { - try { - testFn(); - resolve(); - } catch (error) { - reject(error); - } - }); - - return promise; - }; - - beforeEach(function () { - editor = createHeadlessEditor({nodes: editorNodes}); - dataset = { - type: 'toggle', - version: 1, - heading: 'Hello
    I am a two-line toggle', - content: '

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    ' - }; - }); - - describe('Content load and export testing', function () { - it('handles "normal" content', editorTest(function () { - const toggleNode = $createToggleNode(dataset); - const json = toggleNode.exportJSON(); - expect(json.heading).toEqual('Hello
    I am a two-line toggle'); - expect(json.content).toEqual('

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '); - })); - it('handles less messy html', editorTest(function () { - dataset.heading = 'Hello'; - dataset.content = '

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '; - const toggleNode = $createToggleNode(dataset); - const json = toggleNode.exportJSON(); - expect(json.heading).toEqual('Hello'); - expect(json.content).toEqual('

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '); - })); - it('handles headers with extra br', editorTest(function () { - dataset.heading = 'Toggle for
    Koenig Lexical'; - const toggleNode = $createToggleNode(dataset); - const json = toggleNode.exportJSON(); - expect(json.heading).toEqual('Toggle for
    Koenig Lexical'); - })); - it('loads and unwraps headers when wrapped with p', editorTest(function () { - dataset.heading = '

    Toggle for
    Koenig Lexical

    '; - const toggleNode = $createToggleNode(dataset); - const json = toggleNode.exportJSON(); - expect(json.heading).toEqual('Toggle for
    Koenig Lexical'); - })); - }); -}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/toggleCard.test.ts b/packages/koenig-lexical/test/unit/toggleCard.test.ts new file mode 100644 index 0000000000..187959f641 --- /dev/null +++ b/packages/koenig-lexical/test/unit/toggleCard.test.ts @@ -0,0 +1,68 @@ +import {$createToggleNode, ToggleNode} from '../../src/nodes/ToggleNode'; +import {createHeadlessEditor} from '@lexical/headless'; +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +const editorNodes = [ToggleNode] as unknown as Klass[]; + +describe('ToggleNode', function () { + let editor: LexicalEditor; + let dataset: Record; + + const editorTest = (testFn: () => void) => function () { + let resolve: (value?: unknown) => void, reject: (reason?: unknown) => void; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + + editor.update(() => { + try { + testFn(); + resolve(); + } catch (error) { + reject(error); + } + }); + + return promise; + }; + + beforeEach(function () { + editor = createHeadlessEditor({nodes: editorNodes}); + dataset = { + type: 'toggle', + version: 1, + heading: 'Hello
    I am a two-line toggle', + content: '

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    ' + }; + }); + + describe('Content load and export testing', function () { + it('handles "normal" content', editorTest(function () { + const toggleNode = $createToggleNode(dataset); + const json = toggleNode.exportJSON(); + expect(json.heading).toEqual('Hello
    I am a two-line toggle'); + expect(json.content).toEqual('

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '); + })); + it('handles less messy html', editorTest(function () { + dataset.heading = 'Hello'; + dataset.content = '

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '; + const toggleNode = $createToggleNode(dataset); + const json = toggleNode.exportJSON(); + expect(json.heading).toEqual('Hello'); + expect(json.content).toEqual('

    And I\'m actually pretty awesome

    If I do say so myself.

    And I do.

    '); + })); + it('handles headers with extra br', editorTest(function () { + dataset.heading = 'Toggle for
    Koenig Lexical'; + const toggleNode = $createToggleNode(dataset); + const json = toggleNode.exportJSON(); + expect(json.heading).toEqual('Toggle for
    Koenig Lexical'); + })); + it('loads and unwraps headers when wrapped with p', editorTest(function () { + dataset.heading = '

    Toggle for
    Koenig Lexical

    '; + const toggleNode = $createToggleNode(dataset); + const json = toggleNode.exportJSON(); + expect(json.heading).toEqual('Toggle for
    Koenig Lexical'); + })); + }); +}); \ No newline at end of file diff --git a/packages/koenig-lexical/test/unit/utils/generateEditorState.test.js b/packages/koenig-lexical/test/unit/utils/generateEditorState.test.js deleted file mode 100644 index 4e5d7ccdb6..0000000000 --- a/packages/koenig-lexical/test/unit/utils/generateEditorState.test.js +++ /dev/null @@ -1,180 +0,0 @@ -import generateEditorState, {_$generateNodesFromHTML} from '../../../src/utils/generateEditorState'; -import {DEFAULT_NODES} from '../../../src'; -import {createEditor} from 'lexical'; -import {describe, expect, test} from 'vitest'; - -describe('Utils: generateEditorState', () => { - function runGenerateEditorState(html, {nodes = DEFAULT_NODES} = {}) { - const editor = createEditor({ - // lexical swallows errors inside updates by default, - // so we need to throw them to fail the test - onError: (error) => { - throw error; - }, - nodes - }); - const editorState = generateEditorState({editor, initialHtml: html}); - return editorState.toJSON(); - } - - test('can generate editor state from basic paragraph', function () { - const html = '

    Test

    '; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(1); - expect(editorState.root).toMatchObject({ - children: [ - {type: 'paragraph', children: [{text: 'Test'}]} - ] - }); - }); - - test('handles whitespace between paragraphs', function () { - const html = '

    Test

    Test2

    '; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(2); - expect(editorState.root).toMatchObject({ - children: [ - {type: 'paragraph', children: [{text: 'Test'}]}, - {type: 'paragraph', children: [{text: 'Test2'}]} - ] - }); - }); - - test('handles multiple spans inside paragraph', function () { - const html = '

    Test Test2

    '; - const editorState = runGenerateEditorState(html); - - expect(editorState.root).toMatchObject({ - children: [ - {type: 'paragraph', children: [{text: 'Test Test2'}]} - ] - }); - }); - - test('handles multiple spans with no wrapper', function () { - const html = 'Test Test2'; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(1); - expect(editorState.root).toMatchObject({ - children: [ - {type: 'paragraph', children: [{text: 'Test Test2'}]} - ] - }); - }); - - test('handles line breaks inside paragraph', function () { - const html = '

    Test
    Test2

    '; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children[0]).toMatchObject({ - type: 'paragraph', - children: [ - {text: 'Test'}, - {type: 'linebreak'}, - {text: 'Test2'} - ] - }); - }); - - test('handles line breaks with no wrapper', function () { - const html = 'Test
    Test2'; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(1); - expect(editorState.root.children[0]).toMatchObject({ - type: 'paragraph', - children: [ - {text: 'Test'}, - {type: 'linebreak'}, - {text: 'Test2'} - ] - }); - }); - - test('handles line breaks and spans with no wrapper', function () { - const html = 'Test
    Test2'; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(1); - expect(editorState.root.children[0]).toMatchObject({ - type: 'paragraph', - children: [ - {text: 'Test'}, - {type: 'linebreak'}, - {text: 'Test2'} - ] - }); - }); - - // https://github.com/facebook/lexical/issues/2807 - test('handles whitespace between list items', function () { - const html = '
    • Test
    • Test2
    '; - const editorState = runGenerateEditorState(html); - - expect(editorState.root.children).toHaveLength(1); - expect(editorState.root.children[0].children).toHaveLength(2); - expect(editorState.root.children[0]).toMatchObject({ - type: 'list', - children: [ - {type: 'listitem', children: [{text: 'Test'}]}, - {type: 'listitem', children: [{text: 'Test2'}]} - ] - }); - }); - - describe('_$generateNodesFromHTML', () => { - function testGenerateNodesFromHTML(html, callback) { - const editor = createEditor({ - // lexical swallows errors inside updates by default, - // so we need to throw them to fail the test - onError: (error) => { - throw error; - } - }); - editor.update(() => { - const nodes = _$generateNodesFromHTML(editor, html); - callback(nodes); - }, {discrete: true}); - } - - test('can generate basic paragraph node from html', function () { - const html = '

    Test

    '; - testGenerateNodesFromHTML(html, (nodes) => { - expect(nodes.length).toEqual(1); - expect(nodes[0].getType()).toEqual('paragraph'); - expect(nodes[0].getTextContent()).toEqual('Test'); - }); - }); - - test('handles single span inside paragraph', function () { - const html = '

    Test

    '; - testGenerateNodesFromHTML(html, (nodes) => { - expect(nodes[0].getChildren().length).toEqual(1); - expect(nodes[0].getChildren()[0].getType()).toEqual('text'); - }); - }); - - test('handles multiple spans inside paragraph', function () { - const html = '

    Test Test2

    '; - testGenerateNodesFromHTML(html, (nodes) => { - expect(nodes[0].getChildren().length).toEqual(3); - expect(nodes[0].getChildren()[0].getTextContent()).toEqual('Test'); - expect(nodes[0].getChildren()[1].getTextContent()).toEqual(' '); - expect(nodes[0].getChildren()[2].getTextContent()).toEqual('Test2'); - }); - }); - - test('handles multiple spans with no wrapper', function () { - const html = 'Test Test2'; - testGenerateNodesFromHTML(html, (nodes) => { - expect(nodes.length).toEqual(3); - expect(nodes[0].getTextContent()).toEqual('Test'); - expect(nodes[1].getTextContent()).toEqual(' '); - expect(nodes[2].getTextContent()).toEqual('Test2'); - }); - }); - }); -}); diff --git a/packages/koenig-lexical/test/unit/utils/generateEditorState.test.ts b/packages/koenig-lexical/test/unit/utils/generateEditorState.test.ts new file mode 100644 index 0000000000..5cc045de57 --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/generateEditorState.test.ts @@ -0,0 +1,181 @@ +import generateEditorState, {_$generateNodesFromHTML} from '../../../src/utils/generateEditorState'; +import {DEFAULT_NODES} from '../../../src'; +import {createEditor} from 'lexical'; +import {describe, expect, test} from 'vitest'; +import type {CreateEditorArgs, ElementNode, LexicalNode} from 'lexical'; + +describe('Utils: generateEditorState', () => { + function runGenerateEditorState(html: string, {nodes = DEFAULT_NODES as unknown as CreateEditorArgs['nodes']} = {}) { + const editor = createEditor({ + // lexical swallows errors inside updates by default, + // so we need to throw them to fail the test + onError: (error) => { + throw error; + }, + nodes + }); + const editorState = generateEditorState({editor, initialHtml: html}); + return editorState.toJSON(); + } + + test('can generate editor state from basic paragraph', function () { + const html = '

    Test

    '; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(1); + expect(editorState.root).toMatchObject({ + children: [ + {type: 'paragraph', children: [{text: 'Test'}]} + ] + }); + }); + + test('handles whitespace between paragraphs', function () { + const html = '

    Test

    Test2

    '; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(2); + expect(editorState.root).toMatchObject({ + children: [ + {type: 'paragraph', children: [{text: 'Test'}]}, + {type: 'paragraph', children: [{text: 'Test2'}]} + ] + }); + }); + + test('handles multiple spans inside paragraph', function () { + const html = '

    Test Test2

    '; + const editorState = runGenerateEditorState(html); + + expect(editorState.root).toMatchObject({ + children: [ + {type: 'paragraph', children: [{text: 'Test Test2'}]} + ] + }); + }); + + test('handles multiple spans with no wrapper', function () { + const html = 'Test Test2'; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(1); + expect(editorState.root).toMatchObject({ + children: [ + {type: 'paragraph', children: [{text: 'Test Test2'}]} + ] + }); + }); + + test('handles line breaks inside paragraph', function () { + const html = '

    Test
    Test2

    '; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children[0]).toMatchObject({ + type: 'paragraph', + children: [ + {text: 'Test'}, + {type: 'linebreak'}, + {text: 'Test2'} + ] + }); + }); + + test('handles line breaks with no wrapper', function () { + const html = 'Test
    Test2'; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(1); + expect(editorState.root.children[0]).toMatchObject({ + type: 'paragraph', + children: [ + {text: 'Test'}, + {type: 'linebreak'}, + {text: 'Test2'} + ] + }); + }); + + test('handles line breaks and spans with no wrapper', function () { + const html = 'Test
    Test2'; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(1); + expect(editorState.root.children[0]).toMatchObject({ + type: 'paragraph', + children: [ + {text: 'Test'}, + {type: 'linebreak'}, + {text: 'Test2'} + ] + }); + }); + + // https://github.com/facebook/lexical/issues/2807 + test('handles whitespace between list items', function () { + const html = '
    • Test
    • Test2
    '; + const editorState = runGenerateEditorState(html); + + expect(editorState.root.children).toHaveLength(1); + expect((editorState.root.children[0] as unknown as {children: unknown[]}).children).toHaveLength(2); + expect(editorState.root.children[0]).toMatchObject({ + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Test'}]}, + {type: 'listitem', children: [{text: 'Test2'}]} + ] + }); + }); + + describe('_$generateNodesFromHTML', () => { + function testGenerateNodesFromHTML(html: string, callback: (nodes: LexicalNode[]) => void) { + const editor = createEditor({ + // lexical swallows errors inside updates by default, + // so we need to throw them to fail the test + onError: (error) => { + throw error; + } + }); + editor.update(() => { + const nodes = _$generateNodesFromHTML(editor, html); + callback(nodes); + }, {discrete: true}); + } + + test('can generate basic paragraph node from html', function () { + const html = '

    Test

    '; + testGenerateNodesFromHTML(html, (nodes) => { + expect(nodes.length).toEqual(1); + expect(nodes[0].getType()).toEqual('paragraph'); + expect(nodes[0].getTextContent()).toEqual('Test'); + }); + }); + + test('handles single span inside paragraph', function () { + const html = '

    Test

    '; + testGenerateNodesFromHTML(html, (nodes) => { + expect((nodes[0] as ElementNode).getChildren().length).toEqual(1); + expect((nodes[0] as ElementNode).getChildren()[0].getType()).toEqual('text'); + }); + }); + + test('handles multiple spans inside paragraph', function () { + const html = '

    Test Test2

    '; + testGenerateNodesFromHTML(html, (nodes) => { + expect((nodes[0] as ElementNode).getChildren().length).toEqual(3); + expect((nodes[0] as ElementNode).getChildren()[0].getTextContent()).toEqual('Test'); + expect((nodes[0] as ElementNode).getChildren()[1].getTextContent()).toEqual(' '); + expect((nodes[0] as ElementNode).getChildren()[2].getTextContent()).toEqual('Test2'); + }); + }); + + test('handles multiple spans with no wrapper', function () { + const html = 'Test Test2'; + testGenerateNodesFromHTML(html, (nodes) => { + expect(nodes.length).toEqual(3); + expect(nodes[0].getTextContent()).toEqual('Test'); + expect(nodes[1].getTextContent()).toEqual(' '); + expect(nodes[2].getTextContent()).toEqual('Test2'); + }); + }); + }); +}); diff --git a/packages/koenig-lexical/test/unit/utils/gif-provider.test.js b/packages/koenig-lexical/test/unit/utils/gif-provider.test.js deleted file mode 100644 index e8995ca5e9..0000000000 --- a/packages/koenig-lexical/test/unit/utils/gif-provider.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import {describe, expect, test} from 'vitest'; -import {extractErrorMessage, getGifProviderConfig, isInvalidKeyError} from '../../../src/utils/services/gif.js'; - -describe('Utils: getGifProviderConfig', () => { - test('returns null when neither provider is configured', () => { - expect(getGifProviderConfig(undefined)).toBeNull(); - expect(getGifProviderConfig({})).toBeNull(); - expect(getGifProviderConfig({tenor: null, klipy: null})).toBeNull(); - }); - - test('resolves Tenor when only Tenor is configured', () => { - const config = getGifProviderConfig({tenor: {googleApiKey: 'tenor-key'}}); - - expect(config).toEqual({ - provider: 'tenor', - apiUrl: 'https://tenor.googleapis.com', - apiKey: 'tenor-key', - contentFilter: 'off' - }); - }); - - test('resolves Klipy when only Klipy is configured', () => { - const config = getGifProviderConfig({klipy: {apiKey: 'klipy-key'}}); - - expect(config).toEqual({ - provider: 'klipy', - apiUrl: 'https://api.klipy.com', - apiKey: 'klipy-key', - contentFilter: 'off' - }); - }); - - test('prefers Klipy when both providers are configured', () => { - const config = getGifProviderConfig({ - tenor: {googleApiKey: 'tenor-key'}, - klipy: {apiKey: 'klipy-key'} - }); - - expect(config.provider).toEqual('klipy'); - expect(config.apiKey).toEqual('klipy-key'); - }); - - test('passes through a configured content filter', () => { - expect(getGifProviderConfig({tenor: {googleApiKey: 'k', contentFilter: 'high'}}).contentFilter).toEqual('high'); - expect(getGifProviderConfig({klipy: {apiKey: 'k', contentFilter: 'low'}}).contentFilter).toEqual('low'); - }); - - test('falls back to Tenor when the Klipy config is present but has no key', () => { - expect(getGifProviderConfig({klipy: {}, tenor: {googleApiKey: 'tenor-key'}}).provider).toEqual('tenor'); - expect(getGifProviderConfig({klipy: {apiKey: ''}, tenor: {googleApiKey: 'tenor-key'}}).provider).toEqual('tenor'); - }); -}); - -describe('Utils: extractErrorMessage', () => { - test('reads the Tenor error shape', () => { - expect(extractErrorMessage({error: {message: 'API key not valid'}})).toEqual('API key not valid'); - }); - - test('reads a Tenor string error', () => { - expect(extractErrorMessage({error: 'Something went wrong'})).toEqual('Something went wrong'); - }); - - test('reads the Klipy error shape', () => { - expect(extractErrorMessage({result: false, errors: {message: ['The provided API key is invalid']}})).toEqual('The provided API key is invalid'); - }); - - test('reads a Klipy error message that is not wrapped in an array', () => { - expect(extractErrorMessage({result: false, errors: {message: 'Rate limit exceeded'}})).toEqual('Rate limit exceeded'); - }); - - test('falls back to a generic message when the shape is unknown', () => { - expect(extractErrorMessage({})).toEqual('Unknown error'); - }); -}); - -describe('Utils: isInvalidKeyError', () => { - test('detects the Tenor invalid-key wording', () => { - expect(isInvalidKeyError('API key not valid')).toBe(true); - }); - - test('detects the Klipy invalid-key wording', () => { - expect(isInvalidKeyError('The provided API key is invalid: [xxx]')).toBe(true); - }); - - test('returns false for a non-key error', () => { - expect(isInvalidKeyError('Trouble reaching the GIF service')).toBe(false); - }); - - test('returns false for an empty or missing message', () => { - expect(isInvalidKeyError('')).toBe(false); - expect(isInvalidKeyError(undefined)).toBe(false); - }); -}); diff --git a/packages/koenig-lexical/test/unit/utils/gif-provider.test.ts b/packages/koenig-lexical/test/unit/utils/gif-provider.test.ts new file mode 100644 index 0000000000..0a8ca25263 --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/gif-provider.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, test} from 'vitest'; +import {extractErrorMessage, getGifProviderConfig, isInvalidKeyError} from '../../../src/utils/services/gif.js'; + +describe('Utils: getGifProviderConfig', () => { + test('returns null when neither provider is configured', () => { + expect(getGifProviderConfig(undefined)).toBeNull(); + expect(getGifProviderConfig({})).toBeNull(); + expect(getGifProviderConfig({tenor: null, klipy: null})).toBeNull(); + }); + + test('resolves Tenor when only Tenor is configured', () => { + const config = getGifProviderConfig({tenor: {googleApiKey: 'tenor-key'}}); + + expect(config).toEqual({ + provider: 'tenor', + apiUrl: 'https://tenor.googleapis.com', + apiKey: 'tenor-key', + contentFilter: 'off' + }); + }); + + test('resolves Klipy when only Klipy is configured', () => { + const config = getGifProviderConfig({klipy: {apiKey: 'klipy-key'}}); + + expect(config).toEqual({ + provider: 'klipy', + apiUrl: 'https://api.klipy.com', + apiKey: 'klipy-key', + contentFilter: 'off' + }); + }); + + test('prefers Klipy when both providers are configured', () => { + const config = getGifProviderConfig({ + tenor: {googleApiKey: 'tenor-key'}, + klipy: {apiKey: 'klipy-key'} + }); + + expect(config!.provider).toEqual('klipy'); + expect(config!.apiKey).toEqual('klipy-key'); + }); + + test('passes through a configured content filter', () => { + expect(getGifProviderConfig({tenor: {googleApiKey: 'k', contentFilter: 'high'}})!.contentFilter).toEqual('high'); + expect(getGifProviderConfig({klipy: {apiKey: 'k', contentFilter: 'low'}})!.contentFilter).toEqual('low'); + }); + + test('falls back to Tenor when the Klipy config is present but has no key', () => { + expect(getGifProviderConfig({klipy: {}, tenor: {googleApiKey: 'tenor-key'}})!.provider).toEqual('tenor'); + expect(getGifProviderConfig({klipy: {apiKey: ''}, tenor: {googleApiKey: 'tenor-key'}})!.provider).toEqual('tenor'); + }); +}); + +describe('Utils: extractErrorMessage', () => { + test('reads the Tenor error shape', () => { + expect(extractErrorMessage({error: {message: 'API key not valid'}})).toEqual('API key not valid'); + }); + + test('reads a Tenor string error', () => { + expect(extractErrorMessage({error: 'Something went wrong'})).toEqual('Something went wrong'); + }); + + test('reads the Klipy error shape', () => { + expect(extractErrorMessage({result: false, errors: {message: ['The provided API key is invalid']}})).toEqual('The provided API key is invalid'); + }); + + test('reads a Klipy error message that is not wrapped in an array', () => { + expect(extractErrorMessage({result: false, errors: {message: 'Rate limit exceeded'}})).toEqual('Rate limit exceeded'); + }); + + test('falls back to a generic message when the shape is unknown', () => { + expect(extractErrorMessage({})).toEqual('Unknown error'); + }); +}); + +describe('Utils: isInvalidKeyError', () => { + test('detects the Tenor invalid-key wording', () => { + expect(isInvalidKeyError('API key not valid')).toBe(true); + }); + + test('detects the Klipy invalid-key wording', () => { + expect(isInvalidKeyError('The provided API key is invalid: [xxx]')).toBe(true); + }); + + test('returns false for a non-key error', () => { + expect(isInvalidKeyError('Trouble reaching the GIF service')).toBe(false); + }); + + test('returns false for an empty or missing message', () => { + expect(isInvalidKeyError('')).toBe(false); + expect(isInvalidKeyError(undefined)).toBe(false); + }); +}); diff --git a/packages/koenig-lexical/test/unit/utils/image-card-widths.test.js b/packages/koenig-lexical/test/unit/utils/image-card-widths.test.js deleted file mode 100644 index 364fe9af05..0000000000 --- a/packages/koenig-lexical/test/unit/utils/image-card-widths.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {getAllowedImageCardWidths, getDefaultImageCardWidth} from '../../../src/utils/image-card-widths'; - -describe('image-card-widths utils', () => { - it('returns all widths when config is missing', () => { - expect(getAllowedImageCardWidths()).toEqual(['regular', 'wide', 'full']); - }); - - it('returns all widths when config is invalid or empty', () => { - expect(getAllowedImageCardWidths('regular')).toEqual(['regular', 'wide', 'full']); - expect(getAllowedImageCardWidths([])).toEqual(['regular', 'wide', 'full']); - expect(getAllowedImageCardWidths(['invalid'])).toEqual(['regular', 'wide', 'full']); - }); - - it('filters invalid values and de-duplicates while preserving order', () => { - expect(getAllowedImageCardWidths(['wide', 'invalid', 'full', 'wide'])).toEqual(['wide', 'full']); - }); - - it('defaults to regular width when available', () => { - expect(getDefaultImageCardWidth(['regular', 'wide'])).toBe('regular'); - }); - - it('defaults to first allowed width when regular is not available', () => { - expect(getDefaultImageCardWidth(['wide', 'full'])).toBe('wide'); - }); -}); diff --git a/packages/koenig-lexical/test/unit/utils/image-card-widths.test.ts b/packages/koenig-lexical/test/unit/utils/image-card-widths.test.ts new file mode 100644 index 0000000000..0bb350fe0f --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/image-card-widths.test.ts @@ -0,0 +1,26 @@ +import {describe, expect, it} from 'vitest'; +import {getAllowedImageCardWidths, getDefaultImageCardWidth} from '../../../src/utils/image-card-widths'; + +describe('image-card-widths utils', () => { + it('returns all widths when config is missing', () => { + expect(getAllowedImageCardWidths(undefined)).toEqual(['regular', 'wide', 'full']); + }); + + it('returns all widths when config is invalid or empty', () => { + expect(getAllowedImageCardWidths('regular')).toEqual(['regular', 'wide', 'full']); + expect(getAllowedImageCardWidths([])).toEqual(['regular', 'wide', 'full']); + expect(getAllowedImageCardWidths(['invalid'])).toEqual(['regular', 'wide', 'full']); + }); + + it('filters invalid values and de-duplicates while preserving order', () => { + expect(getAllowedImageCardWidths(['wide', 'invalid', 'full', 'wide'])).toEqual(['wide', 'full']); + }); + + it('defaults to regular width when available', () => { + expect(getDefaultImageCardWidth(['regular', 'wide'])).toBe('regular'); + }); + + it('defaults to first allowed width when regular is not available', () => { + expect(getDefaultImageCardWidth(['wide', 'full'])).toBe('wide'); + }); +}); diff --git a/packages/koenig-lexical/test/unit/utils/sanitize-html.test.js b/packages/koenig-lexical/test/unit/utils/sanitize-html.test.js deleted file mode 100644 index 1a5a29c1fa..0000000000 --- a/packages/koenig-lexical/test/unit/utils/sanitize-html.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import {describe, expect, test} from 'vitest'; -import {sanitizeHtml} from '../../../src/utils/sanitize-html'; - -describe('Utils: sanitize-html', () => { - test('can replace scripts', function () { - const sanitizedHtml = sanitizeHtml('Hey'); - expect(sanitizedHtml).toEqual('Hey
    Embedded JavaScript
    '); - }); - - it('can render html', function () { - const sanitizedHtml = sanitizeHtml('bold'); - expect(sanitizedHtml).toEqual('bold'); - }); - - it('strips style elements but keeps style attributes', function () { - const sanitizedHtml = sanitizeHtml('Hey'); - expect(sanitizedHtml).toEqual('Hey'); - }); - - it('allows https URLs', function () { - const sanitizedHtml = sanitizeHtml('link'); - expect(sanitizedHtml).toEqual('link'); - }); - - it('allows root URLs', function () { - const sanitizedHtml = sanitizeHtml('link'); - expect(sanitizedHtml).toEqual('link'); - }); - - it('allows blob URLs', function () { - const sanitizedHtml = sanitizeHtml('link'); - expect(sanitizedHtml).toEqual('link'); - }); - - it.each([ - 'ftp://example.com', - 'javascript:alert(1)', - 'mailto:hello@example.com', - 'data:text/plain,hello' - ])('disallows non-http protocols: %s', (href) => { - const sanitizedHtml = sanitizeHtml(`link`); - expect(sanitizedHtml).toEqual('link'); - }); - - it('disallows javascript URLs that contain blob text', function () { - const sanitizedHtml = sanitizeHtml('link'); - expect(sanitizedHtml).toEqual('link'); - }); - - it.each([ - 'foo/bar', - './foo', - '../foo' - ])('disallows relative links: %s', (href) => { - const sanitizedHtml = sanitizeHtml(`link`); - expect(sanitizedHtml).toEqual('link'); - }); -}); diff --git a/packages/koenig-lexical/test/unit/utils/sanitize-html.test.ts b/packages/koenig-lexical/test/unit/utils/sanitize-html.test.ts new file mode 100644 index 0000000000..74cb436897 --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/sanitize-html.test.ts @@ -0,0 +1,68 @@ +import {describe, expect, test} from 'vitest'; +import {sanitizeHtml} from '../../../src/utils/sanitize-html'; + +describe('Utils: sanitize-html', () => { + test('can replace scripts', function () { + const sanitizedHtml = sanitizeHtml('Hey'); + expect(sanitizedHtml).toEqual('Hey
    Embedded JavaScript
    '); + }); + + test('can replace scripts with whitespace in the closing tag', function () { + const sanitizedHtml = sanitizeHtml('Hey'); + expect(sanitizedHtml).toEqual('Hey
    Embedded JavaScript
    '); + }); + + test('can replace iframes with whitespace in the closing tag', function () { + const sanitizedHtml = sanitizeHtml('Hey'); + expect(sanitizedHtml).toEqual('Hey
    Embedded iFrame
    '); + }); + + it('can render html', function () { + const sanitizedHtml = sanitizeHtml('bold'); + expect(sanitizedHtml).toEqual('bold'); + }); + + it('strips style elements but keeps style attributes', function () { + const sanitizedHtml = sanitizeHtml('Hey'); + expect(sanitizedHtml).toEqual('Hey'); + }); + + it('allows https URLs', function () { + const sanitizedHtml = sanitizeHtml('link'); + expect(sanitizedHtml).toEqual('link'); + }); + + it('allows root URLs', function () { + const sanitizedHtml = sanitizeHtml('link'); + expect(sanitizedHtml).toEqual('link'); + }); + + it('allows blob URLs', function () { + const sanitizedHtml = sanitizeHtml('link'); + expect(sanitizedHtml).toEqual('link'); + }); + + it.each([ + 'ftp://example.com', + 'javascript:alert(1)', + 'mailto:hello@example.com', + 'data:text/plain,hello' + ])('disallows non-http protocols: %s', (href) => { + const sanitizedHtml = sanitizeHtml(`link`); + expect(sanitizedHtml).toEqual('link'); + }); + + it('disallows javascript URLs that contain blob text', function () { + const sanitizedHtml = sanitizeHtml('link'); + expect(sanitizedHtml).toEqual('link'); + }); + + it.each([ + 'foo/bar', + './foo', + '../foo' + ])('disallows relative links: %s', (href) => { + const sanitizedHtml = sanitizeHtml(`link`); + expect(sanitizedHtml).toEqual('link'); + }); +}); diff --git a/packages/koenig-lexical/test/unit/utils/visibility.test.js b/packages/koenig-lexical/test/unit/utils/visibility.test.js deleted file mode 100644 index e89a083454..0000000000 --- a/packages/koenig-lexical/test/unit/utils/visibility.test.js +++ /dev/null @@ -1,267 +0,0 @@ -import {expect} from 'vitest'; -import { - getVisibilityOptions, - parseVisibilityToToggles, - serializeOptionsToVisibility -} from '../../../src/utils/visibility'; - -describe('parseVisibilityToToggles', function () { - it('should return correct truthy toggles based on the visibility object', function () { - const visibility = { - web: { - nonMember: true, - memberSegment: 'status:free,status:-free' - }, - email: { - memberSegment: 'status:free,status:-free' - } - }; - - const result = parseVisibilityToToggles(visibility); - - expect(result).toEqual({ - web: { - nonMembers: true, - freeMembers: true, - paidMembers: true - }, - email: { - freeMembers: true, - paidMembers: true - } - }); - }); - - it('should return correct falsy toggles based on the visibility object', function () { - const visibility = { - web: { - nonMember: false, - memberSegment: '' - }, - email: { - memberSegment: '' - } - }; - - const result = parseVisibilityToToggles(visibility); - - expect(result).toEqual({ - web: { - nonMembers: false, - freeMembers: false, - paidMembers: false - }, - email: { - freeMembers: false, - paidMembers: false - } - }); - }); - - it('handles partial member segments', function () { - const visibility = { - web: { - nonMember: false, - memberSegment: 'status:free' - }, - email: { - memberSegment: 'status:-free' - } - }; - - const result = parseVisibilityToToggles(visibility); - - expect(result).toEqual({ - web: { - nonMembers: false, - freeMembers: true, - paidMembers: false - }, - email: { - freeMembers: false, - paidMembers: true - } - }); - }); -}); - -describe('getVisibilityOptions', function () { - it('has correct default options', function () { - const options = getVisibilityOptions(); - - expect(options).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - }, - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); - - it('removes paid options if stripe is disabled', function () { - const options = getVisibilityOptions(undefined, {isStripeEnabled: false}); - - expect(options).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: true} - ] - }, - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: true} - ] - } - ]); - }); - - it('updates option checked values based on visibility', function () { - const visibility = { - web: { - nonMember: false, - memberSegment: 'status:free' - }, - email: { - memberSegment: 'status:-free' - } - }; - - const options = getVisibilityOptions(visibility); - - expect(options).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: false}, - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: false} - ] - }, - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: false}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); - - it('returns only web group when showEmail is false', function () { - const options = getVisibilityOptions(undefined, {showEmail: false}); - - expect(options).toEqual([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); - - it('returns only email group when showWeb is false', function () { - const options = getVisibilityOptions(undefined, {showWeb: false}); - - expect(options).toEqual([ - { - label: 'Email', - key: 'email', - toggles: [ - {key: 'freeMembers', label: 'Free members', checked: true}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ]); - }); -}); - -describe('serializeOptionsToVisibility', function () { - it('preserves hidden group visibility values', function () { - const existingVisibility = { - web: { - nonMember: false, - memberSegment: 'status:free,status:-free' - }, - email: { - memberSegment: 'status:free' - } - }; - - const visibility = serializeOptionsToVisibility([ - { - label: 'Web', - key: 'web', - toggles: [ - {key: 'nonMembers', label: 'Public visitors', checked: true}, - {key: 'freeMembers', label: 'Free members', checked: false}, - {key: 'paidMembers', label: 'Paid members', checked: true} - ] - } - ], existingVisibility); - - expect(visibility).toEqual({ - web: { - nonMember: true, - memberSegment: 'status:-free' - }, - email: { - memberSegment: 'status:free' - } - }); - }); - - it('serializes all groups when both are present in options', function () { - const visibility = serializeOptionsToVisibility([ - { - key: 'web', - label: 'Web', - toggles: [ - {key: 'nonMembers', checked: true}, - {key: 'freeMembers', checked: true}, - {key: 'paidMembers', checked: false} - ] - }, - { - key: 'email', - label: 'Email', - toggles: [ - {key: 'freeMembers', checked: false}, - {key: 'paidMembers', checked: true} - ] - } - ]); - - expect(visibility).toEqual({ - web: { - nonMember: true, - memberSegment: 'status:free' - }, - email: { - memberSegment: 'status:-free' - } - }); - }); -}); diff --git a/packages/koenig-lexical/test/unit/utils/visibility.test.ts b/packages/koenig-lexical/test/unit/utils/visibility.test.ts new file mode 100644 index 0000000000..8ce2a06253 --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/visibility.test.ts @@ -0,0 +1,267 @@ +import {expect} from 'vitest'; +import { + getVisibilityOptions, + parseVisibilityToToggles, + serializeOptionsToVisibility +} from '../../../src/utils/visibility'; + +describe('parseVisibilityToToggles', function () { + it('should return correct truthy toggles based on the visibility object', function () { + const visibility = { + web: { + nonMember: true, + memberSegment: 'status:free,status:-free' + }, + email: { + memberSegment: 'status:free,status:-free' + } + }; + + const result = parseVisibilityToToggles(visibility); + + expect(result).toEqual({ + web: { + nonMembers: true, + freeMembers: true, + paidMembers: true + }, + email: { + freeMembers: true, + paidMembers: true + } + }); + }); + + it('should return correct falsy toggles based on the visibility object', function () { + const visibility = { + web: { + nonMember: false, + memberSegment: '' + }, + email: { + memberSegment: '' + } + }; + + const result = parseVisibilityToToggles(visibility); + + expect(result).toEqual({ + web: { + nonMembers: false, + freeMembers: false, + paidMembers: false + }, + email: { + freeMembers: false, + paidMembers: false + } + }); + }); + + it('handles partial member segments', function () { + const visibility = { + web: { + nonMember: false, + memberSegment: 'status:free' + }, + email: { + memberSegment: 'status:-free' + } + }; + + const result = parseVisibilityToToggles(visibility); + + expect(result).toEqual({ + web: { + nonMembers: false, + freeMembers: true, + paidMembers: false + }, + email: { + freeMembers: false, + paidMembers: true + } + }); + }); +}); + +describe('getVisibilityOptions', function () { + it('has correct default options', function () { + const options = getVisibilityOptions(undefined); + + expect(options).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + }, + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); + + it('removes paid options if stripe is disabled', function () { + const options = getVisibilityOptions(undefined, {isStripeEnabled: false}); + + expect(options).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true} + ] + }, + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: true} + ] + } + ]); + }); + + it('updates option checked values based on visibility', function () { + const visibility = { + web: { + nonMember: false, + memberSegment: 'status:free' + }, + email: { + memberSegment: 'status:-free' + } + }; + + const options = getVisibilityOptions(visibility); + + expect(options).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: false}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: false} + ] + }, + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: false}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); + + it('returns only web group when showEmail is false', function () { + const options = getVisibilityOptions(undefined, {showEmail: false}); + + expect(options).toEqual([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); + + it('returns only email group when showWeb is false', function () { + const options = getVisibilityOptions(undefined, {showWeb: false}); + + expect(options).toEqual([ + { + label: 'Email', + key: 'email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + }); +}); + +describe('serializeOptionsToVisibility', function () { + it('preserves hidden group visibility values', function () { + const existingVisibility = { + web: { + nonMember: false, + memberSegment: 'status:free,status:-free' + }, + email: { + memberSegment: 'status:free' + } + }; + + const visibility = serializeOptionsToVisibility([ + { + label: 'Web', + key: 'web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: false}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ], existingVisibility); + + expect(visibility).toEqual({ + web: { + nonMember: true, + memberSegment: 'status:-free' + }, + email: { + memberSegment: 'status:free' + } + }); + }); + + it('serializes all groups when both are present in options', function () { + const visibility = serializeOptionsToVisibility([ + { + key: 'web', + label: 'Web', + toggles: [ + {key: 'nonMembers', label: 'Public visitors', checked: true}, + {key: 'freeMembers', label: 'Free members', checked: true}, + {key: 'paidMembers', label: 'Paid members', checked: false} + ] + }, + { + key: 'email', + label: 'Email', + toggles: [ + {key: 'freeMembers', label: 'Free members', checked: false}, + {key: 'paidMembers', label: 'Paid members', checked: true} + ] + } + ]); + + expect(visibility).toEqual({ + web: { + nonMember: true, + memberSegment: 'status:free' + }, + email: { + memberSegment: 'status:-free' + } + }); + }); +}); diff --git a/packages/koenig-lexical/test/utils/color-select-helper.js b/packages/koenig-lexical/test/utils/color-select-helper.js deleted file mode 100644 index 1fbe56b294..0000000000 --- a/packages/koenig-lexical/test/utils/color-select-helper.js +++ /dev/null @@ -1,26 +0,0 @@ -export async function selectNamedColor(page, colorName, testId) { - if (testId) { - const color = page.locator(`[data-testid="${testId}"]`); - await color.click(); - } - const colorPicker = page.locator(`[data-testid="color-picker-${colorName}"]`); - await colorPicker.click(); -} - -export async function selectCustomColor(page, color, pickerTestId) { - if (pickerTestId) { - const customColor = page.locator(`[data-testid="${pickerTestId}"]`); - await customColor.click(); - } - const customColorInput = page.locator(`[aria-label="Color value"]`); - await customColorInput.fill(color); -} - -export async function selectTitledColor(page, colorName, testId) { - if (testId) { - const color = page.locator(`[data-testid="${testId}"]`); - await color.click(); - } - const colorPicker = page.locator(`[title="${colorName}"]`); - await colorPicker.click(); -} diff --git a/packages/koenig-lexical/test/utils/color-select-helper.ts b/packages/koenig-lexical/test/utils/color-select-helper.ts new file mode 100644 index 0000000000..7cefcc951d --- /dev/null +++ b/packages/koenig-lexical/test/utils/color-select-helper.ts @@ -0,0 +1,28 @@ +import type {Page} from '@playwright/test'; + +export async function selectNamedColor(page: Page, colorName: string, testId?: string) { + if (testId) { + const color = page.locator(`[data-testid="${testId}"]`); + await color.click(); + } + const colorPicker = page.locator(`[data-testid="color-picker-${colorName}"]`); + await colorPicker.click(); +} + +export async function selectCustomColor(page: Page, color: string, pickerTestId?: string) { + if (pickerTestId) { + const customColor = page.locator(`[data-testid="${pickerTestId}"]`); + await customColor.click(); + } + const customColorInput = page.locator(`[aria-label="Color value"]`); + await customColorInput.fill(color); +} + +export async function selectTitledColor(page: Page, colorName: string, testId?: string) { + if (testId) { + const color = page.locator(`[data-testid="${testId}"]`); + await color.click(); + } + const colorPicker = page.locator(`[title="${colorName}"]`); + await colorPicker.click(); +} diff --git a/packages/koenig-lexical/test/utils/e2e.js b/packages/koenig-lexical/test/utils/e2e.js deleted file mode 100644 index 00e2987d11..0000000000 --- a/packages/koenig-lexical/test/utils/e2e.js +++ /dev/null @@ -1,529 +0,0 @@ -import fs from 'fs'; -import jsdom from 'jsdom'; -import prettier from '@prettier/sync'; -import startCase from 'lodash/startCase.js'; -import {E2E_PORT} from '../../playwright.config'; -import {expect} from '@playwright/test'; - -const {JSDOM} = jsdom; -const browserCtrlOrCmdMap = new WeakMap(); - -export async function initialize({page, uri = '/#/?content=false'}) { - const url = `http://localhost:${E2E_PORT}${uri}`; - - const currentViewportSize = page.viewportSize(); - if (currentViewportSize.width !== 1000 || currentViewportSize.height !== 1000) { - await page.setViewportSize({width: 1000, height: 1000}); - } - - const currentUrl = page.url(); - if (currentUrl === 'about:blank') { - // First page load - await page.goto(url); - - await page.waitForSelector('.koenig-lexical'); - - await exposeLexicalEditor(page); - } else { - // Subsequent pages navigated to using react router - await page.evaluate(async ([navigateTo, force]) => { - window.lexicalEditor.blur(); - window.lexicalEditor.setEditorState(window.originalEditorState); - - if (force) { - // Purposefully navigate away from the current page to ensure component is reloaded - window.navigate('/404'); - await new Promise((res) => { - setTimeout(() => { - // Navigate in a task to ensure React Router cannot optimise out our first navigation - window.navigate(navigateTo); - res(); - }, 10); - }); - } else { - await window.navigate(navigateTo); - } - }, [uri.slice(2), currentUrl === url]); - await exposeLexicalEditor(page); - } - - browserCtrlOrCmdMap.set(page, await page.evaluate(() => { - return navigator.platform.includes('Mac') ? 'Meta' : 'Control'; - })); -} - -async function exposeLexicalEditor(page) { - await page.waitForSelector('[data-lexical-editor]'); - await page.evaluate(() => { - window.lexicalEditor = document.querySelector('[data-lexical-editor]').__lexicalEditor; - window.originalEditorState = window.lexicalEditor.getEditorState(); - }); -} - -export async function focusEditor(page, parentSelector = '.koenig-lexical') { - const selector = `${parentSelector} div[contenteditable="true"]`; - await page.focus(selector); -} - -export async function assertHTML( - page, - expectedHtml, - { - selector = 'div[contenteditable="true"]', - ignoreClasses = true, - ignoreInlineStyles = true, - ignoreInnerSVG = true, - getBase64FileFormat = true, - ignoreCardContents = false, - ignoreCardSettings = false, - ignoreCardToolbarContents = false, - ignoreDragDropAttrs = true, - ignoreDataTestId = true, - ignoreCardCaptionContents = false - } = {} -) { - const actualHtml = await page.$eval(selector, e => e.innerHTML); - const actual = prettifyHTML(actualHtml.replace(/\n/gm, ''), { - ignoreClasses, - ignoreInlineStyles, - ignoreInnerSVG, - getBase64FileFormat, - ignoreCardContents, - ignoreCardSettings, - ignoreCardToolbarContents, - ignoreDragDropAttrs, - ignoreDataTestId, - ignoreCardCaptionContents - }); - const expected = prettifyHTML(expectedHtml.replace(/\n/gm, ''), { - ignoreClasses, - ignoreInlineStyles, - ignoreInnerSVG, - getBase64FileFormat, - ignoreCardContents, - ignoreCardSettings, - ignoreCardToolbarContents, - ignoreDragDropAttrs, - ignoreDataTestId, - ignoreCardCaptionContents - }); - expect(actual).toEqual(expected); -} - -export function prettifyHTML(string, options = {}) { - let output = string; - - if (options.ignoreInnerSVG) { - output = output.replace(/]*>.*?<\/svg>/g, ''); - } - - if (options.getBase64FileFormat) { - output = output.replace(/(^|[\s">])data:([^;]*);([^"]*),([^"]*)/g, '$1data:$2;$3,BASE64DATA'); - } - - if (options.ignoreDragDropAttrs) { - output = output.replace(/data-koenig-dnd-.*?=".*?"/g, ''); - } - - // replace all instances of blob:http with "blob:..." - output = output.replace(/blob:http[^"]*/g, 'blob:...'); - - // perform these replacements before class and testid removal so we can use them in selectors - if (options.ignoreCardContents || options.ignoreCardToolbarContents || options.ignoreCardCaptionContents || options.ignoreCardSettings) { - const {document} = (new JSDOM(output)).window; - - const querySelectors = []; - if (options.ignoreCardContents) { - querySelectors.push('[data-kg-card]'); - } - if (options.ignoreCardToolbarContents) { - querySelectors.push('[data-kg-card-toolbar]'); - } - if (options.ignoreCardCaptionContents) { - querySelectors.push('figcaption'); - } - if (options.ignoreCardSettings) { - querySelectors.push('[data-testid="settings-panel"]'); - } - - document.querySelectorAll(querySelectors.join(', ')).forEach((element) => { - element.innerHTML = ''; - }); - output = document.body.innerHTML; - } - - if (options.ignoreClasses) { - output = output.replace(/\sclass="([^"]*)"/g, ''); - } - - if (options.ignoreDataTestId) { - output = output.replace(/\sdata-testid="([^"]*)"/g, ''); - } - - if (options.ignoreInlineStyles) { - output = output.replace(/\sstyle="([^"]*)"/g, ''); - } - - return prettier - .format(output, { - attributeGroups: ['$DEFAULT', '^data-'], - attributeSort: 'ASC', - bracketSameLine: true, - htmlWhitespaceSensitivity: 'ignore', - parser: 'html', - plugins: ['prettier-plugin-organize-attributes'] - }) - .trim(); -} - -export function prettifyJSON(string) { - let output = string; - - // replace urls inside markdown links - output = output.replace(/\(blob:http[^"]*\)/g, '(blob:...)'); - // replace any other urls - output = output.replace(/blob:http[^"]*/g, 'blob:...'); - - return prettier.format(output, { - parser: 'json' - }); -} - -// This function does not suppose to do anything, it's only used as a trigger -// for prettier auto-formatting (https://prettier.io/blog/2020/08/24/2.1.0.html#api) -export function html(partials, ...params) { - let output = ''; - for (let i = 0; i < partials.length; i++) { - output += partials[i]; - if (i < partials.length - 1) { - output += params[i]; - } - } - return output; -} - -export async function assertSelection(page, expected) { - // Assert the selection of the editor matches the snapshot - const selection = await page.evaluate(() => { - const rootElement = document.querySelector('div[contenteditable="true"]'); - - const getPathFromNode = (node) => { - const path = []; - if (node === rootElement) { - return []; - } - while (node !== null) { - const parent = node.parentNode; - if (parent === null || node === rootElement) { - break; - } - path.push(Array.from(parent.childNodes).indexOf(node)); - node = parent; - } - return path.reverse(); - }; - - const {anchorNode, anchorOffset, focusNode, focusOffset} = window.getSelection(); - - return { - anchorOffset, - anchorPath: getPathFromNode(anchorNode), - focusOffset, - focusPath: getPathFromNode(focusNode) - }; - }, expected); - - expect(selection.anchorPath).toEqual(expected.anchorPath); - - if (Array.isArray(expected.anchorOffset)) { - const [start, end] = expected.anchorOffset; - expect(selection.anchorOffset).toBeGreaterThanOrEqual(start); - expect(selection.anchorOffset).toBeLessThanOrEqual(end); - } else { - expect(selection.anchorOffset).toEqual(expected.anchorOffset); - } - - expect(selection.focusPath).toEqual(expected.focusPath); - - if (Array.isArray(expected.focusOffset)) { - const [start, end] = expected.focusOffset; - expect(selection.focusOffset).toBeGreaterThanOrEqual(start); - expect(selection.focusOffset).toBeLessThanOrEqual(end); - } else { - expect(selection.focusOffset).toEqual(expected.focusOffset); - } -} - -export async function assertPosition(page, selector, expectedBox, {threshold = 0} = {}) { - const assertedElem = await page.$(selector); - const assertedBox = await assertedElem.boundingBox(); - - ['x', 'y'].forEach((boxProperty) => { - if (Object.prototype.hasOwnProperty.call(expectedBox, boxProperty)) { - expect(assertedBox[boxProperty], boxProperty).toBeGreaterThanOrEqual(expectedBox[boxProperty] - threshold); - expect(assertedBox[boxProperty], boxProperty).toBeLessThanOrEqual(expectedBox[boxProperty] + threshold); - } - }); -} - -export async function getEditorStateJSON(page) { - const json = await page.evaluate(() => { - const rootElement = document.querySelector('div[contenteditable="true"]'); - const editor = rootElement.__lexicalEditor; - return JSON.stringify(editor.getEditorState().toJSON()); - }); - - return json; -} - -export async function assertRootChildren(page, expectedState) { - const state = await getEditorStateJSON(page); - const actualState = JSON.stringify(JSON.parse(state).root.children); - - const actual = prettifyJSON(actualState); - const expected = prettifyJSON(expectedState); - - expect(actual).toEqual(expected); -} - -export async function paste(page, data) { - const setDataCommands = Object.keys(data).map((mimeType) => { - return ` - dataTransfer.setData('${mimeType}', ${JSON.stringify(data[mimeType])}); - `; - }); - - const pasteCommand = ` - const dataTransfer = new DataTransfer(); - - ${setDataCommands.join('\n')}; - - document.activeElement.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: dataTransfer, - bubbles: true, - cancelable: true - })); - - dataTransfer.clearData(); - `; - - await page.evaluate(pasteCommand); -} - -export async function pasteText(page, content) { - await paste(page, {'text/plain': content}); -} - -export async function pasteHtml(page, content) { - await paste(page, {'text/html': content}); -} - -export async function pasteLexical(page, content) { - await paste(page, {'application/x-lexical-editor': content}); -} - -export async function pasteFiles(page, files) { - const dataTransfer = await createDataTransfer(page, files); - - await page.evaluate(async (clipboardData) => { - document.activeElement.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: clipboardData, - bubbles: true, - cancelable: true - })); - - clipboardData.clearData(); - }, dataTransfer); -} - -export async function pasteFilesWithText(page, files, text = {}) { - const dataTransfer = await createDataTransfer(page, files); - - await page.evaluate(async ({clipboardData, textData}) => { - Object.keys(textData).forEach((mimeType) => { - clipboardData.setData(mimeType, textData[mimeType]); - }); - - document.activeElement.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: clipboardData, - bubbles: true, - cancelable: true - })); - - clipboardData.clearData(); - }, {clipboardData: dataTransfer, textData: text}); -} - -export async function dragMouse( - page, - fromBoundingBox, - toBoundingBox, - positionStart = 'middle', - positionEnd = 'middle', - mouseUp = true, - hover = 0, - steps = 1 -) { - let fromX = fromBoundingBox.x; - let fromY = fromBoundingBox.y; - if (positionStart === 'middle') { - fromX += fromBoundingBox.width / 2; - fromY += fromBoundingBox.height / 2; - } else if (positionStart === 'end') { - fromX += fromBoundingBox.width; - fromY += fromBoundingBox.height; - } - await page.mouse.move(fromX, fromY); - await page.mouse.down(); - - let toX = toBoundingBox.x; - let toY = toBoundingBox.y; - if (positionEnd === 'middle') { - toX += toBoundingBox.width / 2; - toY += toBoundingBox.height / 2; - } else if (positionEnd === 'end') { - toX += toBoundingBox.width; - toY += toBoundingBox.height; - } - - await page.mouse.move(toX, toY, {steps: steps}); - - if (hover > 0) { - await page.waitForTimeout(hover); - } - - if (mouseUp) { - await page.mouse.up(); - } -} - -export function isMac() { - // issue https://github.com/microsoft/playwright/issues/12168 - return process.platform === 'darwin'; -} - -export function ctrlOrCmd(page) { - if (!page) { - return isMac() ? 'Meta' : 'Control'; - } - - const modifier = browserCtrlOrCmdMap.get(page); - - if (!modifier) { - throw new Error('ctrlOrCmd(page) requires initialize({page}) before use'); - } - - return modifier; -} - -// note: we always use lowercase for the cardName but we use start case for the menu item attribute -export async function insertCard(page, {cardName, nth = 0}) { - let card = startCase(cardName); - await page.keyboard.type(`/${cardName}`); - await expect(page.locator(`[data-kg-card-menu-item="${card}" i][data-kg-cardmenu-selected="true"]`)).toBeVisible(); - await page.keyboard.press('Enter'); - // hr is the one case we don't match the card name to the data attribute - if (card === 'Divider') { - await expect(page.locator(`[data-kg-card="horizontalrule"]`).nth(nth)).toBeVisible(); - return page.locator(`[data-kg-card="horizontalrule"]`).nth(nth); - } else { - await expect(page.locator(`[data-kg-card="${cardName}" i]`).nth(nth)).toBeVisible(); - return page.locator(`[data-kg-card="${cardName}" i]`).nth(nth); - } -} - -export async function createSnippet(page) { - await page.waitForSelector('[data-testid="create-snippet"]'); - // Small wait for toolbar to stabilize after card state transitions - // (React re-renders can detach and re-mount toolbar elements) - await page.waitForTimeout(50); - await page.getByTestId('create-snippet').click(); - await page.getByTestId('snippet-name').fill('snippet'); - await page.keyboard.press('Enter'); -} - -export async function getScrollPosition(page) { - return await page.evaluate(() => { - return document.querySelector('.h-full.overflow-auto').scrollTop; - }); -} - -export async function enterUntilScrolled(page) { - let scrollPosition = 0; - - while (scrollPosition === 0) { - await page.keyboard.type('hello\nhello\nhello\nhello\nhello\nhello'); - await page.keyboard.press('Enter'); - - // Get scroll position - scrollPosition = await getScrollPosition(page); - } -} - -export async function expectUnchangedScrollPosition(page, wrapper) { - const start = await getScrollPosition(page); - await wrapper(); - const end = await getScrollPosition(page); - expect(start).toEqual(end); -} - -export async function createDataTransfer(page, data = []) { - const filesData = []; - - data.forEach((file) => { - const buffer = fs.readFileSync(file.filePath); - - filesData.push({ - buffer: buffer.toJSON().data, - name: file.fileName, - type: file.fileType - }); - }); - - return await page.evaluateHandle((dataset = []) => { - const dt = new DataTransfer(); - - dataset.forEach((fileData) => { - const file = new File([new Uint8Array(fileData.buffer)], fileData.name, {type: fileData.type}); - dt.items.add(file); - }); - - return dt; - }, filesData); -} - -export async function getEditorState(page) { - return await page.evaluate(() => { - return window.lexicalEditor.getEditorState().toJSON(); - }); -} - -/** - * Select text backwards from current cursor position by the given number of characters. - * Uses keyboard Shift+ArrowLeft with a short wait to ensure Chrome for Testing - * registers the selection correctly before subsequent keyboard actions. - */ -export async function selectBackwards(page, charCount) { - await page.keyboard.down('Shift'); - for (let i = 0; i < charCount; i++) { - await page.keyboard.press('ArrowLeft'); - } - await page.keyboard.up('Shift'); - // Wait for selection to be registered in Chrome for Testing before keyboard actions - await page.waitForTimeout(50); -} - -/** - * Select text forwards from current cursor position by the given number of characters. - * Uses keyboard Shift+ArrowRight with a short wait to ensure Chrome for Testing - * registers the selection correctly before subsequent keyboard actions. - */ -export async function selectForward(page, charCount) { - await page.keyboard.down('Shift'); - for (let i = 0; i < charCount; i++) { - await page.keyboard.press('ArrowRight'); - } - await page.keyboard.up('Shift'); - // Wait for selection to be registered in Chrome for Testing before keyboard actions - await page.waitForTimeout(50); -} diff --git a/packages/koenig-lexical/test/utils/e2e.ts b/packages/koenig-lexical/test/utils/e2e.ts new file mode 100644 index 0000000000..958ffc7a78 --- /dev/null +++ b/packages/koenig-lexical/test/utils/e2e.ts @@ -0,0 +1,582 @@ +import fs from 'fs'; +import jsdom from 'jsdom'; +import prettier from '@prettier/sync'; +import startCase from 'lodash/startCase.js'; +import {E2E_PORT} from '../../playwright.config'; +import {expect} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +const {JSDOM} = jsdom; +const browserCtrlOrCmdMap = new WeakMap(); + +interface AssertHTMLOptions { + selector?: string; + ignoreClasses?: boolean; + ignoreInlineStyles?: boolean; + ignoreInnerSVG?: boolean; + getBase64FileFormat?: boolean; + ignoreCardContents?: boolean; + ignoreCardSettings?: boolean; + ignoreCardToolbarContents?: boolean; + ignoreDragDropAttrs?: boolean; + ignoreDataTestId?: boolean; + ignoreCardCaptionContents?: boolean; +} + +interface PrettifyHTMLOptions { + ignoreClasses?: boolean; + ignoreInlineStyles?: boolean; + ignoreInnerSVG?: boolean; + getBase64FileFormat?: boolean; + ignoreCardContents?: boolean; + ignoreCardSettings?: boolean; + ignoreCardToolbarContents?: boolean; + ignoreDragDropAttrs?: boolean; + ignoreDataTestId?: boolean; + ignoreCardCaptionContents?: boolean; +} + +interface FileData { + filePath: string; + fileName: string; + fileType: string; +} + +interface SelectionExpectation { + anchorPath: number[]; + anchorOffset: number | [number, number]; + focusPath: number[]; + focusOffset: number | [number, number]; +} + +export interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} + +export async function initialize({page, uri = '/#/?content=false', force: _force = false}: {page: Page; uri?: string; force?: boolean}) { + const url = `http://localhost:${E2E_PORT}${uri}`; + + const currentViewportSize = page.viewportSize(); + if (currentViewportSize!.width !== 1000 || currentViewportSize!.height !== 1000) { + await page.setViewportSize({width: 1000, height: 1000}); + } + + const currentUrl = page.url(); + if (currentUrl === 'about:blank') { + // First page load + await page.goto(url); + + await page.waitForSelector('.koenig-lexical'); + + await exposeLexicalEditor(page); + } else { + // Subsequent pages navigated to using react router + await page.evaluate(async ([navigateTo, force]: [string, boolean]) => { + const w = window; + w.lexicalEditor.blur(); + w.lexicalEditor.setEditorState(w.originalEditorState); + + if (force) { + // Purposefully navigate away from the current page to ensure component is reloaded + w.navigate('/404'); + await new Promise((res) => { + setTimeout(() => { + // Navigate in a task to ensure React Router cannot optimise out our first navigation + w.navigate(navigateTo); + res(); + }, 10); + }); + } else { + await w.navigate(navigateTo); + } + }, [uri.slice(2), currentUrl === url] as [string, boolean]); + await exposeLexicalEditor(page); + } + + browserCtrlOrCmdMap.set(page, await page.evaluate(() => { + return navigator.platform.includes('Mac') ? 'Meta' : 'Control'; + })); +} + +async function exposeLexicalEditor(page: Page) { + await page.waitForSelector('[data-lexical-editor]'); + await page.evaluate(() => { + const el = document.querySelector('[data-lexical-editor]') as HTMLElement & {__lexicalEditor: unknown}; + window.lexicalEditor = el.__lexicalEditor as KoenigTestEditor; + window.originalEditorState = window.lexicalEditor.getEditorState(); + }); +} + +export async function focusEditor(page: Page, parentSelector = '.koenig-lexical') { + const selector = `${parentSelector} div[contenteditable="true"]`; + await page.focus(selector); +} + +export async function assertHTML( + page: Page, + expectedHtml: string, + { + selector = 'div[contenteditable="true"]', + ignoreClasses = true, + ignoreInlineStyles = true, + ignoreInnerSVG = true, + getBase64FileFormat = true, + ignoreCardContents = false, + ignoreCardSettings = false, + ignoreCardToolbarContents = false, + ignoreDragDropAttrs = true, + ignoreDataTestId = true, + ignoreCardCaptionContents = false + }: AssertHTMLOptions = {} +) { + const actualHtml = await page.$eval(selector, e => e.innerHTML); + const actual = prettifyHTML(actualHtml.replace(/\n/gm, ''), { + ignoreClasses, + ignoreInlineStyles, + ignoreInnerSVG, + getBase64FileFormat, + ignoreCardContents, + ignoreCardSettings, + ignoreCardToolbarContents, + ignoreDragDropAttrs, + ignoreDataTestId, + ignoreCardCaptionContents + }); + const expected = prettifyHTML(expectedHtml.replace(/\n/gm, ''), { + ignoreClasses, + ignoreInlineStyles, + ignoreInnerSVG, + getBase64FileFormat, + ignoreCardContents, + ignoreCardSettings, + ignoreCardToolbarContents, + ignoreDragDropAttrs, + ignoreDataTestId, + ignoreCardCaptionContents + }); + expect(actual).toEqual(expected); +} + +export function prettifyHTML(string: string, options: PrettifyHTMLOptions = {}) { + let output = string; + + if (options.ignoreInnerSVG) { + output = output.replace(/]*>.*?<\/svg>/g, ''); + } + + if (options.getBase64FileFormat) { + output = output.replace(/(^|[\s">])data:([^;]*);([^"]*),([^"]*)/g, '$1data:$2;$3,BASE64DATA'); + } + + if (options.ignoreDragDropAttrs) { + output = output.replace(/data-koenig-dnd-.*?=".*?"/g, ''); + } + + // replace all instances of blob:http with "blob:..." + output = output.replace(/blob:http[^"]*/g, 'blob:...'); + + // perform these replacements before class and testid removal so we can use them in selectors + if (options.ignoreCardContents || options.ignoreCardToolbarContents || options.ignoreCardCaptionContents || options.ignoreCardSettings) { + const {document} = (new JSDOM(output)).window; + + const querySelectors: string[] = []; + if (options.ignoreCardContents) { + querySelectors.push('[data-kg-card]'); + } + if (options.ignoreCardToolbarContents) { + querySelectors.push('[data-kg-card-toolbar]'); + } + if (options.ignoreCardCaptionContents) { + querySelectors.push('figcaption'); + } + if (options.ignoreCardSettings) { + querySelectors.push('[data-testid="settings-panel"]'); + } + + document.querySelectorAll(querySelectors.join(', ')).forEach((element: Element) => { + element.innerHTML = ''; + }); + output = document.body.innerHTML; + } + + if (options.ignoreClasses) { + output = output.replace(/\sclass="([^"]*)"/g, ''); + } + + if (options.ignoreDataTestId) { + output = output.replace(/\sdata-testid="([^"]*)"/g, ''); + } + + if (options.ignoreInlineStyles) { + output = output.replace(/\sstyle="([^"]*)"/g, ''); + } + + return prettier + .format(output, { + attributeGroups: ['$DEFAULT', '^data-'], + attributeSort: 'ASC', + bracketSameLine: true, + htmlWhitespaceSensitivity: 'ignore', + parser: 'html', + plugins: ['prettier-plugin-organize-attributes'] + }) + .trim(); +} + +export function prettifyJSON(string: string) { + let output = string; + + // replace urls inside markdown links + output = output.replace(/\(blob:http[^"]*\)/g, '(blob:...)'); + // replace any other urls + output = output.replace(/blob:http[^"]*/g, 'blob:...'); + + return prettier.format(output, { + parser: 'json' + }); +} + +// This function does not suppose to do anything, it's only used as a trigger +// for prettier auto-formatting (https://prettier.io/blog/2020/08/24/2.1.0.html#api) +export function html(partials: TemplateStringsArray, ...params: unknown[]) { + let output = ''; + for (let i = 0; i < partials.length; i++) { + output += partials[i]; + if (i < partials.length - 1) { + output += params[i]; + } + } + return output; +} + +export async function assertSelection(page: Page, expected: SelectionExpectation) { + // Assert the selection of the editor matches the snapshot + const selection = await page.evaluate(() => { + const rootElement = document.querySelector('div[contenteditable="true"]'); + + const getPathFromNode = (node: Node | null): number[] => { + const path: number[] = []; + if (node === rootElement) { + return []; + } + while (node !== null) { + const parent = node.parentNode; + if (parent === null || node === rootElement) { + break; + } + path.push(Array.from(parent.childNodes).indexOf(node as ChildNode)); + node = parent; + } + return path.reverse(); + }; + + const {anchorNode, anchorOffset, focusNode, focusOffset} = window.getSelection()!; + + return { + anchorOffset, + anchorPath: getPathFromNode(anchorNode), + focusOffset, + focusPath: getPathFromNode(focusNode) + }; + }); + + expect(selection.anchorPath).toEqual(expected.anchorPath); + + if (Array.isArray(expected.anchorOffset)) { + const [start, end] = expected.anchorOffset; + expect(selection.anchorOffset).toBeGreaterThanOrEqual(start); + expect(selection.anchorOffset).toBeLessThanOrEqual(end); + } else { + expect(selection.anchorOffset).toEqual(expected.anchorOffset); + } + + expect(selection.focusPath).toEqual(expected.focusPath); + + if (Array.isArray(expected.focusOffset)) { + const [start, end] = expected.focusOffset; + expect(selection.focusOffset).toBeGreaterThanOrEqual(start); + expect(selection.focusOffset).toBeLessThanOrEqual(end); + } else { + expect(selection.focusOffset).toEqual(expected.focusOffset); + } +} + +export async function assertPosition(page: Page, selector: string, expectedBox: Partial, {threshold = 0} = {}) { + const assertedElem = await page.$(selector); + const assertedBox = await assertedElem!.boundingBox(); + + (['x', 'y'] as const).forEach((boxProperty) => { + if (Object.prototype.hasOwnProperty.call(expectedBox, boxProperty)) { + expect(assertedBox![boxProperty], boxProperty).toBeGreaterThanOrEqual(expectedBox[boxProperty]! - threshold); + expect(assertedBox![boxProperty], boxProperty).toBeLessThanOrEqual(expectedBox[boxProperty]! + threshold); + } + }); +} + +export async function getEditorStateJSON(page: Page) { + const json = await page.evaluate(() => { + const rootElement = document.querySelector('div[contenteditable="true"]') as HTMLElement & {__lexicalEditor: {getEditorState: () => {toJSON: () => unknown}}}; + const editor = rootElement.__lexicalEditor; + return JSON.stringify(editor.getEditorState().toJSON()); + }); + + return json; +} + +export async function assertRootChildren(page: Page, expectedState: string) { + const state = await getEditorStateJSON(page); + const actualState = JSON.stringify(JSON.parse(state).root.children); + + const actual = prettifyJSON(actualState); + const expected = prettifyJSON(expectedState); + + expect(actual).toEqual(expected); +} + +export async function paste(page: Page, data: Record) { + const setDataCommands = Object.keys(data).map((mimeType) => { + return ` + dataTransfer.setData('${mimeType}', ${JSON.stringify(data[mimeType])}); + `; + }); + + const pasteCommand = ` + const dataTransfer = new DataTransfer(); + + ${setDataCommands.join('\n')}; + + document.activeElement.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true + })); + + dataTransfer.clearData(); + `; + + await page.evaluate(pasteCommand); +} + +export async function pasteText(page: Page, content: string) { + await paste(page, {'text/plain': content}); +} + +export async function pasteHtml(page: Page, content: string) { + await paste(page, {'text/html': content}); +} + +export async function pasteLexical(page: Page, content: string) { + await paste(page, {'application/x-lexical-editor': content}); +} + +export async function pasteFiles(page: Page, files: FileData[]) { + const dataTransfer = await createDataTransfer(page, files); + + await page.evaluate(async (clipboardData) => { + document.activeElement!.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: clipboardData as unknown as DataTransfer, + bubbles: true, + cancelable: true + })); + + (clipboardData as unknown as DataTransfer).clearData(); + }, dataTransfer); +} + +export async function pasteFilesWithText(page: Page, files: FileData[], text: Record = {}) { + const dataTransfer = await createDataTransfer(page, files); + + await page.evaluate(async ({clipboardData, textData}) => { + Object.keys(textData).forEach((mimeType) => { + (clipboardData as unknown as DataTransfer).setData(mimeType, textData[mimeType]); + }); + + document.activeElement!.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: clipboardData as unknown as DataTransfer, + bubbles: true, + cancelable: true + })); + + (clipboardData as unknown as DataTransfer).clearData(); + }, {clipboardData: dataTransfer, textData: text}); +} + +export async function dragMouse( + page: Page, + fromBoundingBox: BoundingBox | null, + toBoundingBox: BoundingBox | null, + positionStart = 'middle', + positionEnd = 'middle', + mouseUp = true, + hover = 0, + steps = 1 +) { + if (!fromBoundingBox || !toBoundingBox) { + throw new Error('dragMouse: bounding box not found'); + } + let fromX = fromBoundingBox.x; + let fromY = fromBoundingBox.y; + if (positionStart === 'middle') { + fromX += fromBoundingBox.width / 2; + fromY += fromBoundingBox.height / 2; + } else if (positionStart === 'end') { + fromX += fromBoundingBox.width; + fromY += fromBoundingBox.height; + } + await page.mouse.move(fromX, fromY); + await page.mouse.down(); + + let toX = toBoundingBox.x; + let toY = toBoundingBox.y; + if (positionEnd === 'middle') { + toX += toBoundingBox.width / 2; + toY += toBoundingBox.height / 2; + } else if (positionEnd === 'end') { + toX += toBoundingBox.width; + toY += toBoundingBox.height; + } + + await page.mouse.move(toX, toY, {steps: steps}); + + if (hover > 0) { + await page.waitForTimeout(hover); + } + + if (mouseUp) { + await page.mouse.up(); + } +} + +export function isMac() { + // issue https://github.com/microsoft/playwright/issues/12168 + return process.platform === 'darwin'; +} + +export function ctrlOrCmd(page?: Page) { + if (!page) { + return isMac() ? 'Meta' : 'Control'; + } + + const modifier = browserCtrlOrCmdMap.get(page); + + if (!modifier) { + throw new Error('ctrlOrCmd(page) requires initialize({page}) before use'); + } + + return modifier; +} + +// note: we always use lowercase for the cardName but we use start case for the menu item attribute +export async function insertCard(page: Page, {cardName, nth = 0}: {cardName: string; nth?: number}) { + const card = startCase(cardName); + await page.keyboard.type(`/${cardName}`); + await expect(page.locator(`[data-kg-card-menu-item="${card}" i][data-kg-cardmenu-selected="true"]`)).toBeVisible(); + await page.keyboard.press('Enter'); + // hr is the one case we don't match the card name to the data attribute + if (card === 'Divider') { + await expect(page.locator(`[data-kg-card="horizontalrule"]`).nth(nth)).toBeVisible(); + return page.locator(`[data-kg-card="horizontalrule"]`).nth(nth); + } else { + await expect(page.locator(`[data-kg-card="${cardName}" i]`).nth(nth)).toBeVisible(); + return page.locator(`[data-kg-card="${cardName}" i]`).nth(nth); + } +} + +export async function createSnippet(page: Page) { + await page.waitForSelector('[data-testid="create-snippet"]'); + // Small wait for toolbar to stabilize after card state transitions + // (React re-renders can detach and re-mount toolbar elements) + await page.waitForTimeout(50); + await page.getByTestId('create-snippet').click(); + await page.getByTestId('snippet-name').fill('snippet'); + await page.keyboard.press('Enter'); +} + +export async function getScrollPosition(page: Page) { + return await page.evaluate(() => { + return document.querySelector('.h-full.overflow-auto')!.scrollTop; + }); +} + +export async function enterUntilScrolled(page: Page) { + let scrollPosition = 0; + + while (scrollPosition === 0) { + await page.keyboard.type('hello\nhello\nhello\nhello\nhello\nhello'); + await page.keyboard.press('Enter'); + + // Get scroll position + scrollPosition = await getScrollPosition(page); + } +} + +export async function expectUnchangedScrollPosition(page: Page, wrapper: () => Promise) { + const start = await getScrollPosition(page); + await wrapper(); + const end = await getScrollPosition(page); + expect(start).toEqual(end); +} + +export async function createDataTransfer(page: Page, data: FileData[] = []) { + const filesData: {buffer: number[]; name: string; type: string}[] = []; + + data.forEach((file) => { + const buffer = fs.readFileSync(file.filePath); + + filesData.push({ + buffer: buffer.toJSON().data, + name: file.fileName, + type: file.fileType + }); + }); + + return await page.evaluateHandle((dataset) => { + const dt = new DataTransfer(); + + dataset.forEach((fileData: {buffer: number[]; name: string; type: string}) => { + const file = new File([new Uint8Array(fileData.buffer)], fileData.name, {type: fileData.type}); + dt.items.add(file); + }); + + return dt; + }, filesData); +} + +export async function getEditorState(page: Page): Promise<{root: {children: Array>}}> { + return await page.evaluate(() => { + return window.lexicalEditor.getEditorState().toJSON() as {root: {children: Array>}}; + }); +} + +/** + * Select text backwards from current cursor position by the given number of characters. + * Uses keyboard Shift+ArrowLeft with a short wait to ensure Chrome for Testing + * registers the selection correctly before subsequent keyboard actions. + */ +export async function selectBackwards(page: Page, charCount: number) { + await page.keyboard.down('Shift'); + for (let i = 0; i < charCount; i++) { + await page.keyboard.press('ArrowLeft'); + } + await page.keyboard.up('Shift'); + // Wait for selection to be registered in Chrome for Testing before keyboard actions + await page.waitForTimeout(50); +} + +/** + * Select text forwards from current cursor position by the given number of characters. + * Uses keyboard Shift+ArrowRight with a short wait to ensure Chrome for Testing + * registers the selection correctly before subsequent keyboard actions. + */ +export async function selectForward(page: Page, charCount: number) { + await page.keyboard.down('Shift'); + for (let i = 0; i < charCount; i++) { + await page.keyboard.press('ArrowRight'); + } + await page.keyboard.up('Shift'); + // Wait for selection to be registered in Chrome for Testing before keyboard actions + await page.waitForTimeout(50); +} diff --git a/packages/koenig-lexical/test/utils/isTestEnv.js b/packages/koenig-lexical/test/utils/isTestEnv.ts similarity index 100% rename from packages/koenig-lexical/test/utils/isTestEnv.js rename to packages/koenig-lexical/test/utils/isTestEnv.ts diff --git a/packages/koenig-lexical/tsconfig.json b/packages/koenig-lexical/tsconfig.json new file mode 100644 index 0000000000..bcbef41b44 --- /dev/null +++ b/packages/koenig-lexical/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "jsx": "react-jsx", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*", "demo/**/*"] +} diff --git a/packages/koenig-lexical/tsconfig.test.json b/packages/koenig-lexical/tsconfig.test.json new file mode 100644 index 0000000000..f7dc98afe2 --- /dev/null +++ b/packages/koenig-lexical/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/koenig-lexical/tsconfig.types.json b/packages/koenig-lexical/tsconfig.types.json new file mode 100644 index 0000000000..3af936e8c0 --- /dev/null +++ b/packages/koenig-lexical/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.stories.tsx"] +} diff --git a/packages/koenig-lexical/vite.config.demo.js b/packages/koenig-lexical/vite.config.demo.js deleted file mode 100644 index df8818cd35..0000000000 --- a/packages/koenig-lexical/vite.config.demo.js +++ /dev/null @@ -1,53 +0,0 @@ -import react from '@vitejs/plugin-react'; -import svgr from 'vite-plugin-svgr'; -import {defineConfig} from 'vite'; -import {resolve} from 'path'; - -// https://vitejs.dev/config/ -export default (function viteDemoConfig() { - return defineConfig({ - plugins: [ - svgr(), - react() - ], - base: '/', - define: { - __APP_VERSION__: JSON.stringify('0.0.0') - }, - resolve: { - alias: { - // required to prevent double-bundling of yjs due to cjs/esm mismatch - // (see https://github.com/facebook/lexical/issues/2153) - yjs: resolve('../../node_modules/yjs/src/index.js') - } - }, - optimizeDeps: { - include: [ - '@tryghost/kg-clean-basic-html', - '@tryghost/kg-default-transforms', - '@tryghost/kg-markdown-html-renderer', - '@tryghost/kg-simplemde', - '@tryghost/kg-unsplash-selector' - ] - }, - build: { - sourcemap: true, - rolldownOptions: { - input: { - main: resolve(__dirname, 'index.html') - } - } - }, - test: { - globals: true, // required for @testing-library/jest-dom extensions - environment: 'jsdom', - setupFiles: './test/test-setup.js', - include: ['./test/unit/*'], - testTimeout: 10000, - ...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674 - minThreads: 1, - maxThreads: 2 - }) - } - }); -}); diff --git a/packages/koenig-lexical/vite.config.demo.ts b/packages/koenig-lexical/vite.config.demo.ts new file mode 100644 index 0000000000..2348e23ab6 --- /dev/null +++ b/packages/koenig-lexical/vite.config.demo.ts @@ -0,0 +1,56 @@ +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; +import {defineConfig} from 'vite'; +import {resolve, dirname} from 'path'; +import {fileURLToPath} from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// https://vitejs.dev/config/ +export default (function viteDemoConfig() { + return defineConfig({ + plugins: [ + svgr(), + react() + ], + base: '/', + define: { + __APP_VERSION__: JSON.stringify('0.0.0') + }, + resolve: { + alias: { + // required to prevent double-bundling of yjs due to cjs/esm mismatch + // (see https://github.com/facebook/lexical/issues/2153) + yjs: resolve('../../node_modules/yjs/src/index.js') + } + }, + optimizeDeps: { + include: [ + '@tryghost/kg-clean-basic-html', + '@tryghost/kg-default-transforms', + '@tryghost/kg-markdown-html-renderer', + '@tryghost/kg-simplemde', + '@tryghost/kg-unsplash-selector' + ] + }, + build: { + sourcemap: true, + rolldownOptions: { + input: { + main: resolve(__dirname, 'index.html') + } + } + }, + test: { + globals: true, // required for @testing-library/jest-dom extensions + environment: 'jsdom', + setupFiles: './test/test-setup.ts', + include: ['./test/unit/*'], + testTimeout: 10000, + ...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674 + minThreads: 1, + maxThreads: 2 + }) + } + }); +}); diff --git a/packages/koenig-lexical/vite.config.js b/packages/koenig-lexical/vite.config.js deleted file mode 100644 index 1cd11a6586..0000000000 --- a/packages/koenig-lexical/vite.config.js +++ /dev/null @@ -1,132 +0,0 @@ -import 'dotenv/config'; -import mdx from '@mdx-js/rollup'; -import pkg from './package.json'; -import react from '@vitejs/plugin-react'; -import svgr from 'vite-plugin-svgr'; -import {defineConfig, esmExternalRequirePlugin, loadEnv} from 'vite'; -import {resolve} from 'path'; -import {sentryVitePlugin} from '@sentry/vite-plugin'; - -const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name; - -// https://vitejs.dev/config/ -export default (function viteConfig({mode}) { - const env = loadEnv(mode, process.cwd()); - process.env = {...process.env, ...env}; - - const plugins = [ - svgr(), - react(), - mdx(), - // Convert CJS require("react")/require("react-dom") calls inside - // bundled dependencies to ESM imports so the ESM build has no - // runtime require() shims that break in browsers - esmExternalRequirePlugin({ - external: [/^react($|\/)/, /^react-dom($|\/)/], - skipDuplicateCheck: true - }) - ]; - - // Keep sentryVitePlugin as the last plugin - // only include when we have the required details to keep local dev less noisy - if (process.env.IS_SHIPPING) { - plugins.push( - sentryVitePlugin({ - org: 'ghost-foundation', - project: 'admin', - - // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ - // and need `project:releases` and `org:read` scopes - authToken: process.env.VITE_SENTRY_AUTH_TOKEN, - - // We're not injecting release information into the build - // @see https://www.npmjs.com/package/@sentry/vite-plugin#option-release-inject - // Setting this option to true causes our build to fail: this plugin runs before the build is complete, - // and therefore our commonJS dependencies such as `kg-markdown-html-renderer` are not yet transpiled - release: { - inject: false - }, - - telemetry: false - }) - ); - } - - return defineConfig({ - plugins, - server: { - // Allow access from Ghost's Docker dev environment (host.docker.internal) - allowedHosts: true - }, - preview: { - // Allow access from Ghost's Docker dev environment (host.docker.internal) - allowedHosts: true - }, - define: { - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), - 'process.env.VITEST_SEGFAULT_RETRY': 3, - __APP_VERSION__: JSON.stringify(pkg.version) - }, - resolve: { - alias: { - // required to prevent double-bundling of yjs due to cjs/esm mismatch - // (see https://github.com/facebook/lexical/issues/2153) - yjs: resolve('../../node_modules/yjs/src/index.js') - } - }, - optimizeDeps: { - include: [ - '@tryghost/kg-clean-basic-html', - '@tryghost/kg-default-transforms', - '@tryghost/kg-markdown-html-renderer', - '@tryghost/kg-simplemde' - ] - }, - build: { - minify: true, - sourcemap: true, - cssCodeSplit: true, - lib: { - entry: resolve(__dirname, 'src/index.js'), - name: pkg.name, - fileName(format) { - if (format === 'umd') { - return `${outputFileName}.umd.js`; - } - - return `${outputFileName}.js`; - } - }, - rolldownOptions: { - output: { - globals: { - 'react': 'React', - 'react/jsx-runtime': 'React', - 'react-dom': 'ReactDOM', - 'react-dom/client': 'ReactDOM' - }, - assetFileNames: (assetInfo) => { - // Vite 6 changed CSS output naming in lib mode from - // 'style.css' to deriving from the entry filename. - // Preserve 'style.css' for backwards compatibility. - if (assetInfo.names?.[0]?.endsWith('.css')) { - return 'style.css'; - } - return assetInfo.names?.[0] ?? '[name][extname]'; - } - } - } - }, - test: { - globals: true, // required for @testing-library/jest-dom extensions - environment: 'jsdom', - setupFiles: './test/test-setup.js', - include: ['./test/unit/**/*.test.{js,jsx,ts,tsx}'], - testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000, - ...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674 - minThreads: 1, - maxThreads: 2 - }) - } - }); -}); diff --git a/packages/koenig-lexical/vite.config.ts b/packages/koenig-lexical/vite.config.ts new file mode 100644 index 0000000000..0408b6bd06 --- /dev/null +++ b/packages/koenig-lexical/vite.config.ts @@ -0,0 +1,135 @@ +import 'dotenv/config'; +import mdx from '@mdx-js/rollup'; +import pkg from './package.json'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; +import {defineConfig, esmExternalRequirePlugin, loadEnv, type ConfigEnv} from 'vite'; +import {resolve, dirname} from 'path'; +import {fileURLToPath} from 'url'; +import {sentryVitePlugin} from '@sentry/vite-plugin'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name; + +// https://vitejs.dev/config/ +export default (function viteConfig({mode}: ConfigEnv) { + const env = loadEnv(mode, process.cwd()); + process.env = {...process.env, ...env}; + + const plugins = [ + svgr(), + react(), + mdx(), + // Convert CJS require("react")/require("react-dom") calls inside + // bundled dependencies to ESM imports so the ESM build has no + // runtime require() shims that break in browsers + esmExternalRequirePlugin({ + external: [/^react($|\/)/, /^react-dom($|\/)/], + skipDuplicateCheck: true + }) + ]; + + // Keep sentryVitePlugin as the last plugin + // only include when we have the required details to keep local dev less noisy + if (process.env.IS_SHIPPING) { + plugins.push( + sentryVitePlugin({ + org: 'ghost-foundation', + project: 'admin', + + // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ + // and need `project:releases` and `org:read` scopes + authToken: process.env.VITE_SENTRY_AUTH_TOKEN, + + // We're not injecting release information into the build + // @see https://www.npmjs.com/package/@sentry/vite-plugin#option-release-inject + // Setting this option to true causes our build to fail: this plugin runs before the build is complete, + // and therefore our commonJS dependencies such as `kg-markdown-html-renderer` are not yet transpiled + release: { + inject: false + }, + + telemetry: false + }) + ); + } + + return defineConfig({ + plugins, + server: { + // Allow access from Ghost's Docker dev environment (host.docker.internal) + allowedHosts: true + }, + preview: { + // Allow access from Ghost's Docker dev environment (host.docker.internal) + allowedHosts: true + }, + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + 'process.env.VITEST_SEGFAULT_RETRY': 3, + __APP_VERSION__: JSON.stringify(pkg.version) + }, + resolve: { + alias: { + // required to prevent double-bundling of yjs due to cjs/esm mismatch + // (see https://github.com/facebook/lexical/issues/2153) + yjs: resolve('../../node_modules/yjs/src/index.js') + } + }, + optimizeDeps: { + include: [ + '@tryghost/kg-clean-basic-html', + '@tryghost/kg-default-transforms', + '@tryghost/kg-markdown-html-renderer', + '@tryghost/kg-simplemde' + ] + }, + build: { + minify: true, + sourcemap: true, + cssCodeSplit: true, + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: pkg.name, + fileName(format) { + if (format === 'umd') { + return `${outputFileName}.umd.js`; + } + + return `${outputFileName}.js`; + } + }, + rolldownOptions: { + output: { + globals: { + 'react': 'React', + 'react/jsx-runtime': 'React', + 'react-dom': 'ReactDOM', + 'react-dom/client': 'ReactDOM' + }, + assetFileNames: (assetInfo) => { + // Vite 6 changed CSS output naming in lib mode from + // 'style.css' to deriving from the entry filename. + // Preserve 'style.css' for backwards compatibility. + if (assetInfo.names?.[0]?.endsWith('.css')) { + return 'style.css'; + } + return assetInfo.names?.[0] ?? '[name][extname]'; + } + } + } + }, + test: { + globals: true, // required for @testing-library/jest-dom extensions + environment: 'jsdom', + setupFiles: './test/test-setup.ts', + include: ['./test/unit/**/*.test.{js,jsx,ts,tsx}'], + testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000, + ...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674 + minThreads: 1, + maxThreads: 2 + }) + } + }); +}); diff --git a/yarn.lock b/yarn.lock index 8b8edede38..743acaf2dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -66,7 +66,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== -"@babel/core@7.29.7", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.28.0": +"@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.28.0": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== @@ -107,13 +107,6 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz#c70fe3c6ecbdc3fd2dd1b0f498428b88b82ce47f" - integrity sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw== - dependencies: - "@babel/types" "^7.29.7" - "@babel/helper-compilation-targets@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" @@ -125,32 +118,11 @@ lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-create-class-features-plugin@^7.21.0": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz#6eddf286f2ec418f740c91d60a83347c55838ddd" - integrity sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.29.7" - "@babel/helper-member-expression-to-functions" "^7.29.7" - "@babel/helper-optimise-call-expression" "^7.29.7" - "@babel/helper-replace-supers" "^7.29.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" - "@babel/traverse" "^7.29.7" - semver "^6.3.1" - "@babel/helper-globals@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== -"@babel/helper-member-expression-to-functions@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz#8dbdb3ce0b5c487e1aec10e13c9a43a500814df8" - integrity sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg== - dependencies: - "@babel/traverse" "^7.29.7" - "@babel/types" "^7.29.7" - "@babel/helper-module-imports@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" @@ -168,35 +140,6 @@ "@babel/helper-validator-identifier" "^7.29.7" "@babel/traverse" "^7.29.7" -"@babel/helper-optimise-call-expression@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz#77b0b5b94f1997fa9d6e3125f445227b1faf9d85" - integrity sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong== - dependencies: - "@babel/types" "^7.29.7" - -"@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.20.2": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz#c0a0766f1a13617d8a17407d7ab8f9d486225ea4" - integrity sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw== - -"@babel/helper-replace-supers@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz#bc3c3964329043c79112e513c1b198f16589ac21" - integrity sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.29.7" - "@babel/helper-optimise-call-expression" "^7.29.7" - "@babel/traverse" "^7.29.7" - -"@babel/helper-skip-transparent-expression-wrappers@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz#50c95c7e4c4f54936cfa0116428edc559862d551" - integrity sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ== - dependencies: - "@babel/traverse" "^7.29.7" - "@babel/types" "^7.29.7" - "@babel/helper-string-parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" @@ -227,23 +170,6 @@ dependencies: "@babel/types" "^7.29.7" -"@babel/plugin-proposal-private-property-in-object@7.21.11": - version "7.21.11" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz#69d597086b6760c4126525cfa154f34631ff272c" - integrity sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-create-class-features-plugin" "^7.21.0" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" @@ -386,9 +312,9 @@ style-mod "^4.0.0" "@codemirror/lint@^6.0.0": - version "6.9.7" - resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.7.tgz#841fc733674389d91fe49a1c34027ad3babdf105" - integrity sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg== + version "6.9.6" + resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.6.tgz#eae25d5dfac28595521a2b38ebad223f14b7b6d1" + integrity sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A== dependencies: "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.42.0" @@ -421,9 +347,9 @@ "@lezer/highlight" "^1.0.0" "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.42.0": - version "6.43.1" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.1.tgz#bfe3ea568e38812631749c1b6c9c7869784143a5" - integrity sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw== + version "6.43.0" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.0.tgz#a577da65f1d5d8f7cbf08e14849284c12f38365a" + integrity sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA== dependencies: "@codemirror/state" "^6.6.0" crelt "^1.0.6" @@ -441,9 +367,9 @@ integrity sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg== "@csstools/css-color-parser@^4.1.0": - version "4.1.7" - resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz#ca32fc74a3cdec7c288651fe01241fcd59f95a0e" - integrity sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz#70c322112e2aafb0b09f34b51ce3482db6aab2ac" + integrity sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g== dependencies: "@csstools/color-helpers" "^6.0.2" "@csstools/css-calc" "^3.2.1" @@ -508,9 +434,9 @@ tslib "^2.4.0" "@emnapi/core@^1.1.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" - integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== + version "1.11.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.0.tgz#8a655042dbbb10d0266670c9903c34a7001c705b" + integrity sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q== dependencies: "@emnapi/wasi-threads" "1.2.2" tslib "^2.4.0" @@ -537,9 +463,9 @@ tslib "^2.4.0" "@emnapi/runtime@^1.1.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" - integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + version "1.11.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.0.tgz#ce16b3674ff7266bbf50f9668bde8a04f3014d4e" + integrity sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg== dependencies: tslib "^2.4.0" @@ -579,269 +505,269 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== -"@esbuild/aix-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" - integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== "@esbuild/android-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== -"@esbuild/android-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" - integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== "@esbuild/android-arm@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== -"@esbuild/android-arm@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" - integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== "@esbuild/android-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== -"@esbuild/android-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" - integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== "@esbuild/darwin-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== -"@esbuild/darwin-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" - integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== "@esbuild/darwin-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== -"@esbuild/darwin-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" - integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== "@esbuild/freebsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== -"@esbuild/freebsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" - integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== "@esbuild/freebsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== -"@esbuild/freebsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" - integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== "@esbuild/linux-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== -"@esbuild/linux-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" - integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== "@esbuild/linux-arm@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== -"@esbuild/linux-arm@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" - integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== "@esbuild/linux-ia32@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== -"@esbuild/linux-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" - integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== "@esbuild/linux-loong64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== -"@esbuild/linux-loong64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" - integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== "@esbuild/linux-mips64el@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== -"@esbuild/linux-mips64el@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" - integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== "@esbuild/linux-ppc64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== -"@esbuild/linux-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" - integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== "@esbuild/linux-riscv64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== -"@esbuild/linux-riscv64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" - integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== "@esbuild/linux-s390x@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== -"@esbuild/linux-s390x@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" - integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== "@esbuild/linux-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== -"@esbuild/linux-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" - integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== "@esbuild/netbsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== -"@esbuild/netbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" - integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== "@esbuild/netbsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== -"@esbuild/netbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" - integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== "@esbuild/openbsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== -"@esbuild/openbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" - integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== "@esbuild/openbsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== -"@esbuild/openbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" - integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== "@esbuild/openharmony-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== -"@esbuild/openharmony-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" - integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== "@esbuild/sunos-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== -"@esbuild/sunos-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" - integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== "@esbuild/win32-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== -"@esbuild/win32-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" - integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== "@esbuild/win32-ia32@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== -"@esbuild/win32-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" - integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== "@esbuild/win32-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== -"@esbuild/win32-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" - integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== -"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.5.0", "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": +"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.5.0", "@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -1549,11 +1475,11 @@ "@tybys/wasm-util" "^0.9.0" "@napi-rs/wasm-runtime@^1.1.4": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz#cccd6ebc40b991dea6936f9126b1b8155b6c4c95" - integrity sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q== + version "1.1.4" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" + integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== dependencies: - "@tybys/wasm-util" "^0.10.2" + "@tybys/wasm-util" "^0.10.1" "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" @@ -2972,7 +2898,7 @@ dependencies: tslib "^2.4.0" -"@tybys/wasm-util@^0.10.2": +"@tybys/wasm-util@^0.10.1": version "0.10.2" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== @@ -3161,9 +3087,9 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node@*": - version "25.9.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9" - integrity sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg== + version "25.9.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.2.tgz#fc8958e757994b71fee516f9634bdb03d1b19e9f" + integrity sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw== dependencies: undici-types ">=7.24.0 <7.24.7" @@ -3259,6 +3185,20 @@ "@types/expect" "^1.20.4" "@types/node" "*" +"@typescript-eslint/eslint-plugin@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz#8ed8736b8415a9193989220eadb6031dbcd2260a" + integrity sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/type-utils" "8.49.0" + "@typescript-eslint/utils" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.1.0" + "@typescript-eslint/eslint-plugin@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76" @@ -3287,6 +3227,17 @@ natural-compare "^1.4.0" ts-api-utils "^2.5.0" +"@typescript-eslint/parser@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.49.0.tgz#0ede412d59e99239b770f0f08c76c42fba717fa2" + integrity sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA== + dependencies: + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + debug "^4.3.4" + "@typescript-eslint/parser@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.1.tgz#21d13b3d456ffb08614c1d68bb9a4f8d9237cdc7" @@ -3309,6 +3260,15 @@ "@typescript-eslint/visitor-keys" "8.61.0" debug "^4.4.3" +"@typescript-eslint/project-service@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.49.0.tgz#ce220525c88cb2d23792b391c07e14cb9697651a" + integrity sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.49.0" + "@typescript-eslint/types" "^8.49.0" + debug "^4.3.4" + "@typescript-eslint/project-service@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.1.tgz#65c8d645f028b927bfc4928593b54e2ecd809244" @@ -3327,6 +3287,14 @@ "@typescript-eslint/types" "^8.61.0" debug "^4.4.3" +"@typescript-eslint/scope-manager@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz#a3496765b57fb48035d671174552e462e5bffa63" + integrity sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg== + dependencies: + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + "@typescript-eslint/scope-manager@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz#254df93b5789a871351335dd23e20bc164060f24" @@ -3343,16 +3311,32 @@ "@typescript-eslint/types" "8.61.0" "@typescript-eslint/visitor-keys" "8.61.0" +"@typescript-eslint/tsconfig-utils@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz#857777c8e35dd1e564505833d8043f544442fbf4" + integrity sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA== + "@typescript-eslint/tsconfig-utils@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz#1afa830b0fada5865ddcabdc993b790114a879b7" integrity sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ== -"@typescript-eslint/tsconfig-utils@8.61.0", "@typescript-eslint/tsconfig-utils@^8.38.0", "@typescript-eslint/tsconfig-utils@^8.56.1", "@typescript-eslint/tsconfig-utils@^8.61.0": +"@typescript-eslint/tsconfig-utils@8.61.0", "@typescript-eslint/tsconfig-utils@^8.38.0", "@typescript-eslint/tsconfig-utils@^8.49.0", "@typescript-eslint/tsconfig-utils@^8.56.1", "@typescript-eslint/tsconfig-utils@^8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba" integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ== +"@typescript-eslint/type-utils@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz#d8118a0c1896a78a22f01d3c176e9945409b085b" + integrity sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg== + dependencies: + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/utils" "8.49.0" + debug "^4.3.4" + ts-api-utils "^2.1.0" + "@typescript-eslint/type-utils@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz#7a6c4fabf225d674644931e004302cbbdd2f2e24" @@ -3375,16 +3359,36 @@ debug "^4.4.3" ts-api-utils "^2.5.0" +"@typescript-eslint/types@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.49.0.tgz#c1bd3ebf956d9e5216396349ca23c58d74f06aee" + integrity sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ== + "@typescript-eslint/types@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.1.tgz#975e5942bf54895291337c91b9191f6eb0632ab9" integrity sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw== -"@typescript-eslint/types@8.61.0", "@typescript-eslint/types@^8.56.1", "@typescript-eslint/types@^8.61.0": +"@typescript-eslint/types@8.61.0", "@typescript-eslint/types@^8.49.0", "@typescript-eslint/types@^8.56.1", "@typescript-eslint/types@^8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d" integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg== +"@typescript-eslint/typescript-estree@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz#99c5a53275197ccb4e849786dad68344e9924135" + integrity sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA== + dependencies: + "@typescript-eslint/project-service" "8.49.0" + "@typescript-eslint/tsconfig-utils" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + debug "^4.3.4" + minimatch "^9.0.4" + semver "^7.6.0" + tinyglobby "^0.2.15" + ts-api-utils "^2.1.0" + "@typescript-eslint/typescript-estree@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz#3b9e57d8129a860c50864c42188f761bdef3eab0" @@ -3415,6 +3419,16 @@ tinyglobby "^0.2.15" ts-api-utils "^2.5.0" +"@typescript-eslint/utils@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.49.0.tgz#43b3b91d30afd6f6114532cf0b228f1790f43aff" + integrity sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA== + dependencies: + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/utils@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.1.tgz#5a86acaf9f1b4c4a85a42effb217f73059f6deb7" @@ -3435,6 +3449,14 @@ "@typescript-eslint/types" "8.61.0" "@typescript-eslint/typescript-estree" "8.61.0" +"@typescript-eslint/visitor-keys@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz#8e450cc502c0d285cad9e84d400cf349a85ced6c" + integrity sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA== + dependencies: + "@typescript-eslint/types" "8.49.0" + eslint-visitor-keys "^4.2.1" + "@typescript-eslint/visitor-keys@8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz#50e03475c33a42d123dc99e63acf1841c0231f87" @@ -3758,9 +3780,9 @@ acorn@^7.0.0: integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== acorn@^8.0.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" - integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== add-stream@^1.0.0: version "1.0.0" @@ -4055,9 +4077,9 @@ ast-types@^0.16.1: tslib "^2.0.1" ast-v8-to-istanbul@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz#9b3c38bdcadbb17f0da8cab2ca3daf8cdf5e0af2" - integrity sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA== + version "1.0.3" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz#ba858c396a3d45f36a6963594fdfcd4675dfd445" + integrity sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg== dependencies: "@jridgewell/trace-mapping" "^0.3.31" estree-walker "^3.0.3" @@ -4131,13 +4153,6 @@ b4a@^1.6.4: resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4" integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw== -babel-loader@10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.1.1.tgz#ce9748e85b7071eb88006e3cfa9e6cf14eeb97c5" - integrity sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg== - dependencies: - find-up "^5.0.0" - bach@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/bach/-/bach-2.0.1.tgz#45a3a3cbf7dbba3132087185c60357482b988972" @@ -4183,9 +4198,9 @@ base64-js@1.5.1, base64-js@^1.0.2, base64-js@^1.3.1: integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== baseline-browser-mapping@^2.10.12: - version "2.10.37" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d" - integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig== + version "2.10.34" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz#dedb606362446777cfe328d30d4ee15056d06303" + integrity sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw== before-after-hook@^2.2.0: version "2.2.3" @@ -4647,9 +4662,9 @@ camelcase@^6.0.0, camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001787: - version "1.0.30001799" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz#5c909138c27f1a61219d3e092071c1cc7d32dc55" - integrity sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw== + version "1.0.30001797" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz#1332709e1439f01ff92085dd17001e0a45897ec0" + integrity sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w== ccount@^1.0.0: version "1.1.0" @@ -5856,9 +5871,9 @@ ejs@5.0.1: integrity sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A== electron-to-chromium@^1.5.328: - version "1.5.372" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz#ae8ac69942a37b231773b8fb01759f73733599e3" - integrity sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA== + version "1.5.368" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz#8e8d4600c5f5f01e891f8f843b4a941afb640412" + integrity sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw== elliptic@^6.5.3, elliptic@^6.6.1: version "6.6.1" @@ -5940,9 +5955,9 @@ end-of-stream@1.4.5, end-of-stream@^1.4.1, end-of-stream@^1.4.4: once "^1.4.0" enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.1: - version "5.24.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz#cf14b9768a774cb6a5087220c0dc6e55df6ec35a" - integrity sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ== + version "5.23.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz#dfdf8d1c9065e4b52f8a598356138931c07305f9" + integrity sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA== dependencies: graceful-fs "^4.2.4" tapable "^2.3.3" @@ -6072,9 +6087,9 @@ es-errors@1.3.0, es-errors@^1.3.0: integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== es-iterator-helpers@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz#0a229dc38ada0450b8cfaa85809fd73dbb34113c" - integrity sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g== + version "1.3.2" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz#8f4ff1f3603cbd09fbdb72c747a679779a65cc7f" + integrity sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw== dependencies: call-bind "^1.0.9" call-bound "^1.0.4" @@ -6191,36 +6206,36 @@ esast-util-from-js@^2.0.0: "@esbuild/win32-x64" "0.27.7" esbuild@~0.28.0: - version "0.28.1" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" - integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== optionalDependencies: - "@esbuild/aix-ppc64" "0.28.1" - "@esbuild/android-arm" "0.28.1" - "@esbuild/android-arm64" "0.28.1" - "@esbuild/android-x64" "0.28.1" - "@esbuild/darwin-arm64" "0.28.1" - "@esbuild/darwin-x64" "0.28.1" - "@esbuild/freebsd-arm64" "0.28.1" - "@esbuild/freebsd-x64" "0.28.1" - "@esbuild/linux-arm" "0.28.1" - "@esbuild/linux-arm64" "0.28.1" - "@esbuild/linux-ia32" "0.28.1" - "@esbuild/linux-loong64" "0.28.1" - "@esbuild/linux-mips64el" "0.28.1" - "@esbuild/linux-ppc64" "0.28.1" - "@esbuild/linux-riscv64" "0.28.1" - "@esbuild/linux-s390x" "0.28.1" - "@esbuild/linux-x64" "0.28.1" - "@esbuild/netbsd-arm64" "0.28.1" - "@esbuild/netbsd-x64" "0.28.1" - "@esbuild/openbsd-arm64" "0.28.1" - "@esbuild/openbsd-x64" "0.28.1" - "@esbuild/openharmony-arm64" "0.28.1" - "@esbuild/sunos-x64" "0.28.1" - "@esbuild/win32-arm64" "0.28.1" - "@esbuild/win32-ia32" "0.28.1" - "@esbuild/win32-x64" "0.28.1" + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" escalade@3.2.0, escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -6918,7 +6933,7 @@ foreground-child@^3.1.0, foreground-child@^3.1.1, foreground-child@^3.3.1: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@4.0.5: +form-data@4.0.5, form-data@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== @@ -6929,17 +6944,6 @@ form-data@4.0.5: hasown "^2.0.2" mime-types "^2.1.12" -form-data@^4.0.5: - version "4.0.6" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" - integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.4" - mime-types "^2.1.35" - fraction.js@^5.3.4: version "5.3.4" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" @@ -6995,19 +6999,16 @@ function-bind@1.1.2, function-bind@^1.1.2: integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: - version "1.2.0" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.2.0.tgz#758f3e84fa542672454bd5e14cb081a5ce07f70c" - integrity sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew== + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== dependencies: - call-bind "^1.0.9" - call-bound "^1.0.4" - es-define-property "^1.0.1" - es-errors "^1.3.0" + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" functions-have-names "^1.2.3" - has-property-descriptors "^1.0.2" - hasown "^2.0.4" + hasown "^2.0.2" is-callable "^1.2.7" - is-document.all "^1.0.0" functions-have-names@^1.2.3: version "1.2.3" @@ -7572,7 +7573,7 @@ hasown@2.0.2: dependencies: function-bind "^1.1.2" -hasown@^2.0.0, hasown@^2.0.2, hasown@^2.0.3, hasown@^2.0.4: +hasown@^2.0.0, hasown@^2.0.2, hasown@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== @@ -7821,7 +7822,7 @@ ignore-walk@^8.0.0: dependencies: minimatch "^10.0.3" -ignore@7.0.5, ignore@^7.0.5: +ignore@7.0.5, ignore@^7.0.0, ignore@^7.0.5: version "7.0.5" resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== @@ -8165,13 +8166,6 @@ is-docker@^3.0.0: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== -is-document.all@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-document.all/-/is-document.all-1.0.0.tgz#163a4bfb362c6ed7b118ce46cdecc4e37dee3195" - integrity sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g== - dependencies: - call-bound "^1.0.4" - is-extendable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" @@ -10068,7 +10062,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@2.1.35, mime-types@^2.1.12, mime-types@^2.1.35: +mime-types@2.1.35, mime-types@^2.1.12: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -10836,9 +10830,9 @@ object.values@^1.1.6, object.values@^1.2.1: es-object-atoms "^1.0.0" obug@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.3.tgz#c02c60f95abd603409330e767db7f2823193331e" - integrity sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg== + version "2.1.2" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.2.tgz#024d704dceae438ef875556ebf9e22e47fd951c2" + integrity sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg== once@1.4.0, once@^1.3.0, once@^1.4.0: version "1.4.0" @@ -11108,9 +11102,9 @@ pacote@21.0.1: tar "^7.4.3" pacote@^21.0.0, pacote@^21.0.2: - version "21.5.1" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.5.1.tgz#74ab896fc7b1f00545ecbbbf666a61895cd50d38" - integrity sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg== + version "21.5.0" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.5.0.tgz#475fe00db73585dec296590bec484109522e9e6f" + integrity sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ== dependencies: "@gar/promise-retry" "^1.0.0" "@npmcli/git" "^7.0.0" @@ -11535,17 +11529,17 @@ postcss-safe-parser@^7.0.1: integrity sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A== postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: - version "6.1.4" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz#fdec4ca80f5781bd216ca9bf89a2a0fccfffa5f0" - integrity sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ== + version "6.1.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" postcss-selector-parser@^7.0.0, postcss-selector-parser@^7.1.0, postcss-selector-parser@^7.1.1: - version "7.1.4" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz#69dc7a526517572ff6b150e352b36a016017b485" - integrity sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg== + version "7.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz#e75d2e0d843f620e5df69076166f4e16f891cb9f" + integrity sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -12500,7 +12494,7 @@ semver@7.7.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== -semver@7.8.4, semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: +semver@7.8.4, semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: version "7.8.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.4.tgz#c73eceebae0616934be8dff28a7fd70757c8e696" integrity sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA== @@ -12630,7 +12624,7 @@ should@*, should@13.2.3: should-type-adaptors "^1.0.1" should-util "^1.0.0" -side-channel-list@^1.0.1: +side-channel-list@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== @@ -12660,13 +12654,13 @@ side-channel-weakmap@^1.0.2: side-channel-map "^1.0.1" side-channel@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" - integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== dependencies: es-errors "^1.3.0" - object-inspect "^1.13.4" - side-channel-list "^1.0.1" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" @@ -13012,9 +13006,9 @@ stream-splicer@^2.0.0: readable-stream "^2.0.2" streamx@^2.12.0, streamx@^2.12.5, streamx@^2.13.2, streamx@^2.14.0: - version "2.28.0" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11" - integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw== + version "2.27.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.27.0.tgz#0f4811c500e7f17156e4917df0190d21db703442" + integrity sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA== dependencies: events-universal "^1.0.0" fast-fifo "^1.3.2" @@ -13721,7 +13715,7 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== -ts-api-utils@^2.4.0, ts-api-utils@^2.5.0: +ts-api-utils@^2.1.0, ts-api-utils@^2.4.0, ts-api-utils@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== @@ -13734,9 +13728,9 @@ ts-declaration-location@^1.0.6: picomatch "^4.0.2" ts-dedent@^2.0.0, ts-dedent@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.3.0.tgz#8fac36c7902b541c154ac13a27ac467997af11f8" - integrity sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg== + version "2.2.0" + resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" + integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== ts-interface-checker@^0.1.9: version "0.1.13" @@ -13867,6 +13861,16 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== +typescript-eslint@8.49.0: + version "8.49.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.49.0.tgz#4a8b608ae48c0db876c8fb2a2724839fc5a7147c" + integrity sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg== + dependencies: + "@typescript-eslint/eslint-plugin" "8.49.0" + "@typescript-eslint/parser" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/utils" "8.49.0" + typescript-eslint@8.61.0: version "8.61.0" resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.0.tgz#6927fb94f5f29623e370d33fd9fa61f15d6d996b"