UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

142 lines 6.61 kB
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 `![pasted image](data:...)` 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 = `![pasted image](${base64})`; 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