refactor: monorepo setup with lerna (#243)

This commit is contained in:
Daniel Lautzenheiser
2022-12-15 13:44:49 -05:00
committed by GitHub
parent dac29fbf3c
commit 504d95c34f
706 changed files with 16571 additions and 142 deletions

View File

@ -0,0 +1,38 @@
import { red } from '@mui/material/colors';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import React, { useCallback, useState } from 'react';
import type { BooleanField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react';
const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
value,
label,
onChange,
hasErrors,
}) => {
const [internalValue, setInternalValue] = useState(value);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.checked);
onChange(event.target.checked);
},
[onChange],
);
return (
<FormControlLabel
key="boolean-field-label"
control={
<Switch key="boolean-input" checked={internalValue ?? false} onChange={handleChange} />
}
label={label}
labelPlacement="start"
sx={{ marginLeft: '4px', color: hasErrors ? red[500] : undefined }}
/>
);
};
export default BooleanControl;

View File

@ -0,0 +1,21 @@
import controlComponent from './BooleanControl';
import schema from './schema';
import type { BooleanField, WidgetParam } from '@staticcms/core/interface';
const BooleanWidget = (): WidgetParam<boolean, BooleanField> => {
return {
name: 'boolean',
controlComponent,
options: {
schema,
getDefaultValue: (defaultValue: boolean | undefined | null) => {
return typeof defaultValue === 'boolean' ? defaultValue : false;
},
},
};
};
export { controlComponent as BooleanControl, schema as BooleanSchema };
export default BooleanWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'boolean' },
},
};

View File

@ -0,0 +1,224 @@
import { styled } from '@mui/material/styles';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import languages from './data/languages';
import SettingsButton from './SettingsButton';
import SettingsPane from './SettingsPane';
import type {
CodeField,
ProcessedCodeLanguage,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
import type { FC } from 'react';
const StyledCodeControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledCodeControlContentProps {
$collapsed: boolean;
}
const StyledCodeControlContent = styled(
'div',
transientOptions,
)<StyledCodeControlContentProps>(
({ $collapsed }) => `
display: block;
width: 100%;
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
function valueToOption(val: string | { name: string; label?: string }): {
value: string;
label: string;
} {
if (typeof val === 'string') {
return { value: val, label: val };
}
return { value: val.name, label: val.label || val.name };
}
const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, CodeField>> = ({
field,
onChange,
hasErrors,
value,
t,
}) => {
const keys = useMemo(() => {
const defaults = {
code: 'code',
lang: 'lang',
};
const keys = field.keys ?? {};
return { ...defaults, ...keys };
}, [field]);
const valueIsMap = useMemo(() => Boolean(!field.output_code_only), [field.output_code_only]);
const [internalValue, setInternalValue] = useState(value ?? '');
const [lang, setLang] = useState<ProcessedCodeLanguage | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [hasFocus, setHasFocus] = useState(false);
const handleFocus = useCallback(() => {
setHasFocus(true);
}, []);
const handleBlur = useCallback(() => {
setHasFocus(false);
}, []);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const handleOnChange = useCallback(
(newValue: string | { [key: string]: string } | null | undefined) => {
setInternalValue(newValue ?? '');
onChange(newValue ?? '');
},
[onChange],
);
const handleChange = useCallback(
(newValue: string) => {
if (valueIsMap) {
handleOnChange({
lang: lang?.label ?? '',
code: newValue,
});
}
handleOnChange(newValue);
},
[handleOnChange, lang?.label, valueIsMap],
);
const loadedLangExtension = useMemo(() => {
if (!lang) {
return null;
}
return loadLanguage(lang.codemirror_mode as LanguageName);
}, [lang]);
const extensions = useMemo(() => {
if (!loadedLangExtension) {
return [];
}
return [loadedLangExtension];
}, [loadedLangExtension]);
const code = useMemo(() => {
if (typeof internalValue === 'string') {
return internalValue;
}
return internalValue[keys.code];
}, [internalValue, keys.code]);
const [settingsVisible, setSettingsVisible] = useState(false);
const showSettings = useCallback(() => {
setSettingsVisible(true);
}, []);
const hideSettings = useCallback(() => {
setSettingsVisible(false);
}, []);
const uniqueId = useUUID();
// If `allow_language_selection` is not set, default to true. Otherwise, use its value.
const allowLanguageSelection = useMemo(
() => Boolean(field.allow_language_selection),
[field.allow_language_selection],
);
const availableLanguages = languages.map(language => valueToOption(language.label));
const handleSetLanguage = useCallback((langIdentifier: string) => {
const language = languages.find(language => language.identifiers.includes(langIdentifier));
if (language) {
setLang(language);
}
}, []);
useEffect(() => {
let langIdentifier: string;
if (typeof internalValue !== 'string') {
langIdentifier = internalValue[keys.lang];
} else {
langIdentifier = internalValue;
}
if (isEmpty(langIdentifier)) {
return;
}
handleSetLanguage(langIdentifier);
}, [field.default_language, handleSetLanguage, internalValue, keys.lang, valueIsMap]);
return (
<StyledCodeControlWrapper>
{allowLanguageSelection ? (
!settingsVisible ? (
<SettingsButton onClick={showSettings} />
) : (
<SettingsPane
hideSettings={hideSettings}
uniqueId={uniqueId}
languages={availableLanguages}
language={valueToOption(lang?.label ?? '')}
allowLanguageSelection={allowLanguageSelection}
onChangeLanguage={handleSetLanguage}
/>
)
) : null}
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledCodeControlContent $collapsed={collapsed}>
<CodeMirror
value={code}
height="auto"
minHeight="120px"
width="100%"
editable={true}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
extensions={extensions}
/>
</StyledCodeControlContent>
<Outline active={hasFocus} hasError={hasErrors} />
</StyledCodeControlWrapper>
);
};
export default CodeControl;

View File

@ -0,0 +1,34 @@
import isString from 'lodash/isString';
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { CodeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
function toValue(value: string | Record<string, string> | undefined | null, field: CodeField) {
if (isString(value)) {
return value;
}
if (value) {
return value[field.keys?.code ?? 'code'] ?? '';
}
return '';
}
const CodePreview: FC<WidgetPreviewProps<string | Record<string, string>, CodeField>> = ({
value,
field,
}) => {
return (
<WidgetPreviewContainer>
<pre>
<code>{toValue(value, field)}</code>
</pre>
</WidgetPreviewContainer>
);
};
export default CodePreview;

View File

@ -0,0 +1,36 @@
import CloseIcon from '@mui/icons-material/Close';
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import React from 'react';
import { zIndex } from '@staticcms/core/components/UI/styles';
import type { FC, MouseEvent } from 'react';
const StyledSettingsButton = styled(IconButton)`
position: absolute;
z-index: ${zIndex.zIndex100};
right: 8px;
top: 8px;
opacity: 0.8;
padding: 2px 4px;
line-height: 1;
height: auto;
color: #000;
`;
export interface SettingsButtonProps {
showClose?: boolean;
onClick: (event: MouseEvent) => void;
}
const SettingsButton: FC<SettingsButtonProps> = ({ showClose = false, onClick }) => {
return (
<StyledSettingsButton onClick={onClick}>
{showClose ? <CloseIcon /> : <SettingsIcon />}
</StyledSettingsButton>
);
};
export default SettingsButton;

View File

@ -0,0 +1,131 @@
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { styled } from '@mui/material/styles';
import isHotkey from 'is-hotkey';
import React from 'react';
import { shadows, zIndex } from '@staticcms/core/components/UI/styles';
import SettingsButton from './SettingsButton';
import type { SelectChangeEvent } from '@mui/material/Select';
import type { FC } from 'react';
const SettingsPaneContainer = styled('div')`
position: absolute;
top: 1px;
bottom: 1px;
right: 1px;
width: 200px;
z-index: ${zIndex.zIndex10};
background-color: #fff;
overflow: hidden;
padding: 12px;
border-radius: 0 3px 3px 0;
${shadows.drop};
display: flex;
flex-direction: column;
gap: 16px;
`;
const SettingsSectionTitle = styled('h3')`
font-size: 14px;
margin-top: 14px;
margin-bottom: 0;
&:first-of-type {
margin-top: 4px;
}
`;
interface SettingsSelectProps {
type: 'language';
label: string;
uniqueId: string;
value: {
value: string;
label: string;
};
options: {
value: string;
label: string;
}[];
onChange: (newValue: string) => void;
}
const SettingsSelect: FC<SettingsSelectProps> = ({
value,
label,
options,
onChange,
uniqueId,
type,
}) => {
const handleChange = (event: SelectChangeEvent<string>) => {
onChange(event.target.value);
};
return (
<FormControl fullWidth size="small">
<InputLabel id={`${uniqueId}-select-${type}-label`}>{label}</InputLabel>
<Select
labelId={`${uniqueId}-select-${type}-label`}
id={`${uniqueId}-select-${type}`}
value={value.value}
label={label}
onChange={handleChange}
>
{options.map(({ label, value }) =>
value ? (
<MenuItem key={`${uniqueId}-select-${type}-option-${value}`} value={value}>
{label}
</MenuItem>
) : null,
)}
</Select>
</FormControl>
);
};
export interface SettingsPaneProps {
hideSettings: () => void;
uniqueId: string;
languages: {
value: string;
label: string;
}[];
language: {
value: string;
label: string;
};
allowLanguageSelection: boolean;
onChangeLanguage: (lang: string) => void;
}
const SettingsPane: FC<SettingsPaneProps> = ({
hideSettings,
uniqueId,
languages,
language,
onChangeLanguage,
}) => {
return (
<SettingsPaneContainer onKeyDown={e => isHotkey('esc', e) && hideSettings()}>
<SettingsButton onClick={hideSettings} showClose={true} />
<>
<SettingsSectionTitle>Field Settings</SettingsSectionTitle>
<SettingsSelect
type="language"
label="Language"
uniqueId={uniqueId}
value={language}
options={languages}
onChange={onChangeLanguage}
/>
</>
</SettingsPaneContainer>
);
};
export default SettingsPane;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
import controlComponent from './CodeControl';
import previewComponent from './CodePreview';
import schema from './schema';
import type { CodeField, WidgetParam } from '@staticcms/core/interface';
const CodeWidget = (): WidgetParam<string | { [key: string]: string }, CodeField> => {
return {
name: 'code',
controlComponent,
previewComponent,
options: {
schema,
getDefaultValue: (
defaultValue: string | { [key: string]: string } | null | undefined,
field: CodeField,
) => {
if (field.output_code_only) {
return String(defaultValue);
}
const langKey = field.keys?.['lang'] ?? 'lang';
const codeKey = field.keys?.['code'] ?? 'code';
if (typeof defaultValue === 'string') {
return {
[langKey]: field.default_language ?? '',
[codeKey]: defaultValue,
};
}
return {
[langKey]: field.default_language ?? defaultValue?.[langKey] ?? '',
[codeKey]: defaultValue?.[codeKey] ?? '',
};
},
},
};
};
export * from './SettingsButton';
export { default as CodeSettingsButton } from './SettingsButton';
export * from './SettingsPane';
export { default as CodeSettingsPane } from './SettingsPane';
export { controlComponent as CodeControl, previewComponent as CodePreview, schema as CodeSchema };
export default CodeWidget;

View File

@ -0,0 +1,38 @@
import { reactSelectStyles, borders } from '@staticcms/core/components/UI/styles';
import type { CSSProperties } from 'react';
import type { OptionStyleState } from '@staticcms/core/components/UI/styles';
const languageSelectStyles = {
...reactSelectStyles,
container: (provided: CSSProperties) => ({
...reactSelectStyles.container(provided),
'margin-top': '2px',
}),
control: (provided: CSSProperties) => ({
...reactSelectStyles.control(provided),
border: borders.textField,
padding: 0,
fontSize: '13px',
minHeight: 'auto',
}),
dropdownIndicator: (provided: CSSProperties) => ({
...reactSelectStyles.dropdownIndicator(provided),
padding: '4px',
}),
option: (provided: CSSProperties, state: OptionStyleState) => ({
...reactSelectStyles.option(provided, state),
padding: 0,
paddingLeft: '8px',
}),
menu: (provided: CSSProperties) => ({
...reactSelectStyles.menu(provided),
margin: '2px 0',
}),
menuList: (provided: CSSProperties) => ({
...provided,
'max-height': '200px',
}),
};
export default languageSelectStyles;

View File

@ -0,0 +1,14 @@
export default {
properties: {
default_language: { type: 'string' },
allow_language_selection: { type: 'boolean' },
output_code_only: { type: 'boolean' },
keys: {
type: 'object',
properties: { code: { type: 'string' }, lang: { type: 'string' } },
},
default: {
oneOf: [{ type: 'string' }, { type: 'object' }],
},
},
};

View File

@ -0,0 +1,63 @@
import fs from 'fs-extra';
import yaml from 'js-yaml';
import uniq from 'lodash/uniq';
import path from 'path';
import type { ProcessedCodeLanguage } from '@staticcms/core/interface';
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
const rawDataPath = '../data/languages-raw.yml';
const outputPath = '../data/languages.ts';
interface CodeLanguage {
extensions: string[];
aliases: string[];
codemirror_mode: LanguageName;
codemirror_mime_type: string;
}
async function fetchData() {
const filePath = path.resolve(__dirname, rawDataPath);
const fileContent = await fs.readFile(filePath, 'utf-8');
return yaml.load(fileContent) as Record<string, CodeLanguage>;
}
function outputData(data: ProcessedCodeLanguage[]) {
const filePath = path.resolve(__dirname, outputPath);
return fs.writeFile(
filePath,
`import type { ProcessedCodeLanguage } from '@staticcms/core/interface';
const languages: ProcessedCodeLanguage[] = ${JSON.stringify(data, null, 2)};
export default languages;
`,
);
}
function transform(data: Record<string, CodeLanguage>) {
return Object.entries(data).reduce((acc, [label, lang]) => {
const { extensions = [], aliases = [], codemirror_mode, codemirror_mime_type } = lang;
if (codemirror_mode) {
const dotlessExtensions = extensions.map(ext => ext.slice(1));
const identifiers = uniq(
[label.toLowerCase(), ...aliases, ...dotlessExtensions].filter(alias => {
if (!alias) {
return;
}
return !/[^a-zA-Z]/.test(alias);
}),
);
acc.push({ label, identifiers, codemirror_mode, codemirror_mime_type });
}
return acc;
}, [] as ProcessedCodeLanguage[]);
}
async function process() {
const data = await fetchData();
const transformedData = transform(data);
return outputData(transformedData);
}
process();

View File

@ -0,0 +1,225 @@
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useState } from 'react';
import { ChromePicker } from 'react-color';
import validateColor from 'validate-color';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { zIndex } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import type { ColorField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent } from 'react';
import type { ColorResult } from 'react-color';
const StyledColorControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledColorControlContentProps {
$collapsed: boolean;
}
const StyledColorControlContent = styled(
'div',
transientOptions,
)<StyledColorControlContentProps>(
({ $collapsed }) => `
display: flex;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
// color swatch background with checkerboard to display behind transparent colors
const ColorSwatchBackground = styled('div')`
position: absolute;
z-index: ${zIndex.zIndex1};
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==');
height: 38px;
width: 48px;
margin-top: 10px;
margin-left: 10px;
border-radius: 5px;
`;
interface ColorSwatchProps {
$background: string;
$color: string;
}
const ColorSwatch = styled(
'div',
transientOptions,
)<ColorSwatchProps>(
({ $background, $color }) => `
position: absolute;
z-index: ${zIndex.zIndex2};
background: ${$background};
cursor: pointer;
height: 38px;
width: 48px;
margin-top: 10px;
margin-left: 10px;
border-radius: 5px;
border: 2px solid rgb(223, 223, 227);
text-align: center;
font-size: 27px;
line-height: 1;
padding-top: 4px;
user-select: none;
color: ${$color};
`,
);
const ColorPickerContainer = styled('div')`
position: absolute;
z-index: ${zIndex.zIndex1000};
margin-top: 48px;
margin-left: 12px;
`;
// fullscreen div to close color picker when clicking outside of picker
const ClickOutsideDiv = styled('div')`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
field,
onChange,
value,
hasErrors,
t,
}) => {
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const [showColorPicker, setShowColorPicker] = useState(false);
const [internalValue, setInternalValue] = useState(value ?? '');
// show/hide color picker
const handleClick = useCallback(() => {
setShowColorPicker(!showColorPicker);
}, [showColorPicker]);
const handleClear = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
setInternalValue('');
onChange('');
},
[onChange],
);
const handleClose = useCallback(() => {
setShowColorPicker(false);
}, []);
const handlePickerChange = useCallback(
(color: ColorResult) => {
const formattedColor =
(color.rgb?.a ?? 1) < 1
? `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
: color.hex;
setInternalValue(formattedColor);
onChange(formattedColor);
},
[onChange],
);
const handleInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setInternalValue(event.target.value);
onChange(event.target.value);
},
[onChange],
);
const allowInput = field.allow_input ?? false;
// clear button is not displayed if allow_input: true
const showClearButton = !allowInput && internalValue;
return (
<StyledColorControlWrapper>
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledColorControlContent $collapsed={collapsed}>
<ColorSwatchBackground />
<ColorSwatch
key="color-swatch"
$background={validateColor(internalValue) ? internalValue : '#fff'}
$color={validateColor(internalValue) ? 'rgba(255, 255, 255, 0)' : 'rgb(223, 223, 227)'}
onClick={handleClick}
>
?
</ColorSwatch>
{showColorPicker && (
<ColorPickerContainer key="color-swatch-wrapper">
<ClickOutsideDiv key="click-outside" onClick={handleClose} />
<ChromePicker
key="color-picker"
color={internalValue}
onChange={handlePickerChange}
disableAlpha={!(field.enable_alpha ?? false)}
/>
</ColorPickerContainer>
)}
<TextField
key="color-picker-input"
value={internalValue}
onChange={handleInputChange}
sx={{
color: !allowInput ? '#bbb' : undefined,
'.MuiInputBase-input': {
paddingLeft: '75px',
},
}}
// make readonly and open color picker on click if set to allow_input: false
onClick={!allowInput ? handleClick : undefined}
disabled={!allowInput}
fullWidth
InputProps={{
endAdornment: showClearButton ? (
<InputAdornment position="start">
<IconButton onClick={handleClear} aria-label="clear">
<CloseIcon />
</IconButton>
</InputAdornment>
) : undefined,
}}
/>
</StyledColorControlContent>
<Outline hasError={hasErrors} />
</StyledColorControlWrapper>
);
};
export default ColorControl;

