@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
142 lines • 6.61 kB
JavaScript
import { Shade, createComponent } from '@furystack/shades';
import { cssVariableTheme } from '../../services/css-variable-theme.js';
import { FormContextToken } from '../form.js';
import { resolveValidationState } from './markdown-validation.js';
const DEFAULT_MAX_IMAGE_SIZE = 256 * 1024;
/**
* Markdown text input with base64 image paste support.
* When the user pastes an image below the configured size limit,
* it is inlined as a `` Markdown image.
*/
export const MarkdownInput = Shade({
customElementName: 'shade-markdown-input',
css: {
display: 'block',
fontFamily: cssVariableTheme.typography.fontFamily,
marginBottom: '1em',
'& label': {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
fontSize: cssVariableTheme.typography.fontSize.xs,
color: cssVariableTheme.text.secondary,
padding: '1em',
borderRadius: cssVariableTheme.shape.borderRadius.md,
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
transition: `color ${cssVariableTheme.transitions.duration.slow} ${cssVariableTheme.transitions.easing.default}`,
},
'&[data-disabled] label': {
color: cssVariableTheme.text.disabled,
},
'&:focus-within label': {
color: cssVariableTheme.palette.primary.main,
},
'&[data-invalid] label': {
borderColor: cssVariableTheme.palette.error.main,
color: cssVariableTheme.palette.error.main,
},
'& textarea': {
border: 'none',
backgroundColor: 'transparent',
outline: 'none',
fontSize: cssVariableTheme.typography.fontSize.sm,
fontFamily: 'monospace',
width: '100%',
resize: 'vertical',
color: cssVariableTheme.text.primary,
boxShadow: '0px 0px 0px rgba(128,128,128,0.1)',
transition: `box-shadow ${cssVariableTheme.transitions.duration.normal} ease`,
lineHeight: cssVariableTheme.typography.lineHeight.relaxed,
padding: `${cssVariableTheme.spacing.sm} 0`,
},
'&:focus-within textarea': {
boxShadow: `0px 3px 0px ${cssVariableTheme.palette.primary.main}`,
},
'&[data-invalid]:focus-within textarea': {
boxShadow: `0px 3px 0px ${cssVariableTheme.palette.error.main}`,
},
'& .helperText': {
fontSize: cssVariableTheme.typography.fontSize.xs,
marginTop: '6px',
opacity: '0.85',
lineHeight: '1.4',
},
},
render: ({ props, injector, useDisposable, useHostProps, useRef }) => {
const maxSize = props.maxImageSizeBytes ?? DEFAULT_MAX_IMAGE_SIZE;
const textareaRef = useRef('textarea');
useDisposable('form-registration', () => {
const formService = injector.get(FormContextToken);
if (formService) {
queueMicrotask(() => {
if (textareaRef.current)
formService.inputs.add(textareaRef.current);
});
}
return {
[Symbol.dispose]: () => {
if (textareaRef.current && formService)
formService.inputs.delete(textareaRef.current);
},
};
});
const { validationResult, isRequired, isInvalid, helperNode } = resolveValidationState(props);
const formServiceForValidity = injector.get(FormContextToken);
if (formServiceForValidity && props.name) {
const fieldResult = isRequired
? { isValid: false, message: 'Value is required' }
: validationResult || { isValid: true };
const validity = textareaRef.current?.validity ?? {};
formServiceForValidity.setFieldState(props.name, fieldResult, validity);
}
useHostProps({
'data-disabled': props.disabled ? '' : undefined,
'data-invalid': isInvalid ? '' : undefined,
});
const handleInput = (ev) => {
const target = ev.target;
props.onValueChange?.(target.value);
};
const handlePaste = (ev) => {
const items = ev.clipboardData?.items;
if (!items)
return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file || file.size > maxSize)
continue;
ev.preventDefault();
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result;
const textarea = textareaRef.current;
if (!textarea)
return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
const imageMarkdown = ``;
const newValue = before + imageMarkdown + after;
textarea.value = newValue;
const cursorPos = start + imageMarkdown.length;
textarea.setSelectionRange(cursorPos, cursorPos);
props.onValueChange?.(newValue);
};
reader.onerror = () => {
console.warn('Failed to read pasted image file');
};
reader.readAsDataURL(file);
return;
}
}
};
return (createComponent("label", null,
!props.hideChrome && props.labelTitle ? createComponent("span", null, props.labelTitle) : null,
createComponent("textarea", { ref: textareaRef, name: props.name, required: props.required, value: props.value, oninput: handleInput, onpaste: handlePaste, readOnly: props.readOnly, disabled: props.disabled, placeholder: props.placeholder, rows: props.rows ?? 10 }),
!props.hideChrome && helperNode ? createComponent("span", { className: "helperText" }, helperNode) : null));
},
});
//# sourceMappingURL=markdown-input.js.map