From d1bd0a86305f7e6d5a963e3dce2ff34a5feacb1b Mon Sep 17 00:00:00 2001 From: marc fuller Date: Thu, 4 Dec 2025 18:55:13 -0800 Subject: [PATCH 01/12] feat(reporting): add footnote support in rich text editor and DOCX export Signed-off-by: marc fuller --- compose/local/django/Dockerfile | 2 + compose/production/django/Dockerfile | 2 + ghostwriter/modules/reportwriter/base/docx.py | 65 +++- .../modules/reportwriter/richtext/docx.py | 78 ++++- .../modules/reportwriter/richtext/pptx.py | 8 + ghostwriter/reporting/tests/test_footnotes.py | 300 ++++++++++++++++++ .../reporting/tests/test_rich_text_docx.py | 70 ++++ .../reporting/tests/test_rich_text_pptx.py | 24 ++ ghostwriter/static/css/styles.css | 16 + .../rich_text_editor/footnote.tsx | 96 ++++++ .../collab_forms/rich_text_editor/index.tsx | 2 + javascript/src/tiptap_gw/footnote.tsx | 108 +++++++ javascript/src/tiptap_gw/index.ts | 2 + requirements/base.txt | 3 +- 14 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 ghostwriter/reporting/tests/test_footnotes.py create mode 100644 javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx create mode 100644 javascript/src/tiptap_gw/footnote.tsx diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index eeafa5f9f..f1c99633f 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -21,6 +21,8 @@ RUN apk --no-cache add build-base curl \ # Rust and Cargo required by the ``cryptography`` Python package && apk --no-cache add rust \ && apk --no-cache add cargo \ + # Git for installing packages from GitHub + && apk --no-cache add git \ && pip install --no-cache-dir -U setuptools pip COPY ./requirements /requirements diff --git a/compose/production/django/Dockerfile b/compose/production/django/Dockerfile index f0a0b27fa..71da73d96 100644 --- a/compose/production/django/Dockerfile +++ b/compose/production/django/Dockerfile @@ -22,6 +22,8 @@ RUN apk --no-cache add build-base curl \ # Rust and Cargo required by the ``cryptography`` Python package && apk --no-cache add rust \ && apk --no-cache add cargo \ + # Git for installing packages from GitHub + && apk --no-cache add git \ && addgroup -S django \ && adduser -S -G django django \ && pip install --no-cache-dir -U setuptools pip diff --git a/ghostwriter/modules/reportwriter/base/docx.py b/ghostwriter/modules/reportwriter/base/docx.py index 22410251d..e91b5e636 100644 --- a/ghostwriter/modules/reportwriter/base/docx.py +++ b/ghostwriter/modules/reportwriter/base/docx.py @@ -4,12 +4,14 @@ import os import re -from docxtpl import DocxTemplate, RichText as DocxRichText -from docx.opc.exceptions import PackageNotFoundError +from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.shared import Inches, Pt from docx.image.exceptions import UnrecognizedImageError +from docx.opc.exceptions import PackageNotFoundError +from docx.oxml.ns import qn +from docx.shared import Inches, Pt +from docxtpl import DocxTemplate, RichText as DocxRichText from ghostwriter.commandcenter.models import CompanyInformation, ReportConfiguration from ghostwriter.modules.reportwriter.base import ReportExportTemplateError @@ -128,8 +130,65 @@ def run(self) -> io.BytesIO: out = io.BytesIO() self.word_doc.save(out) + + # Post-process to clean up separator footnotes (remove extra empty paragraphs) + out = self._cleanup_footnote_separators(out) + return out + def _cleanup_footnote_separators(self, docx_bytes: io.BytesIO) -> io.BytesIO: + """ + Remove extra empty paragraphs from separator footnotes. + + Some Word templates have extra empty paragraphs in the separator and + continuationSeparator footnotes, which causes unwanted spacing between + the footnote separator line and the actual footnotes. + + This post-processes the saved DOCX to avoid interfering with docxtpl's + template rendering. + """ + try: + docx_bytes.seek(0) + doc = Document(docx_bytes) + + # Access the footnotes part (requires accessing internal python-docx members) + # pylint: disable=protected-access + if not hasattr(doc._part, '_footnotes_part') or doc._part._footnotes_part is None: + docx_bytes.seek(0) + return docx_bytes + + footnotes_part = doc._part._footnotes_part + footnotes_element = footnotes_part._element + # pylint: enable=protected-access + + modified = False + for footnote in footnotes_element: + # Only clean separator footnotes (id=-1 or id=0) + footnote_id = footnote.get(qn("w:id")) + if footnote_id in ("-1", "0"): + # Find all paragraph elements + paragraphs = list(footnote.iterchildren(qn("w:p"))) + # Keep only the first paragraph (which contains the separator) + for para in paragraphs[1:]: + footnote.remove(para) + modified = True + + if modified: + # Save the modified document + out = io.BytesIO() + doc.save(out) + out.seek(0) + return out + + docx_bytes.seek(0) + return docx_bytes + + except Exception as e: # pylint: disable=broad-exception-caught + # Log but don't fail the report generation + logger.warning("Failed to cleanup footnote separators: %s", e) + docx_bytes.seek(0) + return docx_bytes + def create_styles(self): """ Creates default styles diff --git a/ghostwriter/modules/reportwriter/richtext/docx.py b/ghostwriter/modules/reportwriter/richtext/docx.py index 6b5e57db6..e35117c71 100644 --- a/ghostwriter/modules/reportwriter/richtext/docx.py +++ b/ghostwriter/modules/reportwriter/richtext/docx.py @@ -249,6 +249,60 @@ def tag_div(self, el, **kwargs): else: super().tag_div(el, **kwargs) + def tag_span(self, el, *, par, **kwargs): + """Override tag_span to handle footnotes.""" + if "footnote" in el.attrs.get("class", []): + self.make_footnote(el, par=par, **kwargs) + else: + super().tag_span(el, par=par, **kwargs) + + def make_footnote(self, el, *, par=None, **kwargs): + """ + Handle elements by creating a Word footnote. + + The footnote content is the text content of the element. + A footnote reference is inserted at the current position in the paragraph. + """ + if par is None: + logger.warning("Footnote found outside of a paragraph, skipping") + return + + # Get the footnote content from the element's text + footnote_content = el.get_text().strip() + if not footnote_content: + return + + # Emit any pending segment break before adding footnote + self.text_tracking.force_emit_pending_segment_break() + + # Calculate the next footnote ID by finding the max existing ID + # This is simpler and more reliable than the paragraph-based algorithm + # which doesn't work well for table cells or dynamically-built documents + max_existing_id = 0 + for footnote in self.doc.footnotes: + max_existing_id = max(max_existing_id, footnote.id) + next_footnote_id = max_existing_id + 1 + + # Add footnote reference to the run and create the footnote + paragraph_element = par._p.add_r() + paragraph_element.add_footnoteReference(next_footnote_id) + new_footnote = self.doc._add_footnote(next_footnote_id) + + # Add the footnote paragraph with the footnote reference mark at the start + # This is required for Word to properly display the footnote number + footnote_paragraph = new_footnote.add_paragraph() + # Create a run with footnoteRef element (displays the footnote number) + footnote_run = footnote_paragraph._p.add_r() + run_properties = OxmlElement("w:rPr") + style_element = OxmlElement("w:rStyle") + style_element.set(qn("w:val"), "FootnoteReference") + run_properties.append(style_element) + footnote_run.insert(0, run_properties) + footnote_ref_element = OxmlElement("w:footnoteRef") + footnote_run.append(footnote_ref_element) + # Add a space and the footnote text + footnote_paragraph.add_run(" " + footnote_content) + def create_table(self, rows, cols, **kwargs): table = self.doc.add_table(rows=rows, cols=cols, style="Table Grid") table.autofit = True @@ -325,6 +379,8 @@ def tag_span(self, el, *, par, **kwargs): ref_name = el.attrs["data-gw-ref"] self.text_tracking.force_emit_pending_segment_break() self.make_cross_ref(par, ref_name) + elif "footnote" in el.attrs.get("class", []): + self.make_footnote(el, par=par, **kwargs) else: super().tag_span(el, par=par, **kwargs) @@ -503,17 +559,17 @@ def make_evidence(self, par, evidence): try: self._make_image(par, file_path) except UnrecognizedImageError as e: - logger.exception( - "Evidence file known as %s (%s) was not recognized as a %s file.", - evidence["friendly_name"], - file_path, - extension, - ) - error_msg = ( - f'The evidence file, `{evidence["friendly_name"]},` was not recognized as a {extension} file. ' - "Try opening it, exporting as desired type, and re-uploading it." - ) - raise ReportExportTemplateError(error_msg) from e + logger.exception( + "Evidence file known as %s (%s) was not recognized as a %s file.", + evidence["friendly_name"], + file_path, + extension, + ) + error_msg = ( + f'The evidence file, `{evidence["friendly_name"]},` was not recognized as a {extension} file. ' + "Try opening it, exporting as desired type, and re-uploading it." + ) + raise ReportExportTemplateError(error_msg) from e if self.global_report_config.figure_caption_location == "bottom": par_caption = self.doc.add_paragraph() diff --git a/ghostwriter/modules/reportwriter/richtext/pptx.py b/ghostwriter/modules/reportwriter/richtext/pptx.py index e5877f769..27a5a5fe6 100644 --- a/ghostwriter/modules/reportwriter/richtext/pptx.py +++ b/ghostwriter/modules/reportwriter/richtext/pptx.py @@ -63,6 +63,12 @@ def style_run(self, run, style): if "font_color" in style: run.font.color.rgb = PptxRGBColor(*style["font_color"]) + def tag_footnote(self, el, **kwargs): # pylint: disable=unused-argument + """ + Handle elements - PowerPoint doesn't support footnotes, + so we silently ignore them. + """ + def tag_br(self, el, *, par=None, **kwargs): self.text_tracking.new_block() if par is not None: @@ -187,6 +193,8 @@ def tag_span(self, el, *, par, **kwargs): run = par.add_run() run.text = f"See {ref_name}" run.font.italic = True + elif "footnote" in el.attrs.get("class", []): + self.tag_footnote(el, par=par, **kwargs) else: super().tag_span(el, par=par, **kwargs) diff --git a/ghostwriter/reporting/tests/test_footnotes.py b/ghostwriter/reporting/tests/test_footnotes.py new file mode 100644 index 000000000..55ea280c4 --- /dev/null +++ b/ghostwriter/reporting/tests/test_footnotes.py @@ -0,0 +1,300 @@ +"""Tests for footnote functionality using local python-docx fork.""" + +import os +import shutil +import tempfile + +from django.test import TestCase + +from docx import Document + +from ghostwriter.modules.reportwriter.richtext.docx import HtmlToDocx + + +class FootnoteCreationTests(TestCase): + """Test footnote creation with python-docx.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Clean up temporary files.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _get_baseline_footnote_count(self): + """Get the number of footnotes in a blank document (separator footnotes).""" + doc = Document() + output_path = os.path.join(self.temp_dir, "baseline.docx") + doc.save(output_path) + reopened = Document(output_path) + return len(reopened.footnotes) + + def test_create_document_with_footnote(self): + """Test creating a Word document with a footnote.""" + document = Document() + + # Add a paragraph + paragraph = document.add_paragraph("This is a paragraph with a footnote") + + # Add a footnote to the paragraph + new_footnote = paragraph.add_footnote() + new_footnote.add_paragraph("This is the footnote text.") + + # Verify footnote was created + self.assertIsNotNone(new_footnote) + self.assertEqual(len(new_footnote.paragraphs), 1) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_footnote.docx") + document.save(output_path) + + # Verify the file was created + self.assertTrue(os.path.exists(output_path)) + + # Reopen and verify footnote exists + reopened_doc = Document(output_path) + self.assertIsNotNone(reopened_doc.footnotes) + self.assertGreater(len(reopened_doc.footnotes), 0) + + def test_create_multiple_footnotes(self): + """Test creating multiple footnotes in a document.""" + baseline_count = self._get_baseline_footnote_count() + + document = Document() + + # Add first paragraph with footnote + para1 = document.add_paragraph("First paragraph") + footnote1 = para1.add_footnote() + footnote1.add_paragraph("First footnote text.") + + # Add second paragraph with footnote + para2 = document.add_paragraph("Second paragraph") + footnote2 = para2.add_footnote() + footnote2.add_paragraph("Second footnote text.") + + # Verify both footnotes were created + self.assertIsNotNone(footnote1) + self.assertIsNotNone(footnote2) + + # Footnote IDs should be different + self.assertNotEqual(footnote1.id, footnote2.id) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_multiple_footnotes.docx") + document.save(output_path) + self.assertTrue(os.path.exists(output_path)) + + # Reopen and verify both footnotes exist (plus baseline separator footnotes) + reopened_doc = Document(output_path) + self.assertEqual(len(reopened_doc.footnotes), baseline_count + 2) + + def test_footnote_with_multiple_paragraphs(self): + """Test creating a footnote with multiple paragraphs.""" + document = Document() + + paragraph = document.add_paragraph("Paragraph with multi-paragraph footnote") + + # Add footnote with multiple paragraphs + footnote = paragraph.add_footnote() + footnote.add_paragraph("First paragraph in footnote.") + footnote.add_paragraph("Second paragraph in footnote.") + + # Verify footnote has multiple paragraphs + self.assertEqual(len(footnote.paragraphs), 2) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_multi_para_footnote.docx") + document.save(output_path) + self.assertTrue(os.path.exists(output_path)) + + def test_access_footnotes_from_paragraph(self): + """Test accessing footnotes from a paragraph.""" + document = Document() + + # Create a paragraph with a footnote + paragraph = document.add_paragraph("Text with footnote reference") + new_footnote = paragraph.add_footnote() + new_footnote.add_paragraph("The footnote content.") + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_access_footnotes.docx") + document.save(output_path) + + reopened_doc = Document(output_path) + reopened_para = reopened_doc.paragraphs[0] + + # Access footnotes from paragraph + footnotes = reopened_para.footnotes + self.assertIsNotNone(footnotes) + self.assertGreater(len(footnotes), 0) + + # Verify footnote properties + footnote = footnotes[0] + self.assertIsNotNone(footnote.id) + self.assertGreater(len(footnote.paragraphs), 0) + + def test_access_all_document_footnotes(self): + """Test accessing all footnotes from document.""" + baseline_count = self._get_baseline_footnote_count() + + document = Document() + + # Create multiple paragraphs with footnotes + num_footnotes = 3 + for i in range(num_footnotes): + para = document.add_paragraph(f"Paragraph {i + 1}") + footnote = para.add_footnote() + footnote.add_paragraph(f"Footnote {i + 1} content.") + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_all_footnotes.docx") + document.save(output_path) + + reopened_doc = Document(output_path) + + # Access all footnotes (includes baseline separator footnotes) + all_footnotes = reopened_doc.footnotes + self.assertEqual(len(all_footnotes), baseline_count + num_footnotes) + + # Verify each footnote has an ID and paragraphs + for footnote in all_footnotes: + self.assertIsNotNone(footnote.id) + self.assertGreaterEqual(len(footnote.paragraphs), 0) + + def test_footnote_ids_are_sequential(self): + """Test that footnote IDs are assigned sequentially.""" + document = Document() + + footnote_ids = [] + for i in range(3): + para = document.add_paragraph(f"Paragraph {i + 1}") + footnote = para.add_footnote() + footnote.add_paragraph(f"Footnote {i + 1}") + footnote_ids.append(footnote.id) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_sequential_footnotes.docx") + document.save(output_path) + + # Verify IDs are unique and sequential + self.assertEqual(len(footnote_ids), len(set(footnote_ids))) + for i in range(1, len(footnote_ids)): + self.assertGreater(footnote_ids[i], footnote_ids[i - 1]) + + +class FootnoteRichTextConversionTests(TestCase): + """Test footnote conversion from HTML rich text to DOCX.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Clean up temporary files.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_html_to_docx_with_footnotes(self): + """ + Test converting HTML with footnotes to DOCX using HtmlToDocx. + """ + # Create a new document + doc = Document() + + # Sample HTML content with footnotes (simulating rich text from the editor) + html_content = """ +