View File

@ -0,0 +1,12 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ColorField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ColorPreview: FC<WidgetPreviewProps<string, ColorField>> = ({ value }) => {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
};
export default ColorPreview;

View File

@ -0,0 +1,24 @@
import controlComponent from './ColorControl';
import previewComponent from './ColorPreview';
import schema from './schema';
import type { ColorField, WidgetParam } from '@staticcms/core/interface';
const ColorWidget = (): WidgetParam<string, ColorField> => {
return {
name: 'color',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export {
controlComponent as ColorControl,
previewComponent as ColorPreview,
schema as ColorSchema,
};
export default ColorWidget;

View File

@ -0,0 +1,5 @@
export default {
properties: {
default: { type: 'string' },
},
};

View File

@ -0,0 +1,268 @@
import TodayIcon from '@mui/icons-material/Today';
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker';
import { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import formatDate from 'date-fns/format';
import formatISO from 'date-fns/formatISO';
import parse from 'date-fns/parse';
import parseISO from 'date-fns/parseISO';
import React, { useCallback, useMemo, useState } from 'react';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import type { DateTimeField, TranslatedProps, WidgetControlProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
export function localToUTC(dateTime: Date, timezoneOffset: number) {
const utcFromLocal = new Date(dateTime.getTime() - timezoneOffset);
return utcFromLocal;
}
const StyledNowButton = styled('div')`
width: fit-content;
`;
interface NowButtonProps {
handleChange: (value: Date) => void;
disabled: boolean;
}
const NowButton: FC<TranslatedProps<NowButtonProps>> = ({ t, handleChange, disabled }) => {
const handleClick = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
handleChange(new Date());
},
[handleChange],
);
return (
<StyledNowButton key="now-button-wrapper">
<Button
key="now-button"
onClick={handleClick}
disabled={disabled}
startIcon={<TodayIcon key="today-icon" />}
variant="outlined"
>
{t('editor.editorWidgets.datetime.now')}
</Button>
</StyledNowButton>
);
};
const DateTimeControl: FC<WidgetControlProps<string, DateTimeField>> = ({
field,
label,
value,
t,
isDisabled,
onChange,
hasErrors,
}) => {
const { format, dateFormat, timeFormat } = useMemo(() => {
const format = field.format;
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
return {
format,
dateFormat,
timeFormat,
};
}, [field.date_format, field.format, field.time_format]);
const timezoneOffset = useMemo(() => new Date().getTimezoneOffset() * 60000, []);
const inputFormat = useMemo(() => {
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
formatParts.push(dateFormat);
}
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
formatParts.push(timeFormat);
}
if (formatParts.length > 0) {
return formatParts.join(' ');
}
}
return "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
}, [dateFormat, timeFormat]);
const defaultValue = useMemo(() => {
const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
return field.default === undefined
? format
? formatDate(today, format)
: formatDate(today, inputFormat)
: field.default;
}, [field.default, field.picker_utc, format, inputFormat, timezoneOffset]);
const [internalValue, setInternalValue] = useState(value);
const dateValue: Date = useMemo(() => {
let valueToParse = internalValue;
if (!valueToParse) {
valueToParse = defaultValue;
}
return format ? parse(valueToParse, format, new Date()) : parseISO(valueToParse);
}, [defaultValue, format, internalValue]);
const utcDate = useMemo(() => {
const dateTime = new Date(dateValue);
const utcFromLocal = new Date(dateTime.getTime() + timezoneOffset) ?? defaultValue;
return utcFromLocal;
}, [dateValue, defaultValue, timezoneOffset]);
const handleChange = useCallback(
(datetime: Date | null) => {
if (datetime === null) {
setInternalValue(defaultValue);
onChange(defaultValue);
return;
}
const adjustedValue = field.picker_utc ? localToUTC(datetime, timezoneOffset) : datetime;
let formattedValue: string;
if (format) {
formattedValue = formatDate(adjustedValue, format);
} else {
formattedValue = formatISO(adjustedValue);
}
setInternalValue(formattedValue);
onChange(formattedValue);
},
[defaultValue, field.picker_utc, format, onChange, timezoneOffset],
);
const dateTimePicker = useMemo(() => {
if (!internalValue) {
return null;
}
const inputDate = field.picker_utc ? utcDate : dateValue;
if (dateFormat && !timeFormat) {
return (
<MobileDatePicker
key="mobile-date-picker"
inputFormat={inputFormat}
label={label}
value={inputDate}
onChange={handleChange}
renderInput={params => (
<TextField
key="mobile-date-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="mobile-date-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
)}
/>
);
}
if (!dateFormat && timeFormat) {
return (
<TimePicker
key="time-picker"
label={label}
inputFormat={inputFormat}
value={inputDate}
onChange={handleChange}
renderInput={params => (
<TextField
key="time-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="time-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
)}
/>
);
}
return (
<MobileDateTimePicker
key="mobile-date-time-picker"
inputFormat={inputFormat}
label={label}
value={inputDate}
onChange={handleChange}
renderInput={params => (
<TextField
key="mobile-date-time-input"
{...params}
error={hasErrors}
fullWidth
InputProps={{
endAdornment: (
<NowButton
key="mobile-date-time-now"
t={t}
handleChange={v => handleChange(v)}
disabled={isDisabled}
/>
),
}}
/>
)}
/>
);
}, [
dateFormat,
dateValue,
field.picker_utc,
handleChange,
hasErrors,
inputFormat,
internalValue,
isDisabled,
label,
t,
timeFormat,
utcDate,
]);
return (
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
{dateTimePicker}
</LocalizationProvider>
);
};
export default DateTimeControl;

View File

@ -0,0 +1,12 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { DateTimeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const DatePreview: FC<WidgetPreviewProps<string, DateTimeField>> = ({ value }) => {
return <WidgetPreviewContainer>{value ? value.toString() : null}</WidgetPreviewContainer>;
};
export default DatePreview;

View File

@ -0,0 +1,60 @@
import formatDate from 'date-fns/format';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import controlComponent, { localToUTC } from './DateTimeControl';
import previewComponent from './DateTimePreview';
import schema from './schema';
import type { DateTimeField, WidgetParam } from '@staticcms/core/interface';
const DateTimeWidget = (): WidgetParam<string, DateTimeField> => {
return {
name: 'datetime',
controlComponent,
previewComponent,
options: {
schema,
getDefaultValue: (defaultValue: string | null | undefined, field: DateTimeField) => {
if (isNotNullish(defaultValue)) {
return defaultValue;
}
const timezoneOffset = new Date().getTimezoneOffset() * 60000;
const format = field.format;
// dateFormat and timeFormat are strictly for modifying input field with the date/time pickers
const dateFormat: string | boolean = field.date_format ?? true;
// show time-picker? false hides it, true shows it using default format
const timeFormat: string | boolean = field.time_format ?? true;
let inputFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
if (typeof dateFormat === 'string' || typeof timeFormat === 'string') {
const formatParts: string[] = [];
if (typeof dateFormat === 'string' && isNotEmpty(dateFormat)) {
formatParts.push(dateFormat);
}
if (typeof timeFormat === 'string' && isNotEmpty(timeFormat)) {
formatParts.push(timeFormat);
}
if (formatParts.length > 0) {
inputFormat = formatParts.join(' ');
}
}
const today = field.picker_utc ? localToUTC(new Date(), timezoneOffset) : new Date();
return format ? formatDate(today, format) : formatDate(today, inputFormat);
},
},
};
};
export {
controlComponent as DateTimeControl,
previewComponent as DateTimePreview,
schema as DateTimeSchema,
};
export default DateTimeWidget;

View File

@ -0,0 +1,9 @@
export default {
properties: {
format: { type: 'string' },
date_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
time_format: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
picker_utc: { type: 'boolean' },
default: { type: 'string' },
},
};

View File

@ -0,0 +1,62 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import type {
Collection,
Entry,
FileOrImageField,
WidgetPreviewProps,
} from '@staticcms/core/interface';
import type { FC } from 'react';
interface FileLinkProps {
value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
}
const FileLink: FC<FileLinkProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return (
<a href={assetSource} rel="noopener noreferrer" target="_blank">
{value}
</a>
);
};
const FileContent: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = ({
value,
collection,
field,
entry,
}) => {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return (
<div>
{value.map(link => (
<FileLink key={link} value={link} collection={collection} field={field} entry={entry} />
))}
</div>
);
}
return <FileLink key={value} value={value} collection={collection} field={field} entry={entry} />;
};
const FilePreview: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = props => {
return (
<WidgetPreviewContainer>
{props.value ? <FileContent {...props} /> : null}
</WidgetPreviewContainer>
);
};
export default FilePreview;

View File

@ -0,0 +1,30 @@
import previewComponent from './FilePreview';
import schema from './schema';
import withFileControl, { getValidFileValue } from './withFileControl';
import type { WithFileControlProps } from './withFileControl';
import type { FileOrImageField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withFileControl();
const FileWidget = (): WidgetParam<string | string[], FileOrImageField> => {
return {
name: 'file',
controlComponent,
previewComponent,
options: {
schema,
getValidValue: getValidFileValue,
},
};
};
export type { WithFileControlProps };
export {
withFileControl as withFileControl,
previewComponent as FilePreview,
schema as FileSchema,
getValidFileValue,
};
export default FileWidget;

View File

@ -0,0 +1,16 @@
export default {
properties: {
allow_multiple: { type: 'boolean' },
default: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
},
},
],
},
},
};

View File

