Skip to content

Commit 97ba723

Browse files
authored
Merge pull request #626 from code16/editor-text-input-replacements
Allow to set editor text input replacements
2 parents 99f29d7 + b0c2ca1 commit 97ba723

12 files changed

Lines changed: 240 additions & 5 deletions

File tree

demo/app/Sharp/Posts/PostForm.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
use App\Sharp\Utils\Embeds\TableOfContentsEmbed;
1212
use Code16\Sharp\Form\Eloquent\Uploads\Transformers\SharpUploadModelFormAttributeTransformer;
1313
use Code16\Sharp\Form\Eloquent\WithSharpFormEloquentUpdater;
14+
use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacement;
15+
use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacementPreset;
1416
use Code16\Sharp\Form\Fields\Editor\Uploads\SharpFormEditorUpload;
1517
use Code16\Sharp\Form\Fields\SharpFormAutocompleteRemoteField;
1618
use Code16\Sharp\Form\Fields\SharpFormCheckField;
@@ -72,6 +74,10 @@ public function buildFormFields(FieldsContainer $formFields): void
7274
->setMaxFileSize(2)
7375
->setHasLegend()
7476
)
77+
->setTextInputReplacements([
78+
EditorTextInputReplacementPreset::frenchTypography(locale: 'fr', guillemets: true),
79+
new EditorTextInputReplacement('/:\+1:/', '👍'),
80+
])
7581
->allowFullscreen()
7682
->setMaxLength(2000)
7783
->setHeight(300, 0)