This is an executive summary with important findings.Source: Internal Security Assessment, 2024

+ +

During the assessment, several critical vulnerabilities were identified.See Appendix A for full vulnerability details. The team discovered issues in the authentication systemAuthentication bypass via SQL injection in login form. and the session management module.

+ +

Key Findings

+ +

The following critical issues require immediate attention:

+ +
    +
  • SQL Injection vulnerability in user loginCVE-2024-1234 - CVSS Score 9.8
  • +
  • Cross-Site Scripting (XSS) in comment fields
  • +
  • Insecure Direct Object ReferencesAllows unauthorized access to other users' data.
  • +
+ +

We recommend prioritizing remediation based on risk severity.Risk ratings based on CVSS v3.1 scoring methodology.

+ +

Recommendations

+ +

Implement input validation and parameterized queriesOWASP recommends using prepared statements for all database queries. to prevent injection attacks. Additionally, deploy a Web Application Firewall (WAF)Consider solutions like ModSecurity or cloud-based WAF services. for defense in depth.

+ """ + + # Run the HTML to DOCX converter + HtmlToDocx.run(html_content, doc, None) + + # Save to temp file and reopen to verify footnotes + output_path = os.path.join(self.temp_dir, "test_footnotes.docx") + doc.save(output_path) + + # Reopen and verify footnotes exist + reopened_doc = Document(output_path) + self.assertIsNotNone(reopened_doc.footnotes) + + # Count actual footnotes (excluding separator footnotes which have id <= 0) + actual_footnotes = [fn for fn in reopened_doc.footnotes if fn.id > 0] + self.assertEqual(len(actual_footnotes), 8, "Expected 8 footnotes in the document") + + # Verify footnote content (strip leading space from footnoteRef marker) + footnote_texts = [ + fn.paragraphs[0].text.strip() if fn.paragraphs else "" + for fn in actual_footnotes + ] + self.assertIn("Source: Internal Security Assessment, 2024", footnote_texts) + self.assertIn("CVE-2024-1234 - CVSS Score 9.8", footnote_texts) + self.assertIn( + "OWASP recommends using prepared statements for all database queries.", + footnote_texts, + ) + + def test_html_to_docx_footnotes_in_table(self): + """Test footnotes within table cells.""" + doc = Document() + + # HTML with a table containing footnotes + html_content = """ +

