This commit is contained in:
Dan Harrin
2025-09-16 22:18:58 +01:00
parent cd1dcedcdb
commit 90f25fc520
13 changed files with 243 additions and 239 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -31,6 +31,10 @@
}
}
& .fi-fo-rich-editor-file-validation-message {
@apply border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-200 flex items-center gap-x-3 border-b px-5 py-1.5 text-sm leading-6 font-medium dark:border-white/10 dark:bg-white/5;
}
& .fi-fo-rich-editor-main {
@apply flex flex-col-reverse;
}
@@ -124,7 +124,7 @@ export default function fileUploadFormComponent({
itemInsertLocation: shouldAppendFiles ? 'after' : 'before',
...(placeholder && { labelIdle: placeholder }),
maxFiles,
maxFileSize: maxSize,
fileAttachmentsMaxFileSize: maxSize,
minFileSize: minSize,
...(maxParallelUploads && { maxParallelUploads }),
styleButtonProcessItemPosition: uploadButtonPosition,
@@ -4,6 +4,8 @@ import { Selection } from '@tiptap/pm/state'
import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
export default function richEditorFormComponent({
acceptedFileTypes,
acceptedFileTypesValidationMessage,
activePanel,
deleteCustomBlockButtonIconHtml,
editCustomBlockButtonIconHtml,
@@ -14,16 +16,14 @@ export default function richEditorFormComponent({
isLiveOnBlur,
liveDebounce,
livewireId,
maxFileSize,
maxFileSizeValidationMessage,
mergeTags,
noMergeTagSearchResultsMessage,
placeholder,
state,
statePath,
uploadingFileMessage,
allowedMimeTypes,
maxFileSize,
fileSizeExceededMessage,
invalidMimeTypeMessage,
floatingToolbars,
}) {
let editor
@@ -37,6 +37,8 @@ export default function richEditorFormComponent({
isUploadingFile: false,
fileValidationMessage: null,
shouldUpdateState: true,
editorUpdatedAt: Date.now(),
@@ -46,6 +48,8 @@ export default function richEditorFormComponent({
editable: !isDisabled,
element: this.$refs.editor,
extensions: await getExtensions({
acceptedFileTypes,
acceptedFileTypesValidationMessage,
customExtensionUrls: extensions,
deleteCustomBlockButtonIconHtml,
editCustomBlockButtonIconHtml,
@@ -67,15 +71,13 @@ export default function richEditorFormComponent({
{ schemaComponent: key },
),
key,
maxFileSize,
maxFileSizeValidationMessage,
mergeTags,
noMergeTagSearchResultsMessage,
placeholder,
statePath,
uploadingFileMessage,
allowedMimeTypes,
maxFileSize,
fileSizeExceededMessage,
invalidMimeTypeMessage,
$wire: this.$wire,
floatingToolbars,
}),
@@ -123,6 +125,8 @@ export default function richEditorFormComponent({
this.shouldUpdateState = false
this.fileValidationMessage = null
if (isLiveDebounced) {
debouncedCommit()
}
@@ -170,6 +174,7 @@ export default function richEditorFormComponent({
}
this.isUploadingFile = true
this.fileValidationMessage = null
event.stopPropagation()
})
@@ -188,6 +193,24 @@ export default function richEditorFormComponent({
event.stopPropagation()
})
window.addEventListener(
'rich-editor-file-validation-message',
(event) => {
if (event.detail.livewireId !== livewireId) {
return
}
if (event.detail.key !== key) {
return
}
this.isUploadingFile = false
this.fileValidationMessage = event.detail.validationMessage
event.stopPropagation()
},
)
window.dispatchEvent(
new CustomEvent(`schema-component-${livewireId}-${key}-loaded`),
)
@@ -1,39 +1,6 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
const defaultAllowedMimeTypes = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]
const defaultMaxFileSize = 10 * 1024 * 1024 // 10MB in bytes
const defaultFileSizeExceededMessage = 'File size exceeds maximum allowed'
const defaultInvalidMimeTypeMessage = 'File type is not allowed'
const validateFile = (
file,
allowedMimeTypes,
maxFileSize,
invalidMimeTypeMessage,
fileSizeExceededMessage,
) => {
const errors = []
if (!allowedMimeTypes.includes(file.type)) {
errors.push(invalidMimeTypeMessage || defaultInvalidMimeTypeMessage)
}
if (file.size > +maxFileSize * 1024) {
errors.push(fileSizeExceededMessage || defaultFileSizeExceededMessage)
}
return {
isValid: errors.length === 0,
errors,
}
}
const dispatchFormEvent = (editorView, name, detail = {}) => {
editorView.dom.closest('form')?.dispatchEvent(
new CustomEvent(name, {
@@ -44,21 +11,37 @@ const dispatchFormEvent = (editorView, name, detail = {}) => {
)
}
const validateFiles = ({
files,
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
}) => {
for (const file of files) {
if (!acceptedTypes.includes(file.type)) {
return acceptedTypesValidationMessage
}
if (file.size > +maxSize * 1024) {
return maxSizeValidationMessage
}
}
return null
}
const LocalFilesPlugin = ({
editor,
acceptedTypes,
acceptedTypesValidationMessage,
get$WireUsing,
key,
maxSize,
maxSizeValidationMessage,
statePath,
uploadingMessage,
allowedMimeTypes,
maxFileSize,
fileSizeExceededMessage,
invalidMimeTypeMessage,
}) => {
// Use defaults if not provided from PHP
const effectiveAllowedMimeTypes =
allowedMimeTypes || defaultAllowedMimeTypes
const effectiveMaxFileSize = maxFileSize || defaultMaxFileSize
const getFileAttachmentUrl = (fileKey) =>
get$WireUsing().callSchemaComponentMethod(
key,
@@ -76,44 +59,34 @@ const LocalFilesPlugin = ({
return false
}
const allFiles = Array.from(event.dataTransfer.files)
const validFiles = []
const rejectedFiles = []
const files = Array.from(event.dataTransfer.files)
allFiles.forEach((file) => {
const validation = validateFile(
file,
effectiveAllowedMimeTypes,
effectiveMaxFileSize,
invalidMimeTypeMessage,
fileSizeExceededMessage,
)
if (validation.isValid) {
validFiles.push(file)
} else {
rejectedFiles.push({ file, errors: validation.errors })
}
const validationMessage = validateFiles({
files,
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
})
if (rejectedFiles.length > 0) {
const errorMessages = rejectedFiles
.map(
({ file, errors }) =>
`<p>${file.name}: ${errors.join(', ')}</p>`,
)
.join('')
if (validationMessage) {
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-file-validation-message', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
validationMessage,
},
}),
)
new FilamentNotification()
.body(errorMessages)
.danger()
.send()
}
if (!validFiles.length) {
return false
}
const files = validFiles
if (!files.length) {
return false
}
dispatchFormEvent(editorView, 'form-processing-started', {
message: uploadingMessage,
@@ -139,22 +112,6 @@ const LocalFilesPlugin = ({
}),
)
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
editor
.chain()
.insertContentAt(position?.pos ?? 0, {
type: 'image',
attrs: {
class: 'fi-loading',
src: fileReader.result,
},
})
.run()
}
let fileKey = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c) =>
@@ -176,10 +133,12 @@ const LocalFilesPlugin = ({
editor
.chain()
.updateAttributes('image', {
class: null,
id: fileKey,
src: url,
.insertContentAt(position?.pos ?? 0, {
type: 'image',
attrs: {
id: fileKey,
src: url,
},
})
.run()
@@ -219,44 +178,34 @@ const LocalFilesPlugin = ({
return false
}
const allFiles = Array.from(event.clipboardData.files)
const validFiles = []
const rejectedFiles = []
const files = Array.from(event.clipboardData.files)
allFiles.forEach((file) => {
const validation = validateFile(
file,
effectiveAllowedMimeTypes,
effectiveMaxFileSize,
invalidMimeTypeMessage,
fileSizeExceededMessage,
)
if (validation.isValid) {
validFiles.push(file)
} else {
rejectedFiles.push({ file, errors: validation.errors })
}
const validationMessage = validateFiles({
files,
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
})
if (rejectedFiles.length > 0) {
const errorMessages = rejectedFiles
.map(
({ file, errors }) =>
`<p>${file.name}: ${errors.join(', ')}</p>`,
)
.join('')
if (validationMessage) {
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-file-validation-message', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
validationMessage,
},
}),
)
new FilamentNotification()
.body(errorMessages)
.danger()
.send()
}
if (!validFiles.length) {
return false
}
const files = validFiles
if (!files.length) {
return false
}
event.preventDefault()
event.stopPropagation()
@@ -277,22 +226,6 @@ const LocalFilesPlugin = ({
}),
)
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
editor
.chain()
.insertContentAt(editor.state.selection.anchor, {
type: 'image',
attrs: {
class: 'fi-loading',
src: fileReader.result,
},
})
.run()
}
let fileKey = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c) =>
@@ -314,11 +247,16 @@ const LocalFilesPlugin = ({
editor
.chain()
.updateAttributes('image', {
class: null,
id: fileKey,
src: url,
})
.insertContentAt(
editor.state.selection.anchor,
{
type: 'image',
attrs: {
id: fileKey,
src: url,
},
},
)
.run()
editor.setEditable(true)
@@ -357,14 +295,14 @@ export default Extension.create({
addOptions() {
return {
acceptedTypes: [],
acceptedTypesValidationMessage: null,
key: null,
maxSize: null,
maxSizeValidationMessage: null,
statePath: null,
uploadingMessage: null,
get$WireUsing: null,
allowedMimeTypes: defaultAllowedMimeTypes,
maxFileSize: defaultMaxFileSize,
fileSizeExceededMessage: defaultFileSizeExceededMessage,
invalidMimeTypeMessage: defaultInvalidMimeTypeMessage,
}
},
@@ -35,21 +35,21 @@ import Underline from '@tiptap/extension-underline'
import getMergeTagSuggestion from './merge-tag-suggestion.js'
export default async ({
acceptedFileTypes,
acceptedFileTypesValidationMessage,
customExtensionUrls,
deleteCustomBlockButtonIconHtml,
editCustomBlockButtonIconHtml,
editCustomBlockUsing,
insertCustomBlockUsing,
key,
maxFileSize,
maxFileSizeValidationMessage,
mergeTags,
noMergeTagSearchResultsMessage,
placeholder,
statePath,
uploadingFileMessage,
allowedMimeTypes,
maxFileSize,
fileSizeExceededMessage,
invalidMimeTypeMessage,
$wire,
}) => [
Blockquote,
@@ -84,14 +84,14 @@ export default async ({
}),
ListItem,
LocalFiles.configure({
acceptedTypes: acceptedFileTypes,
acceptedTypesValidationMessage: acceptedFileTypesValidationMessage,
get$WireUsing: () => $wire,
key,
maxSize: maxFileSize,
maxSizeValidationMessage: maxFileSizeValidationMessage,
statePath,
uploadingMessage: uploadingFileMessage,
allowedMimeTypes,
maxFileSize,
fileSizeExceededMessage,
invalidMimeTypeMessage,
}),
...(mergeTags.length
? [
@@ -469,6 +469,10 @@ return [
],
'file_attachments_accepted_file_types_message' => 'Uploaded files must be of type: :values.',
'file_attachments_max_size_message' => 'Uploaded files must not be greater than :max kilobytes.',
'no_merge_tag_search_results_message' => 'No merge tag results.',
'tools' => [
@@ -11,6 +11,8 @@
$tools = $getTools();
$toolbarButtons = $getToolbarButtons();
$floatingToolbars = $getFloatingToolbars();
$fileAttachmentsMaxSize = $getFileAttachmentsMaxSize();
$fileAttachmentsAcceptedFileTypes = $getFileAttachmentsAcceptedFileTypes();
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@@ -26,6 +28,8 @@
x-load
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('rich-editor', 'filament/forms') }}"
x-data="richEditorFormComponent({
acceptedFileTypes: @js($fileAttachmentsAcceptedFileTypes),
acceptedFileTypesValidationMessage: @js($fileAttachmentsAcceptedFileTypes ? __('filament-forms::components.rich_editor.file_attachments_accepted_file_types_message', ['values' => implode(', ', $fileAttachmentsAcceptedFileTypes)]) : null),
activePanel: @js($getActivePanel()),
deleteCustomBlockButtonIconHtml: @js(\Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::Trash, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_CUSTOM_BLOCK_DELETE_BUTTON)->toHtml()),
editCustomBlockButtonIconHtml: @js(\Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::PencilSquare, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_CUSTOM_BLOCK_EDIT_BUTTON)->toHtml()),
@@ -36,16 +40,14 @@
isLiveOnBlur: @js($isLiveOnBlur()),
liveDebounce: @js($getNormalizedLiveDebounce()),
livewireId: @js($this->getId()),
maxFileSize: @js($fileAttachmentsMaxSize),
maxFileSizeValidationMessage: @js($fileAttachmentsMaxSize ? trans_choice('filament-forms::components.rich_editor.file_attachments_max_size_message', $fileAttachmentsMaxSize, ['max' => $fileAttachmentsMaxSize]) : null),
mergeTags: @js($mergeTags),
noMergeTagSearchResultsMessage: @js($getNoMergeTagSearchResultsMessage()),
placeholder: @js($getPlaceholder()),
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')", isOptimisticallyLive: false) }},
statePath: @js($statePath),
uploadingFileMessage: @js($getUploadingFileMessage()),
allowedMimeTypes: @js($getAcceptedFileTypes()),
maxFileSize: @js($getMaxSize()),
fileSizeExceededMessage: @js(__('validation.max.file', ['attribute' => '', 'max' => $getMaxSize()])),
invalidMimeTypeMessage: @js(__('validation.mimetypes', ['attribute' => '', 'values' => $getAcceptedFileTypes() ? implode(', ', $getAcceptedFileTypes()) : ''])),
floatingToolbars: @js($floatingToolbars),
})"
x-bind:class="{
@@ -82,6 +84,17 @@
</span>
</div>
<div
x-show="! isUploadingFile && fileValidationMessage"
x-cloak
class="fi-fo-rich-editor-file-validation-message"
>
<span
x-text="fileValidationMessage"
x-show="! isUploadingFile && fileValidationMessage"
></span>
</div>
<div
{{ $getExtraInputAttributeBag()->class(['fi-fo-rich-editor-main']) }}
>
@@ -5,8 +5,11 @@ namespace Filament\Forms\Components\Concerns;
use Closure;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use League\Flysystem\UnableToCheckFileExistence;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Throwable;
@@ -23,6 +26,13 @@ trait HasFileAttachments
protected string | Closure | null $fileAttachmentsVisibility = null;
/**
* @var array<string> | Arrayable | Closure | null
*/
protected array | Arrayable | Closure | null $fileAttachmentsAcceptedFileTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
protected int | Closure | null $fileAttachmentsMaxSize = null;
public function fileAttachmentsDirectory(string | Closure | null $directory): static
{
$this->fileAttachmentsDirectory = $directory;
@@ -51,6 +61,26 @@ trait HasFileAttachments
$attachment = data_get($this->getLivewire(), "componentFileAttachments.{$this->getStatePath()}");
}
if ($attachment instanceof TemporaryUploadedFile) {
$maxSize = $this->getFileAttachmentsMaxSize();
$acceptedFileTypes = $this->getFileAttachmentsAcceptedFileTypes();
try {
Validator::validate(
['file' => $attachment],
rules: [
'file' => [
'file',
...($maxSize ? ["max:{$maxSize}"] : []),
...($acceptedFileTypes ? ['mimetypes:' . implode(',', $acceptedFileTypes)] : []),
],
],
);
} catch (ValidationException $exception) {
return null;
}
}
return $attachment;
}
@@ -218,4 +248,40 @@ trait HasFileAttachments
{
return null;
}
/**
* @param array<string> | Arrayable | Closure $types
*/
public function fileAttachmentsAcceptedFileTypes(array | Arrayable | Closure $types): static
{
$this->fileAttachmentsAcceptedFileTypes = $types;
return $this;
}
/**
* @return array<string> | null
*/
public function getFileAttachmentsAcceptedFileTypes(): ?array
{
$types = $this->evaluate($this->fileAttachmentsAcceptedFileTypes);
if ($types instanceof Arrayable) {
$types = $types->toArray();
}
return $types;
}
public function fileAttachmentsMaxSize(int | Closure | null $size): static
{
$this->fileAttachmentsMaxSize = $size;
return $this;
}
public function getFileAttachmentsMaxSize(): ?int
{
return $this->evaluate($this->fileAttachmentsMaxSize);
}
}
@@ -19,7 +19,6 @@ use Filament\Forms\Components\RichEditor\StateCasts\RichEditorStateCast;
use Filament\Schemas\Components\StateCasts\Contracts\StateCast;
use Filament\Support\Concerns\HasExtraAlpineAttributes;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
@@ -83,13 +82,6 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
*/
protected array | Closure | null $floatingToolbars = null;
/**
* @var array<string> | Arrayable | Closure | null
*/
protected array | Arrayable | Closure | null $acceptedFileTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
protected int | Closure | null $maxSize = null;
protected function setUp(): void
{
parent::setUp();
@@ -936,40 +928,4 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
return parent::callAfterStateUpdated($shouldBubbleToParents);
}
/**
* @param array<string> | Arrayable | Closure $types
*/
public function acceptedFileTypes(array | Arrayable | Closure $types): static
{
$this->acceptedFileTypes = $types;
return $this;
}
/**
* @return array<string> | null
*/
public function getAcceptedFileTypes(): ?array
{
$types = $this->evaluate($this->acceptedFileTypes);
if ($types instanceof Arrayable) {
$types = $types->toArray();
}
return $types;
}
public function maxSize(int | Closure | null $size): static
{
$this->maxSize = $size;
return $this;
}
public function getMaxSize(): ?int
{
return $this->evaluate($this->maxSize);
}
}
@@ -27,8 +27,8 @@ class AttachFilesAction
->label(filled($arguments['src'] ?? null)
? __('filament-forms::components.rich_editor.actions.attach_files.modal.form.file.label.existing')
: __('filament-forms::components.rich_editor.actions.attach_files.modal.form.file.label.new'))
->acceptedFileTypes(fn () => $component->getAcceptedFileTypes())
->maxSize(fn () => $component->getMaxSize())
->acceptedFileTypes($component->getFileAttachmentsAcceptedFileTypes())
->maxSize($component->getFileAttachmentsMaxSize())
->storeFiles(false)
->required(blank($arguments['src'] ?? null))
->hiddenLabel(blank($arguments['src'] ?? null)),
+1 -1
View File
File diff suppressed because one or more lines are too long