Skip to content

Commit

Permalink
Merge pull request #466 from prezly/feature/dev-7586-implement-ui-for…
Browse files Browse the repository at this point in the history
…-video-block-settings

[DEV-7586] Feature - Implement UI for video block settings
  • Loading branch information
e1himself authored Aug 18, 2023
2 parents d1b92d4 + 3e18f60 commit f20968d
Show file tree
Hide file tree
Showing 17 changed files with 252 additions and 65 deletions.
35 changes: 26 additions & 9 deletions packages/slate-editor/src/extensions/video/VideoExtension.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,55 @@
import type { OEmbedInfo } from '@prezly/sdk';
import type { Extension } from '@prezly/slate-commons';
import { createDeserializeElement } from '@prezly/slate-commons';
import { isVideoNode, VIDEO_NODE_TYPE } from '@prezly/slate-types';
import { VideoNode } from '@prezly/slate-types';
import { isEqual } from '@technically/lodash';
import React from 'react';

import { composeElementDeserializer } from '#modules/html-deserialization';

import { VideoElement } from './components';
import { normalizeRedundantVideoAttributes, parseSerializedElement } from './lib';
import type { VideoExtensionParameters } from './types';

export interface VideoExtensionParameters {
fetchOembed: (url: OEmbedInfo['url']) => Promise<OEmbedInfo>;
mode?: 'iframe' | 'thumbnail';
withMenu?: boolean;
withLayoutControls?: boolean;
}

export const EXTENSION_ID = 'VideoExtension';

