Skip to content

Commit 8d39240

Browse files
committed
feat: added image selector for Markdown (#3258)
1 parent 69c76e5 commit 8d39240

File tree

15 files changed

+254
-38
lines changed

15 files changed

+254
-38
lines changed

phpmyfaq/admin/assets/src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
export * from './attachment';
22
export * from './category';
33
export * from './configuration';
4+
export * from './elasticsearch';
5+
export * from './export';
46
export * from './faqs';
57
export * from './forms';
68
export * from './glossary';
79
export * from './group';
810
export * from './instance';
11+
export * from './markdown';
12+
export * from './media-browser';
913
export * from './news';
1014
export * from './question';
1115
export * from './statistics';
16+
export * from './stop-words';
1217
export * from './tags';
1318
export * from './upgrade';
1419
export * from './user';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Fetch data from Markdown processor API
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-03-03
14+
*/
15+
import { Response } from '../interfaces';
16+
17+
export const fetchMarkdownContent = async (text: string): Promise<Response> => {
18+
try {
19+
const response = await fetch(`./api/content/markdown`, {
20+
method: 'POST',
21+
headers: {
22+
Accept: 'application/json, text/plain, */*',
23+
'Content-Type': 'application/json',
24+
},
25+
body: JSON.stringify({ text }),
26+
});
27+
28+
return await response.json();
29+
} catch (error) {
30+
throw error;
31+
}
32+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Fetch data from media browser
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public License,
5+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
6+
* obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* @package phpMyFAQ
9+
* @author Thorsten Rinne <[email protected]>
10+
* @copyright 2025 phpMyFAQ Team
11+
* @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
12+
* @link https://www.phpmyfaq.de
13+
* @since 2025-03-03
14+
*/
15+
16+
import { MediaBrowserApiResponse } from '../interfaces/';
17+
18+
export const fetchMediaBrowserContent = async (): Promise<MediaBrowserApiResponse> => {
19+
try {
20+
const response = await fetch(`./api/media-browser`, {
21+
method: 'POST',
22+
headers: {
23+
Accept: 'application/json, text/plain, */*',
24+
'Content-Type': 'application/json',
25+
},
26+
body: JSON.stringify({ action: 'files' }),
27+
});
28+
29+
return await response.json();
30+
} catch (error) {
31+
throw error;
32+
}
33+
};

phpmyfaq/admin/assets/src/content/attachments.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import { deleteAttachments, refreshAttachments } from '../api';
1717
import { pushErrorNotification, pushNotification } from '../../../../assets/src/utils';
18+
import { Response } from '../interfaces';
1819

1920
export const handleDeleteAttachments = (): void => {
2021
const deleteButtons = document.querySelectorAll<HTMLButtonElement>('.btn-delete-attachment');
@@ -49,21 +50,23 @@ export const handleDeleteAttachments = (): void => {
4950
};
5051

5152
export const handleRefreshAttachments = (): void => {
52-
const refreshButtons = document.querySelectorAll<HTMLButtonElement>('.btn-refresh-attachment');
53+
const refreshButtons = document.querySelectorAll<HTMLButtonElement>(
54+
'.btn-refresh-attachment'
55+
) as NodeListOf<HTMLButtonElement>;
5356

5457
if (refreshButtons.length > 0) {
55-
refreshButtons.forEach((button) => {
58+
refreshButtons.forEach((button: HTMLButtonElement): void => {
5659
const newButton = button.cloneNode(true) as HTMLButtonElement;
5760
button.replaceWith(newButton);
5861

59-
newButton.addEventListener('click', async (event: MouseEvent) => {
62+
newButton.addEventListener('click', async (event: MouseEvent): Promise<void> => {
6063
event.preventDefault();
6164

62-
const attachmentId = newButton.getAttribute('data-attachment-id');
63-
const csrf = newButton.getAttribute('data-csrf');
65+
const attachmentId = newButton.getAttribute('data-attachment-id') as string;
66+
const csrf = newButton.getAttribute('data-csrf') as string;
6467

6568
if (attachmentId && csrf) {
66-
const response = await refreshAttachments(attachmentId, csrf);
69+
const response = (await refreshAttachments(attachmentId, csrf)) as unknown as Response;
6770

6871
if (response.success) {
6972
pushNotification(response.success);

phpmyfaq/admin/assets/src/content/markdown.ts

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
* @since 2023-03-05
1414
*/
1515

16+
import { Modal } from 'bootstrap';
17+
import { fetchMarkdownContent, fetchMediaBrowserContent } from '../api';
18+
import { MediaBrowserApiResponse, Response } from '../interfaces';
19+
1620
export const handleMarkdownForm = (): void => {
1721
const answerHeight = localStorage.getItem('phpmyfaq.answer.height');
18-
const answer = document.getElementById('answer-markdown') as HTMLTextAreaElement | null;
19-
const markdownTabs = document.getElementById('markdown-tabs');
22+
const answer = document.getElementById('answer-markdown') as HTMLTextAreaElement;
23+
const markdownTabs = document.getElementById('markdown-tabs') as HTMLElement;
24+
const insertImage = document.getElementById('pmf-markdown-insert-image') as HTMLElement;
25+
const insertImageButton = document.getElementById('pmf-markdown-insert-image-button') as HTMLElement;
2026

2127
// Store the height of the textarea
2228
if (answer) {
@@ -29,34 +35,18 @@ export const handleMarkdownForm = (): void => {
2935
});
3036
}
3137

32-
// handle the Markdown preview
38+
// Handle the Markdown preview
3339
if (markdownTabs) {
34-
const tab = document.querySelector('a[data-markdown-tab="preview"]') as HTMLElement | null;
40+
const tab = document.querySelector('a[data-markdown-tab="preview"]') as HTMLElement;
3541

3642
if (tab) {
3743
tab.addEventListener('shown.bs.tab', async () => {
38-
const preview = document.getElementById('markdown-preview') as HTMLElement | null;
44+
const preview = document.getElementById('markdown-preview') as HTMLElement;
3945
if (preview && answer) {
4046
preview.style.height = answer.style.height;
41-
4247
try {
43-
const response = await fetch(window.location.pathname + 'api/content/markdown', {
44-
method: 'POST',
45-
headers: {
46-
Accept: 'application/json, text/plain, */*',
47-
'Content-Type': 'application/json',
48-
},
49-
body: JSON.stringify({
50-
text: answer.value,
51-
}),
52-
});
53-
54-
if (!response.ok) {
55-
throw new Error('Network response was not ok');
56-
}
57-
58-
const responseData = await response.json();
59-
preview.innerHTML = responseData.success;
48+
const response = (await fetchMarkdownContent(answer.value)) as unknown as Response;
49+
preview.innerHTML = response.success;
6050
} catch (error) {
6151
if (error instanceof Error) {
6252
console.error(error);
@@ -68,4 +58,58 @@ export const handleMarkdownForm = (): void => {
6858
});
6959
}
7060
}
61+
62+
// Handle inserting images from Modal
63+
if (insertImage) {
64+
const container = document.getElementById('pmf-markdown-insert-image-modal') as HTMLElement;
65+
const modal = new Modal(container);
66+
insertImage.addEventListener('click', async (event: Event): Promise<void> => {
67+
event.preventDefault();
68+
modal.show();
69+
70+
const response = (await fetchMediaBrowserContent()) as MediaBrowserApiResponse;
71+
72+
if (response.success) {
73+
const list = document.getElementById('pmf-markdown-insert-image-list') as HTMLElement;
74+
list.innerHTML = ''; // Clear previous content
75+
76+
response.data.sources.forEach((source): void => {
77+
source.files.forEach((file) => {
78+
const listItem = document.createElement('div') as HTMLElement;
79+
listItem.classList.add('list-group-item', 'd-flex', 'align-items-center');
80+
listItem.innerHTML = `
81+
<div class="form-check me-2">
82+
<input type="checkbox" class="form-check-input" id="checkbox-${file.file}" data-image-url="${source.baseurl}/${source.path}/${file.file}">
83+
<label class="form-check-label d-none" for="checkbox-${file.file}" aria-hidden="true">Select</label>
84+
</div>
85+
<img src="${source.baseurl}/${source.path}/${file.file}" class="img-thumbnail" alt="${file.file}" style="height: 100px;">
86+
`;
87+
list.appendChild(listItem);
88+
});
89+
});
90+
}
91+
});
92+
93+
// Add event listener to the insert image button
94+
insertImageButton.addEventListener('click', () => {
95+
const checkboxes = document.querySelectorAll('.form-check-input:checked') as NodeListOf<HTMLInputElement>;
96+
let markdownImages: string = '';
97+
98+
checkboxes.forEach((checkbox: HTMLInputElement): void => {
99+
const imageUrl = (checkbox as HTMLInputElement).dataset.imageUrl as string;
100+
if (imageUrl) {
101+
markdownImages += `![Image](${imageUrl})\n`;
102+
}
103+
});
104+
105+
// Insert the Markdown images at the cursor position
106+
const startPos: number = answer.selectionStart;
107+
const endPos: number = answer.selectionEnd;
108+
answer.value = answer.value.substring(0, startPos) + markdownImages + answer.value.substring(endPos);
109+
answer.setSelectionRange(startPos + markdownImages.length, startPos + markdownImages.length);
110+
answer.focus();
111+
112+
modal.hide();
113+
});
114+
}
71115
};

phpmyfaq/admin/assets/src/interfaces/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './attachment';
22
export * from './elasticsearch';
33
export * from './instance';
4+
export * from './mediaBrowserApiResponse';
45
export * from './response';
56
export * from './stopWord';
67
export * from './userAutocomplete';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
interface FileItem {
2+
file: string;
3+
size: string;
4+
isImage: boolean;
5+
thumb: string;
6+
changed: string;
7+
}
8+
9+
interface Source {
10+
baseurl: string;
11+
path: string;
12+
files: FileItem[];
13+
name: string;
14+
}
15+
16+
interface Data {
17+
sources: Source[];
18+
code: number;
19+
}
20+
export interface MediaBrowserApiResponse {
21+
success: boolean;
22+
time: string;
23+
data: Data;
24+
}

phpmyfaq/assets/templates/admin/content/faq.editor.twig

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,24 @@
125125
<div class="col-lg-12">
126126
<ul class="nav nav-tabs mb-2" id="markdown-tabs">
127127
<li class="nav-item">
128-
<a class="nav-link active" data-bs-toggle="tab" href="#text">Text</a>
128+
<a class="nav-link active" data-bs-toggle="tab" href="#text">
129+
{{ 'msgNewContentArticle' | translate }}
130+
</a>
129131
</li>
130132
<li class="nav-item">
131-
<a class="nav-link" data-bs-toggle="tab" href="#preview"
132-
data-markdown-tab="preview">Preview</a>
133+
<a class="nav-link" data-bs-toggle="tab" href="#preview" data-markdown-tab="preview">
134+
{{ 'msgPreview' | translate }}
135+
</a>
136+
</li>
137+
<li class="nav-item">
138+
<a class="nav-link" data-bs-toggle="tab" href="#text" id="pmf-markdown-insert-image">
139+
{{ 'msgInsertImage' | translate }}
140+
</a>
141+
</li>
142+
<li class="nav-item">
143+
<a class="nav-link" data-bs-toggle="tab" href="#text" id="pmf-markdown-upload-image">
144+
{{ 'msgImageUpload' | translate }}
145+
</a>
133146
</li>
134147
</ul>
135148
<div class="tab-content">
@@ -540,6 +553,7 @@
540553
</div>
541554
</div>
542555

556+
<!-- Attachment Modal -->
543557
<div class="modal modal-lg fade" id="attachmentModal" tabindex="-1" role="dialog" aria-labelledby="attachmentModalLabel"
544558
aria-hidden="true">
545559
<div class="modal-dialog" role="document">
@@ -586,6 +600,32 @@
586600
</div>
587601
</div>
588602

603+
{% if isMarkdownEditorEnabled %}
604+
<!-- Markdown Image Modal -->
605+
<div class="modal modal-lg fade " id="pmf-markdown-insert-image-modal" tabindex="-1" role="dialog"
606+
aria-labelledby="pmf-markdown-insert-image-modalLabel" aria-hidden="true">
607+
<div class="modal-dialog" role="document">
608+
<div class="modal-content">
609+
<div class="modal-header">
610+
<h5 class="modal-title" id="pmf-markdown-insert-image-modalLabel">
611+
{{ 'msgInsertImage' | translate }}
612+
</h5>
613+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
614+
</div>
615+
<div class="modal-body">
616+
<div id="pmf-markdown-insert-image-list"></div>
617+
</div>
618+
<div class="modal-footer">
619+
<button type="button" class="btn btn-primary" id="pmf-markdown-insert-image-button">
620+
{{ 'ad_categ_paste' | translate }}
621+
</button>
622+
</div>
623+
</div>
624+
</div>
625+
</div>
626+
{% endif %}
627+
628+
589629
<script>
590630
function setRecordDate(how) {
591631
if ('updateDate' === how) {

phpmyfaq/faq.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
* @since 2002-08-27
1717
*/
1818

19-
use League\CommonMark\CommonMarkConverter;
19+
use League\CommonMark\Environment\Environment;
20+
use League\CommonMark\Exception\CommonMarkException;
21+
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
22+
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
23+
use League\CommonMark\MarkdownConverter;
2024
use phpMyFAQ\Attachment\AttachmentException;
2125
use phpMyFAQ\Attachment\AttachmentFactory;
2226
use phpMyFAQ\Captcha\Helper\CaptchaHelper;
@@ -71,10 +75,16 @@
7175
$faqSession = $container->get('phpmyfaq.user.session');
7276
$faqSession->setCurrentUser($user);
7377

74-
$converter = new CommonMarkConverter([
78+
$config = [
7579
'html_input' => 'strip',
7680
'allow_unsafe_links' => false,
77-
]);
81+
];
82+
83+
$environment = new Environment($config);
84+
$environment->addExtension(new CommonMarkCoreExtension());
85+
$environment->addExtension(new GithubFlavoredMarkdownExtension());
86+
87+
$converter = new MarkdownConverter($environment);
7888

7989
if (is_null($user)) {
8090
$user = new CurrentUser($faqConfig);

0 commit comments

Comments
 (0)