@ -0,0 +1,493 @@
import CloseIcon from '@mui/icons-material/Close';
import PhotoIcon from '@mui/icons-material/Photo';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import { styled } from '@mui/material/styles';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { borders, effects, lengths, shadows } from '@staticcms/core/components/UI/styles';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { basename, transientOptions } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type {
Collection,
Entry,
FileOrImageField,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent, MouseEventHandler } from 'react';
const MAX_DISPLAY_LENGTH = 50;
const StyledFileControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledFileControlContentProps {
$collapsed: boolean;
}
const StyledFileControlContent = styled(
'div',
transientOptions,
)<StyledFileControlContentProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
const StyledSelection = styled('div')`
display: flex;
flex-direction: column;
`;
const StyledButtonWrapper = styled('div')`
display: flex;
gap: 16px;
`;
interface ImageWrapperProps {
$sortable?: boolean;
}
const ImageWrapper = styled(
'div',
transientOptions,
)<ImageWrapperProps>(
({ $sortable }) => `
flex-basis: 155px;
width: 155px;
height: 100px;
margin-right: 20px;
margin-bottom: 20px;
border: ${borders.textField};
border-radius: ${lengths.borderRadius};
overflow: hidden;
${effects.checkerboard};
${shadows.inset};
cursor: ${$sortable ? 'pointer' : 'auto'};
`,
);
const SortableImageButtonsWrapper = styled('div')`
display: flex;
justify-content: center;
column-gap: 10px;
margin-right: 20px;
margin-top: -10px;
margin-bottom: 10px;
`;
const StyledImage = styled('img')`
width: 100%;
height: 100%;
object-fit: contain;
`;
interface ImageProps {
value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
}
const Image: FC<ImageProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage key="image" role="presentation" src={assetSource} />;
};
interface SortableImageButtonsProps {
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImageButtons: FC<SortableImageButtonsProps> = ({ onRemove, onReplace }) => {
return (
<SortableImageButtonsWrapper key="image-buttons-wrapper">
<IconButton key="image-replace" onClick={onReplace}>
<PhotoIcon key="image-replace-icon" />
</IconButton>
<IconButton key="image-remove" onClick={onRemove}>
<CloseIcon key="image-remove-icon" />
</IconButton>
</SortableImageButtonsWrapper>
);
};
interface SortableImageProps {
itemValue: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
onRemove: MouseEventHandler;
onReplace: MouseEventHandler;
}
const SortableImage: FC<SortableImageProps> = ({
itemValue,
collection,
field,
entry,
onRemove,
onReplace,
}: SortableImageProps) => {
return (
<div>
<ImageWrapper key="image-wrapper" $sortable>
<Image key="image" value={itemValue} collection={collection} field={field} entry={entry} />
</ImageWrapper>
<SortableImageButtons
key="image-buttons"
onRemove={onRemove}
onReplace={onReplace}
></SortableImageButtons>
</div>
);
};
const StyledMultiImageWrapper = styled('div')`
display: flex;
flex-wrap: wrap;
`;
const FileLink = styled('a')`
margin-bottom: 20px;
font-weight: normal;
color: inherit;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
`;
const FileLinks = styled('div')`
margin-bottom: 12px;
`;
const FileLinkList = styled('ul')`
list-style-type: none;
`;
function isMultiple(value: string | string[] | null | undefined): value is string[] {
return Array.isArray(value);
}
export function getValidFileValue(value: string | string[] | null | undefined) {
if (value) {
return isMultiple(value) ? value.map(v => basename(v)) : basename(value);
}
return value;
}
export interface WithFileControlProps {
forImage?: boolean;
}
const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const FileControl: FC<WidgetControlProps<string | string[], FileOrImageField>> = memo(
({
value,
collection,
field,
entry,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
hasErrors,
t,
}) => {
const controlID = useUUID();
const [collapsed, setCollapsed] = useState(false);
const [internalValue, setInternalValue] = useState(value ?? '');
const handleOnChange = useCallback(
(newValue: string | string[]) => {
if (newValue !== internalValue) {
setInternalValue(newValue);
setTimeout(() => {
onChange(newValue);
});
}
},
[internalValue, onChange],
);
const handleOpenMediaLibrary = useMediaInsert(
internalValue,
{ field, controlID },
handleOnChange,
);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
useEffect(() => {
return () => {
removeMediaControl(controlID);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mediaLibraryFieldOptions = useMemo(() => {
return field.media_library ?? {};
}, [field.media_library]);
const config = useMemo(
() => ('config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined),
[mediaLibraryFieldOptions],
);
const allowsMultiple = useMemo(() => {
return config?.multiple ?? false;
}, [config?.multiple]);
const chooseUrl = useMemo(
() =>
'choose_url' in mediaLibraryFieldOptions && (mediaLibraryFieldOptions.choose_url ?? true),
[mediaLibraryFieldOptions],
);
const handleUrl = useCallback(
(subject: 'image' | 'file') => (e: MouseEvent) => {
e.preventDefault();
const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`));
handleOnChange(url ?? '');
},
[handleOnChange, t],
);
const handleRemove = useCallback(
(e: MouseEvent) => {
e.preventDefault();
clearMediaControl(controlID);
handleOnChange('');
},
[clearMediaControl, controlID, handleOnChange],
);
const onRemoveOne = useCallback(
(index: number) => () => {
if (Array.isArray(internalValue)) {
const newValue = [...internalValue];
newValue.splice(index, 1);
handleOnChange(newValue);
}
},
[handleOnChange, internalValue],
);
const onReplaceOne = useCallback(
(index: number) => () => {
return openMediaLibrary({
controlID,
forImage,
value: internalValue,
replaceIndex: index,
allowMultiple: false,
config,
field,
});
},
[config, controlID, field, openMediaLibrary, internalValue],
);
// TODO Readd when multiple uploads is supported
// const onSortEnd = useCallback(
// ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
// if (Array.isArray(internalValue)) {
// const newValue = arrayMoveImmutable(internalValue, oldIndex, newIndex);
// handleOnChange(newValue);
// }
// },
// [handleOnChange, internalValue],
// );
const renderFileLink = useCallback((link: string | undefined | null) => {
const size = MAX_DISPLAY_LENGTH;
if (!link || link.length <= size) {
return link;
}
const text = `${link.slice(0, size / 2)}\u2026${link.slice(-(size / 2) + 1)}`;
return (
<FileLink key={`file-link-${text}`} href={link} rel="noopener" target="_blank">
{text}
</FileLink>
);
}, []);
const renderedImagesLinks = useMemo(() => {
if (forImage) {
if (!internalValue) {
return null;
}
if (isMultiple(internalValue)) {
return (
<StyledMultiImageWrapper key="multi-image-wrapper">
{internalValue.map((itemValue, index) => (
<SortableImage
key={`item-${itemValue}`}
itemValue={itemValue}
collection={collection}
field={field}
entry={entry}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
</StyledMultiImageWrapper>
);
}
return (
<ImageWrapper key="single-image-wrapper">
<Image
key="single-image"
value={internalValue}
collection={collection}
field={field}
entry={entry}
/>
</ImageWrapper>
);
}
if (isMultiple(internalValue)) {
return (
<FileLinks key="mulitple-file-links">
<FileLinkList key="file-links-list">
{internalValue.map(val => (
<li key={val}>{renderFileLink(val)}</li>
))}
</FileLinkList>
</FileLinks>
);
}
return <FileLinks key="single-file-links">{renderFileLink(internalValue)}</FileLinks>;
}, [collection, entry, field, internalValue, onRemoveOne, onReplaceOne, renderFileLink]);
const content = useMemo(() => {
const subject = forImage ? 'image' : 'file';
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return (
<StyledButtonWrapper>
<Button
color="primary"
variant="outlined"
key="upload"
onClick={handleOpenMediaLibrary}
>
{t(`editor.editorWidgets.${subject}.choose${allowsMultiple ? 'Multiple' : ''}`)}
</Button>
{chooseUrl ? (
<Button
color="primary"
variant="outlined"
key="choose-url"
onClick={handleUrl(subject)}
>
{t(`editor.editorWidgets.${subject}.chooseUrl`)}
</Button>
) : null}
</StyledButtonWrapper>
);
}
return (
<StyledSelection key="selection">
{renderedImagesLinks}
<StyledButtonWrapper key="controls">
<Button
color="primary"
variant="outlined"
key="add-replace"
onClick={handleOpenMediaLibrary}
>
{t(
`editor.editorWidgets.${subject}.${
allowsMultiple ? 'addMore' : 'chooseDifferent'
}`,
)}
</Button>
{chooseUrl && !allowsMultiple ? (
<Button
color="primary"
variant="outlined"
key="replace-url"
onClick={handleUrl(subject)}
>
{t(`editor.editorWidgets.${subject}.replaceUrl`)}
</Button>
) : null}
<Button color="error" variant="outlined" key="remove" onClick={handleRemove}>
{t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)}
</Button>
</StyledButtonWrapper>
</StyledSelection>
);
}, [
internalValue,
renderedImagesLinks,
handleOpenMediaLibrary,
t,
allowsMultiple,
chooseUrl,
handleUrl,
handleRemove,
]);
return useMemo(
() => (
<StyledFileControlWrapper key="file-control-wrapper">
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={field.label ?? field.name}
hasError={hasErrors}
t={t}
/>
<StyledFileControlContent $collapsed={collapsed}>{content}</StyledFileControlContent>
<Outline hasError={hasErrors} />
</StyledFileControlWrapper>
),
[collapsed, content, field.label, field.name, handleCollapseToggle, hasErrors, t],
);
},
);
FileControl.displayName = 'FileControl';
return FileControl;
};
export default withFileControl;

View File

@ -0,0 +1,71 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import type {
Collection,
Entry,
FileOrImageField,
WidgetPreviewProps,
} from '@staticcms/core/interface';
import type { FC } from 'react';
interface StyledImageProps {
src: string;
}
const StyledImage = styled(({ src }: StyledImageProps) => (
<img src={src || ''} role="presentation" />
))`
display: block;
max-width: 100%;
height: auto;
`;
interface ImageAssetProps {
value: string;
collection: Collection<FileOrImageField>;
field: FileOrImageField;
entry: Entry;
}
const ImageAsset: FC<ImageAssetProps> = ({ value, collection, field, entry }) => {
const assetSource = useMediaAsset(value, collection, field, entry);
return <StyledImage src={assetSource} />;
};
const ImagePreviewContent: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = ({
value,
collection,
field,
entry,
}) => {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return (
<>
{value.map(val => (
<ImageAsset key={val} value={val} collection={collection} field={field} entry={entry} />
))}
</>
);
}
return <ImageAsset value={value} collection={collection} field={field} entry={entry} />;
};
const ImagePreview: FC<WidgetPreviewProps<string | string[], FileOrImageField>> = props => {
return (
<WidgetPreviewContainer>
{props.value ? <ImagePreviewContent {...props} /> : null}
</WidgetPreviewContainer>
);
};
export default ImagePreview;

View File

@ -0,0 +1,23 @@
import withFileControl, { getValidFileValue } from '../file/withFileControl';
import previewComponent from './ImagePreview';
import schema from './schema';
import type { FileOrImageField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withFileControl({ forImage: true });
function ImageWidget(): WidgetParam<string | string[], FileOrImageField> {
return {
name: 'image',
controlComponent,
previewComponent,
options: {
schema,
getValidValue: getValidFileValue,
},
};
}
export { previewComponent as ImagePreview, schema as ImageSchema };
export default ImageWidget;

View File

@ -0,0 +1,16 @@
export default {
properties: {
allow_multiple: { type: 'boolean' },
default: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
},
},
],
},
},
};

View File

@ -0,0 +1,32 @@
export * from './boolean';
export { default as BooleanWidget } from './boolean';
export * from './code';
export { default as CodeWidget } from './code';
export * from './colorstring';
export { default as ColorStringWidget } from './colorstring';
export * from './datetime';
export { default as DateTimeWidget } from './datetime';
export * from './file';
export { default as FileWidget } from './file';
export * from './image';
export { default as ImageWidget } from './image';
export * from './list';
export { default as ListWidget } from './list';
export * from './map';
export { default as MapWidget } from './map';
export * from './markdown';
export { default as MarkdownWidget } from './markdown';
export * from './mdx';
export { default as MdxWidget } from './mdx';
export * from './number';
export { default as NumberWidget } from './number';
export * from './object';
export { default as ObjectWidget } from './object';
export * from './relation';
export { default as RelationWidget } from './relation';
export * from './select';
export { default as SelectWidget } from './select';
export * from './string';
export { default as StringWidget } from './string';
export * from './text';
export { default as TextWidget } from './text';

View File

@ -0,0 +1,351 @@
import { DndContext } from '@dnd-kit/core';
import { SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { styled } from '@mui/material/styles';
import { arrayMoveImmutable } from 'array-move';
import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useMemo, useState } from 'react';
import { v4 as uuid } from 'uuid';
import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import ListItem from './ListItem';
import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers';
import type { DragEndEvent } from '@dnd-kit/core';
import type {
Entry,
Field,
FieldsErrors,
I18nSettings,
ListField,
ObjectValue,
UnknownField,
ValueOrNestedValue,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const StyledListWrapper = styled('div')`
position: relative;
width: 100%;
`;
interface StyledSortableListProps {
$collapsed: boolean;
}
const StyledSortableList = styled(
'div',
transientOptions,
)<StyledSortableListProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
${
$collapsed
? `
display: none;
`
: `
padding: 16px;
`
}
`,
);
interface SortableItemProps {
id: string;
item: ObjectValue;
index: number;
valueType: ListValueType;
handleRemove: (index: number, event: MouseEvent) => void;
entry: Entry<ObjectValue>;
field: ListField;
fieldsErrors: FieldsErrors;
submitted: boolean;
isFieldDuplicate: ((field: Field<UnknownField>) => boolean) | undefined;
isFieldHidden: ((field: Field<UnknownField>) => boolean) | undefined;
locale: string | undefined;
path: string;
value: Record<string, ObjectValue>;
i18n: I18nSettings | undefined;
}
const SortableItem: FC<SortableItemProps> = ({
id,
item,
index,
valueType,
handleRemove,
entry,
field,
fieldsErrors,
submitted,
isFieldDuplicate,
isFieldHidden,
locale,
path,
i18n,
}) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
if (valueType === null) {
return <div key={id} />;
}
return (
<div ref={setNodeRef} style={style} {...attributes}>
<ListItem
index={index}
id={id}
key={`sortable-item-${id}`}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
listeners={listeners}
/>
</div>
);
};
export enum ListValueType {
MULTIPLE,
MIXED,
}
function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): ObjectValue {
return fields.reduce((acc, item) => {
const subfields = 'fields' in item && item.fields;
const name = item.name;
const defaultValue: ValueOrNestedValue | null =
'default' in item && item.default ? item.default : null;
if (Array.isArray(subfields)) {
const subDefaultValue = getFieldsDefault(subfields);
if (!isEmpty(subDefaultValue)) {
acc[name] = subDefaultValue;
}
return acc;
} else if (typeof subfields === 'object') {
const subDefaultValue = getFieldsDefault([subfields]);
!isEmpty(subDefaultValue) && (acc[name] = subDefaultValue);
return acc;
}
if (defaultValue !== null) {
acc[name] = defaultValue;
}
return acc;
}, initialValue);
}
const ListControl: FC<WidgetControlProps<ObjectValue[], ListField>> = ({
entry,
field,
fieldsErrors,
submitted,
isFieldDuplicate,
isFieldHidden,
locale,
onChange,
path,
t,
value,
i18n,
hasErrors,
}) => {
const internalValue = useMemo(() => value ?? [], [value]);
const [collapsed, setCollapsed] = useState(field.collapsed ?? true);
const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid()));
const valueType = useMemo(() => {
if ('fields' in field) {
return ListValueType.MULTIPLE;
} else if ('types' in field) {
return ListValueType.MIXED;
} else {
return null;
}
}, [field]);
const multipleDefault = useCallback((fields: Field[]) => {
return getFieldsDefault(fields);
}, []);
const mixedDefault = useCallback(
(typeKey: string, type: string): ObjectValue => {
const selectedType = 'types' in field && field.types?.find(f => f.name === type);
if (!selectedType) {
return {};
}
return getFieldsDefault(selectedType.fields ?? [], { [typeKey]: type });
},
[field],
);
const addItem = useCallback(
(parsedValue: ObjectValue) => {
const addToTop = field.add_to_top ?? false;
const newKeys = [...keys];
const newValue = [...internalValue];
if (addToTop) {
newKeys.unshift(uuid());
newValue.unshift(parsedValue);
} else {
newKeys.push(uuid());
newValue.push(parsedValue);
}
setKeys(newKeys);
onChange(newValue);
setCollapsed(false);
},
[field.add_to_top, onChange, internalValue, keys],
);
const handleAdd = useCallback(
(e: MouseEvent) => {
e.preventDefault();
addItem('fields' in field && field.fields ? multipleDefault(field.fields) : {});
},
[addItem, field, multipleDefault],
);
const handleAddType = useCallback(
(type: string, typeKey: string) => {
const parsedValue = mixedDefault(typeKey, type);
addItem(parsedValue);
},
[addItem, mixedDefault],
);
const handleRemove = useCallback(
(index: number, event: MouseEvent) => {
event.preventDefault();
const newKeys = [...keys];
const newValue = [...internalValue];
newKeys.splice(index, 1);
newValue.splice(index, 1);
setKeys(newKeys);
onChange(newValue);
},
[onChange, internalValue, keys],
);
const handleCollapseAllToggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
setCollapsed(!collapsed);
},
[collapsed],
);
const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return;
}
const oldIndex = keys.indexOf(active.id as string);
const newIndex = keys.indexOf(over.id as string);
// Update value
setKeys(arrayMoveImmutable(keys, oldIndex, newIndex));
onChange(arrayMoveImmutable(internalValue, oldIndex, newIndex));
},
[onChange, internalValue, keys],
);
if (valueType === null) {
return null;
}
const label = field.label ?? field.name;
const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name;
const listLabel = internalValue.length === 1 ? labelSingular.toLowerCase() : label.toLowerCase();
return (
<StyledListWrapper key="list-widget">
<FieldLabel key="label">{label}</FieldLabel>
<ObjectWidgetTopBar
key="header"
allowAdd={field.allow_add ?? true}
onAdd={handleAdd}
types={field[TYPES_KEY] ?? []}
onAddType={type => handleAddType(type, resolveFieldKeyType(field))}
heading={`${internalValue.length} ${listLabel}`}
label={labelSingular.toLowerCase()}
onCollapseToggle={handleCollapseAllToggle}
collapsed={collapsed}
hasError={hasErrors}
t={t}
/>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<StyledSortableList $collapsed={collapsed}>
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
data-testid={`object-control-${index}`}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</StyledSortableList>
</SortableContext>
</DndContext>
) : null}
<Outline key="outline" hasLabel hasError={hasErrors} />
</StyledListWrapper>
);
};
export default ListControl;

View File

