113
common/resources/client/article-editor/article-body-editor-menubar.tsx
Executable file
113
common/resources/client/article-editor/article-body-editor-menubar.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {Divider} from '@common/text-editor/menubar/divider';
|
||||
import {FontStyleButtons} from '@common/text-editor/menubar/font-style-buttons';
|
||||
import {ListButtons} from '@common/text-editor/menubar/list-buttons';
|
||||
import {LinkButton} from '@common/text-editor/menubar/link-button';
|
||||
import {ImageButton} from '@common/text-editor/menubar/image-button';
|
||||
import {ClearFormatButton} from '@common/text-editor/menubar/clear-format-button';
|
||||
import {InsertMenuTrigger} from '@common/text-editor/menubar/insert-menu-trigger';
|
||||
import {FormatMenuTrigger} from '@common/text-editor/menubar/format-menu-trigger';
|
||||
import {ColorButtons} from '@common/text-editor/menubar/color-buttons';
|
||||
import {AlignButtons} from '@common/text-editor/menubar/align-buttons';
|
||||
import {IndentButtons} from '@common/text-editor/menubar/indent-buttons';
|
||||
import {CodeBlockMenuTrigger} from '@common/text-editor/menubar/code-block-menu-trigger';
|
||||
import {MenubarButtonProps} from '@common/text-editor/menubar/menubar-button-props';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {UnfoldMoreIcon} from '@common/icons/material/UnfoldMore';
|
||||
import {UnfoldLessIcon} from '@common/icons/material/UnfoldLess';
|
||||
|
||||
const MenubarRowClassName =
|
||||
'flex items-center px-4 h-42 text-muted border-b overflow-hidden';
|
||||
|
||||
interface Props extends MenubarButtonProps {
|
||||
justify?: string;
|
||||
hideInsertButton?: boolean;
|
||||
imageDiskPrefix?: string;
|
||||
}
|
||||
export function ArticleBodyEditorMenubar({
|
||||
editor,
|
||||
size = 'md',
|
||||
justify = 'justify-center',
|
||||
hideInsertButton = false,
|
||||
imageDiskPrefix,
|
||||
}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const [extendedVisible, setExtendedVisible] = useState(false);
|
||||
return (
|
||||
<div className={clsx(extendedVisible ? 'h-84' : 'h-42')}>
|
||||
<div className={clsx(MenubarRowClassName, justify, 'relative z-20')}>
|
||||
<FormatMenuTrigger editor={editor} size={size} />
|
||||
<Divider />
|
||||
<FontStyleButtons editor={editor} size={size} />
|
||||
<Divider />
|
||||
<AlignButtons editor={editor} size={size} />
|
||||
<IndentButtons editor={editor} size={size} />
|
||||
<Divider />
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
className="flex-shrink-0"
|
||||
color={extendedVisible ? 'primary' : null}
|
||||
size={size}
|
||||
onClick={() => {
|
||||
setExtendedVisible(!extendedVisible);
|
||||
}}
|
||||
>
|
||||
{extendedVisible ? <UnfoldLessIcon /> : <UnfoldMoreIcon />}
|
||||
</IconButton>
|
||||
) : (
|
||||
<ExtendedButtons
|
||||
editor={editor}
|
||||
size={size}
|
||||
hideInsertButton={hideInsertButton}
|
||||
imageDiskPrefix={imageDiskPrefix}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{extendedVisible && (
|
||||
<m.div
|
||||
className={clsx(
|
||||
MenubarRowClassName,
|
||||
justify,
|
||||
'absolute flex h-full w-full',
|
||||
)}
|
||||
initial={{y: '-100%'}}
|
||||
animate={{y: 0}}
|
||||
exit={{y: '-100%'}}
|
||||
>
|
||||
<ExtendedButtons
|
||||
editor={editor}
|
||||
size={size}
|
||||
imageDiskPrefix={imageDiskPrefix}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtendedButtons({
|
||||
editor,
|
||||
size = 'md',
|
||||
hideInsertButton,
|
||||
imageDiskPrefix,
|
||||
}: Props) {
|
||||
return (
|
||||
<Fragment>
|
||||
<ListButtons editor={editor} size={size} />
|
||||
<Divider />
|
||||
<LinkButton editor={editor} size={size} />
|
||||
<ImageButton editor={editor} size={size} diskPrefix={imageDiskPrefix} />
|
||||
{!hideInsertButton && <InsertMenuTrigger editor={editor} size={size} />}
|
||||
<Divider />
|
||||
<ColorButtons editor={editor} size={size} />
|
||||
<Divider />
|
||||
<CodeBlockMenuTrigger editor={editor} size={size} />
|
||||
<ClearFormatButton editor={editor} size={size} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
113
common/resources/client/article-editor/article-body-editor.tsx
Executable file
113
common/resources/client/article-editor/article-body-editor.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import {Editor, EditorContent, FocusPosition, useEditor} from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import {Underline} from '@tiptap/extension-underline';
|
||||
import {Link as LinkExtension} from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import {ReactElement, useEffect, useRef} from 'react';
|
||||
import {Superscript} from '@tiptap/extension-superscript';
|
||||
import {Subscript} from '@tiptap/extension-subscript';
|
||||
import {Color} from '@tiptap/extension-color';
|
||||
import {TextStyle} from '@tiptap/extension-text-style';
|
||||
import {TextAlign} from '@tiptap/extension-text-align';
|
||||
import {CodeBlockLowlight} from '@tiptap/extension-code-block-lowlight';
|
||||
import {BackgroundColor} from '@common/text-editor/extensions/background-color';
|
||||
import {Indent} from '@common/text-editor/extensions/indent';
|
||||
import {Embed} from '@common/text-editor/extensions/embed';
|
||||
import {lowlight} from '@common/text-editor/highlight/lowlight';
|
||||
import {InfoBlock} from '@common/text-editor/extensions/info-block';
|
||||
import {useCallbackRef} from '@common/utils/hooks/use-callback-ref';
|
||||
import {Extension} from '@tiptap/core';
|
||||
|
||||
interface Props {
|
||||
initialContent?: string;
|
||||
onLoad?: (editor: Editor) => void;
|
||||
children: (content: ReactElement, editor: Editor) => ReactElement;
|
||||
minHeight?: string;
|
||||
onCtrlEnter?: () => void;
|
||||
autoFocus?: FocusPosition;
|
||||
}
|
||||
export default function ArticleBodyEditor({
|
||||
initialContent = '',
|
||||
children,
|
||||
onLoad: _onLoad,
|
||||
onCtrlEnter,
|
||||
minHeight = 'min-h-320',
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
const onLoad = useCallbackRef(_onLoad);
|
||||
const calledOnLoad = useRef(false);
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
}),
|
||||
Underline,
|
||||
LinkExtension.extend({
|
||||
inclusive: false,
|
||||
validate: {
|
||||
// only linkify links that start with a protocol
|
||||
url: (value: string) => /^https?:\/\//.test(value),
|
||||
},
|
||||
}),
|
||||
Image,
|
||||
Superscript,
|
||||
Subscript,
|
||||
TextStyle,
|
||||
Color,
|
||||
BackgroundColor,
|
||||
Indent,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
InfoBlock,
|
||||
Embed,
|
||||
];
|
||||
|
||||
if (onCtrlEnter) {
|
||||
extensions.push(
|
||||
Extension.create({
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Cmd-Enter'() {
|
||||
onCtrlEnter();
|
||||
return true;
|
||||
},
|
||||
'Ctrl-Enter'() {
|
||||
onCtrlEnter();
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
onFocus: () => {},
|
||||
autofocus: autoFocus,
|
||||
content: initialContent,
|
||||
});
|
||||
|
||||
// destroy editor
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
return () => editor.destroy();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (editor && onLoad && !calledOnLoad.current) {
|
||||
onLoad(editor);
|
||||
calledOnLoad.current = true;
|
||||
}
|
||||
|
||||
return children(
|
||||
<EditorContent className={minHeight} editor={editor} />,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
148
common/resources/client/article-editor/article-editor-sticky-header.tsx
Executable file
148
common/resources/client/article-editor/article-editor-sticky-header.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import {SlugEditor, SlugEditorProps} from '@common/ui/slug-editor';
|
||||
import {useController, useFormContext} from 'react-hook-form';
|
||||
import React, {Fragment, ReactNode, useEffect, useRef} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useStickySentinel} from '@common/utils/hooks/sticky-sentinel';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {HistoryButtons} from '@common/text-editor/menubar/history-buttons';
|
||||
import {ModeButton} from '@common/text-editor/menubar/mode-button';
|
||||
import {ArticleBodyEditorMenubar} from './article-body-editor-menubar';
|
||||
import {Editor} from '@tiptap/react';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
|
||||
interface StickyHeaderProps {
|
||||
editor: Editor;
|
||||
allowSlugEditing?: boolean;
|
||||
onSave?: (editorContent: string) => void;
|
||||
saveButton?: ReactNode;
|
||||
backLink: string;
|
||||
isLoading?: boolean;
|
||||
slugPrefix?: string;
|
||||
imageDiskPrefix?: string;
|
||||
}
|
||||
export function ArticleEditorStickyHeader({
|
||||
editor,
|
||||
allowSlugEditing = true,
|
||||
onSave,
|
||||
saveButton,
|
||||
isLoading = false,
|
||||
backLink,
|
||||
slugPrefix = 'pages',
|
||||
imageDiskPrefix,
|
||||
}: StickyHeaderProps) {
|
||||
const {isSticky, sentinelRef} = useStickySentinel();
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div ref={sentinelRef} />
|
||||
<div className={clsx('sticky top-0 z-10 mb-20 bg', isSticky && 'shadow')}>
|
||||
<div className="flex items-center justify-between gap-20 border-b px-20 py-10 text-muted sm:justify-start">
|
||||
{!isMobile && (
|
||||
<Fragment>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
elementType={Link}
|
||||
to={backLink}
|
||||
relative="path"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
<Trans message="Back" />
|
||||
</Button>
|
||||
<div className="mr-auto">
|
||||
{allowSlugEditing && (
|
||||
<FormSlugEditor
|
||||
name="slug"
|
||||
showLinkIcon={false}
|
||||
prefix={slugPrefix}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{editor && <HistoryButtons editor={editor} />}
|
||||
{!isMobile && <ModeButton editor={editor} />}
|
||||
{onSave && (
|
||||
<SaveButton
|
||||
onSave={() => {
|
||||
onSave(editor.getHTML());
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
{saveButton}
|
||||
</div>
|
||||
<ArticleBodyEditorMenubar
|
||||
editor={editor}
|
||||
size="sm"
|
||||
imageDiskPrefix={imageDiskPrefix}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface SaveButtonProps {
|
||||
onSave: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
function SaveButton({onSave, isLoading}: SaveButtonProps) {
|
||||
const form = useFormContext();
|
||||
const title = form.watch('title');
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="flat"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="min-w-90"
|
||||
disabled={isLoading || !title}
|
||||
onClick={() => onSave()}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormSlugEditorProps extends SlugEditorProps {
|
||||
name: string;
|
||||
}
|
||||
function FormSlugEditor({name, ...other}: FormSlugEditorProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
const manuallyChanged = useRef(false);
|
||||
|
||||
const {watch, setValue} = useFormContext<CreateCustomPagePayload>();
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((formVal, {name: fieldName}) => {
|
||||
// if user has not changed slug manually, set it based on page title field changes
|
||||
if (fieldName === 'title' && !manuallyChanged.current) {
|
||||
setValue('slug', formVal.title);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue]);
|
||||
|
||||
return (
|
||||
<SlugEditor
|
||||
className={clsx(!value && 'invisible')}
|
||||
onChange={e => {
|
||||
manuallyChanged.current = true;
|
||||
onChange(e);
|
||||
}}
|
||||
onInputBlur={onBlur}
|
||||
value={value}
|
||||
inputRef={ref}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
common/resources/client/article-editor/article-editor-title.tsx
Executable file
49
common/resources/client/article-editor/article-editor-title.tsx
Executable file
@@ -0,0 +1,49 @@
|
||||
import React, {useState} from 'react';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import clsx from 'clsx';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
|
||||
export function ArticleEditorTitle() {
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const {trans} = useTrans();
|
||||
const form = useFormContext<CreateCustomPagePayload>();
|
||||
const watchedTitle = form.watch('title');
|
||||
|
||||
const titlePlaceholder = trans({message: 'Title'});
|
||||
|
||||
if (editingTitle) {
|
||||
return (
|
||||
<FormTextField
|
||||
placeholder={titlePlaceholder}
|
||||
autoFocus
|
||||
className="mb-30"
|
||||
onBlur={() => {
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
name="title"
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<h1
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className={clsx(
|
||||
'hover:bg-primary/focus rounded cursor-pointer',
|
||||
!watchedTitle && 'text-muted'
|
||||
)}
|
||||
>
|
||||
{watchedTitle || titlePlaceholder}
|
||||
<EditIcon className="icon-sm mx-8 mt-8 align-top text-muted" />
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user