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

UTOPIA-1535: Different size headers #21

Merged
merged 3 commits into from
Nov 21, 2023
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
158 changes: 135 additions & 23 deletions src/RichTextEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import "./styles.css";
import {
FontSizeIcon,
FontSizeIconDisabled,
HighlighterIcon,
HighlighterIconDisabled,
ListIcon,
ListIconDisabled,
NumberedListIcon,
NumberedListIconDisabled,
TextIcon,
} from "./assets";
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect, useState } from "react";
import { HTMLTag, RichTextEditorProps } from "./types";
import {
getParentElement,
Expand Down Expand Up @@ -56,6 +59,15 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
}
};

// State to manage popover visibility
const [showHeaderPopover, setShowHeaderPopover] = useState(false);

// Function to handle header style change and close popover
const handleHeaderStyleChange = (size) => {
toggleHeaderStyle(size);
setShowHeaderPopover(false); // Hide popover
};

const toggleStyle = (tag: HTMLTag, className?: string) => {
const { selection, range } = getSelectionContext();
const styledParentElement = getParentElement({
Expand Down Expand Up @@ -117,6 +129,17 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
element.appendChild(range?.extractContents());
range?.insertNode(element);
}

// Remove any parent header sizes
if (
tag.startsWith("H") &&
["H1", "H2", "H3"].includes(
getParentElement({ contentRef })?.nodeName
)
) {
getParentElement({ contentRef }).replaceWith(element);
range.setEndAfter(element);
}
}

// Update content
Expand All @@ -129,12 +152,13 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
selection?.addRange(range);
}
}

// Maintain focus.
contentRef.current?.focus();
};

