Commit 9ec1cb93 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

fix(md-editor): build new markdown editor with lit + github/markdown-toolbar-element

- create markdown-write-preview + markdown-preview webcomponents using lit
- create
form_markdown_editor helper form component
- simplify form_dropdown and form_multiselect
components
- fix partner fields display

fixes #93, #94, #120
parent 910d457c
......@@ -25,19 +25,20 @@
}
},
"extensions": [
"mikestead.dotenv",
"bierner.lit-html",
"bmewburn.vscode-intelephense-client",
"streetsidesoftware.code-spell-checker",
"naumovs.color-highlight",
"heybourn.headwind",
"wayou.vscode-todo-highlight",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"jamesbirtles.svelte-vscode",
"breezelin.phpstan",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"eamodio.gitlens",
"breezelin.phpstan",
"kasik96.latte"
"esbenp.prettier-vscode",
"heybourn.headwind",
"jamesbirtles.svelte-vscode",
"kasik96.latte",
"mikestead.dotenv",
"naumovs.color-highlight",
"streetsidesoftware.code-spell-checker",
"stylelint.vscode-stylelint",
"wayou.vscode-todo-highlight"
]
}
......@@ -157,17 +157,9 @@ if (! function_exists('form_multiselect')) {
): string {
$defaultExtra = [
'data-class' => $customExtra['class'],
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
'multiple' => 'multiple',
];
$extra = stringify_attributes(array_merge($defaultExtra, $customExtra));
if (stripos($extra, 'multiple') === false) {
$extra .= ' multiple="multiple"';
}
$extra = array_merge($defaultExtra, $customExtra);
return form_dropdown($name, $options, $selected, $extra);
}
......@@ -179,43 +171,31 @@ if (! function_exists('form_dropdown')) {
/**
* Drop-down Menu (based on html select tag)
*
* @param array<string, mixed>|string $data
* @param array<string, string> $options
* @param string|string[] $selected
* @param array<string, mixed>|string $extra
* @param array<string, mixed> $options
* @param string[] $selected
* @param array<string, mixed> $customExtra
*/
function form_dropdown(
string | array $data = '',
string $name = '',
array $options = [],
string | array $selected = [],
string | array $extra = ''
array $selected = [],
array $customExtra = []
): string {
$defaults = [];
if (is_array($data)) {
if (isset($data['selected'])) {
$selected = $data['selected'];
unset($data['selected']); // select tags don't have a selected attribute
}
if (isset($data['options'])) {
$options = $data['options'];
unset($data['options']); // select tags don't use an options attribute
}
} else {
$defaults = [
'name' => $data,
];
}
if (! is_array($selected)) {
$selected = [$selected];
}
if (! is_array($options)) {
$options = [$options];
}
$defaultExtra = [
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
];
$extra = array_merge($defaultExtra, $customExtra);
$defaults = [
'name' => $name,
];
// standardize selected as strings, like the option keys will be.
foreach ($selected as $key => $item) {
$selected[$key] = (string) $item;
$selected[$key] = $item;
}
$placeholderOption = '';
......@@ -230,11 +210,10 @@ if (! function_exists('form_dropdown')) {
$extra = stringify_attributes($extra);
$multiple = (count($selected) > 1 && stripos($extra, 'multiple') === false) ? ' multiple="multiple"' : '';
$form = '<select ' . rtrim(parse_form_attributes($data, $defaults)) . $extra . $multiple . ">\n";
$form = '<select ' . rtrim(parse_form_attributes($name, $defaults)) . $extra . $multiple . ">\n";
$form .= $placeholderOption;
foreach ($options as $key => $val) {
$key = (string) $key;
if (is_array($val)) {
if ($val === []) {
continue;
......@@ -257,4 +236,80 @@ if (! function_exists('form_dropdown')) {
}
}
//--------------------------------------------------------------------
if (! function_exists('form_editor')) {
/**
* Markdown editor
*
* @param array<string, mixed> $data
* @param array<string, mixed>|string $extra
*/
function form_markdown_editor(array $data = [], string $value = '', string | array $extra = ''): string
{
$editorClass = 'w-full flex flex-col bg-white border border-gray-500 focus-within:ring-1 focus-within:ring-blue-600';
if (array_key_exists('class', $data) && $data['class'] !== '') {
$editorClass .= ' ' . $data['class'];
unset($data['class']);
}
$data['class'] = 'border-none outline-none focus:border-none focus:outline-none w-full h-full';
return '<div class="' . $editorClass . '">' .
'<header class="sticky top-0 z-20 flex flex-wrap justify-between bg-white border-b border-gray-500">' .
'<markdown-write-preview for="' . $data['id'] . '" class="relative inline-flex h-8">' .
'<button type="button" slot="write" class="px-2 font-semibold focus:outline-none focus:ring-inset focus:ring-2 focus:ring-pine-600">' . lang(
'Common.forms.editor.write'
) . '</button>' .
'<button type="button" slot="preview" class="px-2 focus:outline-none focus:ring-inset focus:ring-2 focus:ring-pine-600">' . lang(
'Common.forms.editor.preview'
) . '</button>' .
'</markdown-write-preview>' .
'<markdown-toolbar for="' . $data['id'] . '" class="flex gap-4 px-2 py-1">' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-header class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'heading'
) . '</md-header>' .
'<md-bold class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'bold'
) . '</md-bold>' .
'<md-italic class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'italic'
) . '</md-italic>' .
'</div>' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-unordered-list class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'list-unordered'
) . '</md-unordered-list>' .
'<md-ordered-list class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'list-ordered'
) . '</md-ordered-list>' .
'</div>' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-quote class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'quote'
) . '</md-quote>' .
'<md-link class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'link'
) . '</md-link>' .
'<md-image class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'image-add'
) . '</md-image>' .
'</div>' .
'</markdown-toolbar>' .
'</header>' .
'<div class="relative">' .
form_textarea($data, $value, $extra) .
'<markdown-preview for="' . $data['id'] . '" class="absolute top-0 left-0 hidden w-full h-full p-2 overflow-y-auto prose bg-gray-50" showClass="bg-white"></markdown-preview>' .
'</div>' .
'<footer class="flex px-2 py-1 bg-gray-100 border-t">' .
'<a href="https://commonmark.org/help/" class="inline-flex items-center text-xs font-semibold text-gray-500 hover:text-gray-700" target="_blank" rel="noopener noreferrer">' . icon(
'markdown',
'mr-1 text-lg text-gray-400'
) . lang('Common.forms.editor.help') . '</a>' .
'</footer>' .
'</div>';
}
}
// ------------------------------------------------------------------------
......@@ -24,6 +24,11 @@ return [
'pageInfo' => 'Page {currentPage} out of {pageCount}',
'go_back' => 'Go back',
'forms' => [
'editor' => [
'write' => 'Write',
'preview' => 'Preview',
'help' => 'Powered by markdown',
],
'multiSelect' => [
'selectText' => 'Press to select',
'loadingText' => 'Loading...',
......
......@@ -24,6 +24,11 @@ return [
'pageInfo' => 'Page {currentPage} sur {pageCount}',
'go_back' => 'Retour en arrière',
'forms' => [
'editor' => [
'write' => 'Écrire',
'preview' => 'Aperçu',
'help' => 'Propulsé par markdown',
],
'multiSelect' => [
'selectText' => 'Cliquez pour selectionner',
'loadingText' => 'Chargement...',
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M8 11h4.5a2.5 2.5 0 1 0 0-5H8v5zm10 4.5a4.5 4.5 0 0 1-4.5 4.5H6V4h6.5a4.5 4.5 0 0 1 3.256 7.606A4.498 4.498 0 0 1 18 15.5zM8 13v5h5.5a2.5 2.5 0 1 0 0-5H8z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M17 11V4h2v17h-2v-8H7v8H5V4h2v7z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M21 15v3h3v2h-3v3h-2v-3h-3v-2h3v-3h2zm.008-12c.548 0 .992.445.992.993v9.349A5.99 5.99 0 0 0 20 13V5H4l.001 14 9.292-9.293a.999.999 0 0 1 1.32-.084l.093.085 3.546 3.55a6.003 6.003 0 0 0-3.91 7.743L2.992 21A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016zM8 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M15 20H7v-2h2.927l2.116-12H9V4h8v2h-2.927l-2.116 12H15z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M8 4h13v2H8V4zM5 3v3h1v1H3V6h1V4H3V3h2zM3 14v-2.5h2V11H3v-1h3v2.5H4v.5h2v1H3zm2 5.5H3v-1h2V18H3v-1h3v4H3v-1h2v-.5zM8 11h13v2H8v-2zm0 7h13v2H8v-2z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M8 4h13v2H8V4zM4.5 6.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 6.9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM8 11h13v2H8v-2zm0 7h13v2H8v-2z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm4 12.5v-4l2 2 2-2v4h2v-7h-2l-2 2-2-2H5v7h2zm11-3v-4h-2v4h-2l3 3 3-3h-2z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 0 1-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179zm10 0C13.553 16.227 13 15 13 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 0 1-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179z"/>
</g>
</svg>
import "@github/markdown-toolbar-element";
import ClientTimezone from "./modules/ClientTimezone";
import Clipboard from "./modules/Clipboard";
import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown";
import MarkdownEditor from "./modules/MarkdownEditor";
import "./modules/markdown-preview";
import "./modules/markdown-write-preview";
import MultiSelect from "./modules/MultiSelect";
import PublishMessageWarning from "./modules/PublishMessageWarning";
import Select from "./modules/Select";
......@@ -15,7 +17,6 @@ import Tooltip from "./modules/Tooltip";
Dropdown();
Tooltip();
MarkdownEditor();
Select();
MultiSelect();
Slugify();
......
import { exampleSetup } from "prosemirror-example-setup";
import "prosemirror-example-setup/style/style.css";
import {
defaultMarkdownParser,
defaultMarkdownSerializer,
schema,
} from "prosemirror-markdown";
import "prosemirror-menu/style/menu.css";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import "prosemirror-view/style/prosemirror.css";
class MarkdownView {
textarea: HTMLTextAreaElement;
constructor(target: HTMLTextAreaElement) {
this.textarea = target;
this.textarea.classList.add("w-full", "h-full");
}
content() {
return this.textarea.innerHTML;
}
focus() {
this.textarea.focus();
}
show() {
this.textarea.classList.remove("hidden");
}
hide() {
this.textarea.classList.add("hidden");
}
}
class ProseMirrorView {
editorContainer: HTMLDivElement;
view: EditorView;
constructor(target: HTMLTextAreaElement, content: string) {
this.editorContainer = document.createElement("div");
this.editorContainer.classList.add("bg-white", "border");
this.editorContainer.style.minHeight = "200px";
const editor = target.parentNode?.insertBefore(
this.editorContainer,
target.nextSibling
);
this.view = new EditorView(editor, {
state: EditorState.create({
doc: defaultMarkdownParser.parse(content),
plugins: exampleSetup({ schema }),
}),
dispatchTransaction: (transaction) => {
const newState = this.view.state.apply(transaction);
this.view.updateState(newState);
if (transaction.docChanged) {
target.innerHTML = this.content();
}
},
attributes: {
class: "prose-sm px-3 py-2 overflow-y-auto focus:ring",
style: "min-height: 200px; max-height: 500px",
},
});
}
content(): string {
return defaultMarkdownSerializer.serialize(this.view.state.doc) || "";
}
focus() {
this.view.focus();
}
show() {
this.editorContainer.classList.remove("hidden");
}
hide() {
this.editorContainer.classList.add("hidden");
}
}
const MarkdownEditor = (): void => {
const targets: NodeListOf<HTMLTextAreaElement> = document.querySelectorAll(
"textarea[data-editor='markdown']"
);
const activeClass = "font-semibold";
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
const wysiwygBtn = document.createElement("button");
wysiwygBtn.classList.add(
activeClass,
"py-1",
"px-2",
"bg-white",
"border",
"text-xs",
"outline-none",
"focus:ring"
);
wysiwygBtn.setAttribute("type", "button");
wysiwygBtn.innerHTML = "Wysiwyg";
const markdownBtn = document.createElement("button");
markdownBtn.classList.add(
"py-1",
"px-2",
"bg-white",
"border",
"text-xs",
"outline-none",
"focus:ring"
);
markdownBtn.setAttribute("type", "button");
markdownBtn.innerHTML = "Markdown";
const viewButtons = document.createElement("div");
viewButtons.appendChild(wysiwygBtn);
viewButtons.appendChild(markdownBtn);
viewButtons.classList.add(
"inline-flex",
"absolute",
"top-0",
"right-0",
"-mt-6"
);
const markdownEditorContainer = document.createElement("div");
markdownEditorContainer.classList.add("relative");
markdownEditorContainer.style.minHeight = "200px";
target.parentNode?.appendChild(markdownEditorContainer);
markdownEditorContainer.appendChild(target);
// show WYSIWYG editor by default
target.classList.add("hidden");
const markdownView = new MarkdownView(target);
const wysiwygView = new ProseMirrorView(target, markdownView.content());
markdownEditorContainer.appendChild(viewButtons);
markdownBtn.addEventListener("click", () => {
if (markdownBtn.classList.contains(activeClass)) return;
markdownBtn.classList.add(activeClass);
wysiwygBtn.classList.remove(activeClass);
wysiwygView.hide();
markdownView.show();
});
wysiwygBtn.addEventListener("click", () => {
if (wysiwygBtn.classList.contains(activeClass)) return;
wysiwygBtn.classList.add(activeClass);
markdownBtn.classList.remove(activeClass);
markdownView.hide();
wysiwygView.show();
});
}
};
export default MarkdownEditor;
......@@ -10,8 +10,11 @@ const MultiSelect = (): void => {
new Choices(multiSelect, {
maxItemCount: parseInt(multiSelect.dataset.maxItemCount || "-1"),
loadingText: multiSelect.dataset.loadingText,
itemSelectText: multiSelect.dataset.selectText,
maxItemText: multiSelect.dataset.maxItemText,
noChoicesText: multiSelect.dataset.noChoicesText,
noResultsText: multiSelect.dataset.noResultsText,
removeItemButton: true,
classNames: {
containerOuter: "choices",
......
......@@ -10,6 +10,11 @@ const Select = (): void => {
const select = selects[i];
new Choices(select, {
loadingText: select.dataset.loadingText,
itemSelectText: select.dataset.selectText,
maxItemText: select.dataset.maxItemText,
noChoicesText: select.dataset.noChoicesText,
noResultsText: select.dataset.noResultsText,
classNames: {
containerOuter: "choices",
containerInner: "choices__inner",
......
import MarkdownToolbarElement from "@github/markdown-toolbar-element";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import marked from "marked";
@customElement("markdown-preview")
export class MarkdownPreview extends LitElement {
@property()
for!: string;
@property()
_textarea!: HTMLTextAreaElement;
@property()
_markdownToolbar!: MarkdownToolbarElement;
@property()
_show = false;
connectedCallback(): void {
super.connectedCallback();
this._textarea = document.getElementById(this.for) as HTMLTextAreaElement;
this._markdownToolbar = document.querySelector(
`markdown-toolbar[for=${this.for}]`
) as MarkdownToolbarElement;
}
hide(): void {
this._show = false;
this.classList.add("hidden");
this._markdownToolbar.classList.remove("hidden");
}
show(): void {
this._show = true;
this.classList.remove("hidden");
this._markdownToolbar.classList.add("hidden");
}
markdownToHtml(): string {
const renderer = new marked.Renderer();
renderer.link = function () {
// eslint-disable-next-line prefer-rest-params
const link = marked.Renderer.prototype.link.apply(this, arguments as any);
return link.replace("<a", "<a target='_blank' rel='noopener noreferrer'");
};
return marked(this._textarea.value, {
renderer: renderer,
});
}
render(): TemplateResult<1> {
return html`${this._show
? html`${unsafeHTML(this.markdownToHtml())}`
: html``}`;
}
}
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, queryAssignedNodes } from "lit/decorators.js";
import { MarkdownPreview } from "./markdown-preview";
@customElement("markdown-write-preview")
export class MarkdownWritePreview extends LitElement {
@property()
for!: string;
@property()
_textarea: HTMLTextAreaElement | null = null;
@property()
_markdownPreview!: MarkdownPreview;
@queryAssignedNodes("write", true)
_write!: NodeListOf<HTMLButtonElement>;
@queryAssignedNodes("preview", true)
_preview!: NodeListOf<HTMLButtonElement>;
connectedCallback(): void {
super.connectedCallback();
this._textarea = document.getElementById(this.for) as HTMLTextAreaElement;
this._markdownPreview = document.querySelector(
`markdown-preview[for=${this.for}]`
) as MarkdownPreview;
}
write(): void {
this._markdownPreview.hide();
this._write[0].classList.add("font-semibold");
this._preview[0].classList.remove("font-semibold");