refactor: monorepo setup with lerna (#243)
This commit is contained in:
committed by
GitHub
parent
dac29fbf3c
commit
504d95c34f
38
packages/core/src/widgets/boolean/BooleanControl.tsx
Normal file
38
packages/core/src/widgets/boolean/BooleanControl.tsx
Normal 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;
|
21
packages/core/src/widgets/boolean/index.ts
Normal file
21
packages/core/src/widgets/boolean/index.ts
Normal 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;
|
5
packages/core/src/widgets/boolean/schema.ts
Normal file
5
packages/core/src/widgets/boolean/schema.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
properties: {
|
||||
default: { type: 'boolean' },
|
||||
},
|
||||
};
|
224
packages/core/src/widgets/code/CodeControl.tsx
Normal file
224
packages/core/src/widgets/code/CodeControl.tsx
Normal 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;
|
34
packages/core/src/widgets/code/CodePreview.tsx
Normal file
34
packages/core/src/widgets/code/CodePreview.tsx
Normal 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;
|
36
packages/core/src/widgets/code/SettingsButton.tsx
Normal file
36
packages/core/src/widgets/code/SettingsButton.tsx
Normal 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;
|
131
packages/core/src/widgets/code/SettingsPane.tsx
Normal file
131
packages/core/src/widgets/code/SettingsPane.tsx
Normal 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;
|
6133
packages/core/src/widgets/code/data/languages-raw.yml
Normal file
6133
packages/core/src/widgets/code/data/languages-raw.yml
Normal file
File diff suppressed because it is too large
Load Diff
1508
packages/core/src/widgets/code/data/languages.ts
Normal file
1508
packages/core/src/widgets/code/data/languages.ts
Normal file
File diff suppressed because it is too large
Load Diff
47
packages/core/src/widgets/code/index.ts
Normal file
47
packages/core/src/widgets/code/index.ts
Normal 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;
|
38
packages/core/src/widgets/code/languageSelectStyles.ts
Normal file
38
packages/core/src/widgets/code/languageSelectStyles.ts
Normal 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;
|
14
packages/core/src/widgets/code/schema.ts
Normal file
14
packages/core/src/widgets/code/schema.ts
Normal 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' }],
|
||||
},
|
||||
},
|
||||
};
|
63
packages/core/src/widgets/code/scripts/process-languages.ts
Normal file
63
packages/core/src/widgets/code/scripts/process-languages.ts
Normal 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();
|
225
packages/core/src/widgets/colorstring/ColorControl.tsx
Normal file
225
packages/core/src/widgets/colorstring/ColorControl.tsx
Normal 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;
|
12
packages/core/src/widgets/colorstring/ColorPreview.tsx
Normal file
12
packages/core/src/widgets/colorstring/ColorPreview.tsx
Normal 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;
|
24
packages/core/src/widgets/colorstring/index.ts
Normal file
24
packages/core/src/widgets/colorstring/index.ts
Normal 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;
|
5
packages/core/src/widgets/colorstring/schema.ts
Normal file
5
packages/core/src/widgets/colorstring/schema.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
properties: {
|
||||
default: { type: 'string' },
|
||||
},
|
||||
};
|
268
packages/core/src/widgets/datetime/DateTimeControl.tsx
Normal file
268
packages/core/src/widgets/datetime/DateTimeControl.tsx
Normal 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;
|
12
packages/core/src/widgets/datetime/DateTimePreview.tsx
Normal file
12
packages/core/src/widgets/datetime/DateTimePreview.tsx
Normal 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;
|
60
packages/core/src/widgets/datetime/index.tsx
Normal file
60
packages/core/src/widgets/datetime/index.tsx
Normal 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;
|
9
packages/core/src/widgets/datetime/schema.ts
Normal file
9
packages/core/src/widgets/datetime/schema.ts
Normal 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' },
|
||||
},
|
||||
};
|
62
packages/core/src/widgets/file/FilePreview.tsx
Normal file
62
packages/core/src/widgets/file/FilePreview.tsx
Normal 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;
|
30
packages/core/src/widgets/file/index.ts
Normal file
30
packages/core/src/widgets/file/index.ts
Normal 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;
|
16
packages/core/src/widgets/file/schema.ts
Normal file
16
packages/core/src/widgets/file/schema.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
properties: {
|
||||
allow_multiple: { type: 'boolean' },
|
||||
default: {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
493
packages/core/src/widgets/file/withFileControl.tsx
Normal file
493
packages/core/src/widgets/file/withFileControl.tsx
Normal 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;
|
71
packages/core/src/widgets/image/ImagePreview.tsx
Normal file
71
packages/core/src/widgets/image/ImagePreview.tsx
Normal 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;
|
23
packages/core/src/widgets/image/index.ts
Normal file
23
packages/core/src/widgets/image/index.ts
Normal 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;
|
16
packages/core/src/widgets/image/schema.ts
Normal file
16
packages/core/src/widgets/image/schema.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
properties: {
|
||||
allow_multiple: { type: 'boolean' },
|
||||
default: {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
32
packages/core/src/widgets/index.tsx
Normal file
32
packages/core/src/widgets/index.tsx
Normal 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';
|
351
packages/core/src/widgets/list/ListControl.tsx
Normal file
351
packages/core/src/widgets/list/ListControl.tsx
Normal 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;
|
225
packages/core/src/widgets/list/ListItem.tsx
Normal file
225
packages/core/src/widgets/list/ListItem.tsx
Normal 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;
|
12
packages/core/src/widgets/list/ListPreview.tsx
Normal file
12
packages/core/src/widgets/list/ListPreview.tsx
Normal 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;
|
22
packages/core/src/widgets/list/index.ts
Normal file
22
packages/core/src/widgets/list/index.ts
Normal 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;
|
11
packages/core/src/widgets/list/schema.ts
Normal file
11
packages/core/src/widgets/list/schema.ts
Normal 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' },
|
||||
},
|
||||
};
|
52
packages/core/src/widgets/list/typedListHelpers.ts
Normal file
52
packages/core/src/widgets/list/typedListHelpers.ts
Normal 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;
|
||||
}
|
12
packages/core/src/widgets/map/MapPreview.tsx
Normal file
12
packages/core/src/widgets/map/MapPreview.tsx
Normal 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;
|
22
packages/core/src/widgets/map/index.ts
Normal file
22
packages/core/src/widgets/map/index.ts
Normal 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;
|
6
packages/core/src/widgets/map/schema.ts
Normal file
6
packages/core/src/widgets/map/schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
properties: {
|
||||
decimals: { type: 'integer' },
|
||||
type: { type: 'string', enum: ['Point', 'LineString', 'Polygon'] },
|
||||
},
|
||||
};
|
153
packages/core/src/widgets/map/withMapControl.tsx
Normal file
153
packages/core/src/widgets/map/withMapControl.tsx
Normal 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;
|
79
packages/core/src/widgets/markdown/MarkdownPreview.tsx
Normal file
79
packages/core/src/widgets/markdown/MarkdownPreview.tsx
Normal 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;
|
27
packages/core/src/widgets/markdown/index.ts
Normal file
27
packages/core/src/widgets/markdown/index.ts
Normal 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;
|
2
packages/core/src/widgets/markdown/mdx/index.ts
Normal file
2
packages/core/src/widgets/markdown/mdx/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './withShortcodeMdxComponent';
|
||||
export { default as withShortcodeElement } from './withShortcodeMdxComponent';
|
@ -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;
|
327
packages/core/src/widgets/markdown/plate/PlateEditor.tsx
Normal file
327
packages/core/src/widgets/markdown/plate/PlateEditor.tsx
Normal 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;
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
export * from './BalloonToolbar';
|
||||
export { default as BalloonToolbar } from './BalloonToolbar';
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
export * from './ToolbarDropdown';
|
||||
export { default as ToolbarDropdown } from './ToolbarDropdown';
|
@ -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';
|
@ -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';
|
@ -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' }} />
|
||||
) : (
|
||||
<> </>
|
||||
)}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorButton;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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';
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
export { default as MediaPopover } from './MediaPopover';
|
||||
export * from './MediaPopover';
|
@ -0,0 +1,6 @@
|
||||
export * from './balloon-toolbar';
|
||||
export * from './buttons';
|
||||
export * from './color-picker';
|
||||
export * from './common';
|
||||
export * from './nodes';
|
||||
export * from './toolbar';
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BlockquoteElement } from './BlockquoteElement';
|
@ -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;
|
@ -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;
|
@ -0,0 +1,3 @@
|
||||
export { default as CodeBlockElement } from './CodeBlockElement';
|
||||
export * from './CodeBlockFrame';
|
||||
export { default as CodeBlockFrame } from './CodeBlockFrame';
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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';
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as HrElement } from './HrElement';
|
@ -0,0 +1,2 @@
|
||||
export { default as withImageElement } from './withImageElement';
|
||||
export * from './withImageElement';
|
@ -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;
|
@ -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';
|
@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as withLinkElement } from './withLinkElement';
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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
Reference in New Issue
Block a user