Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions docs/fields/rich-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,56 @@ The modal lists and inserts media the way a picker does — see [What a picker l

## Rendering saved content

Inserted media is stored as a plain `<img>` with the media's URL, alt text and title, so it renders through Filament's `RichContentRenderer` like any other image.
By default, inserted media is stored as a plain `<img>` with the media's URL, alt text and title, so it renders through Filament's `RichContentRenderer` like any other image. The URL is fixed when the media is inserted: replacing the file or moving the disk leaves the content pointing at the old one.

Content saved with earlier versions also stored the media's key in a `data-id` attribute. Filament treats `data-id` as a file attachment path, and its renderer replaces the `src` of any image that has one, so those images render without a `src`. Removing the attribute fixes them. For content stored as HTML, this removes it from images whose `data-id` is a media key (an integer, UUID or ULID), and leaves Filament's own file attachments, which store a path, untouched:
## Storing media ids

To have images follow their media, store each one's key in the image's `data-id`, the attribute Filament uses for its own file attachments:

```php
AttachCuratorMediaPlugin::make()->storeMediaIds()
```

Filament reads `data-id` through the editor's file attachment provider, so the plugin then supplies `CuratorFileAttachmentProvider`. Each time the content is edited or rendered, the provider looks the key up in the library and gives the image the media's current URL. A key naming media that has been deleted, or that isn't public, gets no URL. Images uploaded, dropped or pasted into the editor are added to the library through the same checks as the picker's uploads (detected file type, SVG sanitizing, size), and stored publicly.

Filament takes the provider from the plugin wherever it reads the plugins on their own: a model's rich content attribute, and `RichContentRenderer`. Render with the same plugin so the URLs are filled in:

```php
use Filament\Forms\Components\RichEditor\RichContentRenderer;

RichContentRenderer::make($content)
->plugins([AttachCuratorMediaPlugin::make()->storeMediaIds()])
->toHtml();
```

A field that isn't bound to a rich content attribute, such as one inside a nested form, doesn't consult its plugins for a provider. Give the field the provider's lookup and upload yourself:

```php
use Awcodes\Curator\Components\Forms\RichEditor\CuratorFileAttachmentProvider;

$provider = CuratorFileAttachmentProvider::make();

RichEditor::make('content')
->plugins([AttachCuratorMediaPlugin::make()->storeMediaIds(provider: $provider)])
->getFileAttachmentUrlUsing(fn (mixed $file): ?string => $provider->getFileAttachmentUrl($file))
->saveUploadedFileAttachmentUsing(fn ($file): mixed => $provider->saveUploadedFileAttachment($file));
```

The provider uploads to Curator's disk, directory and accepted types, and caps uploads at Curator's maximum size. Set them on the provider with `disk()`, `directory()`, `acceptedFileTypes()` and `maxSize()` (in kilobytes), and pass it to `storeMediaIds(provider: ...)`. A provider of your own can be passed the same way, as long as it reads media keys.

Filament deletes attachments a saved document no longer uses by calling the provider's `cleanUpFileAttachments()`. Curator's does nothing there: the library is shared, so media stays until it is deleted from the library.

If the editor uses `preventFileAttachmentPathTampering()`, allow the ids the provider resolves, since media inserted from the library was never uploaded to the record:

```php
->preventFileAttachmentPathTampering(allowFilePathUsing: fn (string $file): bool => $provider->findMedia($file) !== null)
```

## Content saved before 5.5

Content saved with versions before 5.5 stored the media's key in `data-id` whether or not a provider could read it. Filament treats `data-id` as a file attachment path, and without a provider its renderer replaces the `src` of any image that has one, so those images render without a `src`.

Turning on `storeMediaIds()` fixes them, since the provider resolves the keys. Otherwise remove the attribute. For content stored as HTML, this removes it from images whose `data-id` is a media key (an integer, UUID or ULID), and leaves Filament's own file attachments, which store a path, untouched:

```php
$content = preg_replace(
Expand Down
2 changes: 1 addition & 1 deletion resources/dist/rich-editor-integration.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions resources/js/rich-editor-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,14 @@
{ name: 'focus' },
{
name: 'setImage',
// No id: Filament treats an image's id as a file attachment path, and its renderer replaces the
// src of any image that has one, which left Curator images without a src when rendered.
// The media key goes in the image's id (data-id) only when the plugin stores media ids. Filament
// reads an id through the editor's file attachment provider, or else as a path on its disk, which
// left Curator images without a src when rendered.
arguments: [{
src: item.url,
alt: item.alt || item.title || '',
title: item.title || '',
...(data.storeMediaIds && item.id ? { id: item.id } : {}),
}]
}
],
Expand Down
44 changes: 43 additions & 1 deletion src/Components/Forms/RichEditor/AttachCuratorMediaPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,64 @@
namespace Awcodes\Curator\Components\Forms\RichEditor;

use Awcodes\Curator\Facades\Curator;
use Closure;
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\RichEditor\FileAttachmentProviders\Contracts\FileAttachmentProvider;
use Filament\Forms\Components\RichEditor\Plugins\Contracts\HasFileAttachmentProvider;
use Filament\Forms\Components\RichEditor\Plugins\Contracts\RichContentPlugin;
use Filament\Forms\Components\RichEditor\RichEditorTool;
use Filament\Support\Concerns\EvaluatesClosures;
use Filament\Support\Enums\Width;
use Filament\Support\Facades\FilamentAsset;
use Illuminate\Support\Facades\Config;
use Illuminate\View\View;
use Tiptap\Core\Extension;

class AttachCuratorMediaPlugin implements RichContentPlugin
class AttachCuratorMediaPlugin implements HasFileAttachmentProvider, RichContentPlugin
{
use EvaluatesClosures;

protected bool | Closure $shouldStoreMediaIds = false;

protected ?FileAttachmentProvider $fileAttachmentProvider = null;

public static function make(): static
{
return app(static::class);
}

/**
* Insert media with its key in the image's `data-id`, as Filament stores its own attachments, so the image's URL
* is looked up from the library whenever the content is edited or rendered.
*
* Filament reads `data-id` through the editor's file attachment provider and, without one, as a path on its disk,
* which loses the image's src. The plugin supplies CuratorFileAttachmentProvider, or the one given, wherever
* Filament takes a provider from its plugins: a model's rich content attribute and RichContentRenderer. An editor
* outside both, such as one in a nested form, needs the provider set on the field itself.
*/
public function storeMediaIds(bool | Closure $condition = true, ?FileAttachmentProvider $provider = null): static
{
$this->shouldStoreMediaIds = $condition;
$this->fileAttachmentProvider = $provider;

return $this;
}

public function shouldStoreMediaIds(): bool
{
return (bool) $this->evaluate($this->shouldStoreMediaIds);
}

public function getFileAttachmentProvider(): ?FileAttachmentProvider
{
if (! $this->shouldStoreMediaIds()) {
return null;
}

return $this->fileAttachmentProvider ??= CuratorFileAttachmentProvider::make();
}

/**
* @return array<Extension>
*/
Expand Down Expand Up @@ -88,6 +129,7 @@ public function getEditorActions(): array
'shouldPreserveFilenames' => Curator::shouldPreserveFilenames(),
'statePath' => $component->getStatePath(),
'context' => 'richEditor',
'storeMediaIds' => $this->shouldStoreMediaIds(),
'visibility' => $this->getExplicitAttachmentSetting($component, 'fileAttachmentsVisibility', 'getDefaultFileAttachmentsVisibility') ?? Curator::getVisibility(),
],
]))
Expand Down
183 changes: 183 additions & 0 deletions src/Components/Forms/RichEditor/CuratorFileAttachmentProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

namespace Awcodes\Curator\Components\Forms\RichEditor;

use Awcodes\Curator\Components\Forms\Uploader;
use Awcodes\Curator\Facades\Curator;
use Awcodes\Curator\Models\Media;
use Closure;
use Filament\Forms\Components\RichEditor\FileAttachmentProviders\Contracts\FileAttachmentProvider;
use Filament\Forms\Components\RichEditor\RichContentAttribute;
use Filament\Schemas\Schema;
use Filament\Support\Concerns\EvaluatesClosures;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;

/**
* Rich editor images kept in the Curator library, by media key.
*
* An image stores its media key in `data-id`, and Filament asks this provider for its URL whenever the content is
* edited or rendered, so the image follows its media item: a replaced file or a moved disk shows up everywhere it is
* used, and a deleted item drops out rather than leaving a dead link. Images uploaded, dropped or pasted into the
* editor are added to the library the way the panel's uploader adds them.
*
* The key comes from client-submitted content, so only public media resolves: a key naming private media gets no
* URL. Uploads are stored publicly for the same reason.
*
* cleanUpFileAttachments() does nothing. Filament calls it to delete the attachments a saved document no longer
* uses, which suits files owned by one record and not a library shared between them.
*/
class CuratorFileAttachmentProvider implements FileAttachmentProvider
{
use EvaluatesClosures;

protected array | Closure | null $acceptedFileTypes = null;

protected string | Closure | null $directory = null;

protected string | Closure | null $diskName = null;

protected int | Closure | null $maxSize = null;

public static function make(): static
{
return app(static::class);
}

public function attribute(RichContentAttribute $attribute): static
{
return $this;
}

public function acceptedFileTypes(array | Closure | null $types): static
{
$this->acceptedFileTypes = $types;

return $this;
}

public function directory(string | Closure | null $directory): static
{
$this->directory = $directory;

return $this;
}

public function disk(string | Closure | null $disk): static
{
$this->diskName = $disk;

return $this;
}

/**
* In kilobytes.
*/
public function maxSize(int | Closure | null $size): static
{
$this->maxSize = $size;

return $this;
}

public function getAcceptedFileTypes(): array
{
return $this->evaluate($this->acceptedFileTypes) ?? Curator::getAcceptedFileTypes();
}

public function getDirectory(): ?string
{
return $this->evaluate($this->directory) ?? Curator::getDirectory();
}

public function getDiskName(): string
{
return $this->evaluate($this->diskName) ?? Curator::getDiskName();
}

public function getMaxSize(): int
{
return $this->evaluate($this->maxSize) ?? Curator::getMaxSize();
}

/**
* The public media item a key names, or null.
*/
public function findMedia(mixed $key): ?Media
{
if (! is_int($key) && (! is_string($key) || $key === '')) {
return null;
}

$media = App::make(Media::class)::query()->whereKey($key)->first();

return $media instanceof Media && $media->isPublic() ? $media : null;
}

public function getFileAttachmentUrl(mixed $file): ?string
{
return $this->findMedia($file)?->url;
}

/**
* @throws ValidationException when the upload is too large, of a type the library doesn't accept, or an SVG that
* cannot be sanitized
*/
public function saveUploadedFileAttachment(TemporaryUploadedFile $file): mixed
{
$uploader = $this->makeUploader();

if ($file->getSize() > $this->getMaxSize() * 1024) {
throw ValidationException::withMessages([
$uploader->getStatePath() => __('validation.max.file', ['attribute' => $uploader->getValidationAttribute(), 'max' => $this->getMaxSize()]),
]);
}

if (! $uploader->isAcceptedFile($file, $this->getAcceptedFileTypes())) {
throw ValidationException::withMessages([
$uploader->getStatePath() => __('validation.mimetypes', ['attribute' => $uploader->getValidationAttribute(), 'values' => implode(', ', $this->getAcceptedFileTypes())]),
]);
}

$attributes = $uploader->storeUploadedFile($file);

if ($attributes === null) {
return null;
}

$attributes['exif'] = empty($attributes['exif']) ? null : Curator::sanitizeExif($attributes['exif']);
$attributes['title'] = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);

return App::make(Media::class)::query()->create($attributes)->getKey();
}

public function getDefaultFileAttachmentVisibility(): ?string
{
return 'public';
}

public function isExistingRecordRequiredToSaveNewFileAttachments(): bool
{
return false;
}

public function cleanUpFileAttachments(array $exceptIds): void {}

/**
* The panel's uploader, so editor uploads get the same type detection, SVG sanitizing, naming and paths. It sits
* in a schema of its own because its validation messages are keyed by its state path.
*/
protected function makeUploader(): Uploader
{
return Uploader::make('file')
->disk($this->getDiskName())
->directory($this->getDirectory())
->visibility('public')
->acceptedFileTypes($this->getAcceptedFileTypes())
->preserveFilenames(Curator::shouldPreserveFilenames())
->container(Schema::make());
}
}
Loading
Loading