diff --git a/lang/en/fieldtypes.php b/lang/en/fieldtypes.php
index d1d6792579c..444f862ea81 100644
--- a/lang/en/fieldtypes.php
+++ b/lang/en/fieldtypes.php
@@ -140,6 +140,7 @@
'list.title' => 'List',
'markdown.config.automatic_line_breaks' => 'Enables automatic line breaks.',
'markdown.config.automatic_links' => 'Enables automatic linking of any URLs.',
+ 'markdown.config.buttons' => 'Choose which buttons to show in the toolbar. Drag to rearrange.',
'markdown.config.container' => 'Choose which asset container to use for this field.',
'markdown.config.escape_markup' => 'Escapes inline HTML markup (e.g. `
` to `<div>`).',
'markdown.config.folder' => 'Specify the initial directory.',
diff --git a/resources/js/components/fieldtypes/markdown/MarkdownButtonsSettingFieldtype.vue b/resources/js/components/fieldtypes/markdown/MarkdownButtonsSettingFieldtype.vue
index 5f41895aa98..f6422c79f42 100644
--- a/resources/js/components/fieldtypes/markdown/MarkdownButtonsSettingFieldtype.vue
+++ b/resources/js/components/fieldtypes/markdown/MarkdownButtonsSettingFieldtype.vue
@@ -4,8 +4,12 @@
@@ -91,6 +95,17 @@ export default {
swapAnimation: { horizontal: true },
plugins: [Plugins.SwapAnimation],
distance: 10,
+ classes: {
+ 'source:dragging': [
+ 'draggable-source--is-dragging',
+ 'bg-gray-800!',
+ 'opacity-100!',
+ 'shadow-lg',
+ 'ring-1',
+ 'ring-white/15',
+ 'z-10',
+ ],
+ },
})
.on('sortable:stop', (e) => {
this.buttons.splice(e.newIndex, 0, this.buttons.splice(e.oldIndex, 1)[0]);
diff --git a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
index c140ecfd078..1376f9979bd 100644
--- a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
+++ b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
@@ -392,6 +392,26 @@ export default {
return text.slice(delimiter.length, -delimiter.length);
},
+ toggleHeading(level) {
+ const startPoint = this.codemirror.getCursor('start');
+ const endPoint = this.codemirror.getCursor('end');
+ const pattern = /^(\s*)(#{1,6})(?:\s+|$)/;
+ const delimiter = '#'.repeat(level);
+ const prefix = delimiter + ' ';
+
+ for (let i = startPoint.line; i <= endPoint.line; i++) {
+ const text = this.codemirror.getLine(i);
+ const heading = text.match(pattern);
+ const newText = heading
+ ? text.replace(pattern, heading[2] === delimiter ? '$1' : '$1' + prefix)
+ : text.replace(/^(\s*)/, '$1' + prefix);
+
+ this.codemirror.replaceRange(newText, { line: i, ch: 0 }, { line: i, ch: Infinity });
+ }
+
+ this.codemirror.focus();
+ },
+
toggleLine(type) {
const startPoint = this.codemirror.getCursor('start');
const endPoint = this.codemirror.getCursor('end');
diff --git a/resources/js/components/fieldtypes/markdown/buttons.js b/resources/js/components/fieldtypes/markdown/buttons.js
index d7d277c5925..bbd946a48a3 100644
--- a/resources/js/components/fieldtypes/markdown/buttons.js
+++ b/resources/js/components/fieldtypes/markdown/buttons.js
@@ -1,4 +1,40 @@
const availableButtons = () => [
+ {
+ name: 'h1',
+ text: __('Heading 1'),
+ command: (editor) => editor.toggleHeading(1),
+ svg: 'h1',
+ },
+ {
+ name: 'h2',
+ text: __('Heading 2'),
+ command: (editor) => editor.toggleHeading(2),
+ svg: 'h2',
+ },
+ {
+ name: 'h3',
+ text: __('Heading 3'),
+ command: (editor) => editor.toggleHeading(3),
+ svg: 'h3',
+ },
+ {
+ name: 'h4',
+ text: __('Heading 4'),
+ command: (editor) => editor.toggleHeading(4),
+ svg: 'h4',
+ },
+ {
+ name: 'h5',
+ text: __('Heading 5'),
+ command: (editor) => editor.toggleHeading(5),
+ svg: 'h5',
+ },
+ {
+ name: 'h6',
+ text: __('Heading 6'),
+ command: (editor) => editor.toggleHeading(6),
+ svg: 'h6',
+ },
{ name: 'bold', text: __('Bold'), command: (editor) => editor.toggleInline('bold'), svg: 'text-bold' },
{ name: 'italic', text: __('Italic'), command: (editor) => editor.toggleInline('italic'), svg: 'text-italic' },
{ name: 'strikethrough', text: __('Strikethrough'), command: (editor) => editor.toggleInline('strikethrough'), svg: 'text-strike-through' },
diff --git a/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js b/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js
index ffb0f6a3d2b..21052b23792 100644
--- a/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js
+++ b/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js
@@ -1,9 +1,11 @@
import { mount } from '@vue/test-utils';
-import { expect, test } from 'vitest';
+import { expect, test, vi } from 'vitest';
+import CodeMirror from 'codemirror';
import { computed, ref } from 'vue';
import * as Globals from '@/bootstrap/globals';
import MarkdownFieldtype from '@/components/fieldtypes/markdown/MarkdownFieldtype.vue';
import { containerContextKey } from '@/components/ui/Publish/Container.vue';
+import { availableButtons } from '@/components/fieldtypes/markdown/buttons';
Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn]));
window.cp_url = (url) => url;
@@ -59,3 +61,32 @@ test('the preview pane gets the content direction', async () => {
// dir attribute can be asserted without switching into preview mode.
expect(wrapper.find('.markdown-preview').attributes('dir')).toBe('rtl');
});
+
+function headingEditor(text) {
+ const codemirror = new CodeMirror.Doc(text);
+ codemirror.focus = vi.fn();
+
+ return { codemirror, toggleHeading: MarkdownFieldtype.methods.toggleHeading };
+}
+
+test.each([1, 2, 3, 4, 5, 6])('the H%i button toggles its heading level', (level) => {
+ const editor = headingEditor('hello world');
+ const button = availableButtons().find((button) => button.name === `h${level}`);
+
+ editor.codemirror.setCursor({ line: 0, ch: 5 });
+ button.command(editor);
+ expect(editor.codemirror.getValue()).toBe(`${'#'.repeat(level)} hello world`);
+
+ button.command(editor);
+ expect(editor.codemirror.getValue()).toBe('hello world');
+
+ expect(editor.codemirror.focus).toHaveBeenCalledTimes(2);
+});
+
+test('heading buttons replace existing levels across selected lines', () => {
+ const editor = headingEditor('# First\n ### Second\nPlain text\n####### Not a heading');
+ editor.codemirror.setSelection({ line: 0, ch: 0 }, { line: 3, ch: 24 });
+ editor.toggleHeading(2);
+
+ expect(editor.codemirror.getValue()).toBe('## First\n ## Second\n## Plain text\n## ####### Not a heading');
+});
diff --git a/src/Fieldtypes/Markdown.php b/src/Fieldtypes/Markdown.php
index 4b82a98c41a..02360c06ae7 100644
--- a/src/Fieldtypes/Markdown.php
+++ b/src/Fieldtypes/Markdown.php
@@ -53,8 +53,9 @@ protected function configFieldItems(): array
'fields' => [
'buttons' => [
'display' => __('Buttons'),
- 'instructions' => __('statamic::fieldtypes.bard.config.buttons'),
+ 'instructions' => __('statamic::fieldtypes.markdown.config.buttons'),
'type' => 'markdown_buttons_setting',
+ 'full_width_setting' => true,
'default' => [
'bold',
'italic',
@@ -65,7 +66,6 @@ protected function configFieldItems(): array
'image',
'table',
],
- 'width' => 66,
],
'toolbar_mode' => [
'display' => __('Toolbar Mode'),
diff --git a/tests/Fieldtypes/MarkdownTest.php b/tests/Fieldtypes/MarkdownTest.php
index cca36bfd157..ce2cf050297 100644
--- a/tests/Fieldtypes/MarkdownTest.php
+++ b/tests/Fieldtypes/MarkdownTest.php
@@ -16,6 +16,28 @@ class MarkdownTest extends TestCase
{
use PreventSavingStacheItemsToDisk;
+ #[Test]
+ public function heading_buttons_are_opt_in()
+ {
+ $buttons = (new Markdown)->configFields()->get('buttons');
+
+ $this->assertSame([
+ 'bold', 'italic', 'unorderedlist', 'orderedlist', 'quote', 'link', 'image', 'table',
+ ], $buttons->defaultValue());
+ $this->assertTrue($buttons->get('full_width_setting'));
+ }
+
+ #[Test]
+ public function existing_button_selections_and_order_are_preserved()
+ {
+ foreach ([[], ['table', 'bold'], ['h6', 'h1', 'italic']] as $buttons) {
+ $fieldtype = $this->fieldtype(['buttons' => $buttons]);
+
+ $this->assertSame($buttons, $fieldtype->config('buttons'));
+ $this->assertSame($buttons, $fieldtype->config()['buttons']);
+ }
+ }
+
#[Test]
public function it_augments_to_html()
{