Skip to content

Commit bfa8c9e

Browse files
authored
Merge pull request #3 from devforth/feature/AdminForth/1263/add-top-panel-with-buttons-of-
fix: add top panel
2 parents b92a15d + 1553e5e commit bfa8c9e

File tree

4 files changed

+221
-11
lines changed

4 files changed

+221
-11
lines changed

custom/MarkdownEditor.vue

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
<template>
2-
<div class="mb-2"></div>
3-
<div
4-
ref="editorContainer"
5-
id="editor"
6-
:class="[
7-
'text-sm rounded-lg block w-full transition-all box-border overflow-hidden',
8-
isFocused
9-
? 'ring-1 ring-lightPrimary border ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary'
10-
: 'border border-gray-300 dark:border-gray-600',
11-
]"
12-
></div>
2+
<div class="mb-2 w-full flex flex-col">
3+
<TopPanelButtons :editor="editor" :meta="meta" />
4+
<div
5+
ref="editorContainer"
6+
id="editor"
7+
:class="[
8+
'text-sm block w-full transition-all box-border overflow-hidden rounded-b-lg border border-t-0 pt-3',
9+
isFocused
10+
? 'ring-1 ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary'
11+
: 'border-gray-300 dark:border-gray-600',
12+
]"
13+
></div>
14+
</div>
1315
</template>
1416