// Function to toggle the header (h3) style
const toggleHeaderStyle = () => {
// Function to toggle the header style
const toggleHeaderStyle = (size: "H1" | "H2" | "H3") => {
const { currentNode, range } = getSelectionContext();
const selectedText = range?.toString() ?? "";
const currentElement =
Expand All @@ -149,35 +173,35 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
currentElement.outerHTML === "<p><br></p>";

if (selectedText.trim() === "" && isEmptyOrBr) {
// Clear the line and replace with an h3 containing a zero-width space
const h3 = document.createElement("h3");
h3.innerHTML = "\u200B"; // Zero-width space
// Clear the line and replace with a header containing a zero-width space
const header = document.createElement(size);
header.innerHTML = "\u200B"; // Zero-width space

if (currentElement) {
// If the current element is a P that contains only a BR, replace it entirely with the H3
// If the current element is a P that contains only a BR, replace it entirely with the header
if (currentElement.tagName === "P") {
currentElement.replaceWith(h3);
currentElement.replaceWith(header);
} else {
// For other cases, just clear the innerHTML and set it to the H3
// For other cases, just clear the innerHTML and set it to the header
currentElement.innerHTML = "";
currentElement.appendChild(h3);
currentElement.appendChild(header);
}
} else if (contentRef.current) {
// If there's no current element, append the H3 to the contentRef
contentRef.current.appendChild(h3);
// If there's no current element, append the header to the contentRef
contentRef.current.appendChild(header);
}

// Set the cursor inside the new h3 element
setCursorAtStartOfElement(h3);
// Set the cursor inside the new header element
setCursorAtStartOfElement(header);
} else if (selectedText.trim() === "") {
// No selection and current line is not empty: insert a blank h3 at the end
const h3 = document.createElement("h3");
h3.innerHTML = "\u200B"; // Zero-width space
contentRef.current?.appendChild(h3);
setCursorAtStartOfElement(h3);
// No selection and current line is not empty: insert a blank header at the end
const header = document.createElement(size);
header.innerHTML = "\u200B"; // Zero-width space
contentRef.current?.appendChild(header);
setCursorAtStartOfElement(header);
} else {
// There is selected text: toggle the h3 tag on the selection
toggleStyle("H3");
// There is selected text: toggle the header tag on the selection
toggleStyle(size);
}

// Update content
Expand Down Expand Up @@ -239,6 +263,54 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
contentRef.current?.focus();
};

// Replaces all headers in selection with p tags
const removeHeadersFromSelection = () => {
const { range } = getSelectionContext();
if (!range) return;

// Get the common ancestor container of the selection range
const commonAncestorContainer = range?.commonAncestorContainer;

if (
commonAncestorContainer &&
commonAncestorContainer?.nodeType === Node.ELEMENT_NODE
) {
// Get all header elements (h1, h2, h3, etc.) within the common ancestor container
const headers = (commonAncestorContainer as Element).querySelectorAll(
"h1, h2, h3, h4, h5, h6"
);

// Iterate over each header and replace it with a paragraph
headers.forEach((header) => {
// Check if the header is the only child of a paragraph
if (
header.parentNode.nodeName === "P" &&
header.parentNode.childNodes.length >= 1
) {
// Replace the entire paragraph with a new paragraph containing the header's content
const newP = document.createElement("p");
newP.innerHTML = header.innerHTML;
header.parentNode.parentNode.replaceChild(newP, header.parentNode);
} else {
// If the header is not the only child of a paragraph, just replace the header with a paragraph
const p = document.createElement("p");
p.innerHTML = header.innerHTML;
header.parentNode.replaceChild(p, header);
}
});
} else {
// Single line selection where parent element is header
const parentElement = getParentElement({ contentRef });
if (["H1", "H2", "H3"].includes(parentElement?.nodeName)) {
const rangeContents = range.extractContents();
parentElement.replaceWith(rangeContents);
}
}

// Update content after replacing headers
handleChange();
};

// Handle when a key is pressed.
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const { currentNode } = getSelectionContext();
Expand Down Expand Up @@ -332,9 +404,49 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
<button
className="rt-button"
disabled={readOnly}
onClick={() => toggleHeaderStyle()}
onClick={() => setShowHeaderPopover(!showHeaderPopover)}
>
<b>H</b>
<img
src={!readOnly ? FontSizeIcon : FontSizeIconDisabled}
alt="Font Size Icon"
className="rt-icon"
/>
{/* Popover for header styles */}
{showHeaderPopover && !readOnly && (
<div className="rt-headerPopover">
<button
className="rt-button"
onClick={() => handleHeaderStyleChange("H1")}
>
<b>
H<sup>1</sup>
</b>
</button>
<button
className="rt-button"
onClick={() => handleHeaderStyleChange("H2")}
>
<b>
H<sup>2</sup>
</b>
</button>
<button
className="rt-button"
onClick={() => handleHeaderStyleChange("H3")}
>
<b>
H<sup>3</sup>
</b>
</button>
<button
className="rt-button"
style={{ marginRight: 0 }}
onClick={() => removeHeadersFromSelection()}
>
<img className="rt-icon" src={TextIcon} alt="Text Icon" />
</button>
</div>
)}
</button>
<button
className="rt-button"
Expand Down
3 changes: 3 additions & 0 deletions src/assets/FontSizeIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/FontSizeIconDisabled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/TextIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export { default as NumberedListIcon } from "./NumberedListIcon.svg";
export { default as NumberedListIconDisabled } from "./NumberedListIconDisabled.svg";
export { default as HighlighterIcon } from "./HighlighterIcon.svg";
export { default as HighlighterIconDisabled } from "./HighlighterIconDisabled.svg";
export { default as FontSizeIcon } from "./FontSizeIcon.svg";
export { default as FontSizeIconDisabled } from "./FontSizeIconDisabled.svg";
export { default as TextIcon } from "./TextIcon.svg";
26 changes: 25 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,22 @@
margin: 0;
}

.rt-content h3 {
.rt-content h1, h2, h3 {
margin-top: 0;
margin-bottom: 8px;
font-weight: 600;
}

.rt-content h1 {
font-size: xx-large;
}

.rt-content h2 {
font-size: x-large;
}

.rt-content h3 {
font-size: large;
}

.rt-button[disabled] {
Expand Down Expand Up @@ -102,3 +115,14 @@
display: inline;
background-color: yellow;
}

.rt-headerPopover {
position: relative;
display: flex;
top: 25%;
border: 1px solid #ccc;
border-radius: 5px;
padding: 1px;
width: fit-content;
background-color: #fff;
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type RichTextEditorProps = {
readOnly?: boolean;
};

export type HTMLTag = "B" | "I" | "S" | "H3" | "P" | "LI" | "DIV";
export type HTMLTag = "B" | "I" | "S" | "H1" | "H2" | "H3" | "P" | "LI" | "DIV";

export type SelectionContext = {
currentNode: Node | null;
Expand Down