export function VideoExtension({ mode = 'thumbnail' }: VideoExtensionParameters): Extension {
export function VideoExtension({
mode = 'thumbnail',
withMenu = false,
withLayoutControls = true,
}: VideoExtensionParameters): Extension {
return {
id: EXTENSION_ID,
deserialize: {
element: composeElementDeserializer({
[VIDEO_NODE_TYPE]: createDeserializeElement(parseSerializedElement),
[VideoNode.TYPE]: createDeserializeElement(parseSerializedElement),
}),
},
isElementEqual: (node, another) => {
if (isVideoNode(node) && isVideoNode(another)) {
if (VideoNode.isVideoNode(node) && VideoNode.isVideoNode(another)) {
return node.url === another.url && isEqual(node.oembed, another.oembed);
}
return undefined;
},
isRichBlock: isVideoNode,
isVoid: isVideoNode,
isRichBlock: VideoNode.isVideoNode,
isVoid: VideoNode.isVideoNode,
normalizeNode: normalizeRedundantVideoAttributes,
renderElement: ({ attributes, children, element }) => {
if (isVideoNode(element)) {
if (VideoNode.isVideoNode(element)) {
return (
<VideoElement attributes={attributes} element={element} mode={mode}>
<VideoElement
attributes={attributes}
element={element}
mode={mode}
withMenu={withMenu}
withLayoutControls={withLayoutControls}
>
{children}
</VideoElement>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,69 @@
import type { VideoNode } from '@prezly/slate-types';
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import type { RenderElementProps } from 'slate-react';
import { useSlateStatic } from 'slate-react';

import { EditorBlock, HtmlInjection } from '#components';
import { PlayButton } from '#icons';

import { removeVideo, updateVideo } from '../transforms';

import styles from './VideoElement.module.scss';
import { type FormState, VideoMenu } from './VideoMenu';

interface Props extends RenderElementProps {
element: VideoNode;
mode: 'iframe' | 'thumbnail';
withMenu: boolean;
withLayoutControls: boolean;
}

export function VideoElement({ attributes, children, element, mode }: Props) {
export function VideoElement({
attributes,
children,
element,
mode,
withMenu,
withLayoutControls,
}: Props) {
const editor = useSlateStatic();

const { url, oembed } = element;
const [isHtmlEmbeddedWithErrors, setHtmlEmbeddedWithErrors] = useState<boolean>(false);

const handleUpdate = useCallback(
(patch: Partial<FormState>) => {
updateVideo(editor, element, patch);
},
[editor, element],
);
const handleRemove = useCallback(() => {
removeVideo(editor, element);
}, [editor, element]);

return (
<EditorBlock
{...attributes}
element={element}
overlay="autohide"
overlay="always"
layout={element.layout ?? 'contained'}
// We have to render children or Slate will fail when trying to find the node.
renderAboveFrame={children}
renderMenu={
withMenu
? ({ onClose }) => (
<VideoMenu
onChange={handleUpdate}
onClose={onClose}
onRemove={handleRemove}
url={element.url}
value={{ layout: element.layout }}
withLayoutControls={withLayoutControls}
/>
)
: undefined
}
renderReadOnlyFrame={() => (
<div className={styles.Container}>
{!isHtmlEmbeddedWithErrors &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@import "styles/variables";

.icon {
fill: white;

&.active {
fill: $yellow-300;
}
}
107 changes: 107 additions & 0 deletions packages/slate-editor/src/extensions/video/components/VideoMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { VideoNode } from '@prezly/slate-types';
import classNames from 'classnames';
import React from 'react';

import type { OptionsGroupOption } from '#components';
import { Button, OptionsGroup, Toolbox } from '#components';
import {
Delete,
ExternalLink,
ImageLayoutContained,
ImageLayoutExpanded,
ImageLayoutFullWidth,
} from '#icons';

import styles from './VideoMenu.module.scss';

export interface FormState {
layout: VideoNode['layout'];
}

interface Props {
url: VideoNode['url'];
onChange: (props: Partial<FormState>) => void;
onClose: () => void;
onRemove: () => void;
value: FormState;
withLayoutControls: boolean;
}

const VIDEO_LAYOUT_OPTIONS: OptionsGroupOption<VideoNode.Layout>[] = [
{
value: VideoNode.Layout.CONTAINED,
label: 'Contained',
icon: ({ isActive }) => (
<ImageLayoutContained
className={classNames(styles.icon, { [styles.active]: isActive })}
/>
),
},
{
value: VideoNode.Layout.EXPANDED,
label: 'Expanded',
icon: ({ isActive }) => (
<ImageLayoutExpanded
className={classNames(styles.icon, { [styles.active]: isActive })}
/>
),
},
{
value: VideoNode.Layout.FULL_WIDTH,
label: 'Full width',
icon: ({ isActive }) => (
<ImageLayoutFullWidth
className={classNames(styles.icon, { [styles.active]: isActive })}
/>
),
},
];

export function VideoMenu({ url, onChange, onClose, onRemove, value, withLayoutControls }: Props) {
const isSelfHosted =
url.startsWith('https://cdn.uc.assets.prezly.com/') ||
url.startsWith('https://ucarecdn.com/');

return (
<>
<Toolbox.Header withCloseButton onCloseClick={onClose}>
Video settings
</Toolbox.Header>

{!isSelfHosted && (
<Toolbox.Section noPadding>
<Button
type="link"
href={url}
target="_blank"
rel="noreferrer"
icon={ExternalLink}
iconPosition="right"
fullWidth
>
Go to video
</Button>
</Toolbox.Section>
)}

{withLayoutControls && (
<Toolbox.Section caption="Size">
<OptionsGroup
name="layout"
options={VIDEO_LAYOUT_OPTIONS}
selectedValue={value.layout}
onChange={(layout) => {
onChange({ layout });
}}
/>
</Toolbox.Section>
)}

<Toolbox.Footer>
<Button variant="clear-faded" icon={Delete} fullWidth onClick={onRemove}>
Remove video
</Button>
</Toolbox.Footer>
</>
);
}
3 changes: 1 addition & 2 deletions packages/slate-editor/src/extensions/video/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { VideoExtension, EXTENSION_ID } from './VideoExtension';
export { EXTENSION_ID, VideoExtension, type VideoExtensionParameters } from './VideoExtension';

export { VIDEO_TYPES } from './constants';
export { createVideoBookmark } from './lib';
export type { VideoExtensionParameters } from './types';
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import type { VideoNode } from '@prezly/slate-types';
import { VIDEO_NODE_TYPE } from '@prezly/slate-types';
import { VideoNode } from '@prezly/slate-types';
import { v4 as uuidV4 } from 'uuid';

type RequiredProps = Pick<VideoNode, 'url' | 'oembed'>;
type OptionaProps = Pick<VideoNode, 'uuid' | 'layout'>;

function withoutExtraAttributes<T extends VideoNode>(node: T): VideoNode {
const { type, uuid, url, oembed, children, ...extra } = node;
if (Object.keys(extra).length === 0) {
return node;
}
return { type, uuid, url, oembed, children };
}

export function createVideoBookmark(props: RequiredProps): VideoNode {
return withoutExtraAttributes({
uuid: uuidV4(),
...props,
export function createVideoBookmark(props: RequiredProps & Partial<OptionaProps>): VideoNode {
const { uuid = uuidV4(), url, oembed, layout = VideoNode.Layout.CONTAINED } = props;
return {
children: [{ text: '' }],
type: VIDEO_NODE_TYPE, // disallowed to override type
});
type: VideoNode.TYPE, // disallowed to override type
uuid,
url,
oembed,
layout,
};
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { EditorCommands } from '@prezly/slate-commons';
import type { VideoNode } from '@prezly/slate-types';
import { isVideoNode } from '@prezly/slate-types';
import { VideoNode } from '@prezly/slate-types';
import type { Editor, NodeEntry } from 'slate';

const shape: Record<keyof VideoNode, true> = {
type: true,
uuid: true,
url: true,
layout: true,
oembed: true,
children: true,
};
Expand All @@ -17,7 +17,7 @@ export function normalizeRedundantVideoAttributes(
editor: Editor,
[node, path]: NodeEntry,
): boolean {
if (!isVideoNode(node)) {
if (!VideoNode.isVideoNode(node)) {
return false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { VideoNode } from '@prezly/slate-types';
import { validateVideoNode } from '@prezly/slate-types';
import { VideoNode } from '@prezly/slate-types';

import { createVideoBookmark } from './createVideoBookmark';

export function parseSerializedElement(serialized: string): VideoNode | undefined {
const parsed = JSON.parse(serialized);

if (validateVideoNode(parsed)) {
if (VideoNode.validateVideoNode(parsed)) {
return createVideoBookmark(parsed);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { removeVideo } from './removeVideo';
export { updateVideo } from './updateVideo';
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { EditorCommands } from '@prezly/slate-commons';
import type { VideoNode } from '@prezly/slate-types';
import { isVideoNode } from '@prezly/slate-types';
import { VideoNode } from '@prezly/slate-types';
import type { Editor } from 'slate';

export function removeVideo(editor: Editor): VideoNode | null {
export function removeVideo(editor: Editor, element?: VideoNode): VideoNode | null {
return EditorCommands.removeNode<VideoNode>(editor, {
match: isVideoNode,
match: element ? (node) => node === element : VideoNode.isVideoNode,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { VideoNode } from '@prezly/slate-types';
import type { Editor } from 'slate';
import { Transforms } from 'slate';

export function updateVideo(
editor: Editor,
video: VideoNode,
patch: Partial<Pick<VideoNode, 'url' | 'oembed' | 'layout' | 'uuid'>>,
) {
Transforms.setNodes<VideoNode>(editor, patch, {
at: [],
match: (node) => node === video,
});
}
6 changes: 0 additions & 6 deletions packages/slate-editor/src/extensions/video/types.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
QUOTE_NODE_TYPE,
STORY_BOOKMARK_NODE_TYPE,
STORY_EMBED_NODE_TYPE,
VIDEO_NODE_TYPE,
VideoNode,
TABLE_NODE_TYPE,
isTableRowNode,
TABLE_ROW_NODE_TYPE,
Expand Down Expand Up @@ -145,5 +145,5 @@ export const hierarchySchema: NodesHierarchySchema = {
),
),
],
[VIDEO_NODE_TYPE]: [allowChildren(isEmptyTextNode, fixers.liftNodeNoSplit)],
[VideoNode.TYPE]: [allowChildren(isEmptyTextNode, fixers.liftNodeNoSplit)],
};
Loading

0 comments on commit f20968d

Please sign in to comment.