Skip to content

Commit aee7203

Browse files
committed
feat: Add "Media Picker" & "Content Picker" button to rich editor
1 parent 8b02287 commit aee7203

File tree

8 files changed

+751
-2
lines changed

8 files changed

+751
-2
lines changed

bin/build.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,9 @@ compile({
5252
...defaultOptions,
5353
entryPoints: ['./resources/js/components/code-editor.js'],
5454
outfile: './resources/dist/components/code-editor.js',
55+
})
56+
compile({
57+
...defaultOptions,
58+
entryPoints: ['./resources/js/rich-editor-enhancement.js'],
59+
outfile: './resources/dist/rich-editor-enhancement.js',
5560
})

resources/dist/inspirecms.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/dist/rich-editor-enhancement.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const insertMediaAssets = (trixElem, state) => {
2+
const editor = trixElem.editor;
3+
if (!editor || !state || !Array.isArray(state) || state.length === 0) {
4+
return;
5+
}
6+
state.forEach(item => {
7+
const attachment = new Trix.Attachment(item);
8+
editor.insertAttachment(attachment);
9+
});
10+
}
11+
12+
const insertContent = (trixElem, state) => {
13+
const editor = trixElem.editor;
14+
if (!editor || !state || !Array.isArray(state) || state.length === 0) {
15+
return;
16+
}
17+
state.forEach(item => {
18+
editor.insertHTML(item);
19+
});
20+
}
21+
22+
document.addEventListener('alpine:init', () => {
23+
window.Alpine.magic('insertRichMediaPicker', () => insertMediaAssets)
24+
window.Alpine.magic('insertRichContentPicker', () => insertContent)
25+
})

resources/views/filament/forms/components/rich-editor.blade.php

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

src/Fields/Configs/RichEditor.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace SolutionForest\InspireCms\Fields\Configs;
44

55
use Filament\Forms\Components\Component;
6-
use Filament\Forms\Components\RichEditor as FormsRichEditor;
76
use Filament\Forms\Components\Section;
87
use SolutionForest\FilamentFieldGroup\FieldTypes\Configs\Attributes\ConfigName;
98
use SolutionForest\FilamentFieldGroup\FieldTypes\Configs\Attributes\DbType;
@@ -13,6 +12,7 @@
1312
use SolutionForest\InspireCms\Fields\Configs\Attributes\Converter;
1413
use SolutionForest\InspireCms\Fields\Configs\Concerns\EditorBasicTrait;
1514
use SolutionForest\InspireCms\Fields\Converters\RichEditorConverter;
15+
use SolutionForest\InspireCms\Filament\Forms\Components\RichEditor as FormsRichEditor;
1616

1717
#[ConfigName('richEditor', 'Rich Editor', 'Rich', 'heroicon-o-document-text')]
1818
#[FormComponent(FormsRichEditor::class)]
@@ -38,6 +38,8 @@ class RichEditor extends FieldTypeBaseConfig implements FieldTypeConfig
3838
'strike',
3939
'underline',
4040
'undo',
41+
'contentPicker',
42+
'mediaPicker',
4143
];
4244

