Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Markdown editor #728

Merged
merged 4 commits into from
Apr 7, 2024
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
12 changes: 12 additions & 0 deletions helpers/frontend/views/frontend-form.njk
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@
hint: "Textarea hint text"
}) }}

{{ textarea({
errorMessage: { text: "Richtext input error" } if errors,
name: "richtext",
label: "Richtext",
hint: "Richtext hint text",
field: {
attributes: {
editor: true
}
}
}) }}

{{ textarea({
errorMessage: { text: "Readonly textarea input error" } if errors,
name: "textarea-readonly",
Expand Down
1 change: 1 addition & 0 deletions indiekit.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dotenv.config();
const config = {
application: {
_devMode: process.env.NODE_ENV === "development",
locale: "en-GB",
mongodbUrl: process.env.MONGO_URL,
...(process.env.RAILWAY_ENVIRONMENT && {
url: `https://${process.env.RAILWAY_STATIC_URL}`,
Expand Down
63 changes: 63 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions packages/endpoint-posts/includes/post-types/content-field.njk
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
{{ characterCount({
{{ textarea({
name: "content",
value: properties.content.text or fieldData("content").value,
label: __("posts.form.content.label"),
optional: not field.required,
errorMessage: fieldData("content").errorMessage
errorMessage: fieldData("content").errorMessage,
field: {
attributes: {
editor: true,
"editor-id": (properties.uid or ("new-" + postType)) + "-content",
"editor-height": "50vh" if field.required else "6rem",
"editor-locale": application.locale,
"editor-image-upload": "false" if postType == "note",
"editor-status": "false" if not field.required
}
}
}) }}
12 changes: 11 additions & 1 deletion packages/endpoint-posts/includes/post-types/summary-field.njk
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,15 @@
value: properties.summary,
label: __("posts.form.summary.label"),
optional: not field.required,
rows: 1
rows: 1,
field: {
attributes: {
editor: true,
"editor-id": (properties.uid or ("new-" + postType)) + "-summary",
"editor-height": "60vh" if field.required else "2rem",
"editor-locale": application.locale,
"editor-status": "false",
"editor-toolbar": "false"
}
}
}) }}
Binary file removed packages/frontend/assets/mona-sans.woff2
Binary file not shown.
167 changes: 152 additions & 15 deletions packages/frontend/components/textarea/index.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,168 @@
import { debounce } from "../../lib/utils/debounce.js";
import EasyMDE from "easymde";

const paths = {
bold: "M17 30c6.1 0 10-3 10-8 0-3.5-2.7-6.3-6.5-6.5V15c3-.4 5-3 5-6 0-4.5-3.5-7-9-7H5v28h12ZM12 7h2c2.5 0 4 1 4 3 0 1.5-1.5 3-4 3h-2V7Zm0 18v-7h2.3c3.1 0 4.7 1.1 4.7 3.4 0 2.5-1.4 3.6-4.8 3.6H12Z",
code: "m13.5 8.5-3-3L2 14C.5 15.5.5 16.5 2 18l8.5 8.5 3-3L6 16l7.5-7.5Zm5 0 3-3L30 14c1.5 1.5 1.5 2.5 0 4l-8.5 8.5-3-3L26 16l-7.5-7.5Z",
fullscreen:
"M4 20H0v10c0 1.1.9 2 2 2h10v-4H4v-8Zm-4-8h4V4h8V0H2a2 2 0 0 0-2 2v10Zm28 16h-8v4h10a2 2 0 0 0 2-2V20h-4v8ZM20 0v4h8v8h4V2a2 2 0 0 0-2-2H20Z",
heading: "M27 30h-6V18H11v12H5V2h6v11h10V2h6z",
italic: "m21.5 30 .8-4h-6l4-20h6l.7-4H10l-.7 4h6l-4 20h-6l-.8 4z",
link: "M14 8v4H8a4 4 0 1 0 0 8h6v4H8A8 8 0 1 1 8 8h6Zm10 0a8 8 0 1 1 0 16h-6v-4h6a4 4 0 1 0 0-8h-6V8h6Zm-2 6v4H10v-4h12Z",
"ordered-list":
"M10 2h22v4H10Zm0 12h22v4H10Zm0 12h22v4H10ZM0 0h4v8H2V2H0V0Zm5 12h1v2l-3 4h3v2H0v-2l3.3-4H0v-2h5ZM0 26v-2h6v8H0v-2h4v-1H2v-2h2v-1H0Z",
quote:
"M4 6h7a3 3 0 0 1 3 3v6.6a4 4 0 0 1-1 2.5L6.6 26h-3l3-8H4a3 3 0 0 1-3-3V9a3 3 0 0 1 3-3Zm17 0h7a3 3 0 0 1 3 3v6.6a4 4 0 0 1-1 2.5L23.6 26h-3l3-8H21a3 3 0 0 1-3-3V9a3 3 0 0 1 3-3Z",
"side-by-side":
"M16 6c7 0 13.5 4 16 10-2.5 6-9 10-16 10S2.5 22 0 16C2.5 10 9 6 16 6Zm0 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14Zm0 3a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z",
table:
"M32 2v28a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2C0 .9.9 0 2 0h28a2 2 0 0 1 2 2Zm-4 2H4v6h24V4ZM14 14H4v5h10v-5Zm0 9H4v5h10v-5Zm14-9H18v5h10v-5Zm0 9H18v5h10v-5Z",
undo: "M2 24h-.1A2 2 0 0 1 0 22V9h4v7.5A15 15 0 0 1 32 24h-4a11 11 0 0 0-21.2-4H15v4H2Z",
"unordered-list":
"M9 2h22v4H9Zm0 12h22v4H9Zm0 12h22v4H9ZM3 7a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",
"upload-image":
"M2 0h28a2 2 0 0 1 2 2v28a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2C0 .9.9 0 2 0Zm26 24-9-14-7 11-3-4.8L4 24h24Z",
};

/**
* Get SVG icon
* @param {string} name - Icon name
* @returns {string} SVG
*/
const getButtonSvg = (name) => {
return `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 32 32" focusable="false" aria-hidden="true">
<path fill="currentColor" d="${paths[name]}"/>
</svg>`;
};

export const TextareaFieldComponent = class extends HTMLElement {
constructor() {
super();

this.adjustHeight = this.adjustHeight.bind(this);
this.editor = this.getAttribute("editor");
this.editorId = this.getAttribute("editor-id");
this.editorHeight = this.getAttribute("editor-height");
this.editorImageUpload = this.getAttribute("editor-image-upload");
this.editorLocale = this.getAttribute("editor-locale");
this.editorStatus = this.getAttribute("editor-status");
this.editorToolbar = this.getAttribute("editor-toolbar");
this.$label = this.querySelector("label");
this.$textarea = this.querySelector("textarea");
}

connectedCallback() {
const delay = 100;
if (this.editor !== "") {
return;
}

this.$textarea.style.overflow = "hidden";
this.onResize =
delay > 0 ? debounce(this.adjustHeight, delay) : this.adjustHeight;
this.adjustHeight();
const status =
this?.editorStatus === "false"
? false
: [
...(this.editorImageUpload === "false" ? [] : ["upload-image"]),
"words",
"characters",
"autosave",
];

this.$textarea.addEventListener("input", this.adjustHeight);
window.addEventListener("resize", this.onResize);
}
const toolbar =
this?.editorToolbar === "false"
? false
: [
"bold",
"italic",
"heading",
"quote",
"ordered-list",
"unordered-list",
"table",
"code",
"link",
...(this.editorImageUpload === "false" ? [] : ["upload-image"]),
"|",
"undo",
"side-by-side",
"fullscreen",
];

const editor = new EasyMDE({
autoDownloadFontAwesome: false,
autosave: {
enabled: true,
uniqueId: this.editorId || this.$textarea.id,
timeFormat: { locale: this.editorLocale || "en" },
},
blockStyles: {
bold: "**",
italic: "_",
},
element: this.$textarea,
imageUploadFunction: this.uploadFile,
maxHeight: this.editorHeight,
previewClass: ["editor-preview", "s-flow"],
status,
// @ts-ignore
toolbar,
unorderedListStyle: "-",
});

disconnectedCallback() {
window.removeEventListener("resize", this.onResize);
// Restore label behaviour
/** @type {HTMLTextAreaElement} */
const $codeMirrorTextarea = this.querySelector(".CodeMirror textarea");
this.$label.addEventListener("click", () => {
$codeMirrorTextarea.focus();
});

// Update character count
/** @type {HTMLElement} */
const $characters = this.querySelector(".editor-statusbar .characters");
editor.codemirror.on("update", () => {
$characters.innerHTML = String(editor.value().length);
});

const $editorToolbar = this.querySelector(".editor-toolbar");
if ($editorToolbar) {
// Use custom SVG icons
const buttons = $editorToolbar.querySelectorAll("button");
for (const button of buttons) {
button.innerHTML = getButtonSvg(button.classList[0]);
}

// Get toolbar height to offset editor and preview in fullscreen mode
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.style.setProperty(
"--toolbar-height",
`${entry.contentRect.height}px`,
);
}
});

resizeObserver.observe($editorToolbar);
}
}

adjustHeight() {
this.$textarea.style.height = "auto";
this.$textarea.style.height = `${this.$textarea.scrollHeight + 4}px`;
/**
* Upload file
* @param {object} file - File
* @param {Function} onSuccess - Success callback
* @param {Function} onError - Error callback
* @returns {Promise<string>} - File URL or error message
*/
async uploadFile(file, onSuccess, onError) {
const formData = new FormData();
formData.append("file", file);

try {
const endpointResponse = await fetch("http://localhost:3000/media", {
method: "POST",
body: formData,
});

return endpointResponse.ok
? onSuccess(endpointResponse.headers.get("location"))
: onError(endpointResponse.statusText);
} catch (error) {
onError(error.message);
}
}
};
1 change: 1 addition & 0 deletions packages/frontend/components/textarea/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ textarea-field {
border-color: var(--color-on-background);
border-width: var(--input-border-width-focus);
inset-block-start: calc(var(--input-border-width-focus-offset) * -1);
margin-block-end: calc(var(--input-border-width-focus-offset) * -2);
padding-inline-start: calc(
var(--space-s) - var(--input-border-width-focus-offset)
);
Expand Down
2 changes: 0 additions & 2 deletions packages/frontend/layouts/default.njk
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
{% from "button/macro.njk" import button with context %}
{% from "card/macro.njk" import card with context %}
{% from "card-grid/macro.njk" import cardGrid with context %}
{% from "character-count/macro.njk" import characterCount with context %}
{% from "checkboxes/macro.njk" import checkboxes with context %}
{% from "details/macro.njk" import details with context %}
{% from "error-summary/macro.njk" import errorSummary with context %}
Expand Down Expand Up @@ -49,7 +48,6 @@
</style>

<link rel="stylesheet" href="{{ application.cssPath }}">
<link rel="preload" href="{{ application.url }}{{ assetPath | default("/assets") }}/mona-sans.woff2" as="font" type="font/woff2" crossorigin>
<link rel="alternate" href="{{ application.url }}/feed.jf2" type="application/jf2feed+json" title="JF2 Feed">
<link rel="apple-touch-icon" href="{{ application.url }}{{ assetPath | default("/assets") }}/app-icon-180.png">
<link rel="icon" href="{{ application.url }}{{ assetPath | default("/assets") }}/icon.svg">
Expand Down
Loading