resources/js/form/components/fields/editor/Editor.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
import EditorHelpText from "@/form/components/fields/editor/EditorHelpText.vue";
5858
import FormFieldError from "@/form/components/FormFieldError.vue";
5959
import EditorMaybeFullscreenDialog from "@/form/components/fields/editor/EditorMaybeFullscreenDialog.vue";
60+
import { DecorateHiddenCharacters } from "@/form/components/fields/editor/extensions/DecorateHiddenCharacters";
61+
import {
62+
getTextInputReplacementsExtension
63+
} from "@/form/components/fields/editor/extensions/TextInputReplacements";
6064
6165
const emit = defineEmits<FormFieldEmits<FormEditorFieldData>>();
6266
const props = defineProps<FormFieldProps<FormEditorFieldData>>();
@@ -101,6 +105,13 @@
101105
field.markdown && Markdown.configure({
102106
breaks: config('sharp.markdown_editor.nl2br'),
103107
}),
108+
getTextInputReplacementsExtension(field, locale),
109+
DecorateHiddenCharacters.configure({
110+
class: cn(
111+
`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`,
112+
`data-[key=nbsp]:after:content-['°']`,
113+
),
114+
}),
104115
props.field.uploads && Upload.configure({
105116
uploadManager,
106117
locale,
@@ -121,7 +132,7 @@
121132
? props.value?.text?.[locale] ?? ''
122133
: props.value?.text ?? '',
123134
editable: !field.readOnly,
124-
enableInputRules: false,
135+
enableInputRules: ['textInputReplacements'],
125136
enablePasteRules: [Iframe],
126137
extensions,
127138
injectCSS: false,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Extension } from "@tiptap/core";
2+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
3+
import { Plugin } from "@tiptap/pm/state";
4+
import { cn } from "@/utils/cn";
5+
6+
export const DecorateHiddenCharacters = Extension.create({
7+
name: 'decorateHiddenCharacters',
8+
9+
addOptions() {
10+
return {
11+
class: '',
12+
}
13+
},
14+
15+
16+
addProseMirrorPlugins() {
17+
const buildDecorations = (doc) => {
18+
const decorations = []
19+
20+
21+
doc.descendants((node, pos) => {
22+
if (!node.isText) return true
23+
24+
25+
const text = node.text
26+
if (!text) return true
27+
28+
let idx = text.indexOf('\u00A0')
29+
while (idx !== -1) {
30+
const from = pos + idx
31+
const to = from + 1
32+
const deco = Decoration.inline(from, to, {
33+
'data-key': 'nbsp',
34+
class: this.options.class,
35+
})
36+
decorations.push(deco)
37+
idx = text.indexOf('\u00A0', idx + 1)
38+
}
39+
40+
41+
return true
42+
})
43+
44+
45+
return DecorationSet.create(doc, decorations)
46+
}
47+
48+
49+
return [
50+
new Plugin({
51+
state: {
52+
init(_, { doc }) {
53+
return buildDecorations(doc)
54+
},
55+
apply(tr, old) {
56+
if (!tr.docChanged) return old
57+
return buildDecorations(tr.doc)
58+
},
59+
},
60+
props: {
61+
decorations(state) {
62+
return this.getState(state)
63+
},
64+
},
65+
}),
66+
]
67+
},
68+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Extension, textInputRule } from "@tiptap/core";
2+
import { FormEditorFieldData } from "@/types";
3+
4+
5+
export function getTextInputReplacementsExtension(field: FormEditorFieldData, locale: string) {
6+
return Extension.create({
7+
name: 'textInputReplacements',
8+
addInputRules() {
9+
return field.textInputReplacements
10+
.filter(replacement => !replacement.locale || replacement.locale === locale)
11+
.map(replacement => {
12+
const pattern = replacement.pattern.replace(/^\//, '').replace(/\/$/, '').replace(/\$?$/, '$');
13+
try {
14+
return textInputRule({
15+
find: new RegExp(pattern, 'u'),
16+
replace: replacement.replacement,
17+
});
18+
} catch (e) {
19+
console.error(e);
20+
}
21+
return null;
22+
})
23+
.filter(Boolean);
24+
},
25+
});
26+
}

resources/js/form/components/fields/editor/extensions/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Extension, getExtensionField, getSchema } from "@tiptap/core";
1+
import { Extension, getExtensionField, getSchema, textInputRule } from "@tiptap/core";
22
import { Document } from '@tiptap/extension-document';
33
import { Text } from '@tiptap/extension-text';
44
import { Paragraph } from '@tiptap/extension-paragraph';

resources/js/types/generated.d.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,6 @@ export type FormAutocompleteLocalFieldData = {
260260
resultItemTemplate: string | null;
261261
templateData: { [key: string]: any } | null;
262262
searchKeys: Array<string> | null;
263-
localized: boolean | null;
264263
dynamicAttributes: Array<FormDynamicAttributeData> | null;
265264
label: string | null;
266265
readOnly: boolean | null;
@@ -369,6 +368,11 @@ export type FormEditorFieldData = {
369368
inline: boolean;
370369
showCharacterCount: boolean;
371370
allowFullscreen: boolean;
371+
textInputReplacements: {
372+
pattern: string;
373+
replacement: string;
374+
locale?: string;
375+
}[];
372376
uploads: FormEditorFieldUploadData | null;
373377
embeds: { [embedKey: string]: EmbedData };
374378
toolbar: Array<FormEditorToolbarButton | `embed:${string}`>;
@@ -544,7 +548,6 @@ export type FormSelectFieldData = {
544548
inline: boolean;
545549
dynamicAttributes: Array<FormDynamicAttributeData> | null;
546550
maxSelected: number | null;
547-
localized: boolean | null;
548551
label: string | null;
549552
readOnly: boolean | null;
550553
conditionalDisplay: FormConditionalDisplayData | null;
@@ -560,7 +563,6 @@ export type FormTagsFieldData = {
560563
options: Array<{ id: string | number; label: string }>;
561564
maxTagCount: number | null;
562565
placeholder: string | null;
563-
localized: boolean | null;
564566
label: string | null;
565567
readOnly: boolean | null;
566568
conditionalDisplay: FormConditionalDisplayData | null;

src/Data/Form/Fields/FormEditorFieldData.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public function __construct(
3232
public bool $inline,
3333
public bool $showCharacterCount,
3434
public bool $allowFullscreen,
35+
#[LiteralTypeScriptType('{ pattern: string, replacement: string, locale?: string }[]')]
36+
public array $textInputReplacements,
3537
#[LiteralTypeScriptType('FormEditorFieldUploadData | null')]
3638
public ?array $uploads = null,
3739
#[LiteralTypeScriptType('{ [embedKey:string]:EmbedData }')]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Code16\Sharp\Form\Fields\Editor\TextInputReplacement\Concerns;
4+
5+
use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\EditorTextInputReplacement;
6+
7+
/**
8+
* @internal
9+
*/
10+
trait ReplacesFrench
11+
{
12+
public static function frenchTypography(
13+
?string $locale = null,
14+
bool $nbsp = true,
15+
bool $guillemets = false,
16+
): self {
17+
return (new self())
18+
->when($nbsp)->add(new EditorTextInputReplacement('/( )[!?:;»]/', ' ', $locale))
19+
->when($guillemets)->add(new EditorTextInputReplacement('/(["«][^\n\S])/', '« ', $locale))
20+
->when($guillemets)->add(new EditorTextInputReplacement('/[«][^\n\S][^»]+([^\n\S]")/', ' »', $locale))
21+
->when($guillemets)->add(new EditorTextInputReplacement('/[«][^\n\S][^»]+(")/', ' »', $locale));
22+
}
23+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Code16\Sharp\Form\Fields\Editor\TextInputReplacement;
4+
5+
use Illuminate\Contracts\Support\Arrayable;
6+
7+
class EditorTextInputReplacement implements Arrayable
8+
{
9+
/**
10+
* @throws \Exception
11+
*/
12+
public function __construct(
13+
protected string $pattern,
14+
protected string $replacement,
15+
protected ?string $locale = null,
16+
) {
17+
if(!str_starts_with($pattern, '/') || !str_ends_with($pattern, '/')) {
18+
throw new \Exception("The replacement pattern \"$pattern\" must start and end with a slash");
19+
}
20+
}
21+
22+
public function toArray(): array
23+
{
24+
return [
25+
'pattern' => $this->pattern,
26+
'replacement' => $this->replacement,
27+
'locale' => $this->locale,
28+
];
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Code16\Sharp\Form\Fields\Editor\TextInputReplacement;
4+
5+
use Code16\Sharp\Form\Fields\Editor\TextInputReplacement\Concerns\ReplacesFrench;
6+
use Illuminate\Contracts\Support\Arrayable;
7+
use Illuminate\Support\Traits\Conditionable;
8+
use Illuminate\Support\Traits\Macroable;
9+
10+
class EditorTextInputReplacementPreset implements Arrayable
11+
{
12+
use Conditionable;
13+
use Macroable;
14+
use ReplacesFrench;
15+
16+
public function __construct(
17+
protected array $replacements = [],
18+
) {}
19+
20+
public function add(EditorTextInputReplacement $replacement): self
21+
{
22+
$this->replacements[] = $replacement;
23+
24+
return $this;
25+
}
26+
27+
public function toArray(): array
28+
{
29+
return collect($this->replacements)
30+
->map(fn (EditorTextInputReplacement $replacement) => $replacement->toArray())
31+
->all();
32+
}
33+
}

0 commit comments

Comments
 (0)