1517
<script setup lang="ts">
@@ -19,6 +21,7 @@ import * as monaco from 'monaco-editor';
1921
import TurndownService from 'turndown';
2022
import { gfm, tables } from 'turndown-plugin-gfm';
2123
import { toggleWrapSmart } from './utils/monacoMarkdownToggle';
24+
import TopPanelButtons from './topPanelButtons.vue';
2225
2326
const props = defineProps<{
2427
column: any,
@@ -529,6 +532,33 @@ onMounted(async () => {
529532
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyU, () => {
530533
toggleWrapSmart(editor!, '<u>', '</u>');
531534
});
535+
536+
disposables.push(
537+
editor.onKeyDown((e) => {
538+
if (e.keyCode !== monaco.KeyCode.Enter) {
539+
return;
540+
}
541+
const pos = editor!.getPosition();
542+
if (!pos) {
543+
return;
544+
}
545+
const line = model!.getLineContent(pos.lineNumber);
546+
const match = line.match(/^(\s*)([*+-]|\d+\.)\s+/);
547+
if (!match) {
548+
return;
549+
}
550+
e.preventDefault();
551+
552+
if (line.trim() === match[2].trim()) {
553+
const range = new monaco.Range(pos.lineNumber, 1, pos.lineNumber, line.length + 1);
554+
editor!.executeEdits('exit-list', [{ range, text: '', forceMoveMarkers: true }]);
555+
} else {
556+
const isNum = match[2].includes('.');
557+
const next = isNum ? `${parseInt(match[2]) + 1}. ` : `${match[2]} `;
558+
editor!.trigger('keyboard', 'type', { text: `\n${match[1]}${next}` });
559+
}
560+
}),
561+
);
532562
533563
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
534564
const selection = editor!.getSelection();

custom/topPanelButtons.vue

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script setup lang="ts">
2+
import { markRaw } from 'vue';
3+
import * as monaco from 'monaco-editor';
4+
import { toggleWrapSmart } from './utils/monacoMarkdownToggle';
5+
6+
import {
7+
IconLinkOutline, IconCodeOutline, IconRectangleListOutline,
8+
IconOrderedListOutline, IconLetterBoldOutline, IconLetterUnderlineOutline,
9+
IconLetterItalicOutline, IconTextSlashOutline
10+
} from '@iconify-prerendered/vue-flowbite';
11+
import { IconH116Solid, IconH216Solid, IconH316Solid } from '@iconify-prerendered/vue-heroicons';
12+
13+
const props = defineProps<{
14+
editor: monaco.editor.IStandaloneCodeEditor | null;
15+
meta: any;
16+
}>();
17+
18+
const isBtnVisible = (btnKey: string) => {
19+
const settings = props.meta?.topPanelSettings;
20+
if (!settings || Object.keys(settings).length === 0) return true;
21+
return settings[btnKey] !== undefined ? settings[btnKey] : true;
22+
};
23+
24+
const btnClass = 'flex items-center justify-center h-8 px-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200';
25+
26+
const fenceForCodeBlock = (text: string): string => {
27+
let maxBackticks = 0;
28+
let current = 0;
29+
for (let i = 0; i < text.length; i++) {
30+
if (text[i] === '`') { current++; if (current > maxBackticks) maxBackticks = current; }
31+
else { current = 0; }
32+
}
33+
return '`'.repeat(Math.max(3, maxBackticks + 1));
34+
};
35+
36+
const applyFormat = (type: string) => {
37+
const editor = props.editor;
38+
if (!editor) return;
39+
40+
const model = editor.getModel();
41+
if (!model) return;
42+
43+
editor.focus();
44+
const rawSelection = editor.getSelection();
45+
if (!rawSelection) return;
46+
47+
const selection = rawSelection.startLineNumber !== rawSelection.endLineNumber && rawSelection.endColumn === 1
48+
? new monaco.Selection(rawSelection.startLineNumber, rawSelection.startColumn, rawSelection.endLineNumber - 1, model.getLineMaxColumn(rawSelection.endLineNumber - 1))
49+
: rawSelection;
50+
51+
const selectedText = model.getValueInRange(selection);
52+
53+
const applyEdits = (id: string, edits: monaco.editor.IIdentifiedSingleEditOperation[]) => {
54+
editor.executeEdits(id, edits);
55+
};
56+
57+
switch (type) {
58+
case 'bold': toggleWrapSmart(editor, '**'); break;
59+
case 'italic': toggleWrapSmart(editor, '*'); break;
60+
case 'strike': toggleWrapSmart(editor, '~~'); break;
61+
case 'underline': toggleWrapSmart(editor, '<u>', '</u>'); break;
62+
case 'codeBlock': {
63+
const trimmed = selectedText.trim();
64+
const match = trimmed.match(/^(`{3,})[^\n]*\n([\s\S]*)\n\1$/);
65+
if (match) {
66+
applyEdits('unwrap-code', [{ range: selection, text: match[2], forceMoveMarkers: true }]);
67+
} else {
68+
const fence = fenceForCodeBlock(selectedText);
69+
applyEdits('wrap-code', [{ range: selection, text: `\n${fence}\n${selectedText}\n${fence}\n`, forceMoveMarkers: true }]);
70+
}
71+
break;
72+
}
73+
case 'link': {
74+
const match = selectedText.trim().match(/^\[(.*?)\]\(.*?\)$/);
75+
if (match) {
76+
applyEdits('unlink', [{ range: selection, text: match[1], forceMoveMarkers: true }]);
77+
} else {
78+
applyEdits('insert-link', [{ range: selection, text: `[${selectedText}](url)`, forceMoveMarkers: true }]);
79+
}
80+
break;
81+
}
82+
case 'h1': case 'h2': case 'h3': case 'ul': case 'ol': {
83+
const prefixMap: any = { h1: '# ', h2: '## ', h3: '### ', ul: '* ' };
84+
const edits: any[] = [];
85+
for (let i = selection.startLineNumber; i <= selection.endLineNumber; i++) {
86+
const line = model.getLineContent(i);
87+
const targetPrefix = type === 'ol' ? `${i - selection.startLineNumber + 1}. ` : prefixMap[type];
88+
const match = line.match(/^(#{1,6}\s+|[*+-]\s+|\d+[.)]\s+)/);
89+
if (match) {
90+
edits.push({ range: new monaco.Range(i, 1, i, match[0].length + 1), text: match[0].trim() === targetPrefix.trim() ? '' : targetPrefix });
91+
} else {
92+
edits.push({ range: new monaco.Range(i, 1, i, 1), text: targetPrefix });
93+
}
94+
}
95+
applyEdits('format-block', edits);
96+
break;
97+
}
98+
}
99+
};
100+
101+
const buttons = [
102+
{ id: 'bold', title: 'Bold', icon: markRaw(IconLetterBoldOutline), group: 1 },
103+
{ id: 'italic', title: 'Italic', icon: markRaw(IconLetterItalicOutline), group: 1 },
104+
{ id: 'underline', title: 'Underline', icon: markRaw(IconLetterUnderlineOutline), group: 1 },
105+
{ id: 'strike', title: 'Strike', icon: markRaw(IconTextSlashOutline), group: 1, separator: true },
106+
{ id: 'h1', title: 'H1', icon: markRaw(IconH116Solid), group: 2 },
107+
{ id: 'h2', title: 'H2', icon: markRaw(IconH216Solid), group: 2 },
108+
{ id: 'h3', title: 'H3', icon: markRaw(IconH316Solid), group: 2, separator: true },
109+
{ id: 'ul', title: 'UL', icon: markRaw(IconRectangleListOutline), group: 3 },
110+
{ id: 'ol', title: 'OL', icon: markRaw(IconOrderedListOutline), group: 3 },
111+
{ id: 'link', title: 'Link', icon: markRaw(IconLinkOutline), group: 3 },
112+
{ id: 'codeBlock', title: 'Code', icon: markRaw(IconCodeOutline), group: 3 },
113+
];
114+
</script>
115+
116+
<template>
117+
<div class="flex flex-wrap items-center gap-3 p-1.5 border border-gray-300 dark:border-gray-600 rounded-t-lg bg-gray-50 dark:bg-gray-800 w-full box-border">
118+
<template v-for="btn in buttons" :key="btn.id">
119+
<button
120+
v-if="isBtnVisible(btn.id)"
121+
type="button"
122+
@click="applyFormat(btn.id)"
123+
:class="btnClass"
124+
:title="btn.title"
125+
>
126+
<component :is="btn.icon" class="w-5 h-5" />
127+
</button>
128+
<div v-if="btn.separator && isBtnVisible(btn.id)" class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1"></div>
129+
</template>
130+
</div>
131+
</template>

index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ export default class MarkdownPlugin extends AdminForthPlugin {
126126
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
127127
},
128128
};
129+
130+
const topPanelSettings = this.options.topPanelSettings || {};
131+
132+
const commonMeta = {
133+
pluginInstanceId: this.pluginInstanceId,
134+
columnName: fieldName,
135+
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
136+
topPanelSettings: topPanelSettings,
137+
};
138+
139+
column.components.edit = {
140+
file: this.componentPath("MarkdownEditor.vue"),
141+
meta: commonMeta,
142+
};
143+
144+
column.components.create = {
145+
file: this.componentPath("MarkdownEditor.vue"),
146+
meta: commonMeta,
147+
};
148+
129149
const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
130150
if (this.options.attachments) {
131151

types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,33 @@ export interface PluginOptions {
5353
*/
5454
attachmentAltFieldName?: string; // e.g. 'alt',
5555
},
56+
57+
/**
58+
* Optional configuration for the editor's top toolbar (formatting panel).
59+
*
60+
* If `topPanelSettings` is omitted, the editor uses its internal default
61+
* toolbar configuration.
62+
*
63+
* If `topPanelSettings` is provided as an empty object, all controls behave
64+
* as if their flags were `undefined`, i.e. they also fall back to the same
65+
* internal defaults.
66+
*
67+
* For each flag below:
68+
* - `true` – explicitly enable/show the control in the top panel.
69+
* - `false` – explicitly disable/hide the control.
70+
* - `undefined` – use the editor's default behavior for that control.
71+
*/
72+
topPanelSettings?: {
73+
bold?: boolean;
74+
italic?: boolean;
75+
underline?: boolean;
76+
strike?: boolean;
77+
h1?: boolean;
78+
h2?: boolean;
79+
h3?: boolean;
80+
ul?: boolean;
81+
ol?: boolean;
82+
link?: boolean;
83+
codeBlock?: boolean;
84+
};
5685
}

0 commit comments

Comments
 (0)