Summary of Findings:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FindingSeverityStatus
SQL InjectionFound in login and search forms.CriticalOpen
XSS VulnerabilityReflected XSS in query parameters.HighIn Progress
Missing CSRF TokenState-changing operations lack protection.MediumFixed
+ +

See the detailed findings section for remediation steps.Remediation guidance follows OWASP best practices.

+ """ + + HtmlToDocx.run(html_content, doc, None) + + # Save to temp file and reopen to verify footnotes + output_path = os.path.join(self.temp_dir, "test_table_footnotes.docx") + doc.save(output_path) + + reopened_doc = Document(output_path) + actual_footnotes = [fn for fn in reopened_doc.footnotes if fn.id > 0] + self.assertEqual(len(actual_footnotes), 4, "Expected 4 footnotes in the document") diff --git a/ghostwriter/reporting/tests/test_rich_text_docx.py b/ghostwriter/reporting/tests/test_rich_text_docx.py index c938035f4..2d5b71a08 100644 --- a/ghostwriter/reporting/tests/test_rich_text_docx.py +++ b/ghostwriter/reporting/tests/test_rich_text_docx.py @@ -673,3 +673,73 @@ class RichTextToDocxTests(TestCase): "

Hello World!

This is a test!

", """Hello World!This is a test!""", ) + + +class FootnoteToDocxTests(TestCase): + """Tests for footnote HTML to DOCX conversion.""" + + maxDiff = None + + def test_footnote_creates_footnote_in_document(self): + """Test that elements create Word footnotes.""" + html = '

Text with a footnoteThis is the footnote content. and more text.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + # Check that footnotes.xml exists and contains the footnote + with ZipFile(out) as zip: + self.assertIn("word/footnotes.xml", zip.namelist()) + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("This is the footnote content.", contents) + + def test_multiple_footnotes(self): + """Test that multiple footnotes are created correctly.""" + html = """ +

