Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions custom/MarkdownEditor.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<template>
<div class="mb-2"></div>
<div
ref="editorContainer"
id="editor"
:class="[
'text-sm rounded-lg block w-full transition-all box-border overflow-hidden',
isFocused
? 'ring-1 ring-lightPrimary border ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary'
: 'border border-gray-300 dark:border-gray-600',
]"
></div>
<div class="mb-2 w-full flex flex-col">
<TopPanelButtons :editor="editor" :meta="meta" />
<div
ref="editorContainer"
id="editor"
:class="[
'text-sm block w-full transition-all box-border overflow-hidden rounded-b-lg border border-t-0 pt-3',
isFocused
? 'ring-1 ring-lightPrimary border-lightPrimary dark:ring-darkPrimary dark:border-darkPrimary'
: 'border-gray-300 dark:border-gray-600',
]"
></div>
</div>
</template>

<script setup lang="ts">
Expand All @@ -19,6 +21,7 @@ import * as monaco from 'monaco-editor';
import TurndownService from 'turndown';
import { gfm, tables } from 'turndown-plugin-gfm';
import { toggleWrapSmart } from './utils/monacoMarkdownToggle';
import TopPanelButtons from './topPanelButtons.vue';

const props = defineProps<{
column: any,
Expand Down Expand Up @@ -529,6 +532,33 @@ onMounted(async () => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyU, () => {
toggleWrapSmart(editor!, '<u>', '</u>');
});

disposables.push(
editor.onKeyDown((e) => {
if (e.keyCode !== monaco.KeyCode.Enter) {
return;
}
const pos = editor!.getPosition();
if (!pos) {
return;
}
const line = model!.getLineContent(pos.lineNumber);
const match = line.match(/^(\s*)([*+-]|\d+\.)\s+/);
if (!match) {
return;
}
e.preventDefault();

if (line.trim() === match[2].trim()) {
const range = new monaco.Range(pos.lineNumber, 1, pos.lineNumber, line.length + 1);
editor!.executeEdits('exit-list', [{ range, text: '', forceMoveMarkers: true }]);
} else {
const isNum = match[2].includes('.');
const next = isNum ? `${parseInt(match[2]) + 1}. ` : `${match[2]} `;
editor!.trigger('keyboard', 'type', { text: `\n${match[1]}${next}` });
}
}),
);

editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
const selection = editor!.getSelection();
Expand Down
131 changes: 131 additions & 0 deletions custom/topPanelButtons.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { markRaw } from 'vue';
import * as monaco from 'monaco-editor';
import { toggleWrapSmart } from './utils/monacoMarkdownToggle';

import {
IconLinkOutline, IconCodeOutline, IconRectangleListOutline,
IconOrderedListOutline, IconLetterBoldOutline, IconLetterUnderlineOutline,
IconLetterItalicOutline, IconTextSlashOutline
} from '@iconify-prerendered/vue-flowbite';
import { IconH116Solid, IconH216Solid, IconH316Solid } from '@iconify-prerendered/vue-heroicons';

const props = defineProps<{
editor: monaco.editor.IStandaloneCodeEditor | null;
meta: any;
}>();

const isBtnVisible = (btnKey: string) => {
const settings = props.meta?.topPanelSettings;
if (!settings || Object.keys(settings).length === 0) return true;
return settings[btnKey] !== undefined ? settings[btnKey] : true;
};

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';

const fenceForCodeBlock = (text: string): string => {
let maxBackticks = 0;
let current = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '`') { current++; if (current > maxBackticks) maxBackticks = current; }
else { current = 0; }
}
return '`'.repeat(Math.max(3, maxBackticks + 1));
};