4345
public function getFormSchema(): array
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
<?php
2+
3+
namespace SolutionForest\InspireCms\Filament\Forms\Components;
4+
5+
use Closure;
6+
use Filament\Forms\Components\Actions\Action;
7+
use Filament\Forms\Components\RichEditor as BaseRichEditor;
8+
use Illuminate\Database\Eloquent\Model;
9+
use SolutionForest\InspireCms\InspireCmsConfig;
10+
use SolutionForest\InspireCms\Models\Contracts\Content;
11+
use SolutionForest\InspireCms\Support\MediaLibrary\Forms\Components\Concerns\InteractsWithMediaLibraryModal;
12+
use SolutionForest\InspireCms\Support\Models\Contracts\MediaAsset;
13+
use Spatie\MediaLibrary\MediaCollections\Models\Media;
14+
15+
class RichEditor extends BaseRichEditor
16+
{
17+
use InteractsWithMediaLibraryModal;
18+
19+
/**
20+
* @var view-string
21+
*/
22+
protected string $view = 'inspirecms::filament.forms.components.rich-editor';
23+
24+
/**
25+
* @var array<string>
26+
*/
27+
protected array | Closure $toolbarButtons = [
28+
'attachFiles',
29+
'blockquote',
30+
'bold',
31+
'bulletList',
32+
'codeBlock',
33+
'h2',
34+
'h3',
35+
'italic',
36+
'link',
37+
'orderedList',
38+
'redo',
39+
'strike',
40+
'underline',
41+
'undo',
42+
'contentPicker',
43+
'mediaPicker',
44+
];
45+
46+
protected function setUp(): void
47+
{
48+
parent::setUp();
49+
50+
$this->extraAlpineAttributes([
51+
'x-init' => $this->getExtraTrixActionsRegEvent(),
52+
]);
53+
54+
$this->registerActions([
55+
Action::make('selectContent')
56+
->slideOver()
57+
->fillForm(['selection' => []])
58+
->form([
59+
ContentTree::make('selection')->hiddenLabel(),
60+
])
61+
->action(function (array $data, self $component) {
62+
$component->getLivewire()->dispatch(
63+
'content-picker-trix-appead',
64+
statePath: $component->getStatePath(),
65+
data: $component->formatContentPickerState($data['selection']),
66+
);
67+
}),
68+
]);
69+
70+
$this->registerListeners([
71+
'mediaPicker::select' => [
72+
function (self $component, string $statePath, $ids = null, $callback = null) {
73+
if ($statePath === $component->getStatePath() && ! empty($ids)) {
74+
$component->getLivewire()->dispatch(
75+
'media-picker-trix-appead',
76+
statePath: $statePath,
77+
data: $component->formatMediaPickerState($ids),
78+
);
79+
}
80+
},
81+
],
82+
]);
83+
}
84+
85+
protected function getExtraTrixActionsRegEvent()
86+
{
87+
return <<<'JS'
88+
document.addEventListener("trix-action-invoke", function(event) {
89+
const { target, invokingElement, actionName } = event
90+
switch (actionName) {
91+
case 'x-content-picker':
92+
$dispatch('content-picker-trix-click');
93+
break;
94+
case 'x-media-picker':
95+
$dispatch('media-picker-trix-click');
96+
break;
97+
default:
98+
break;
99+
}
100+
});
101+
JS;
102+
}
103+
104+
public function formatMediaPickerState($state)
105+
{
106+
if (! is_array($state)) {
107+
$state = is_null($state) || empty($state) ? [] : [$state];
108+
}
109+
if (empty($state)) {
110+
return [];
111+
}
112+
113+
return InspireCmsConfig::getMediaAssetModelClass()::query()
114+
->with(['media'])
115+
->findMany($state)
116+
->filter(fn ($record) => $record instanceof MediaAsset)
117+
->sortBy(fn (Model $record) => array_search($record->getKey(), $state))
118+
->map(fn (Model | MediaAsset $record) => $this->mutateMediaPickerState($record))
119+
->all();
120+
}
121+
122+
public function formatContentPickerState($state)
123+
{
124+
if (! is_array($state)) {
125+
$state = is_null($state) || empty($state) ? [] : [$state];
126+
}
127+
if (empty($state)) {
128+
return [];
129+
}
130+
131+
return InspireCmsConfig::getContentModelClass()::query()
132+
->findMany($state)
133+
->filter(fn ($record) => $record instanceof Content)
134+
->sortBy(fn (Model $record) => array_search($record->getKey(), $state))
135+
->map(fn (Model | Content $record) => $this->mutateContentPickerState($record))->all();
136+
}
137+
138+
protected function mutateMediaPickerState(Model | MediaAsset $mediaAsset)
139+
{
140+
/** @var null | Media */
141+
$media = $mediaAsset->getFirstMedia();
142+
$mediaUrl = $mediaAsset->getUrl(isAbsolute: false);
143+
$title = $media?->title ?? $mediaAsset->title;
144+
145+
$data = [
146+
'contentType' => $media?->mime_type,
147+
'filename' => $media?->file_name,
148+
'href' => $mediaUrl,
149+
'url' => $mediaUrl,
150+
'id' => $mediaAsset->getKey(),
151+
'title' => $title,
152+
'caption' => $mediaAsset->caption,
153+
'description' => $mediaAsset->description,
154+
'name' => $mediaAsset->caption,
155+
];
156+
157+
if ($mediaAsset->isImage()) {
158+
// Define conversion sizes with their widths
159+
$conversions = [
160+
'thumbnail' => 150,
161+
'small' => 300,
162+
'medium' => 600,
163+
'large' => 1200,
164+
];
165+
166+
// Build sizes attribute for responsive images
167+
$sizesAttr = collect([
168+
'(max-width: 300px) 300px',
169+
'(max-width: 600px) 600px',
170+
'(max-width: 1200px) 1200px',
171+
'100vw',
172+
])->implode(', ');
173+
174+
// Generate URLs for each conversion
175+
$conversionUrls = collect($conversions)
176+
->mapWithKeys(fn ($width, $conversion) => [
177+
$conversion => $mediaAsset->getUrl($conversion, false),
178+
])
179+
->all();
180+
181+
// Build srcset string
182+
$srcset = collect($conversions)
183+
->map(function ($width, $conversion) use ($mediaAsset) {
184+
$url = $mediaAsset->getUrl($conversion, false);
185+
186+
return "{$url} {$width}w";
187+
})
188+
->implode(', ');
189+
190+
// Create responsive image HTML with srcset
191+
$content = sprintf(
192+
'<img src="%s" alt="%s" srcset="%s" sizes="%s" loading="lazy" class="trix-attachment-image trix-attachment-mediapicker">',
193+
htmlspecialchars($mediaUrl),
194+
htmlspecialchars($title),
195+
htmlspecialchars($srcset),
196+
$sizesAttr
197+
);
198+
199+
$data['sizes'] = $conversionUrls;
200+
$data['content'] = $content;
201+
$data['srcset'] = $srcset;
202+
}
203+
204+
return $data;
205+
}
206+
207+
protected function mutateContentPickerState(Model | Content $content)
208+
{
209+
$livewire = $this->getLivewire();
210+
$translatedLocale = null;
211+
foreach ([
212+
'getActiveLocale',
213+
'getActiveFormsLocale',
214+
'getLocale',
215+
] as $method) {
216+
try {
217+
if (method_exists($livewire, $method)) {
218+
$translatedLocale = $livewire->{$method}();
219+
220+
break;
221+
}
222+
} catch (\Throwable $th) {
223+
$translatedLocale = null;
224+
}
225+
}
226+
227+
if (! is_string($translatedLocale) || empty($translatedLocale)) {
228+
$translatedLocale = app()->getLocale();
229+
}
230+
231+
$url = $content->getUrl($translatedLocale);
232+
233+
$relativeUrl = str($url)
234+
->replaceFirst(url(''), '')
235+
->trim()->trim('/')
236+
->prepend('/')
237+
->toString();
238+
239+
$template = ' <a href="%s" class="trix-attachment-contentpicker" data-id="%s" data-slug="%s">%s</a> ';
240+
241+
return sprintf(
242+
$template,
243+
htmlspecialchars($relativeUrl),
244+
htmlspecialchars($content->getKey()),
245+
htmlspecialchars($content->slug),
246+
htmlspecialchars($content->title)
247+
);
248+
}
249+
}

src/InspireCmsServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ protected function getAssets(): array
177177
return [
178178
Theme::make('inspirecms', __DIR__ . '/../resources/dist/inspirecms.css'),
179179
Js::make('inspirecms', __DIR__ . '/../resources/dist/inspirecms.js'),
180+
Js::make('rich-editor-enhance', __DIR__ . '/../resources/dist/rich-editor-enhancement.js'),
180181
Css::make('filament-code-editor', __DIR__ . '/../resources/dist/components/code-editor.css')->loadedOnRequest(),
181182
AlpineComponent::make('filament-code-editor', __DIR__ . '/../resources/dist/components/code-editor.js')->loadedOnRequest(),
182183
Css::make('filament-alert', __DIR__ . '/../resources/dist/components/alert.css')->loadedOnRequest(),

0 commit comments

Comments
 (0)