First footnoteFootnote one. and + second footnoteFootnote two. in same paragraph.

+ """ + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("Footnote one.", contents) + self.assertIn("Footnote two.", contents) + + def test_footnote_with_formatted_content(self): + """Test that footnotes preserve basic text content.""" + html = '

TextFootnote with content.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("Footnote with content.", contents) + + def test_footnote_reference_in_document(self): + """Test that footnote reference is inserted in the document.""" + html = '

Text with footnoteThe footnote. here.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/document.xml") as file: + contents = file.read().decode("utf-8") + # Should contain a footnote reference element + self.assertIn("footnoteReference", contents) + diff --git a/ghostwriter/reporting/tests/test_rich_text_pptx.py b/ghostwriter/reporting/tests/test_rich_text_pptx.py index a0c76d623..5dd4a36f2 100644 --- a/ghostwriter/reporting/tests/test_rich_text_pptx.py +++ b/ghostwriter/reporting/tests/test_rich_text_pptx.py @@ -557,3 +557,27 @@ class RichTextToPptxTests(TestCase): """, add_suffix=False, ) + + # Footnotes are not supported in PowerPoint - they should be silently ignored + test_footnote_ignored = mk_test_pptx( + "test_footnote_ignored", + "

Text with footnoteThis footnote should be ignored. continues here.

