Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
127 changes: 12 additions & 115 deletions src/components/article-creator/custom_questions/AdvancedTextbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import React, { useRef, useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import type { QuestionFile, QuestionFormat } from "@/types/questions";
import { Paperclip, Trash, ChevronLeft, ChevronRight } from "lucide-react";
import {
Expand All @@ -17,6 +16,7 @@ import {
import { getUser } from "@/components/hooks/users";
import { getFileFromIndexedDB } from "./RenderAdvancedTextbox";
import { isSvgFileName, resolveUploadContentType } from "@/lib/utils";
import RichTextEditor from "./RichTextEditor";

interface Props {
questions: QuestionFormat[];
Expand Down Expand Up @@ -177,7 +177,7 @@ export default function AdvancedTextbox({
>
>({});

const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

// Sync state when loaded
Expand Down Expand Up @@ -209,8 +209,9 @@ export default function AdvancedTextbox({
}
}, [questionInstance, oIndex, origin]);

// Handle keys logic
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Keep navigation keys inside the block editor rather than letting the host
// EditorJS instance handle them.
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const key = e.key;

if (
Expand All @@ -224,90 +225,7 @@ export default function AdvancedTextbox({
e.stopPropagation();
}

if (key === "Enter") {
const textarea = textareaRef.current;
if (!textarea) return;

const cursorPosition = textarea.selectionStart;
const textBeforeCursor = currentText.substring(0, cursorPosition);
const textAfterCursor = currentText.substring(textarea.selectionEnd);

const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n");
const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1);

const match = currentLine.match(/^\s*/);
const leadingWhitespace = match ? match[0] : "";

if (leadingWhitespace) {
e.preventDefault();
const newText =
textBeforeCursor + "\n" + leadingWhitespace + textAfterCursor;
updateQuestionText(newText);

setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart =
textareaRef.current.selectionEnd =
cursorPosition + 1 + leadingWhitespace.length;
}
}, 0);
}
}

if (key === "Backspace") {
const textarea = textareaRef.current;
if (!textarea) return;

const cursorPosition = textarea.selectionStart;
if (cursorPosition === textarea.selectionEnd && cursorPosition > 0) {
const textBeforeCursor = currentText.substring(0, cursorPosition);
const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n");
const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1);

if (currentLine.length > 0 && /^\s+$/.test(currentLine)) {
e.preventDefault();

const spacesToDelete = currentLine.length % 2 !== 0 ? 1 : 2;

const newText =
currentText.substring(0, cursorPosition - spacesToDelete) +
currentText.substring(cursorPosition);

updateQuestionText(newText);

setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart =
textareaRef.current.selectionEnd =
cursorPosition - spacesToDelete;
}
}, 0);
}
}
}

if (key === "Tab") {
e.stopPropagation();
e.preventDefault();

const textarea = textareaRef.current;
if (!textarea) return;

const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const indent = " ";
const newText =
currentText.substring(0, start) + indent + currentText.substring(end);

updateQuestionText(newText);

setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart =
textareaRef.current.selectionEnd = start + indent.length;
}
}, 0);
}
if (key === "Tab") e.stopPropagation();
};

const updateQuestionsWithFiles = (newFiles: QuestionFile[]) => {
Expand Down Expand Up @@ -381,10 +299,6 @@ export default function AdvancedTextbox({
setQuestions(updatedQuestions);
};

const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
updateQuestionText(e.target.value);
};

const handleUploadClick = () => {
fileInputRef.current?.click();
};
Expand Down Expand Up @@ -568,35 +482,18 @@ export default function AdvancedTextbox({
};

const insertPlaceholder = (file: QuestionFile, index: number) => {
const textarea = textareaRef.current;
if (!textarea) return;

const placeholderText = `[image:${index + 1}]`;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;

const newText =
currentText.substring(0, start) +
placeholderText +
currentText.substring(end);

updateQuestionText(newText);

setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(
start + placeholderText.length,
start + placeholderText.length,
);
}, 0);
editorRef.current?.focus();
document.execCommand("insertText", false, placeholderText);
if (editorRef.current) updateQuestionText(editorRef.current.innerHTML);
};

