Skip to content

Commit

Permalink
UTOPIA-1535: Different size headers (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
BradyMitch authored Nov 21, 2023
2 parents 843b302 + 6a573e5 commit f7dfa66
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 25 deletions.
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

0 comments on commit f7dfa66

Please sign in to comment.