@ -0,0 +1,225 @@
import { styled } from '@mui/material/styles';
import partial from 'lodash/partial';
import React, { useCallback, useMemo, useState } from 'react';
import EditorControl from '@staticcms/core/components/Editor/EditorControlPane/EditorControl';
import ListItemTopBar from '@staticcms/core/components/UI/ListItemTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import { colors } from '@staticcms/core/components/UI/styles';
import { transientOptions } from '@staticcms/core/lib';
import {
addFileTemplateFields,
compileStringTemplate,
} from '@staticcms/core/lib/widgets/stringTemplate';
import { ListValueType } from './ListControl';
import { getTypedFieldForValue } from './typedListHelpers';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type {
Entry,
EntryData,
ListField,
ObjectField,
ObjectValue,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const StyledListItem = styled('div')`
position: relative;
`;
const StyledListItemTopBar = styled(ListItemTopBar)`
background-color: ${colors.textFieldBorder};
`;
interface StyledObjectFieldWrapperProps {
$collapsed: boolean;
}
const StyledObjectFieldWrapper = styled(
'div',
transientOptions,
)<StyledObjectFieldWrapperProps>(
({ $collapsed }) => `
display: flex;
flex-direction: column;
gap: 16px;
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
function handleSummary(summary: string, entry: Entry, label: string, item: ObjectValue) {
const labeledItem: EntryData = {
...item,
fields: {
label,
},
};
const data = addFileTemplateFields(entry.path, labeledItem);
return compileStringTemplate(summary, null, '', data);
}
function validateItem(field: ListField, item: ObjectValue) {
if (!(typeof item === 'object')) {
console.warn(
`'${field.name}' field item value value should be an object but is a '${typeof item}'`,
);
return false;
}
return true;
}
interface ListItemProps
extends Pick<
WidgetControlProps<ObjectValue, ListField>,
| 'entry'
| 'field'
| 'fieldsErrors'
| 'submitted'
| 'isFieldDuplicate'
| 'isFieldHidden'
| 'locale'
| 'path'
| 'value'
| 'i18n'
> {
valueType: ListValueType;
index: number;
id: string;
listeners: SyntheticListenerMap | undefined;
handleRemove: (index: number, event: MouseEvent) => void;
}
const ListItem: FC<ListItemProps> = ({
id,
index,
entry,
field,
fieldsErrors,
submitted,
isFieldDuplicate,
isFieldHidden,
locale,
path,
valueType,
handleRemove,
value,
i18n,
listeners,
}) => {
const [objectLabel, objectField] = useMemo((): [string, ListField | ObjectField] => {
const childObjectField: ObjectField = {
name: `${index}`,
label: field.label,
summary: field.summary,
widget: 'object',
fields: [],
};
const base = field.label ?? field.name;
if (valueType === null) {
return [base, childObjectField];
}
const objectValue = value ?? {};
switch (valueType) {
case ListValueType.MIXED: {
if (!validateItem(field, objectValue)) {
return [base, childObjectField];
}
const itemType = getTypedFieldForValue(field, objectValue, index);
if (!itemType) {
return [base, childObjectField];
}
const label = itemType.label ?? itemType.name;
// each type can have its own summary, but default to the list summary if exists
const summary = ('summary' in itemType && itemType.summary) ?? field.summary;
const labelReturn = summary
? `${label} - ${handleSummary(summary, entry, label, objectValue)}`
: label;
return [labelReturn, itemType];
}
case ListValueType.MULTIPLE: {
childObjectField.fields = field.fields ?? [];
if (!validateItem(field, objectValue)) {
return [base, childObjectField];
}
const multiFields = field.fields;
const labelField = multiFields && multiFields[0];
if (!labelField) {
return [base, childObjectField];
}
const labelFieldValue = objectValue[labelField.name];
const summary = field.summary;
const labelReturn = summary
? handleSummary(summary, entry, String(labelFieldValue), objectValue)
: labelFieldValue;
return [(labelReturn || `No ${labelField.name}`).toString(), childObjectField];
}
}
}, [entry, field, index, value, valueType]);
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
setCollapsed(!collapsed);
},
[collapsed],
);
const isDuplicate = isFieldDuplicate && isFieldDuplicate(field);
const isHidden = isFieldHidden && isFieldHidden(field);
return (
<StyledListItem key="sortable-list-item">
<>
<StyledListItemTopBar
key="list-item-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
onRemove={partial(handleRemove, index)}
data-testid={`styled-list-item-top-bar-${id}`}
title={objectLabel}
isVariableTypesList={valueType === ListValueType.MIXED}
listeners={listeners}
/>
<StyledObjectFieldWrapper $collapsed={collapsed}>
<EditorControl
key={`control-${id}`}
field={objectField}
value={value}
fieldsErrors={fieldsErrors}
submitted={submitted}
parentPath={path}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
locale={locale}
i18n={i18n}
forList
/>
</StyledObjectFieldWrapper>
<Outline key="outline" />
</>
</StyledListItem>
);
};
export default ListItem;

View File

@ -0,0 +1,12 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { ListField, ObjectValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const ObjectPreview: FC<WidgetPreviewProps<ObjectValue[], ListField>> = ({ field }) => {
return <WidgetPreviewContainer>{field.fields ?? null}</WidgetPreviewContainer>;
};
export default ObjectPreview;

View File

@ -0,0 +1,22 @@
import controlComponent from './ListControl';
import previewComponent from './ListPreview';
import schema from './schema';
import type { ListField, ObjectValue, WidgetParam } from '@staticcms/core/interface';
const ListWidget = (): WidgetParam<ObjectValue[], ListField> => {
return {
name: 'list',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { default as ListItem } from './ListItem';
export * from './typedListHelpers';
export { controlComponent as ListControl, previewComponent as ListPreview, schema as ListSchema };
export default ListWidget;

View File

@ -0,0 +1,11 @@
export default {
properties: {
allow_add: { type: 'boolean' },
collapsed: { type: 'boolean' },
summary: { type: 'string' },
label_singular: { type: 'string' },
i18n: { type: 'boolean' },
min: { type: 'number' },
max: { type: 'number' },
},
};

View File

@ -0,0 +1,52 @@
import type { ListField, ObjectField, ObjectValue } from '@staticcms/core/interface';
export const TYPES_KEY = 'types';
export const TYPE_KEY = 'type_key';
export const DEFAULT_TYPE_KEY = 'type';
export function getTypedFieldForValue(
field: ListField,
value: ObjectValue | undefined | null,
index: number,
): ObjectField | undefined {
const typeKey = resolveFieldKeyType(field);
const types = field[TYPES_KEY] ?? [];
const valueType = value?.[typeKey] ?? {};
const typeField = types.find(type => type.name === valueType);
if (!typeField) {
return typeField;
}
return {
...typeField,
name: `${index}`,
};
}
export function resolveFunctionForTypedField(field: ListField) {
const typeKey = resolveFieldKeyType(field);
const types = field[TYPES_KEY] ?? [];
return (value: ObjectValue) => {
const valueType = value[typeKey];
return types.find(type => type.name === valueType);
};
}
export function resolveFieldKeyType(field: ListField) {
return (TYPE_KEY in field && field[TYPE_KEY]) || DEFAULT_TYPE_KEY;
}
export function getErrorMessageForTypedFieldAndValue(
field: ListField,
value: ObjectValue | undefined | null,
) {
const keyType = resolveFieldKeyType(field);
const type = value?.[keyType] ?? {};
let errorMessage;
if (!type) {
errorMessage = `Error: item has no '${keyType}' property`;
} else {
errorMessage = `Error: item has illegal '${keyType}' property: '${type}'`;
}
return errorMessage;
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import type { MapField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const MapPreview: FC<WidgetPreviewProps<string, MapField>> = ({ value }) => {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
};
export default MapPreview;

View File

@ -0,0 +1,22 @@
import previewComponent from './MapPreview';
import schema from './schema';
import withMapControl from './withMapControl';
import type { MapField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withMapControl();
const MapWidget = (): WidgetParam<string, MapField> => {
return {
name: 'map',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { previewComponent as MapPreview, schema as MapSchema, withMapControl };
export default MapWidget;

View File

@ -0,0 +1,6 @@
export default {
properties: {
decimals: { type: 'integer' },
type: { type: 'string', enum: ['Point', 'LineString', 'Polygon'] },
},
};

View File

@ -0,0 +1,153 @@
import { css, styled } from '@mui/material/styles';
import GeoJSON from 'ol/format/GeoJSON';
import Draw from 'ol/interaction/Draw';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import Map from 'ol/Map.js';
import olStyles from 'ol/ol.css';
import OSMSource from 'ol/source/OSM';
import VectorSource from 'ol/source/Vector';
import View from 'ol/View.js';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import ObjectWidgetTopBar from '@staticcms/core/components/UI/ObjectWidgetTopBar';
import Outline from '@staticcms/core/components/UI/Outline';
import transientOptions from '@staticcms/core/lib/util/transientOptions';
import type { MapField, WidgetControlProps } from '@staticcms/core/interface';
import type { Geometry } from 'ol/geom';
import type { FC } from 'react';
const StyledMapControlWrapper = styled('div')`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
`;
interface StyledMapControlContentProps {
$collapsed: boolean;
$height: string;
}
const StyledMapControlContent = styled(
'div',
transientOptions,
)<StyledMapControlContentProps>(
({ $collapsed, $height }) => `
display: flex;
postion: relative;
height: ${$height}
${
$collapsed
? `
display: none;
`
: ''
}
`,
);
const StyledMap = styled('div')`
width: 100%;
position: relative;
${css`
${olStyles}
`}
`;
const formatOptions = {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
};
function getDefaultFormat() {
return new GeoJSON(formatOptions);
}
function getDefaultMap(target: HTMLDivElement, featuresLayer: VectorLayer<VectorSource<Geometry>>) {
return new Map({
target,
layers: [new TileLayer({ source: new OSMSource() }), featuresLayer],
view: new View({ center: [0, 0], zoom: 2 }),
});
}
interface WithMapControlProps {
getFormat?: (field: MapField) => GeoJSON;
getMap?: (target: HTMLDivElement, featuresLayer: VectorLayer<VectorSource<Geometry>>) => Map;
}
const withMapControl = ({ getFormat, getMap }: WithMapControlProps = {}) => {
const MapControl: FC<WidgetControlProps<string, MapField>> = ({
path,
value,
field,
onChange,
hasErrors,
label,
t,
}) => {
const [collapsed, setCollapsed] = useState(false);
const handleCollapseToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const { height = '400px' } = field;
const mapContainer = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const format = getFormat ? getFormat(field) : getDefaultFormat();
const features = value ? [format.readFeature(value)] : [];
const featuresSource = new VectorSource({ features, wrapX: false });
const featuresLayer = new VectorLayer({ source: featuresSource });
const target = mapContainer.current;
if (!target) {
return;
}
const map = getMap ? getMap(target, featuresLayer) : getDefaultMap(target, featuresLayer);
if (features.length > 0) {
map.getView().fit(featuresSource.getExtent(), { maxZoom: 16, padding: [80, 80, 80, 80] });
}
const draw = new Draw({ source: featuresSource, type: field.type ?? 'Point' });
map.addInteraction(draw);
const writeOptions = { decimals: field.decimals ?? 7 };
draw.on('drawend', ({ feature }) => {
featuresSource.clear();
const geometry = feature.getGeometry();
if (geometry) {
onChange(format.writeGeometry(geometry, writeOptions));
}
});
}, [field, mapContainer, onChange, path, value]);
return (
<StyledMapControlWrapper>
<ObjectWidgetTopBar
key="file-control-top-bar"
collapsed={collapsed}
onCollapseToggle={handleCollapseToggle}
heading={label}
hasError={hasErrors}
t={t}
/>
<StyledMapControlContent $collapsed={collapsed} $height={height}>
<StyledMap ref={mapContainer} />
</StyledMapControlContent>
<Outline hasError={hasErrors} />
</StyledMapControlWrapper>
);
};
MapControl.displayName = 'MapControl';
return MapControl;
};
export default withMapControl;

View File

@ -0,0 +1,79 @@
import { MDXProvider } from '@mdx-js/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { VFileMessage } from 'vfile-message';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx';
import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
interface FallbackComponentProps {
error: string;
}
function FallbackComponent({ error }: FallbackComponentProps) {
const message = new VFileMessage(error);
message.fatal = true;
return (
<pre>
<code>{String(message)}</code>
</pre>
);
}
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
const { value } = previewProps;
const components = useMemo(
() => ({
Shortcode: withShortcodeMdxComponent({ previewProps }),
}),
[previewProps],
);
const [state, setValue] = useMdx(value ?? '');
const [prevValue, setPrevValue] = useState('');
useEffect(() => {
if (prevValue !== value) {
const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? '');
setPrevValue(parsedValue);
setValue(parsedValue);
}
}, [prevValue, setValue, value]);
// Create a preview component that can handle errors with try-catch block; for catching invalid JS expressions errors that ErrorBoundary cannot catch.
const MdxComponent = useCallback(() => {
if (!state.file) {
return null;
}
try {
return (state.file.result as FC)({});
} catch (error) {
return <FallbackComponent error={String(error)} />;
}
}, [state.file]);
return useMemo(() => {
if (!value) {
return null;
}
return (
<WidgetPreviewContainer>
{state.file && state.file.result ? (
<MDXProvider components={components}>
<MdxComponent />
</MDXProvider>
) : null}
</WidgetPreviewContainer>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MdxComponent]);
};
export default MarkdownPreview;

View File

@ -0,0 +1,27 @@
import withMarkdownControl from './withMarkdownControl';
import previewComponent from './MarkdownPreview';
import schema from './schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withMarkdownControl({ useMdx: false });
const MarkdownWidget = (): WidgetParam<string, MarkdownField> => {
return {
name: 'markdown',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export * from './plate';
export {
controlComponent as MarkdownControl,
previewComponent as MarkdownPreview,
schema as MarkdownSchema,
};
export default MarkdownWidget;

View File

@ -0,0 +1,2 @@
export * from './withShortcodeMdxComponent';
export { default as withShortcodeElement } from './withShortcodeMdxComponent';

View File

@ -0,0 +1,36 @@
import React, { useMemo } from 'react';
import { getShortcode } from '../../../lib/registry';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface WithShortcodeMdxComponentProps {
previewProps: WidgetPreviewProps<string, MarkdownField>;
}
interface ShortcodeMdxComponentProps {
shortcode: string;
args: string[];
}
const withShortcodeMdxComponent = ({ previewProps }: WithShortcodeMdxComponentProps) => {
const ShortcodeMdxComponent: FC<ShortcodeMdxComponentProps> = ({ shortcode, args }) => {
const config = useMemo(() => getShortcode(shortcode), [shortcode]);
const [ShortcodePreview, props] = useMemo(() => {
if (!config) {
return [null, {}];
}
const props = config.toProps ? config.toProps(args) : {};
return [config.preview, props];
}, [config, args]);
return ShortcodePreview ? <ShortcodePreview previewProps={previewProps} {...props} /> : null;
};
return ShortcodeMdxComponent;
};
export default withShortcodeMdxComponent;

View File

@ -0,0 +1,327 @@
import { styled } from '@mui/material/styles';
import {
createAlignPlugin,
createAutoformatPlugin,
createBlockquotePlugin,
createBoldPlugin,
createCodePlugin,
createExitBreakPlugin,
createFontBackgroundColorPlugin,
createFontColorPlugin,
createHeadingPlugin,
createImagePlugin,
createItalicPlugin,
createLinkPlugin,
createParagraphPlugin,
createResetNodePlugin,
createSoftBreakPlugin,
createStrikethroughPlugin,
createSubscriptPlugin,
createSuperscriptPlugin,
createTodoListPlugin,
createTrailingBlockPlugin,
createUnderlinePlugin,
ELEMENT_BLOCKQUOTE,
ELEMENT_CODE_BLOCK,
ELEMENT_H1,
ELEMENT_H2,
ELEMENT_H3,
ELEMENT_H4,
ELEMENT_H5,
ELEMENT_H6,
ELEMENT_IMAGE,
ELEMENT_LI,
ELEMENT_LIC,
ELEMENT_LINK,
ELEMENT_OL,
ELEMENT_PARAGRAPH,
ELEMENT_TABLE,
ELEMENT_TD,
ELEMENT_TH,
ELEMENT_TR,
ELEMENT_UL,
MARK_BOLD,
MARK_ITALIC,
MARK_STRIKETHROUGH,
MARK_SUBSCRIPT,
MARK_SUPERSCRIPT,
MARK_UNDERLINE,
Plate,
PlateProvider,
withProps,
} from '@udecode/plate';
import { StyledLeaf } from '@udecode/plate-styled-components';
import React, { useMemo, useRef } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { withShortcodeElement } from './components';
import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block';
import {
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
} from './components/nodes/headings';
import { withImageElement } from './components/nodes/image';
import { withLinkElement } from './components/nodes/link';
import {
ListItemContentElement,
ListItemElement,
OrderedListElement,
UnorderedListElement,
} from './components/nodes/list';
import ParagraphElement from './components/nodes/paragraph/ParagraphElement';
import {
TableCellElement,
TableElement,
TableHeaderCellElement,
TableRowElement,
} from './components/nodes/table';
import { Toolbar } from './components/toolbar';
import editableProps from './editableProps';
import { createMdPlugins, ELEMENT_SHORTCODE } from './plateTypes';
import { alignPlugin } from './plugins/align';
import { autoformatPlugin } from './plugins/autoformat';
import { createCodeBlockPlugin } from './plugins/code-block';
import { CursorOverlayContainer } from './plugins/cursor-overlay';
import { exitBreakPlugin } from './plugins/exit-break';
import { createListPlugin } from './plugins/list';
import { resetBlockTypePlugin } from './plugins/reset-node';
import { createShortcodePlugin } from './plugins/shortcode';
import { softBreakPlugin } from './plugins/soft-break';
import { createTablePlugin } from './plugins/table';
import { trailingBlockPlugin } from './plugins/trailing-block';
import type {
Collection,
Entry,
MarkdownField,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate';
import type { CSSProperties, FC } from 'react';
import type { MdEditor, MdValue } from './plateTypes';
const StyledPlateEditor = styled('div')`
position: relative;
padding: 1.25rem;
padding-bottom: 0;
margin-bottom: 1.25rem;
`;
const styles: Record<string, CSSProperties> = {
container: { position: 'relative' },
};
export interface PlateEditorProps {
initialValue: MdValue;
collection: Collection<MarkdownField>;
entry: Entry;
field: MarkdownField;
useMdx: boolean;
controlProps: WidgetControlProps<string, MarkdownField>;
onChange: (value: MdValue) => void;
onFocus: () => void;
onBlur: () => void;
}
const PlateEditor: FC<PlateEditorProps> = ({
initialValue,
collection,
entry,
field,
useMdx,
controlProps,
onChange,
onFocus,
onBlur,
}) => {
const outerEditorContainerRef = useRef<HTMLDivElement | null>(null);
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const innerEditorContainerRef = useRef<HTMLDivElement | null>(null);
const components = useMemo(() => {
const baseComponents = {
[ELEMENT_H1]: Heading1,
[ELEMENT_H2]: Heading2,
[ELEMENT_H3]: Heading3,
[ELEMENT_H4]: Heading4,
[ELEMENT_H5]: Heading5,
[ELEMENT_H6]: Heading6,
[ELEMENT_PARAGRAPH]: ParagraphElement,
[ELEMENT_TABLE]: TableElement,
[ELEMENT_TR]: TableRowElement,
[ELEMENT_TH]: TableHeaderCellElement,
[ELEMENT_TD]: TableCellElement,
[ELEMENT_BLOCKQUOTE]: BlockquoteElement,
[ELEMENT_CODE_BLOCK]: CodeBlockElement,
[ELEMENT_LINK]: withLinkElement({
containerRef: innerEditorContainerRef.current,
collection,
entry,
field,
}),
[ELEMENT_IMAGE]: withImageElement({
containerRef: innerEditorContainerRef.current,
collection,
entry,
field,
}),
[ELEMENT_OL]: OrderedListElement,
[ELEMENT_UL]: UnorderedListElement,
[ELEMENT_LI]: ListItemElement,
[ELEMENT_LIC]: ListItemContentElement,
[ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
[MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }),
[MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }),
[MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }),
};
if (useMdx) {
// MDX Widget
return {
...baseComponents,
[MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }),
[MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
[MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
};
}
// Markdown widget
return {
...baseComponents,
[ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
};
}, [collection, controlProps, entry, field, useMdx]);
const plugins = useMemo(() => {
const basePlugins: PlatePlugin<AnyObject, MdValue>[] = [
createParagraphPlugin(),
createBlockquotePlugin(),
createTodoListPlugin(),
createHeadingPlugin(),
createImagePlugin(),
// createHorizontalRulePlugin(),
createLinkPlugin(),
createListPlugin(),
createTablePlugin(),
// createMediaEmbedPlugin(),
createCodeBlockPlugin(),
createBoldPlugin(),
createCodePlugin(),
createItalicPlugin(),
// createHighlightPlugin(),
createStrikethroughPlugin(),
// createFontSizePlugin(),
// createKbdPlugin(),
// createNodeIdPlugin(),
// createDndPlugin({ options: { enableScroller: true } }),
// dragOverCursorPlugin,
// createIndentPlugin(indentPlugin),
createAutoformatPlugin<AutoformatPlugin<MdValue, MdEditor>, MdValue, MdEditor>(
autoformatPlugin,
),
createResetNodePlugin(resetBlockTypePlugin),
createSoftBreakPlugin(softBreakPlugin),
createExitBreakPlugin(exitBreakPlugin),
createTrailingBlockPlugin(trailingBlockPlugin),
// createSelectOnBackspacePlugin(selectOnBackspacePlugin),
// createComboboxPlugin(),
// createMentionPlugin(),
// createDeserializeMdPlugin(),
// createDeserializeCsvPlugin(),
// createDeserializeDocxPlugin(),
// createJuicePlugin() as MdPlatePlugin,
];
if (useMdx) {
// MDX Widget
return createMdPlugins(
[
...basePlugins,
createFontColorPlugin(),
createFontBackgroundColorPlugin(),
createSubscriptPlugin(),
createSuperscriptPlugin(),
createUnderlinePlugin(),
createAlignPlugin(alignPlugin),
],
{
components,
},
);
}
// Markdown Widget
return createMdPlugins([...basePlugins, createShortcodePlugin()], {
components,
});
}, [components, useMdx]);
const id = useUUID();
return useMemo(
() => (
<StyledPlateEditor>
<DndProvider backend={HTML5Backend}>
<PlateProvider<MdValue>
id={id}
key="plate-provider"
initialValue={initialValue}
plugins={plugins}
onChange={onChange}
>
<div key="editor-outer_wrapper" ref={outerEditorContainerRef} style={styles.container}>
<Toolbar
key="toolbar"
useMdx={useMdx}
containerRef={outerEditorContainerRef.current}
collection={collection}
field={field}
entry={entry}
/>
<div key="editor-wrapper" ref={editorContainerRef} style={styles.container}>
<Plate
key="editor"
id={id}
editableProps={{
...editableProps,
onFocus,
onBlur,
}}
>
<div
key="editor-inner-wrapper"
ref={innerEditorContainerRef}
style={styles.container}
>
<BalloonToolbar
key="balloon-toolbar"
useMdx={useMdx}
containerRef={innerEditorContainerRef.current}
collection={collection}
field={field}
entry={entry}
/>
<CursorOverlayContainer containerRef={editorContainerRef} />
</div>
</Plate>
</div>
</div>
</PlateProvider>
</DndProvider>
</StyledPlateEditor>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, field, onBlur, onFocus, initialValue, onChange, plugins],
);
};
export default PlateEditor;

View File

@ -0,0 +1,302 @@
import Box from '@mui/material/Box';
import Popper from '@mui/material/Popper';
import { styled } from '@mui/material/styles';
import {
ELEMENT_LINK,
ELEMENT_TD,
findNodePath,
getNode,
getParentNode,
getSelectionBoundingClientRect,
getSelectionText,
isElement,
isElementEmpty,
isSelectionExpanded,
isText,
someNode,
usePlateSelection,
} from '@udecode/plate';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import { VOID_ELEMENTS } from '../../serialization/slate/ast-types';
import BasicElementToolbarButtons from '../buttons/BasicElementToolbarButtons';
import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import MediaToolbarButtons from '../buttons/MediaToolbarButtons';
import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import TableToolbarButtons from '../buttons/TableToolbarButtons';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { ClientRectObject } from '@udecode/plate';
import type { FC, ReactNode } from 'react';
const StyledPopperContent = styled('div')(
({ theme }) => `
display: flex;
gap: 4px;
background: ${theme.palette.background.paper};
box-shadow: ${theme.shadows[8]};
margin-bottom: 10px;
padding: 6px;
border-radius: 4px;
align-items: center;
`,
);
const StyledDivider = styled('div')(
({ theme }) => `
height: 18px;
width: 1px;
background: ${theme.palette.text.secondary};
margin: 0 4px;
opacity: 0.5;
`,
);
export interface BalloonToolbarProps {
useMdx: boolean;
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
}
const BalloonToolbar: FC<BalloonToolbarProps> = ({
useMdx,
containerRef,
collection,
field,
entry,
}) => {
const hasEditorFocus = useFocused();
const editor = useMdPlateEditorState();
const selection = usePlateSelection();
const [hasFocus, setHasFocus] = useState(false);
const debouncedHasFocus = useDebounce(hasFocus, 150);
const [childFocusState, setChildFocusState] = useState<Record<string, boolean>>({});
const childHasFocus = useMemo(
() => Object.keys(childFocusState).reduce((acc, value) => acc || childFocusState[value], false),
[childFocusState],
);
const debouncedChildHasFocus = useDebounce(hasFocus, 150);
const handleFocus = useCallback(() => {
setHasFocus(true);
}, []);
const handleBlur = useCallback(() => {
setHasFocus(false);
}, []);
const handleChildFocus = useCallback(
(key: string) => () => {
setChildFocusState(oldState => ({
...oldState,
[key]: true,
}));
},
[],
);
const handleChildBlur = useCallback(
(key: string) => () => {
setChildFocusState(oldState => ({
...oldState,
[key]: false,
}));
},
[],
);
const anchorEl = useRef<HTMLDivElement>();
const [selectionBoundingClientRect, setSelectionBoundingClientRect] =
useState<ClientRectObject | null>(null);
const [mediaOpen, setMediaOpen] = useState(false);
const [selectionExpanded, selectionText] = useMemo(() => {
if (!editor) {
return [undefined, undefined, undefined];
}
return [isSelectionExpanded(editor), getSelectionText(editor)];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, selection]);
const node = getNode(editor, editor.selection?.anchor.path ?? []);
useEffect(() => {
if (!editor || !hasEditorFocus) {
return;
}
setTimeout(() => {
setSelectionBoundingClientRect(getSelectionBoundingClientRect());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, debouncedHasFocus]);
const isInTableCell = useMemo(() => {
return Boolean(
selection && someNode(editor, { match: { type: ELEMENT_TD }, at: selection?.anchor }),
);
}, [editor, selection]);
const debouncedEditorFocus = useDebounce(hasEditorFocus, 150);
const groups: ReactNode[] = useMemo(() => {
if (
!mediaOpen &&
!debouncedEditorFocus &&
!hasFocus &&
!debouncedHasFocus &&
!debouncedChildHasFocus &&
!childHasFocus
) {
return [];
}
if (selection && someNode(editor, { match: { type: ELEMENT_LINK }, at: selection?.anchor })) {
return [];
}
// Selected text buttons
if (selectionText && selectionExpanded) {
return [
<BasicMarkToolbarButtons key="selection-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons
key="selection-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
hideCodeBlock
/>,
isInTableCell && <TableToolbarButtons key="selection-table-toolbar-buttons" />,
<MediaToolbarButtons
key="selection-media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
onMediaToggle={setMediaOpen}
hideImages
handleChildFocus={handleChildFocus}
handleChildBlur={handleChildBlur}
/>,
].filter(Boolean);
}
// Empty paragraph, not first line
if (
editor.children.length > 1 &&
node &&
((isElement(node) && isElementEmpty(editor, node)) || (isText(node) && isEmpty(node.text)))
) {
const path = findNodePath(editor, node) ?? [];
const parent = getParentNode(editor, path);
if (
path.length > 0 &&
path[0] !== 0 &&
parent &&
parent.length > 0 &&
'children' in parent[0] &&
!VOID_ELEMENTS.includes(parent[0].type as string) &&
parent[0].children.length === 1
) {
return [
<BasicMarkToolbarButtons key="empty-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons
key="empty-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
hideCodeBlock
/>,
<TableToolbarButtons key="empty-table-toolbar-buttons" isInTable={isInTableCell} />,
<MediaToolbarButtons
key="empty-media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
onMediaToggle={setMediaOpen}
handleChildFocus={handleChildFocus}
handleChildBlur={handleChildBlur}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
].filter(Boolean);
}
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
mediaOpen,
debouncedEditorFocus,
hasFocus,
debouncedHasFocus,
selection,
editor,
selectionText,
selectionExpanded,
node,
useMdx,
isInTableCell,
containerRef,
collection,
field,
]);
const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState(
selectionBoundingClientRect,
);
const debouncedGroups = useDebounce(
groups,
prevSelectionBoundingClientRect !== selectionBoundingClientRect ? 0 : 150,
);
const open = useMemo(
() => groups.length > 0 || debouncedGroups.length > 0,
[debouncedGroups.length, groups.length],
);
const debouncedOpen = useDebounce(
open,
prevSelectionBoundingClientRect !== selectionBoundingClientRect ? 0 : 50,
);
useEffect(() => {
setPrevSelectionBoundingClientRect(selectionBoundingClientRect);
}, [selectionBoundingClientRect]);
return (
<>
<Box
ref={anchorEl}
sx={{
position: 'fixed',
top: selectionBoundingClientRect?.y,
left: selectionBoundingClientRect?.x,
}}
/>
<Popper
open={Boolean(debouncedOpen && anchorEl.current)}
placement="top"
anchorEl={anchorEl.current ?? null}
sx={{ zIndex: 100 }}
onFocus={handleFocus}
onBlur={handleBlur}
disablePortal
tabIndex={0}
>
<StyledPopperContent>
{(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [
index !== 0 ? <StyledDivider key={`balloon-toolbar-divider-${index}`} /> : null,
group,
])}
</StyledPopperContent>
</Popper>
</>
);
};
export default BalloonToolbar;

View File

@ -0,0 +1,169 @@
/**
* @jest-environment jsdom
*/
import { render, screen } from '@testing-library/react';
import {
findNodePath,
getNode,
getParentNode,
isElement,
isElementEmpty,
someNode,
usePlateEditorState,
} from '@udecode/plate';
import React, { useRef } from 'react';
import { useFocused } from 'slate-react';
import '@testing-library/jest-dom';
import { mockMarkdownCollection, mockMarkdownField } from '@staticcms/test/data/collection';
import BalloonToolbar from '../BalloonToolbar';
import type { Entry } from '@staticcms/core/interface';
import type { MdEditor } from '@staticcms/markdown/plate/plateTypes';
import type { FC } from 'react';
let entry: Entry;
interface BalloonToolbarWrapperProps {
useMdx?: boolean;
}
const BalloonToolbarWrapper: FC<BalloonToolbarWrapperProps> = ({ useMdx = false }) => {
const ref = useRef<HTMLDivElement | null>(null);
return (
<div ref={ref}>
<BalloonToolbar
key="balloon-toolbar"
useMdx={useMdx}
containerRef={ref.current}
collection={mockMarkdownCollection}
field={mockMarkdownField}
entry={entry}
/>
</div>
);
};
describe(BalloonToolbar.name, () => {
const mockUseEditor = usePlateEditorState as jest.Mock;
let mockEditor: MdEditor;
const mockGetNode = getNode as jest.Mock;
const mockIsElement = isElement as unknown as jest.Mock;
const mockIsElementEmpty = isElementEmpty as jest.Mock;
const mockSomeNode = someNode as jest.Mock;
const mockUseFocused = useFocused as jest.Mock;
const mockFindNodePath = findNodePath as jest.Mock;
const mockGetParentNode = getParentNode as jest.Mock;
beforeEach(() => {
entry = {
collection: 'posts',
slug: '2022-12-13-post-number-1',
path: '_posts/2022-12-13-post-number-1.md',
partial: false,
raw: '--- title: "This is post # 1" draft: false date: 2022-12-13T00:00:00.000Z --- # The post is number 1\n\nAnd some text',
label: '',
author: '',
mediaFiles: [],
isModification: null,
newRecord: false,
updatedOn: '',
data: {
title: 'This is post # 1',
draft: false,
date: '2022-12-13T00:00:00.000Z',
body: '# The post is number 1\n\nAnd some text',
},
};
mockEditor = {
selection: undefined,
} as unknown as MdEditor;
mockUseEditor.mockReturnValue(mockEditor);
});
it('renders empty div by default', () => {
render(<BalloonToolbarWrapper />);
expect(screen.queryAllByRole('button').length).toBe(0);
});
describe('empty node toolbar', () => {
interface EmptyNodeToolbarSetupOptions {
useMdx?: boolean;
}
const emptyNodeToolbarSetup = ({ useMdx }: EmptyNodeToolbarSetupOptions = {}) => {
mockEditor = {
selection: undefined,
children: [
{
type: 'p',
children: [{ text: '' }],
},
{
type: 'p',
children: [{ text: '' }],
},
],
} as unknown as MdEditor;
mockUseEditor.mockReturnValue(mockEditor);
mockGetNode.mockReturnValue({ text: '' });
mockIsElement.mockReturnValue(true);
mockIsElementEmpty.mockReturnValue(true);
mockSomeNode.mockReturnValue(false);
mockUseFocused.mockReturnValue(true);
mockFindNodePath.mockReturnValue([1, 0]);
mockGetParentNode.mockReturnValue([
{
type: 'p',
children: [{ text: '' }],
},
]);
const { rerender } = render(<BalloonToolbarWrapper />);
rerender(<BalloonToolbarWrapper useMdx={useMdx} />);
};
it('renders empty node toolbar for markdown', () => {
emptyNodeToolbarSetup();
expect(screen.queryByTestId('toolbar-button-bold')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-italic')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-code')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-strikethrough')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-blockquote')).toBeInTheDocument();
expect(screen.queryByTestId('font-type-select')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-add-table')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-link')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-image')).toBeInTheDocument();
// MDX Only do not show for markdown version
expect(screen.queryByTestId('toolbar-button-underline')).not.toBeInTheDocument();
});
it('renders empty node toolbar for mdx', () => {
emptyNodeToolbarSetup({ useMdx: true });
expect(screen.queryByTestId('toolbar-button-bold')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-italic')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-code')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-strikethrough')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-blockquote')).toBeInTheDocument();
expect(screen.queryByTestId('font-type-select')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-add-table')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-link')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-insert-image')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-underline')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,2 @@
export * from './BalloonToolbar';
export { default as BalloonToolbar } from './BalloonToolbar';

View File

@ -0,0 +1,35 @@
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
import React from 'react';
import AlignToolbarButton from './common/AlignToolbarButton';
import type { FC } from 'react';
const AlignToolbarButtons: FC = () => {
return (
<>
<AlignToolbarButton
key="algin-button-left"
tooltip="Align Left"
value="left"
icon={<FormatAlignLeftIcon />}
/>
<AlignToolbarButton
key="algin-button-center"
tooltip="Align Center"
value="center"
icon={<FormatAlignCenterIcon />}
/>
<AlignToolbarButton
key="algin-button-right"
tooltip="Align Right"
value="right"
icon={<FormatAlignRightIcon />}
/>
</>
);
};
export default AlignToolbarButtons;

View File

@ -0,0 +1,46 @@
import CodeIcon from '@mui/icons-material/Code';
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import { ELEMENT_BLOCKQUOTE, ELEMENT_CODE_BLOCK, insertEmptyCodeBlock } from '@udecode/plate';
import React from 'react';
import BlockToolbarButton from './common/BlockToolbarButton';
import FontTypeSelect from './FontTypeSelect';
import type { FC } from 'react';
export interface BasicElementToolbarButtonsProps {
hideFontTypeSelect?: boolean;
disableFontTypeSelect?: boolean;
hideCodeBlock?: boolean;
}
const BasicElementToolbarButtons: FC<BasicElementToolbarButtonsProps> = ({
hideFontTypeSelect = false,
disableFontTypeSelect = false,
hideCodeBlock = false,
}) => {
return (
<>
{!hideFontTypeSelect ? <FontTypeSelect disabled={disableFontTypeSelect} /> : null}
<BlockToolbarButton
tooltip="Blockquote"
type={ELEMENT_BLOCKQUOTE}
icon={<FormatQuoteIcon />}
/>
{!hideCodeBlock ? (
<BlockToolbarButton
tooltip="Code Block"
type={ELEMENT_CODE_BLOCK}
icon={<CodeIcon />}
onClick={editor =>
insertEmptyCodeBlock(editor, {
insertNodesOptions: { select: true },
})
}
/>
) : null}
</>
);
};
export default BasicElementToolbarButtons;

View File

@ -0,0 +1,72 @@
import CodeIcon from '@mui/icons-material/Code';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatStrikethroughIcon from '@mui/icons-material/FormatStrikethrough';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';
import SubscriptIcon from '@mui/icons-material/Subscript';
import SuperscriptIcon from '@mui/icons-material/Superscript';
import {
MARK_BOLD,
MARK_CODE,
MARK_ITALIC,
MARK_STRIKETHROUGH,
MARK_SUBSCRIPT,
MARK_SUPERSCRIPT,
MARK_UNDERLINE,
} from '@udecode/plate';
import React from 'react';
import MarkToolbarButton from './common/MarkToolbarButton';
import type { FC } from 'react';
export interface BasicMarkToolbarButtonsProps {
extended?: boolean;
useMdx: boolean;
}
const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({
extended = false,
useMdx,
}) => {
return (
<>
<MarkToolbarButton tooltip="Bold" type={MARK_BOLD} icon={<FormatBoldIcon />} />
<MarkToolbarButton tooltip="Italic" type={MARK_ITALIC} icon={<FormatItalicIcon />} />
{useMdx ? (
<MarkToolbarButton
key="underline-button"
tooltip="Underline"
type={MARK_UNDERLINE}
icon={<FormatUnderlinedIcon />}
/>
) : null}
<MarkToolbarButton
tooltip="Strikethrough"
type={MARK_STRIKETHROUGH}
icon={<FormatStrikethroughIcon />}
/>
<MarkToolbarButton tooltip="Code" type={MARK_CODE} icon={<CodeIcon />} />
{useMdx && extended ? (
<>
<MarkToolbarButton
key="superscript-button"
tooltip="Superscript"
type={MARK_SUPERSCRIPT}
clear={MARK_SUBSCRIPT}
icon={<SuperscriptIcon />}
/>
<MarkToolbarButton
key="subscript-button"
tooltip="Subscript"
type={MARK_SUBSCRIPT}
clear={MARK_SUPERSCRIPT}
icon={<SubscriptIcon />}
/>
</>
) : null}
</>
);
};
export default BasicMarkToolbarButtons;

View File

@ -0,0 +1,29 @@
import FontDownloadIcon from '@mui/icons-material/FontDownload';
import FormatColorTextIcon from '@mui/icons-material/FormatColorText';
import { MARK_BG_COLOR, MARK_COLOR } from '@udecode/plate';
import React from 'react';
import ColorPickerToolbarDropdown from './common/ColorPickerToolbarDropdown';
import type { FC } from 'react';
const ColorToolbarButtons: FC = () => {
return (
<>
<ColorPickerToolbarDropdown
key="color-picker-button"
pluginKey={MARK_COLOR}
icon={<FormatColorTextIcon />}
tooltip="Color"
/>
<ColorPickerToolbarDropdown
key="background-color-picker-button"
pluginKey={MARK_BG_COLOR}
icon={<FontDownloadIcon />}
tooltip="Background Color"
/>
</>
);
};
export default ColorToolbarButtons;

View File

@ -0,0 +1,126 @@
import FormControl from '@mui/material/FormControl';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { styled } from '@mui/material/styles';
import {
ELEMENT_H1,
ELEMENT_H2,
ELEMENT_H3,
ELEMENT_H4,
ELEMENT_H5,
ELEMENT_H6,
ELEMENT_PARAGRAPH,
focusEditor,
} from '@udecode/plate';
import { someNode, toggleNodeType } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { SelectChangeEvent } from '@mui/material/Select';
import type { FC } from 'react';
const StyledSelect = styled(Select<string>)`
padding: 0;
& .MuiSelect-select {
padding: 4px 7px;
}
`;
const types = [
{
type: ELEMENT_H1,
label: 'Heading 1',
},
{
type: ELEMENT_H2,
label: 'Heading 2',
},
{
type: ELEMENT_H3,
label: 'Heading 3',
},
{
type: ELEMENT_H4,
label: 'Heading 4',
},
{
type: ELEMENT_H5,
label: 'Heading 5',
},
{
type: ELEMENT_H6,
label: 'Heading 6',
},
{
type: ELEMENT_PARAGRAPH,
label: 'Paragraph',
},
];
export interface FontTypeSelectProps {
disabled?: boolean;
}
/**
* Toolbar button to toggle the type of elements in selection.
*/
const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
const editor = useMdPlateEditorState();
const [version, setVersion] = useState(0);
const selection = useDebounce(editor?.selection, 100);
const value = useMemo(() => {
return (
selection &&
types.find(type => someNode(editor, { match: { type: type.type }, at: selection?.anchor }))
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, selection, version]);
const handleChange = useCallback(
(event: SelectChangeEvent<string>) => {
event.preventDefault();
if (value?.type === event.target.value) {
return;
}
toggleNodeType(editor, {
activeType: event.target.value,
});
setVersion(oldVersion => oldVersion + 1);
setTimeout(() => {
focusEditor(editor);
});
},
[editor, value?.type],
);
return (
<FormControl sx={{ width: 120 }}>
<StyledSelect
labelId="font-type-select-label"
id="font-type-select"
data-testid="font-type-select"
value={value?.type ?? ELEMENT_PARAGRAPH}
onChange={handleChange}
size="small"
disabled={disabled}
>
{types.map(type => (
<MenuItem key={type.type} value={type.type}>
{type.label}
</MenuItem>
))}
</StyledSelect>
</FormControl>
);
};
export default FontTypeSelect;

View File

@ -0,0 +1,44 @@
import FormatIndentDecreaseIcon from '@mui/icons-material/FormatIndentDecrease';
import FormatIndentIncreaseIcon from '@mui/icons-material/FormatIndentIncrease';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import { ELEMENT_OL, ELEMENT_UL, getPluginType, indent, outdent } from '@udecode/plate';
import React, { useCallback } from 'react';
import { useMdPlateEditorRef } from '@staticcms/markdown';
import ListToolbarButton from './common/ListToolbarButton';
import ToolbarButton from './common/ToolbarButton';
import type { FC } from 'react';
import type { MdEditor } from '@staticcms/markdown';
const ListToolbarButtons: FC = () => {
const editor = useMdPlateEditorRef();
const handleOutdent = useCallback((editor: MdEditor) => {
outdent(editor);
}, []);
const handleIndent = useCallback((editor: MdEditor) => {
indent(editor);
}, []);
return (
<>
<ListToolbarButton tooltip="List" type={ELEMENT_UL} icon={<FormatListBulletedIcon />} />
<ListToolbarButton
tooltip="Numbered List"
type={getPluginType(editor, ELEMENT_OL)}
icon={<FormatListNumberedIcon />}
/>
<ToolbarButton
tooltip="Outdent"
onClick={handleOutdent}
icon={<FormatIndentDecreaseIcon />}
/>
<ToolbarButton tooltip="Indent" onClick={handleIndent} icon={<FormatIndentIncreaseIcon />} />
</>
);
};
export default ListToolbarButtons;

View File

@ -0,0 +1,85 @@
import ImageIcon from '@mui/icons-material/Image';
import LinkIcon from '@mui/icons-material/Link';
import React, { useEffect, useState } from 'react';
import ImageToolbarButton from './common/ImageToolbarButton';
import LinkToolbarButton from './common/LinkToolbarButton';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface MediaToolbarButtonsProps {
containerRef: HTMLElement | null;
hideImages?: boolean;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
inserting?: boolean;
onMediaToggle?: (open: boolean) => void;
handleChildFocus?: (key: string) => () => void;
handleChildBlur?: (key: string) => () => void;
}
const MediaToolbarButtons: FC<MediaToolbarButtonsProps> = ({
containerRef,
collection,
field,
entry,
hideImages = false,
onMediaToggle,
handleChildFocus,
handleChildBlur,
}) => {
const [open, setOpen] = useState(false);
const [linkMediaOpen, setLinkMediaOpen] = useState(false);
const [imageMediaOpen, setImageMediaOpen] = useState(false);
useEffect(() => {
if (open && !linkMediaOpen && !imageMediaOpen) {
setOpen(false);
onMediaToggle?.(false);
return;
}
if (!open && (linkMediaOpen || imageMediaOpen)) {
setOpen(true);
onMediaToggle?.(true);
return;
}
}, [imageMediaOpen, linkMediaOpen, onMediaToggle, open]);
return (
<>
<LinkToolbarButton
containerRef={containerRef}
tooltip="Insert Link"
key="link-button"
icon={<LinkIcon />}
collection={collection}
field={field}
entry={entry}
mediaOpen={linkMediaOpen}
onMediaToggle={setLinkMediaOpen}
onFocus={handleChildFocus?.('link')}
onBlur={handleChildBlur?.('link')}
/>
{!hideImages ? (
<ImageToolbarButton
containerRef={containerRef}
tooltip="Insert Image"
key="image-button"
icon={<ImageIcon />}
collection={collection}
field={field}
entry={entry}
mediaOpen={imageMediaOpen}
onMediaToggle={setImageMediaOpen}
onFocus={handleChildFocus?.('image')}
onBlur={handleChildBlur?.('image')}
/>
) : null}
</>
);
};
export default MediaToolbarButtons;

View File

@ -0,0 +1,73 @@
import DataArrayIcon from '@mui/icons-material/DataArray';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { focusEditor, insertNodes } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import { getShortcodes } from '../../../../../lib/registry';
import { toTitleCase } from '../../../../../lib/util/string.util';
import { ELEMENT_SHORTCODE, useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './common/ToolbarButton';
import type { FC, MouseEvent } from 'react';
import type { MdEditor } from '../../plateTypes';
const ShortcodeToolbarButton: FC = () => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const configs = useMemo(() => getShortcodes(), []);
const handleShortcodeClick = useCallback(
(shortcode: string) => () => {
insertNodes(editor, {
type: ELEMENT_SHORTCODE,
shortcode,
args: [],
children: [{ text: '' }],
});
focusEditor(editor);
handleClose();
},
[editor, handleClose],
);
return (
<>
<ToolbarButton
key="shortcode-button"
tooltip="Add Shortcode"
icon={<DataArrayIcon />}
onClick={handleClick}
/>
<Menu
id="shortcode-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'shortcode-button',
}}
>
{Object.keys(configs).map(name => {
const config = configs[name];
return (
<MenuItem key={`shortcode-${name}`} onClick={handleShortcodeClick(name)}>
{config.label ?? toTitleCase(name)}
</MenuItem>
);
})}
</Menu>
</>
);
};
export default ShortcodeToolbarButton;

View File

@ -0,0 +1,97 @@
import { TableAdd } from '@styled-icons/fluentui-system-regular/TableAdd';
import { TableDeleteColumn } from '@styled-icons/fluentui-system-regular/TableDeleteColumn';
import { TableDeleteRow } from '@styled-icons/fluentui-system-regular/TableDeleteRow';
import { TableDismiss } from '@styled-icons/fluentui-system-regular/TableDismiss';
import { TableInsertColumn } from '@styled-icons/fluentui-system-regular/TableInsertColumn';
import { TableInsertRow } from '@styled-icons/fluentui-system-regular/TableInsertRow';
import {
deleteColumn,
deleteRow,
deleteTable,
insertTable,
insertTableColumn,
insertTableRow,
} from '@udecode/plate';
import React, { useCallback } from 'react';
import ToolbarButton from './common/ToolbarButton';
import type { FC } from 'react';
import type { MdEditor } from '@staticcms/markdown';
export interface TableToolbarButtonsProps {
isInTable?: boolean;
}
const TableToolbarButtons: FC<TableToolbarButtonsProps> = ({ isInTable = true }) => {
const handleTableAdd = useCallback((editor: MdEditor) => {
insertTable(editor, {
rowCount: 2,
colCount: 2,
});
}, []);
const handleInsertTableRow = useCallback((editor: MdEditor) => {
insertTableRow(editor);
}, []);
const handleDeleteRow = useCallback((editor: MdEditor) => {
deleteRow(editor);
}, []);
const handleInsertTableColumn = useCallback((editor: MdEditor) => {
insertTableColumn(editor);
}, []);
const handleDeleteColumn = useCallback((editor: MdEditor) => {
deleteColumn(editor);
}, []);
const handleDeleteTable = useCallback((editor: MdEditor) => {
deleteTable(editor);
}, []);
return isInTable ? (
<>
<ToolbarButton
key="insertRow"
tooltip="Insert Row"
icon={<TableInsertRow />}
onClick={handleInsertTableRow}
/>
<ToolbarButton
key="deleteRow"
tooltip="Delete Row"
icon={<TableDeleteRow />}
onClick={handleDeleteRow}
/>
<ToolbarButton
key="insertColumn"
tooltip="Insert Column"
icon={<TableInsertColumn />}
onClick={handleInsertTableColumn}
/>
<ToolbarButton
key="deleteColumn"
tooltip="Delete Column"
icon={<TableDeleteColumn />}
onClick={handleDeleteColumn}
/>
<ToolbarButton
key="deleteTable"
tooltip="Delete Table"
icon={<TableDismiss />}
onClick={handleDeleteTable}
/>
</>
) : (
<ToolbarButton
key="insertRow"
tooltip="Add Table"
icon={<TableAdd />}
onClick={handleTableAdd}
/>
);
};
export default TableToolbarButtons;

View File

@ -0,0 +1,45 @@
import { isCollapsed, KEY_ALIGN, setAlign, someNode } from '@udecode/plate';
import React, { useCallback } from 'react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MdEditor } from '@staticcms/markdown';
import type { Alignment } from '@udecode/plate';
import type { FC } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface AlignToolbarButtonProps extends Omit<ToolbarButtonProps, 'active' | 'onClick'> {
value: Alignment;
pluginKey?: string;
}
const AlignToolbarButton: FC<AlignToolbarButtonProps> = ({
value,
pluginKey = KEY_ALIGN,
...props
}) => {
const editor = useMdPlateEditorState();
const handleOnClick = useCallback(
(editor: MdEditor) => {
setAlign(editor, {
value,
key: pluginKey,
});
},
[pluginKey, value],
);
return (
<ToolbarButton
active={
isCollapsed(editor?.selection) && someNode(editor!, { match: { [pluginKey]: value } })
}
onClick={handleOnClick}
{...props}
/>
);
};
export default AlignToolbarButton;

View File

@ -0,0 +1,41 @@
import { someNode, toggleNodeType } from '@udecode/plate';
import React, { useCallback } from 'react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MdEditor } from '@staticcms/markdown';
import type { FC } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface BlockToolbarButtonProps extends Omit<ToolbarButtonProps, 'active' | 'onClick'> {
type: string;
inactiveType?: string;
onClick?: (editor: MdEditor) => void;
}
const BlockToolbarButton: FC<BlockToolbarButtonProps> = ({
type,
inactiveType,
onClick,
...props
}) => {
const editor = useMdPlateEditorState();
const handleOnClick = useCallback(
(editor: MdEditor) => {
toggleNodeType(editor, { activeType: type, inactiveType });
},
[inactiveType, type],
);
return (
<ToolbarButton
active={!!editor?.selection && someNode(editor, { match: { type } })}
onClick={onClick ?? handleOnClick}
{...props}
/>
);
};
export default BlockToolbarButton;

View File

@ -0,0 +1,106 @@
import { DEFAULT_COLORS, DEFAULT_CUSTOM_COLORS } from '@udecode/plate';
import {
getMark,
getPluginType,
removeMark,
setMarks,
usePlateEditorRef,
} from '@udecode/plate-core';
import React, { useCallback, useEffect, useState } from 'react';
import { Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ColorPicker from '../../color-picker/ColorPicker';
import ToolbarDropdown from './dropdown/ToolbarDropdown';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
import type { BaseEditor } from 'slate';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface ColorPickerToolbarDropdownProps extends Omit<ToolbarButtonProps, 'onClick'> {
pluginKey: string;
colors?: ColorType[];
customColors?: ColorType[];
closeOnSelect?: boolean;
}
const ColorPickerToolbarDropdown: FC<ColorPickerToolbarDropdownProps> = ({
pluginKey,
colors = DEFAULT_COLORS,
customColors = DEFAULT_CUSTOM_COLORS,
closeOnSelect = true,
...controlProps
}) => {
const [open, setOpen] = useState(false);
const editor = useMdPlateEditorState();
const editorRef = usePlateEditorRef();
const type = getPluginType(editorRef, pluginKey);
const color = editorRef && getMark(editorRef, type);
const [selectedColor, setSelectedColor] = useState<string>();
const onToggle = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const updateColor = useCallback(
(value: string) => {
if (editorRef && editor && editor.selection) {
setSelectedColor(value);
Transforms.select(editorRef as BaseEditor, editor.selection);
ReactEditor.focus(editorRef as unknown as ReactEditor);
setMarks(editor, { [type]: value });
}
},
[editor, editorRef, type],
);
const updateColorAndClose = useCallback(
(value: string) => {
updateColor(value);
closeOnSelect && onToggle();
},
[closeOnSelect, onToggle, updateColor],
);
const clearColor = useCallback(() => {
if (editorRef && editor && editor.selection) {
Transforms.select(editorRef as BaseEditor, editor.selection);
ReactEditor.focus(editorRef as unknown as ReactEditor);
if (selectedColor) {
removeMark(editor, { key: type });
}
closeOnSelect && onToggle();
}
}, [closeOnSelect, editor, editorRef, onToggle, selectedColor, type]);
useEffect(() => {
if (editor?.selection) {
setSelectedColor(color);
}
}, [color, editor?.selection]);
return (
<ToolbarDropdown active={Boolean(color)} activeColor={color} {...controlProps}>
<ColorPicker
color={selectedColor || color}
colors={colors}
customColors={customColors}
updateColor={updateColorAndClose}
updateCustomColor={updateColor}
clearColor={clearColor}
open={open}
/>
</ToolbarDropdown>
);
};
export default ColorPickerToolbarDropdown;

View File

@ -0,0 +1,25 @@
import { insertImage } from '@udecode/plate';
import React, { useCallback } from 'react';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import MediaToolbarButton from './MediaToolbarButton';
import type { FC } from 'react';
import type { MediaToolbarButtonProps } from './MediaToolbarButton';
const ImageToolbarButton: FC<Omit<MediaToolbarButtonProps, 'onChange'>> = props => {
const editor = useMdPlateEditorState();
const handleInsert = useCallback(
(newUrl: string) => {
if (isNotEmpty(newUrl)) {
insertImage(editor, newUrl);
}
},
[editor],
);
return <MediaToolbarButton {...props} onChange={handleInsert} inserting forImage />;
};
export default ImageToolbarButton;

View File

@ -0,0 +1,31 @@
import { ELEMENT_LINK, insertLink, someNode } from '@udecode/plate';
import React, { useCallback } from 'react';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import MediaToolbarButton from './MediaToolbarButton';
import type { FC } from 'react';
import type { MediaToolbarButtonProps } from './MediaToolbarButton';
const LinkToolbarButton: FC<Omit<MediaToolbarButtonProps, 'onChange'>> = props => {
const editor = useMdPlateEditorState();
const handleInsert = useCallback(
(newUrl: string, newText: string | undefined) => {
if (isNotEmpty(newUrl)) {
insertLink(
editor,
{ url: newUrl, text: isNotEmpty(newText) ? newText : newUrl },
{ at: editor.selection ?? editor.prevSelection! },
);
}
},
[editor],
);
const isLink = !!editor?.selection && someNode(editor, { match: { type: ELEMENT_LINK } });
return <MediaToolbarButton {...props} active={isLink} onChange={handleInsert} inserting />;
};
export default LinkToolbarButton;

View File

@ -0,0 +1,34 @@
import { getListItemEntry, toggleList } from '@udecode/plate-list';
import React, { useCallback } from 'react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MdEditor } from '@staticcms/markdown';
import type { FC } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface ListToolbarButtonProps extends Omit<ToolbarButtonProps, 'active' | 'onClick'> {
type: string;
}
const ListToolbarButton: FC<ListToolbarButtonProps> = ({ type, ...props }) => {
const editor = useMdPlateEditorState();
const handleOnClick = useCallback(
(editor: MdEditor) => {
toggleList(editor, {
type,
});
},
[type],
);
const res = !!editor?.selection && getListItemEntry(editor);
return (
<ToolbarButton active={!!res && res.list[0].type === type} onClick={handleOnClick} {...props} />
);
};
export default ListToolbarButton;

View File

@ -0,0 +1,35 @@
import { isMarkActive, toggleMark } from '@udecode/plate';
import React, { useCallback } from 'react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MdEditor } from '@staticcms/markdown';
import type { FC } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface MarkToolbarButtonProps extends Omit<ToolbarButtonProps, 'active' | 'onClick'> {
type: string;
clear?: string | string[];
}
const MarkToolbarButton: FC<MarkToolbarButtonProps> = ({ type, clear, ...props }) => {
const editor = useMdPlateEditorState();
const handleOnClick = useCallback(
(editor: MdEditor) => {
toggleMark(editor, { key: type, clear });
},
[clear, type],
);
return (
<ToolbarButton
active={!!editor?.selection && isMarkActive(editor, type)}
onClick={handleOnClick}
{...props}
/>
);
};
export default MarkToolbarButton;

View File

@ -0,0 +1,124 @@
import { focusEditor } from '@udecode/plate-core';
import React, { useCallback, useEffect, useState } from 'react';
import MediaPopover from '../../common/MediaPopover';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ToolbarButton from './ToolbarButton';
import type { MarkdownField } from '@staticcms/core/interface';
import type { MdEditor, MediaPopoverProps } from '@staticcms/markdown';
import type { FC, MouseEvent } from 'react';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface MediaToolbarButtonProps
extends Omit<ToolbarButtonProps, 'onClick'>,
Pick<
MediaPopoverProps<MarkdownField>,
'collection' | 'field' | 'entry' | 'inserting' | 'forImage' | 'textLabel'
> {
containerRef: HTMLElement | null;
mediaOpen: boolean;
onMediaToggle: (open: boolean) => void;
onChange: (newUrl: string, newText: string | undefined) => void;
onFocus?: () => void;
onBlur?: () => void;
}
const MediaToolbarButton: FC<MediaToolbarButtonProps> = ({
containerRef,
collection,
field,
entry,
inserting,
forImage,
textLabel,
mediaOpen,
onMediaToggle,
onChange,
onFocus,
onBlur,
...props
}) => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [internalUrl, setInternalUrl] = useState('');
const [internalText, setInternalText] = useState('');
const handleClose = useCallback(
(newValue: string | undefined, shouldFocus: boolean) => {
setAnchorEl(null);
setInternalUrl('');
setInternalText('');
if (shouldFocus) {
focusEditor(editor, editor.selection ?? editor.prevSelection!);
}
const finalValue = newValue ?? internalUrl;
if (finalValue) {
onChange(finalValue, internalText);
}
},
[editor, onChange, internalUrl, internalText],
);
const handleOnClick = useCallback(
(_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
if (anchorEl) {
handleClose(undefined, true);
return;
}
setAnchorEl(event.currentTarget);
},
[anchorEl, handleClose],
);
const handleMediaChange = useCallback(
(newValue: string) => {
handleClose(newValue, true);
},
[handleClose],
);
const handlePopoverClose = useCallback(
(shouldFocus: boolean) => {
handleClose(undefined, shouldFocus);
},
[handleClose],
);
useEffect(() => {
if (anchorEl && !mediaOpen) {
handleClose(undefined, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mediaOpen]);
return (
<>
<ToolbarButton onClick={handleOnClick} disableFocusAfterClick {...props} />
<MediaPopover
containerRef={containerRef}
anchorEl={anchorEl}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalText}
inserting={inserting}
forImage={forImage}
textLabel={textLabel}
onUrlChange={setInternalUrl}
onTextChange={setInternalText}
mediaOpen={mediaOpen}
onMediaToggle={onMediaToggle}
onMediaChange={handleMediaChange}
onClose={handlePopoverClose}
onFocus={onFocus}
onBlur={onBlur}
/>
</>
);
};
export default MediaToolbarButton;

View File

@ -0,0 +1,81 @@
import Button from '@mui/material/Button';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { focusEditor } from '@udecode/plate';
import React, { useCallback } from 'react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { MdEditor } from '@staticcms/markdown';
import type { FC, MouseEvent, ReactNode } from 'react';
export interface ToolbarButtonProps {
label?: string;
tooltip: string;
active?: boolean;
activeColor?: string;
icon: ReactNode;
disableFocusAfterClick?: boolean;
onClick: (editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => void;
}
const ToolbarButton: FC<ToolbarButtonProps> = ({
icon,
tooltip,
label,
active = false,
activeColor,
disableFocusAfterClick = false,
onClick,
}) => {
const editor = useMdPlateEditorState();
const theme = useTheme();
const handleOnClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!editor) {
return;
}
onClick(editor, event);
if (!disableFocusAfterClick) {
setTimeout(() => {
focusEditor(editor);
});
}
},
[disableFocusAfterClick, editor, onClick],
);
return (
<Tooltip title={tooltip} disableInteractive>
<Button
aria-label={label ?? tooltip}
size="small"
color="inherit"
data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()}
sx={{
padding: '2px',
minWidth: 'unset',
borderRadius: '4px',
height: '26px',
width: '26px',
color: active ? activeColor ?? theme.palette.primary.main : theme.palette.text.secondary,
'& svg': {
height: '24px',
width: '24px',
},
}}
onClick={handleOnClick}
>
{icon}
</Button>
</Tooltip>
);
};
export default ToolbarButton;

View File

@ -0,0 +1,67 @@
import Popover from '@mui/material/Popover';
import { styled } from '@mui/material/styles';
import React, { useCallback, useState } from 'react';
import ToolbarButton from '../ToolbarButton';
import type { FC, ReactNode } from 'react';
import type { ToolbarButtonProps } from '../ToolbarButton';
const StyledPopperContent = styled('div')`
display: flex;
gap: 4px;
padding: 16px;
border-radius: 4px;
align-items: center;
`;
export interface ToolbarDropdownProps extends Omit<ToolbarButtonProps, 'onClick'> {
children: ReactNode;
onClose?: () => void;
}
const ToolbarDropdown: FC<ToolbarDropdownProps> = ({ children, onClose, ...controlProps }) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const handleClose = useCallback(() => {
onClose?.();
setOpen(false);
}, [onClose]);
const handleControlClick = useCallback(() => {
if (open) {
handleClose();
return;
}
setOpen(!open);
}, [handleClose, open]);
return (
<>
<div ref={setAnchorEl}>
<ToolbarButton {...controlProps} onClick={handleControlClick} />
</div>
<Popover
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClose={handleClose}
disablePortal
>
<StyledPopperContent>{children}</StyledPopperContent>
</Popover>
</>
);
};
export default ToolbarDropdown;

View File

@ -0,0 +1,2 @@
export * from './ToolbarDropdown';
export { default as ToolbarDropdown } from './ToolbarDropdown';

View File

@ -0,0 +1,17 @@
export * from './AlignToolbarButton';
export { default as AlignToolbarButton } from './AlignToolbarButton';
export * from './BlockToolbarButton';
export { default as BlockToolbarButton } from './BlockToolbarButton';
export * from './ColorPickerToolbarDropdown';
export { default as ColorPickerToolbarDropdown } from './ColorPickerToolbarDropdown';
export * from './dropdown';
export { default as ImageToolbarButton } from './ImageToolbarButton';
export { default as LinkToolbarButton } from './LinkToolbarButton';
export * from './ListToolbarButton';
export { default as ListToolbarButton } from './ListToolbarButton';
export * from './MarkToolbarButton';
export { default as MarkToolbarButton } from './MarkToolbarButton';
export * from './MediaToolbarButton';
export { default as MediaToolbarButton } from './MediaToolbarButton';
export * from './ToolbarButton';
export { default as ToolbarButton } from './ToolbarButton';

View File

@ -0,0 +1,14 @@
export { default as AlignToolbarButtons } from './AlignToolbarButtons';
export * from './BasicElementToolbarButtons';
export { default as BasicElementToolbarButtons } from './BasicElementToolbarButtons';
export * from './BasicMarkToolbarButtons';
export { default as BasicMarkToolbarButtons } from './BasicMarkToolbarButtons';
export { default as ColorToolbarButtons } from './ColorToolbarButtons';
export * from './common';
export * from './FontTypeSelect';
export { default as FontTypeSelect } from './FontTypeSelect';
export { default as ListToolbarButtons } from './ListToolbarButtons';
export * from './MediaToolbarButtons';
export { default as MediaToolbarButtons } from './MediaToolbarButtons';
export * from './TableToolbarButtons';
export { default as TableToolbarButtons } from './TableToolbarButtons';

View File

@ -0,0 +1,51 @@
import CheckIcon from '@mui/icons-material/Check';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import React, { useCallback } from 'react';
import type { FC } from 'react';
export type ColorButtonProps = {
name: string;
value: string;
isBrightColor: boolean;
isSelected: boolean;
updateColor: (color: string) => void;
};
const ColorButton: FC<ColorButtonProps> = ({
name,
value,
isBrightColor,
isSelected,
updateColor,
}) => {
const handleOnClick = useCallback(() => {
updateColor(value);
}, [updateColor, value]);
return (
<Tooltip title={name} disableInteractive>
<IconButton onClick={handleOnClick} sx={{ p: 0 }}>
<Avatar
alt={name}
sx={{
background: value,
width: 32,
height: 32,
border: isBrightColor ? '1px solid rgba(209,213,219, 1)' : 'transparent',
}}
>
{isSelected ? (
<CheckIcon sx={{ color: isBrightColor ? '#000000' : '#ffffff' }} />
) : (
<>&nbsp;</>
)}
</Avatar>
</IconButton>
</Tooltip>
);
};
export default ColorButton;

View File

@ -0,0 +1,39 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { useRef } from 'react';
import type { ChangeEvent, FC } from 'react';
const StyledInput = styled('input')`
visibility: hidden;
position: absolute;
`;
export interface ColorInputProps {
value?: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
const ColorInput: FC<ColorInputProps> = ({ value = '#000000', onChange }) => {
const ref = useRef<HTMLInputElement | null>(null);
function handleClick() {
// force click action on the input to open color picker
ref.current?.click();
}
function handleOnChange(event: ChangeEvent<HTMLInputElement>) {
onChange?.(event);
}
return (
<div>
<Button onClick={handleClick} fullWidth>
CUSTOM
</Button>
<StyledInput ref={ref} type="color" onChange={handleOnChange} value={value} />
</div>
);
};
export default ColorInput;

View File

@ -0,0 +1,71 @@
import Button from '@mui/material/Button';
import { styled } from '@mui/material/styles';
import React, { memo } from 'react';
import Colors from './Colors';
import CustomColors from './CustomColors';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
const StyledColorPicker = styled('div')`
display: flex;
flex-direction: column;
gap: 16px;
`;
const StyledDivider = styled('div')(
({ theme }) => `
height: 1px;
width: 100%;
background: ${theme.palette.text.secondary};
opacity: 0.1;
`,
);
export type ColorPickerProps = {
color?: string;
colors: ColorType[];
customColors: ColorType[];
updateColor: (color: string) => void;
updateCustomColor: (color: string) => void;
clearColor: () => void;
open?: boolean;
};
const ColorPickerInternal: FC<ColorPickerProps> = ({
color,
colors,
customColors,
updateColor,
updateCustomColor,
clearColor,
}) => {
return (
<StyledColorPicker>
<CustomColors
color={color}
colors={colors}
customColors={customColors}
updateColor={updateColor}
updateCustomColor={updateCustomColor}
/>
<StyledDivider />
<Colors color={color} colors={colors} updateColor={updateColor} />
<Button onClick={clearColor} disabled={!color}>
Clear
</Button>
</StyledColorPicker>
);
};
const ColorPicker = memo(
ColorPickerInternal,
(prev, next) =>
prev.color === next.color &&
prev.colors === next.colors &&
prev.customColors === next.customColors &&
prev.open === next.open,
);
export default ColorPicker;

View File

@ -0,0 +1,38 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import ColorButton from './ColorButton';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
const StyledColors = styled('div')`
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.25rem;
`;
export type ColorsProps = {
color?: string;
colors: ColorType[];
updateColor: (color: string) => void;
};
const Colors: FC<ColorsProps> = ({ color, colors, updateColor }) => {
return (
<StyledColors>
{colors.map(({ name, value, isBrightColor }) => (
<ColorButton
key={name ?? value}
name={name}
value={value}
isBrightColor={isBrightColor}
isSelected={color === value}
updateColor={updateColor}
/>
))}
</StyledColors>
);
};
export default Colors;

View File

@ -0,0 +1,83 @@
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { styled } from '@mui/material/styles';
import ColorInput from './ColorInput';
import Colors from './Colors';
import type { ColorType } from '@udecode/plate';
import type { ChangeEvent, FC } from 'react';
const StyledCustomColors = styled('div')`
display: flex;
flex-direction: column;
gap: 8px;
`;
export type CustomColorsProps = {
color?: string;
colors: ColorType[];
customColors: ColorType[];
updateColor: (color: string) => void;
updateCustomColor: (color: string) => void;
};
const CustomColors: FC<CustomColorsProps> = ({
color,
colors,
customColors,
updateColor,
updateCustomColor,
}: CustomColorsProps) => {
const [customColor, setCustomColor] = useState<string>();
// eslint-disable-next-line react-hooks/exhaustive-deps
const updateCustomColorDebounced = useCallback(debounce(updateCustomColor, 100), [
updateCustomColor,
]);
const [value, setValue] = useState<string>(color || '#000000');
useEffect(() => {
if (
!color ||
customColors.some(c => c.value === color) ||
colors.some(c => c.value === color)
) {
return;
}
setCustomColor(color);
}, [color, colors, customColors]);
const computedColors = useMemo(
() =>
customColor
? [
...customColors,
{
name: '',
value: customColor,
isBrightColor: false,
},
]
: customColors,
[customColor, customColors],
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
updateCustomColorDebounced(event.target.value);
},
[updateCustomColorDebounced],
);
return (
<StyledCustomColors>
<ColorInput value={value} onChange={handleChange} />
<Colors color={color} colors={computedColors} updateColor={updateColor} />
</StyledCustomColors>
);
};
export default CustomColors;

View File

@ -0,0 +1,10 @@
export * from './ColorButton';
export { default as ColorButton } from './ColorButton';
export * from './ColorInput';
export { default as ColorInput } from './ColorInput';
export * from './ColorPicker';
export { default as ColorPicker } from './ColorPicker';
export * from './Colors';
export { default as Colors } from './Colors';
export * from './CustomColors';
export { default as CustomColors } from './CustomColors';

View File

@ -0,0 +1,353 @@
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import Button from '@mui/material/Button';
import Popper from '@mui/material/Popper';
import { styled, useTheme } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import useIsMediaAsset from '@staticcms/core/lib/hooks/useIsMediaAsset';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import type { Collection, Entry, FileOrImageField, MarkdownField } from '@staticcms/core/interface';
import type { ChangeEvent, KeyboardEvent } from 'react';
const StyledPopperContent = styled('div')(
({ theme }) => `
display: flex;
gap: 4px;
background: ${theme.palette.background.paper};
box-shadow: ${theme.shadows[8]};
margin: 10px 0;
padding: 6px;
border-radius: 4px;
align-items: center;
position: relative;
`,
);
const StyledPopoverContent = styled(StyledPopperContent)`
display: flex;
align-items: center;
padding: 4px 8px;
gap: 2px;
`;
const StyledPopoverEditingContent = styled(StyledPopperContent)`
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
gap: 16px;
width: 300px;
`;
const StyledFloatingVerticalDivider = styled('div')`
width: 1px;
height: 20px;
background-color: rgba(229, 231, 235, 1);
margin: 0 4px;
`;
export interface MediaPopoverProps<T extends FileOrImageField | MarkdownField> {
containerRef: HTMLElement | null;
anchorEl: HTMLElement | null;
url: string;
text?: string;
textLabel?: string;
inserting?: boolean;
forImage?: boolean;
collection: Collection<T>;
field: T;
entry: Entry;
onUrlChange: (newValue: string) => void;
onTextChange?: (newValue: string) => void;
onClose: (shouldFocus: boolean) => void;
mediaOpen?: boolean;
onMediaToggle?: (open: boolean) => void;
onMediaChange: (newValue: string) => void;
onRemove?: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
const MediaPopover = <T extends FileOrImageField | MarkdownField>({
containerRef,
anchorEl,
url,
text,
textLabel = 'Text',
inserting = false,
forImage = false,
collection,
field,
entry,
onUrlChange,
onTextChange,
onClose,
mediaOpen,
onMediaToggle,
onMediaChange,
onRemove,
onFocus,
onBlur,
}: MediaPopoverProps<T>) => {
const theme = useTheme();
const buttonRef = useRef<HTMLButtonElement | null>(null);
const urlRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLInputElement | null>(null);
const [editing, setEditing] = useState(inserting);
const hasEditorFocus = useFocused();
const [hasFocus, setHasFocus] = useState(false);
const debouncedHasFocus = useDebounce(hasFocus, 150);
const handleClose = useCallback(
(shouldFocus: boolean) => {
onClose(shouldFocus);
if (!inserting) {
setEditing(false);
}
},
[inserting, onClose],
);
const isMediaAsset = useIsMediaAsset(url, collection, field, entry);
const mediaLibraryFieldOptions = useMemo(() => {
return field.media_library ?? {};
}, [field.media_library]);
const chooseUrl = useMemo(
() => 'choose_url' in mediaLibraryFieldOptions && (mediaLibraryFieldOptions.choose_url ?? true),
[mediaLibraryFieldOptions],
);
const urlDisabled = useMemo(
() => !chooseUrl && isMediaAsset && forImage,
[chooseUrl, forImage, isMediaAsset],
);
useEffect(() => {
if (anchorEl) {
if (!editing) {
return;
}
if (urlDisabled) {
setTimeout(() => {
textRef.current?.focus();
});
return;
}
setTimeout(() => {
urlRef.current?.focus();
});
return;
}
if (!inserting) {
setEditing(false);
}
}, [anchorEl, editing, inserting, urlDisabled]);
const [
{ prevAnchorEl, prevHasEditorFocus, prevHasFocus, prevDebouncedHasFocus },
setPrevFocusState,
] = useState<{
prevAnchorEl: HTMLElement | null;
prevHasEditorFocus: boolean;
prevHasFocus: boolean;
prevDebouncedHasFocus: boolean;
}>({
prevAnchorEl: anchorEl,
prevHasEditorFocus: hasEditorFocus,
prevHasFocus: hasFocus,
prevDebouncedHasFocus: debouncedHasFocus,
});
useEffect(() => {
if (mediaOpen) {
return;
}
if (anchorEl && !prevHasEditorFocus && hasEditorFocus) {
handleClose(false);
}
if (anchorEl && (prevHasFocus || prevDebouncedHasFocus) && !hasFocus && !debouncedHasFocus) {
handleClose(false);
}
setPrevFocusState({
prevAnchorEl: anchorEl,
prevHasEditorFocus: hasEditorFocus,
prevHasFocus: hasFocus,
prevDebouncedHasFocus: debouncedHasFocus,
});
}, [
anchorEl,
debouncedHasFocus,
handleClose,
hasEditorFocus,
hasFocus,
mediaOpen,
prevAnchorEl,
prevDebouncedHasFocus,
prevHasEditorFocus,
prevHasFocus,
]);
const handleFocus = useCallback(() => {
setHasFocus(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(() => {
setHasFocus(false);
onBlur?.();
}, [onBlur]);
const handleMediaChange = useCallback(
(newValue: string) => {
onMediaChange(newValue);
onMediaToggle?.(false);
},
[onMediaChange, onMediaToggle],
);
const handleOpenMediaLibrary = useMediaInsert(url, { field, forImage }, handleMediaChange);
const handleMediaOpen = useCallback(() => {
onMediaToggle?.(true);
handleOpenMediaLibrary();
}, [handleOpenMediaLibrary, onMediaToggle]);
const handleUrlChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onUrlChange(event.target.value);
},
[onUrlChange],
);
const handleTextChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onTextChange?.(event.target.value);
},
[onTextChange],
);
const handleEditStart = useCallback(() => {
setEditing(true);
}, []);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.stopPropagation();
event.preventDefault();
handleClose(true);
if (!inserting) {
setTimeout(() => {
setEditing(false);
});
}
}
},
[inserting, handleClose],
);
const open = Boolean(anchorEl);
const id = open ? 'edit-popover' : undefined;
return (
<Popper
id={id}
open={open}
anchorEl={anchorEl}
placeholder="bottom"
container={containerRef}
sx={{ zIndex: 100 }}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={0}
>
{!editing ? (
<StyledPopoverContent key="edit-content" contentEditable={false}>
<Button
ref={buttonRef}
size="small"
color="inherit"
sx={{
padding: '4px 8px',
textTransform: 'none',
color: theme.palette.text.secondary,
}}
onClick={handleEditStart}
>
{forImage ? 'Edit Image' : 'Edit Link'}
</Button>
<StyledFloatingVerticalDivider />
{!forImage ? (
<Button
size="small"
color="inherit"
sx={{ padding: '4px', minWidth: 'unset', color: theme.palette.text.secondary }}
href={url}
target="_blank"
>
<OpenInNewIcon />
</Button>
) : null}
<Button
size="small"
color="inherit"
sx={{ padding: '4px', minWidth: 'unset', color: theme.palette.text.secondary }}
onClick={onRemove}
>
<DeleteForeverIcon />
</Button>
</StyledPopoverContent>
) : (
<StyledPopoverEditingContent key="editing-content" contentEditable={false}>
<TextField
key="url-input"
inputRef={urlRef}
id="url"
label="Source"
variant="outlined"
value={url}
onKeyDown={handleKeyDown}
onChange={handleUrlChange}
fullWidth
size="small"
disabled={urlDisabled}
/>
{!inserting || !forImage ? (
<TextField
key="text-input"
inputRef={textRef}
id="text"
label={textLabel}
variant="outlined"
value={text}
onKeyDown={handleKeyDown}
onChange={handleTextChange}
fullWidth
size="small"
/>
) : null}
<Button fullWidth onClick={handleMediaOpen}>
Open Media Library
</Button>
</StyledPopoverEditingContent>
)}
</Popper>
);
};
export default MediaPopover;

View File

@ -0,0 +1,2 @@
export { default as MediaPopover } from './MediaPopover';
export * from './MediaPopover';

View File

@ -0,0 +1,6 @@
export * from './balloon-toolbar';
export * from './buttons';
export * from './color-picker';
export * from './common';
export * from './nodes';
export * from './toolbar';

View File

@ -0,0 +1,21 @@
import Box from '@mui/system/Box';
import React from 'react';
import type { MdBlockquoteElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const BlockquoteElement: FC<PlateRenderElementProps<MdValue, MdBlockquoteElement>> = ({
children,
}) => {
return (
<Box
component="blockquote"
sx={{ borderLeft: '2px solid rgba(209,213,219,0.5)', marginLeft: '8px', paddingLeft: '8px' }}
>
{children}
</Box>
);
};
export default BlockquoteElement;

View File

@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as BlockquoteElement } from './BlockquoteElement';

View File

@ -0,0 +1,162 @@
import { styled } from '@mui/material/styles';
import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Frame from 'react-frame-component';
import Outline from '@staticcms/core/components/UI/Outline';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import CodeBlockFrame from './CodeBlockFrame';
import type { MdCodeBlockElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps, TCodeBlockElement } from '@udecode/plate';
import type { FC, MutableRefObject, RefObject } from 'react';
const StyledCodeBlock = styled('div')`
position: relative;
margin: 12px 0;
overflow: hidden;
display: flex;
flex-direction: column;
`;
const StyledInput = styled('input')`
flex-grow: 1;
outline: none;
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.35);
border-radius: 4px 4px 0 0;
width: 100%;
height: 34px;
`;
const StyledCodeBlockContent = styled('div')`
position: relative;
display: flex;
& div {
outline: none;
}
`;
const StyledHiddenChildren = styled('div')`
height: 0;
position: absolute;
`;
const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>> = props => {
const [langHasFocus, setLangHasFocus] = useState(false);
const [codeHasFocus, setCodeHasFocus] = useState(false);
const { attributes, nodeProps, element, editor, children } = props;
const id = useUUID();
const lang = ('lang' in element ? element.lang : '') as string | undefined;
const code = ('code' in element ? element.code ?? '' : '') as string;
const handleChange = useCallback(
(value: string) => {
const path = findNodePath(editor, element);
path && setNodes<TCodeBlockElement>(editor, { code: value }, { at: path });
},
[editor, element],
);
const receiveMessage = useCallback(
(event: MessageEvent) => {
switch (event.data.message) {
case `code_block_${id}_onChange`:
handleChange(event.data.value);
break;
case `code_block_${id}_onFocus`:
setCodeHasFocus(true);
break;
case `code_block_${id}_onBlur`:
setCodeHasFocus(false);
break;
}
},
[handleChange, id],
);
useWindowEvent('message', receiveMessage);
const initialFrameContent = useMemo(
() => `
<!DOCTYPE html>
<html>
<head>
<base target="_blank"/>
<style>
body {
margin: 0;
overflow: hidden;
position: fixed;
top: 0;
width: 100%;
}
</style>
</head>
<body><div></div></body>
</html>
`,
[],
);
const [height, setHeight] = useState(24);
const iframeRef = useRef<Frame & HTMLIFrameElement>();
const handleResize = useCallback(
(iframe: MutableRefObject<(Frame & HTMLIFrameElement) | undefined>) => {
const height = iframe.current?.contentDocument?.body?.scrollHeight ?? 0;
if (height !== 0) {
setHeight(height);
}
},
[],
);
useEffect(() => handleResize(iframeRef), [handleResize, iframeRef, code]);
useEffect(() => {
setTimeout(() => handleResize(iframeRef), 500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<StyledCodeBlock {...attributes} {...nodeProps} contentEditable={false}>
<StyledInput
id={id}
value={lang}
onFocus={() => setLangHasFocus(true)}
onBlur={() => setLangHasFocus(false)}
onChange={event => {
const value = event.target.value;
const path = findNodePath(editor, element);
path && setNodes<TCodeBlockElement>(editor, { lang: value }, { at: path });
}}
/>
<StyledCodeBlockContent>
<Frame
key={`code-frame-${id}`}
id={id}
ref={iframeRef as RefObject<Frame> & RefObject<HTMLIFrameElement>}
style={{
border: 'none',
width: '100%',
height,
overflow: 'hidden',
}}
initialContent={initialFrameContent}
>
<CodeBlockFrame id={id} code={code} lang={lang} />
</Frame>
</StyledCodeBlockContent>
<Outline active={langHasFocus || codeHasFocus} />
<StyledHiddenChildren>{children}</StyledHiddenChildren>
</StyledCodeBlock>
</>
);
};
export default CodeBlockElement;

View File

@ -0,0 +1,75 @@
import { indentWithTab } from '@codemirror/commands';
import { keymap } from '@codemirror/view';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror';
import { basicSetup } from 'codemirror';
import React, { useCallback, useMemo } from 'react';
import { useFrame } from 'react-frame-component';
import languages from '@staticcms/code/data/languages';
import type { FC } from 'react';
export interface CodeBlockFrameProps {
id: string;
lang?: string;
code: string;
}
const CodeBlockFrame: FC<CodeBlockFrameProps> = ({ id, lang, code }) => {
const { window } = useFrame();
const loadedLangExtension = useMemo(() => {
if (!lang) {
return null;
}
const languageName = languages.find(language =>
language.identifiers.includes(lang),
)?.codemirror_mode;
if (!languageName) {
return null;
}
return loadLanguage(languageName);
}, [lang]);
const extensions = useMemo(() => {
const coreExtensions = [basicSetup, keymap.of([indentWithTab])];
if (!loadedLangExtension) {
return coreExtensions;
}
return [...coreExtensions, loadedLangExtension];
}, [loadedLangExtension]);
const handleChange = useCallback(
(value: string) => {
window?.parent.postMessage({ message: `code_block_${id}_onChange`, value });
},
[id, window],
);
const handleFocus = useCallback(() => {
window?.parent.postMessage({ message: `code_block_${id}_onFocus` });
}, [id, window?.parent]);
const handleBlur = useCallback(() => {
window?.parent.postMessage({ message: `code_block_${id}_onBlur` });
}, [id, window?.parent]);
return (
<CodeMirror
value={code}
height="auto"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
extensions={extensions}
/>
);
};
export default CodeBlockFrame;

View File

@ -0,0 +1,3 @@
export { default as CodeBlockElement } from './CodeBlockElement';
export * from './CodeBlockFrame';
export { default as CodeBlockFrame } from './CodeBlockFrame';

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { MdH1Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const Heading1: FC<PlateRenderElementProps<MdValue, MdH1Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h1 {...attributes} {...nodeProps}>
{children}
</h1>
);
};
export default Heading1;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import type { MdH2Element, MdValue } from '@staticcms/markdown';
const Heading2: FC<PlateRenderElementProps<MdValue, MdH2Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h2 {...attributes} {...nodeProps}>
{children}
</h2>
);
};
export default Heading2;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { MdH3Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const Heading3: FC<PlateRenderElementProps<MdValue, MdH3Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h3 {...attributes} {...nodeProps}>
{children}
</h3>
);
};
export default Heading3;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { MdH4Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const Heading4: FC<PlateRenderElementProps<MdValue, MdH4Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h4 {...attributes} {...nodeProps}>
{children}
</h4>
);
};
export default Heading4;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { MdH5Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const Heading5: FC<PlateRenderElementProps<MdValue, MdH5Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h5 {...attributes} {...nodeProps}>
{children}
</h5>
);
};
export default Heading5;

View File

@ -0,0 +1,19 @@
import React from 'react';
import type { MdH6Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const Heading6: FC<PlateRenderElementProps<MdValue, MdH6Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h6 {...attributes} {...nodeProps}>
{children}
</h6>
);
};
export default Heading6;

View File

@ -0,0 +1,6 @@
export { default as Heading1 } from './Heading1';
export { default as Heading2 } from './Heading2';
export { default as Heading3 } from './Heading3';
export { default as Heading4 } from './Heading4';
export { default as Heading5 } from './Heading5';
export { default as Heading6 } from './Heading6';

View File

@ -0,0 +1,18 @@
import React from 'react';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { MdValue, MdHrElement } from '@staticcms/markdown';
import type { FC } from 'react';
const HrElement: FC<PlateRenderElementProps<MdValue, MdHrElement>> = props => {
const { attributes, children, nodeProps } = props;
return (
<div {...attributes} {...nodeProps}>
<hr contentEditable={false} {...nodeProps} />
{children}
</div>
);
};
export default HrElement;

View File

@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as HrElement } from './HrElement';

View File

@ -0,0 +1,2 @@
export { default as withImageElement } from './withImageElement';
export * from './withImageElement';

View File

@ -0,0 +1,152 @@
import {
findNodePath,
getNode,
removeNodes,
setNodes,
setSelection,
usePlateSelection,
} from '@udecode/plate';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { MediaPopover } from '@staticcms/markdown';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { MdImageElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { TMediaElement } from '@udecode/plate-media';
import type { FC } from 'react';
export interface WithImageElementProps {
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
entry: Entry;
field: MarkdownField;
}
const withImageElement = ({ containerRef, collection, entry, field }: WithImageElementProps) => {
const ImageElement: FC<PlateRenderElementProps<MdValue, MdImageElement>> = ({
element,
editor,
children,
}) => {
const { url, alt } = element;
const [internalUrl, setInternalUrl] = useState(url);
const [internalAlt, setInternalAlt] = useState(alt);
const imageRef = useRef<HTMLImageElement | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLImageElement | null>(null);
const hasEditorFocus = useFocused();
const handleBlur = useCallback(() => {
setAnchorEl(null);
}, []);
const handleChange = useCallback(
(value: string, key: string) => {
const path = findNodePath(editor, element);
path && setNodes<TMediaElement>(editor, { [key]: value }, { at: path });
},
[editor, element],
);
const handleOpenPopover = useCallback(() => {
const path = findNodePath(editor, element);
let selection = editor.prevSelection!;
if (path) {
const childPath = [...path, 0];
selection = {
anchor: {
path: childPath,
offset: 0,
},
focus: {
path: childPath,
offset: 0,
},
};
}
setSelection(editor, selection);
setAnchorEl(imageRef.current);
}, [editor, element]);
const handleClose = useCallback(() => {
setAnchorEl(null);
handleChange(internalUrl, 'url');
handleChange(internalAlt ?? '', 'alt');
}, [handleChange, internalAlt, internalUrl]);
const assetSource = useMediaAsset(url, collection, field, entry);
const handleMediaChange = useCallback(
(newValue: string) => {
handleChange(newValue, 'url');
setInternalUrl(newValue);
},
[handleChange],
);
const handleRemove = useCallback(() => {
const path = findNodePath(editor, element);
removeNodes(editor, { at: path });
}, [editor, element]);
const selection = usePlateSelection();
useEffect(() => {
if (!hasEditorFocus || !selection) {
return;
}
const node = getNode(editor, selection.anchor.path);
const firstChild =
'children' in element && element.children.length > 0 ? element.children[0] : undefined;
if (!node) {
return;
}
if (node !== element && node !== firstChild) {
handleClose();
return;
}
handleOpenPopover();
}, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]);
return (
<span onBlur={handleBlur}>
<img
ref={imageRef}
src={assetSource}
alt={!isEmpty(alt) ? alt : undefined}
draggable={false}
onClick={handleOpenPopover}
/>
<MediaPopover
anchorEl={anchorEl}
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalAlt ?? ''}
textLabel="Alt"
onUrlChange={setInternalUrl}
onTextChange={setInternalAlt}
onClose={handleClose}
onMediaChange={handleMediaChange}
onRemove={handleRemove}
forImage
/>
{children}
</span>
);
};
return ImageElement;
};
export default withImageElement;

View File

@ -0,0 +1,10 @@
export * from './blockquote';
export * from './code-block';
export * from './headings';
export * from './horizontal-rule';
export * from './image';
export * from './link';
export * from './list';
export * from './paragraph';
export * from './shortcode';
export * from './table';

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as withLinkElement } from './withLinkElement';

View File

@ -0,0 +1,101 @@
import {
findNodePath,
focusEditor,
getEditorString,
setNodes,
unwrapLink,
upsertLink,
} from '@udecode/plate';
import React, { useCallback, useState } from 'react';
import MediaPopover from '../../common/MediaPopover';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { MdLinkElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC, MouseEvent } from 'react';
export interface WithLinkElementProps {
containerRef: HTMLElement | null;
collection: Collection<MarkdownField>;
field: MarkdownField;
entry: Entry;
}
const withLinkElement = ({ containerRef, collection, field, entry }: WithLinkElementProps) => {
const LinkElement: FC<PlateRenderElementProps<MdValue, MdLinkElement>> = ({
attributes,
children,
nodeProps,
element,
editor,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLAnchorElement | null>(null);
const { url } = element;
const path = findNodePath(editor, element);
const [internalUrl, setInternalUrl] = useState(url);
const [internalText, setInternalText] = useState(getEditorString(editor, path));
const handleClick = useCallback((event: MouseEvent<HTMLAnchorElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleRemove = useCallback(() => {
if (!editor.selection) {
return;
}
unwrapLink(editor);
focusEditor(editor, editor.selection);
}, [editor]);
const handleChange = useCallback(
(newUrl: string, newText: string) => {
const path = findNodePath(editor, element);
path && setNodes<MdLinkElement>(editor, { url: newUrl }, { at: path });
upsertLink(editor, { url: newUrl, text: newText });
},
[editor, element],
);
const handleMediaChange = useCallback(
(newValue: string) => {
handleChange(newValue, internalText);
setInternalUrl(newValue);
},
[handleChange, internalText],
);
const handleClose = useCallback(() => {
setAnchorEl(null);
handleChange(internalUrl, internalText);
}, [handleChange, internalText, internalUrl]);
return (
<>
<a {...attributes} href={url} {...nodeProps} onClick={handleClick}>
{children}
</a>
<MediaPopover
anchorEl={anchorEl}
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
url={internalUrl}
text={internalText}
onUrlChange={setInternalUrl}
onTextChange={setInternalText}
onClose={handleClose}
onMediaChange={handleMediaChange}
onRemove={handleRemove}
/>
</>
);
};
return LinkElement;
};
export default withLinkElement;

View File

@ -0,0 +1,13 @@
import React from 'react';
import type { MdListItemElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const ListItemContentElement: FC<PlateRenderElementProps<MdValue, MdListItemElement>> = ({
children,
}) => {
return <span>{children}</span>;
};
export default ListItemContentElement;

View File

@ -0,0 +1,36 @@
import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback } from 'react';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import type { MdListItemElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { ChangeEvent, FC } from 'react';
const ListItemElement: FC<PlateRenderElementProps<MdValue, MdListItemElement>> = ({
children,
editor,
element,
}) => {
const checked = element.checked;
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.checked;
const path = findNodePath(editor, element);
path && setNodes<MdListItemElement>(editor, { checked: value }, { at: path });
},
[editor, element],
);
return (
<li>
{isNotNullish(checked) ? (
<input type="checkbox" checked={checked} onChange={handleChange} />
) : null}
{children}
</li>
);
};
export default ListItemElement;

View File

@ -0,0 +1,13 @@
import React from 'react';
import type { MdNumberedListElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
const OrderedListElement: FC<PlateRenderElementProps<MdValue, MdNumberedListElement>> = ({
children,
}) => {
return <ol>{children}</ol>;
};
export default OrderedListElement;

Some files were not shown because too many files have changed in this diff Show More