diff --git a/demo/app/Sharp/Posts/PostForm.php b/demo/app/Sharp/Posts/PostForm.php index 142c31637..43859f150 100644 --- a/demo/app/Sharp/Posts/PostForm.php +++ b/demo/app/Sharp/Posts/PostForm.php @@ -11,6 +11,8 @@ use App\Sharp\Utils\Embeds\TableOfContentsEmbed; use Code16\Sharp\Form\Eloquent\Uploads\Transformers\SharpUploadModelFormAttributeTransformer; use Code16\Sharp\Form\Eloquent\WithSharpFormEloquentUpdater; +use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacement; +use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacementPreset; use Code16\Sharp\Form\Fields\Editor\Uploads\SharpFormEditorUpload; use Code16\Sharp\Form\Fields\SharpFormAutocompleteRemoteField; use Code16\Sharp\Form\Fields\SharpFormCheckField; @@ -72,6 +74,10 @@ public function buildFormFields(FieldsContainer $formFields): void ->setMaxFileSize(2) ->setHasLegend() ) + ->setTextInputReplacements([ + EditorTextInputReplacementPreset::frenchTypography(locale: 'fr', guillemets: true), + new EditorTextInputReplacement('/:\+1:/', '👍'), + ]) ->allowFullscreen() ->setMaxLength(2000) ->setHeight(300, 0) diff --git a/resources/js/form/components/fields/editor/Editor.vue b/resources/js/form/components/fields/editor/Editor.vue index 165025d40..dfc7621e2 100644 --- a/resources/js/form/components/fields/editor/Editor.vue +++ b/resources/js/form/components/fields/editor/Editor.vue @@ -57,6 +57,10 @@ import EditorHelpText from "@/form/components/fields/editor/EditorHelpText.vue"; import FormFieldError from "@/form/components/FormFieldError.vue"; import EditorMaybeFullscreenDialog from "@/form/components/fields/editor/EditorMaybeFullscreenDialog.vue"; + import { DecorateHiddenCharacters } from "@/form/components/fields/editor/extensions/DecorateHiddenCharacters"; + import { + getTextInputReplacementsExtension + } from "@/form/components/fields/editor/extensions/TextInputReplacements"; const emit = defineEmits>(); const props = defineProps>(); @@ -101,6 +105,13 @@ field.markdown && Markdown.configure({ breaks: config('sharp.markdown_editor.nl2br'), }), + getTextInputReplacementsExtension(field, locale), + DecorateHiddenCharacters.configure({ + class: cn( + `relative pl-[.125em] cursor-text after:block after:absolute after:top-1/2 after:-translate-y-1/2 after:left-1/2 after:-translate-x-1/2 after:opacity-25`, + `data-[key=nbsp]:after:content-['°']`, + ), + }), props.field.uploads && Upload.configure({ uploadManager, locale, @@ -121,7 +132,7 @@ ? props.value?.text?.[locale] ?? '' : props.value?.text ?? '', editable: !field.readOnly, - enableInputRules: false, + enableInputRules: ['textInputReplacements'], enablePasteRules: [Iframe], extensions, injectCSS: false, diff --git a/resources/js/form/components/fields/editor/extensions/DecorateHiddenCharacters.ts b/resources/js/form/components/fields/editor/extensions/DecorateHiddenCharacters.ts new file mode 100644 index 000000000..300219d93 --- /dev/null +++ b/resources/js/form/components/fields/editor/extensions/DecorateHiddenCharacters.ts @@ -0,0 +1,68 @@ +import { Extension } from "@tiptap/core"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Plugin } from "@tiptap/pm/state"; +import { cn } from "@/utils/cn"; + +export const DecorateHiddenCharacters = Extension.create({ + name: 'decorateHiddenCharacters', + + addOptions() { + return { + class: '', + } + }, + + + addProseMirrorPlugins() { + const buildDecorations = (doc) => { + const decorations = [] + + + doc.descendants((node, pos) => { + if (!node.isText) return true + + + const text = node.text + if (!text) return true + + let idx = text.indexOf('\u00A0') + while (idx !== -1) { + const from = pos + idx + const to = from + 1 + const deco = Decoration.inline(from, to, { + 'data-key': 'nbsp', + class: this.options.class, + }) + decorations.push(deco) + idx = text.indexOf('\u00A0', idx + 1) + } + + + return true + }) + + + return DecorationSet.create(doc, decorations) + } + + + return [ + new Plugin({ + state: { + init(_, { doc }) { + return buildDecorations(doc) + }, + apply(tr, old) { + if (!tr.docChanged) return old + return buildDecorations(tr.doc) + }, + }, + props: { + decorations(state) { + return this.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/resources/js/form/components/fields/editor/extensions/TextInputReplacements.ts b/resources/js/form/components/fields/editor/extensions/TextInputReplacements.ts new file mode 100644 index 000000000..2f7b28f87 --- /dev/null +++ b/resources/js/form/components/fields/editor/extensions/TextInputReplacements.ts @@ -0,0 +1,26 @@ +import { Extension, textInputRule } from "@tiptap/core"; +import { FormEditorFieldData } from "@/types"; + + +export function getTextInputReplacementsExtension(field: FormEditorFieldData, locale: string) { + return Extension.create({ + name: 'textInputReplacements', + addInputRules() { + return field.textInputReplacements + .filter(replacement => !replacement.locale || replacement.locale === locale) + .map(replacement => { + const pattern = replacement.pattern.replace(/^\//, '').replace(/\/$/, '').replace(/\$?$/, '$'); + try { + return textInputRule({ + find: new RegExp(pattern, 'u'), + replace: replacement.replacement, + }); + } catch (e) { + console.error(e); + } + return null; + }) + .filter(Boolean); + }, + }); +} diff --git a/resources/js/form/components/fields/editor/extensions/index.ts b/resources/js/form/components/fields/editor/extensions/index.ts index 17af376d1..7c0ed32b1 100644 --- a/resources/js/form/components/fields/editor/extensions/index.ts +++ b/resources/js/form/components/fields/editor/extensions/index.ts @@ -1,4 +1,4 @@ -import { Extension, getExtensionField, getSchema } from "@tiptap/core"; +import { Extension, getExtensionField, getSchema, textInputRule } from "@tiptap/core"; import { Document } from '@tiptap/extension-document'; import { Text } from '@tiptap/extension-text'; import { Paragraph } from '@tiptap/extension-paragraph'; diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 190d0591d..fa6b53717 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -260,7 +260,6 @@ export type FormAutocompleteLocalFieldData = { resultItemTemplate: string | null; templateData: { [key: string]: any } | null; searchKeys: Array | null; - localized: boolean | null; dynamicAttributes: Array | null; label: string | null; readOnly: boolean | null; @@ -369,6 +368,11 @@ export type FormEditorFieldData = { inline: boolean; showCharacterCount: boolean; allowFullscreen: boolean; + textInputReplacements: { + pattern: string; + replacement: string; + locale?: string; + }[]; uploads: FormEditorFieldUploadData | null; embeds: { [embedKey: string]: EmbedData }; toolbar: Array; @@ -544,7 +548,6 @@ export type FormSelectFieldData = { inline: boolean; dynamicAttributes: Array | null; maxSelected: number | null; - localized: boolean | null; label: string | null; readOnly: boolean | null; conditionalDisplay: FormConditionalDisplayData | null; @@ -560,7 +563,6 @@ export type FormTagsFieldData = { options: Array<{ id: string | number; label: string }>; maxTagCount: number | null; placeholder: string | null; - localized: boolean | null; label: string | null; readOnly: boolean | null; conditionalDisplay: FormConditionalDisplayData | null; diff --git a/src/Data/Form/Fields/FormEditorFieldData.php b/src/Data/Form/Fields/FormEditorFieldData.php index 70101a7f4..b60383c15 100644 --- a/src/Data/Form/Fields/FormEditorFieldData.php +++ b/src/Data/Form/Fields/FormEditorFieldData.php @@ -32,6 +32,8 @@ public function __construct( public bool $inline, public bool $showCharacterCount, public bool $allowFullscreen, + #[LiteralTypeScriptType('{ pattern: string, replacement: string, locale?: string }[]')] + public array $textInputReplacements, #[LiteralTypeScriptType('FormEditorFieldUploadData | null')] public ?array $uploads = null, #[LiteralTypeScriptType('{ [embedKey:string]:EmbedData }')] diff --git a/src/Form/Fields/Editor/TextInputReplacement/Concerns/ReplacesFrench.php b/src/Form/Fields/Editor/TextInputReplacement/Concerns/ReplacesFrench.php new file mode 100644 index 000000000..1c728296a --- /dev/null +++ b/src/Form/Fields/Editor/TextInputReplacement/Concerns/ReplacesFrench.php @@ -0,0 +1,23 @@ +when($nbsp)->add(new EditorTextInputReplacement('/( )[!?:;»]/', ' ', $locale)) + ->when($guillemets)->add(new EditorTextInputReplacement('/(["«][^\n\S])/', '« ', $locale)) + ->when($guillemets)->add(new EditorTextInputReplacement('/[«][^\n\S][^»]+([^\n\S]")/', ' »', $locale)) + ->when($guillemets)->add(new EditorTextInputReplacement('/[«][^\n\S][^»]+(")/', ' »', $locale)); + } +} diff --git a/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacement.php b/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacement.php new file mode 100644 index 000000000..d2d1917d5 --- /dev/null +++ b/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacement.php @@ -0,0 +1,30 @@ + $this->pattern, + 'replacement' => $this->replacement, + 'locale' => $this->locale, + ]; + } +} diff --git a/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacementPreset.php b/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacementPreset.php new file mode 100644 index 000000000..b8596d8bd --- /dev/null +++ b/src/Form/Fields/Editor/TextInputReplacement/EditorTextInputReplacementPreset.php @@ -0,0 +1,33 @@ +replacements[] = $replacement; + + return $this; + } + + public function toArray(): array + { + return collect($this->replacements) + ->map(fn (EditorTextInputReplacement $replacement) => $replacement->toArray()) + ->all(); + } +} diff --git a/src/Form/Fields/SharpFormEditorField.php b/src/Form/Fields/SharpFormEditorField.php index 16f7bf86e..ee8dda064 100644 --- a/src/Form/Fields/SharpFormEditorField.php +++ b/src/Form/Fields/SharpFormEditorField.php @@ -4,6 +4,8 @@ use Code16\Sharp\Enums\FormEditorToolbarButton; use Code16\Sharp\Exceptions\SharpInvalidConfigException; +use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacement; +use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacementPreset; use Code16\Sharp\Form\Fields\Editor\Uploads\FormEditorUploadForm; use Code16\Sharp\Form\Fields\Editor\Uploads\SharpFormEditorUpload; use Code16\Sharp\Form\Fields\Formatters\EditorFormatter; @@ -66,6 +68,7 @@ class SharpFormEditorField extends SharpFormField implements IsSharpFieldWithEmb protected bool $withoutParagraphs = false; protected bool $showCharacterCount = false; protected bool $allowFullscreen = false; + protected array $textInputReplacements = []; protected function __construct(string $key, string $type, ?SharpFieldFormatter $formatter = null) { @@ -141,6 +144,13 @@ public function setRenderContentAsMarkdown(bool $renderAsMarkdown = true): self return $this; } + public function setTextInputReplacements(array $replacements): self + { + $this->textInputReplacements = $replacements; + + return $this; + } + public function allowFullscreen(bool $allowFullscreen = true): self { $this->allowFullscreen = $allowFullscreen; @@ -260,6 +270,12 @@ public function toArray(): array 'showCharacterCount' => $this->showCharacterCount, 'maxLength' => $this->maxLength, 'allowFullscreen' => $this->allowFullscreen, + 'textInputReplacements' => collect($this->textInputReplacements) + ->flatMap(fn (EditorTextInputReplacement|EditorTextInputReplacementPreset $replacement) => $replacement instanceof EditorTextInputReplacementPreset + ? $replacement->toArray() + : [$replacement->toArray()] + ) + ->all(), 'uploads' => $this->innerComponentUploadsConfiguration(), 'embeds' => $this->innerComponentEmbedsConfiguration(), ], diff --git a/tests/Unit/Form/Fields/SharpFormEditorFieldTest.php b/tests/Unit/Form/Fields/SharpFormEditorFieldTest.php index ef7e812ce..abd21d32e 100644 --- a/tests/Unit/Form/Fields/SharpFormEditorFieldTest.php +++ b/tests/Unit/Form/Fields/SharpFormEditorFieldTest.php @@ -1,6 +1,8 @@ false, 'inline' => false, 'allowFullscreen' => false, + 'textInputReplacements' => [], ]); }); @@ -177,3 +180,18 @@ $formField->toArray(); })->throws(SharpInvalidConfigException::class); + +it('allows to define text input replacements', function () { + $formField = SharpFormEditorField::make('text') + ->setTextInputReplacements([ + new EditorTextInputReplacementPreset([ + new EditorTextInputReplacement('/(c)/', '©'), + ]), + new EditorTextInputReplacement('/(r)/', '®'), + ]); + + expect($formField->toArray()['textInputReplacements'])->toEqual([ + ['pattern' => '/(c)/', 'replacement' => '©', 'locale' => null], + ['pattern' => '/(r)/', 'replacement' => '®', 'locale' => null], + ]); +});