return (
<div className="relative mb-4">
<Textarea
ref={textareaRef}
<RichTextEditor
ref={editorRef}
value={currentText}
onChange={handleTextChange}
onChange={updateQuestionText}
onKeyDown={handleKeyDown}
placeholder={
placeholder ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "lucide-react";
import AdvancedTextbox from "./AdvancedTextbox";
import { Input } from "@/components/ui/input";
import { richTextToPlainText } from "./richText";

interface Props {
questions: QuestionFormat[];
Expand Down Expand Up @@ -233,7 +234,7 @@ const QuestionsInputInterface: React.FC<Props> = ({
>
<span className="shrink-0 font-bold">Question {qIndex + 1}</span>
<span className="overflow-hidden text-ellipsis text-nowrap opacity-75">
{questionInstance.question.value}
{richTextToPlainText(questionInstance.question.value)}
</span>
<span className="shrink-0">
{collapsed[qIndex] ? <ChevronDown /> : <ChevronUp />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "../../../styles/katexStyling.css";
import { decodeEntities, katexMacros } from "../Renderer";

import { cn, isSvgFileName, parseSvgIntrinsicSize } from "@/lib/utils";
import { sanitizeQuestionRichText } from "./richText";

interface Props {
content: QuestionInput;
Expand Down Expand Up @@ -209,14 +210,15 @@ export function RenderContent({ content, origin }: Props) {
return null;
};

const tokens = decodeEntities(content.value)
const renderText = (text: string, keyPrefix: string): React.ReactNode[] =>
decodeEntities(text)
.split(/(```[\s\S]*?```|\$@[^$]+\$|\[image:[^\]]+\])/g)
.map((token, tokenIndex) => {
if (token.startsWith("```") && token.endsWith("```")) {
const codeContent = token.slice(3, -3).replace(/^\n/, "");
return (
<pre
key={`code-${tokenIndex}`}
key={`${keyPrefix}-code-${tokenIndex}`}
className="my-2 max-w-full overflow-x-auto whitespace-pre-wrap rounded p-2 font-mono text-sm leading-relaxed text-black"
style={{
fontFamily: "'Consolas', monospace",
Expand All @@ -229,7 +231,7 @@ export function RenderContent({ content, origin }: Props) {
} else if (token.startsWith("$@") && token.endsWith("$")) {
return (
<span
key={`latex-${tokenIndex}`}
key={`${keyPrefix}-latex-${tokenIndex}`}
dangerouslySetInnerHTML={{
__html: katex.renderToString(token.slice(2, -1), {
throwOnError: false,
Expand All @@ -245,7 +247,7 @@ export function RenderContent({ content, origin }: Props) {
renderedInlineKeys.add(matchedFile.key);
return (
<div
key={`inline-img-${tokenIndex}`}
key={`${keyPrefix}-inline-img-${tokenIndex}`}
className="my-4 block text-center"
>
<FileRenderer file={matchedFile} />
Expand All @@ -258,9 +260,34 @@ export function RenderContent({ content, origin }: Props) {
);
}
}
return <span key={`text-${tokenIndex}`}>{token}</span>;
return <React.Fragment key={`${keyPrefix}-text-${tokenIndex}`}>{token}</React.Fragment>;
});

const renderNode = (node: Node, key: string): React.ReactNode => {
if (node.nodeType === Node.TEXT_NODE) return renderText(node.textContent ?? "", key);
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const children = Array.from(node.childNodes).map((child, index) => renderNode(child, `${key}-${index}`));
switch ((node as Element).tagName.toLowerCase()) {
case "strong": return <strong key={key}>{children}</strong>;
case "em": return <em key={key}>{children}</em>;
case "u": return <u key={key}>{children}</u>;
case "mark": return <mark key={key} className="rounded bg-yellow-200 px-0.5 text-gray-950">{children}</mark>;
case "br": return <br key={key} />;
case "div": return <div key={key}>{children}</div>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
};

const safeValue = sanitizeQuestionRichText(content.value);
const tokens: React.ReactNode[] =
typeof window === "undefined"
? renderText(safeValue.replace(/<[^>]*>/g, ""), "text")
: (() => {
const template = document.createElement("template");
template.innerHTML = safeValue;
return Array.from(template.content.childNodes).map((node, index) => renderNode(node, `node-${index}`));
})();

// Filter out files that were already rendered inline
const remainingFiles = sortedFiles.filter(
(file) => !renderedInlineKeys.has(file.key),
Expand Down
Loading
Loading