", + """ + + Text with footnote + continues here. + + """, + ) + + test_multiple_footnotes_ignored = mk_test_pptx( + "test_multiple_footnotes_ignored", + "

FirstNote 1 and secondNote 2 footnotes.

", + """ + + First + and second + footnotes. + + """, + ) diff --git a/ghostwriter/static/css/styles.css b/ghostwriter/static/css/styles.css index 183350fed..f4bdd8e87 100644 --- a/ghostwriter/static/css/styles.css +++ b/ghostwriter/static/css/styles.css @@ -4448,6 +4448,22 @@ input:checked + .theme-slider .star { margin: 0; } +/* --------------------------------------------------- + Footnote styling for rich text previews +-----------------------------------------------------*/ +span.footnote { + vertical-align: super; + font-size: 0.75em; + color: #0066cc; + cursor: help; + font-weight: 500; +} + +span.footnote:hover { + color: #0052a3; + text-decoration: underline; +} + /* --------------------------------------------------- Evidence lightbox modal styles ----------------------------------------------------- */ diff --git a/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx b/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx new file mode 100644 index 000000000..57e736c06 --- /dev/null +++ b/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx @@ -0,0 +1,96 @@ +import { useId, useState } from "react"; +import ReactModal from "react-modal"; +import { Editor, useEditorState } from "@tiptap/react"; +import { MenuItem } from "@szhsin/react-menu"; + +export default function FootnoteButton({ editor }: { editor: Editor }) { + const [modalOpen, setModalOpen] = useState(false); + const [footnoteContent, setFootnoteContent] = useState(""); + const fieldId = useId(); + + const enabled = useEditorState({ + editor, + selector: ({ editor }) => editor.can().insertFootnote({ content: "" }), + }); + + return ( + <> + { + setFootnoteContent(""); + setModalOpen(true); + }} + > + Insert Footnote + + setModalOpen(false)} + contentLabel="Insert Footnote" + className="modal-dialog modal-dialog-centered" + > +
+
+
Insert Footnote
+
+
{ + ev.preventDefault(); + const content = footnoteContent.trim(); + if (content) { + editor + .chain() + .focus() + .insertFootnote({ content }) + .run(); + } + setModalOpen(false); + }} + > +
+ +