const applyFormat = (type: string) => {
const editor = props.editor;
if (!editor) return;

const model = editor.getModel();
if (!model) return;

editor.focus();
const rawSelection = editor.getSelection();
if (!rawSelection) return;

const selection = rawSelection.startLineNumber !== rawSelection.endLineNumber && rawSelection.endColumn === 1
? new monaco.Selection(rawSelection.startLineNumber, rawSelection.startColumn, rawSelection.endLineNumber - 1, model.getLineMaxColumn(rawSelection.endLineNumber - 1))
: rawSelection;

const selectedText = model.getValueInRange(selection);

const applyEdits = (id: string, edits: monaco.editor.IIdentifiedSingleEditOperation[]) => {
editor.executeEdits(id, edits);
};

switch (type) {
case 'bold': toggleWrapSmart(editor, '**'); break;
case 'italic': toggleWrapSmart(editor, '*'); break;
case 'strike': toggleWrapSmart(editor, '~~'); break;
case 'underline': toggleWrapSmart(editor, '<u>', '</u>'); break;
case 'codeBlock': {
const trimmed = selectedText.trim();
const match = trimmed.match(/^(`{3,})[^\n]*\n([\s\S]*)\n\1$/);
if (match) {
applyEdits('unwrap-code', [{ range: selection, text: match[2], forceMoveMarkers: true }]);
} else {
const fence = fenceForCodeBlock(selectedText);
applyEdits('wrap-code', [{ range: selection, text: `\n${fence}\n${selectedText}\n${fence}\n`, forceMoveMarkers: true }]);
}
break;
}
case 'link': {
const match = selectedText.trim().match(/^\[(.*?)\]\(.*?\)$/);
if (match) {
applyEdits('unlink', [{ range: selection, text: match[1], forceMoveMarkers: true }]);
} else {
applyEdits('insert-link', [{ range: selection, text: `[${selectedText}](url)`, forceMoveMarkers: true }]);
}
break;
}
case 'h1': case 'h2': case 'h3': case 'ul': case 'ol': {
const prefixMap: any = { h1: '# ', h2: '## ', h3: '### ', ul: '* ' };
const edits: any[] = [];
for (let i = selection.startLineNumber; i <= selection.endLineNumber; i++) {
const line = model.getLineContent(i);
const targetPrefix = type === 'ol' ? `${i - selection.startLineNumber + 1}. ` : prefixMap[type];
const match = line.match(/^(#{1,6}\s+|[*+-]\s+|\d+[.)]\s+)/);
if (match) {
edits.push({ range: new monaco.Range(i, 1, i, match[0].length + 1), text: match[0].trim() === targetPrefix.trim() ? '' : targetPrefix });
} else {
edits.push({ range: new monaco.Range(i, 1, i, 1), text: targetPrefix });
}
}
applyEdits('format-block', edits);
break;
}
}
};

const buttons = [
{ id: 'bold', title: 'Bold', icon: markRaw(IconLetterBoldOutline), group: 1 },
{ id: 'italic', title: 'Italic', icon: markRaw(IconLetterItalicOutline), group: 1 },
{ id: 'underline', title: 'Underline', icon: markRaw(IconLetterUnderlineOutline), group: 1 },
{ id: 'strike', title: 'Strike', icon: markRaw(IconTextSlashOutline), group: 1, separator: true },
{ id: 'h1', title: 'H1', icon: markRaw(IconH116Solid), group: 2 },
{ id: 'h2', title: 'H2', icon: markRaw(IconH216Solid), group: 2 },
{ id: 'h3', title: 'H3', icon: markRaw(IconH316Solid), group: 2, separator: true },
{ id: 'ul', title: 'UL', icon: markRaw(IconRectangleListOutline), group: 3 },
{ id: 'ol', title: 'OL', icon: markRaw(IconOrderedListOutline), group: 3 },
{ id: 'link', title: 'Link', icon: markRaw(IconLinkOutline), group: 3 },
{ id: 'codeBlock', title: 'Code', icon: markRaw(IconCodeOutline), group: 3 },
];
</script>

<template>
<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">
<template v-for="btn in buttons" :key="btn.id">
<button
v-if="isBtnVisible(btn.id)"
type="button"
@click="applyFormat(btn.id)"
:class="btnClass"
:title="btn.title"
>
<component :is="btn.icon" class="w-5 h-5" />
</button>
<div v-if="btn.separator && isBtnVisible(btn.id)" class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1"></div>
</template>
</div>
</template>
20 changes: 20 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ export default class MarkdownPlugin extends AdminForthPlugin {
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
},
};

const topPanelSettings = this.options.topPanelSettings || {};

const commonMeta = {
pluginInstanceId: this.pluginInstanceId,
columnName: fieldName,
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
topPanelSettings: topPanelSettings,
};

column.components.edit = {
file: this.componentPath("MarkdownEditor.vue"),
meta: commonMeta,
};

column.components.create = {
file: this.componentPath("MarkdownEditor.vue"),
meta: commonMeta,
};

const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
if (this.options.attachments) {

Expand Down
29 changes: 29 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,33 @@ export interface PluginOptions {
*/
attachmentAltFieldName?: string; // e.g. 'alt',
},

/**
* Optional configuration for the editor's top toolbar (formatting panel).
*
* If `topPanelSettings` is omitted, the editor uses its internal default
* toolbar configuration.
*
* If `topPanelSettings` is provided as an empty object, all controls behave
* as if their flags were `undefined`, i.e. they also fall back to the same
* internal defaults.
*
* For each flag below:
* - `true` – explicitly enable/show the control in the top panel.
* - `false` – explicitly disable/hide the control.
* - `undefined` – use the editor's default behavior for that control.
*/
topPanelSettings?: {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
h1?: boolean;
h2?: boolean;
h3?: boolean;
ul?: boolean;
ol?: boolean;
link?: boolean;
codeBlock?: boolean;
};
}