panda-editor 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +101 -0
- data/app/javascript/panda/editor/application.js +8 -0
- data/app/javascript/panda/editor/editor_js_config.js +28 -1
- data/app/javascript/panda/editor/editor_js_initializer.js +4 -1
- data/app/javascript/panda/editor/rich_text_editor.js +6 -1
- data/app/javascript/panda/editor/tools/footnote_tool.js +392 -0
- data/app/javascript/panda/editor/tools/paragraph_with_footnotes.js +280 -0
- data/docs/FOOTNOTES.md +591 -0
- data/lib/panda/editor/blocks/paragraph.rb +38 -0
- data/lib/panda/editor/content.rb +4 -2
- data/lib/panda/editor/engine.rb +2 -6
- data/lib/panda/editor/footnote_registry.rb +95 -0
- data/lib/panda/editor/renderer.rb +17 -1
- data/lib/panda/editor/version.rb +1 -1
- data/lib/panda/editor.rb +11 -0
- data/panda-editor.gemspec +3 -2
- data/test_footnotes_standalone.html +957 -0
- metadata +28 -5
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: b3b8232a60a069137aa8a04c6611f0583877cf036ba6c62c82bef72584c5216d
         | 
| 4 | 
            +
              data.tar.gz: 975f2cb5e0fb8ce6eaa5ae8eb80e3135e1ff44126c102271dd318b148b40b47a
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: a012d400a6af140737018adb0187ce7e54be0614d1fd833492cebb35d4310aac91eeab2109ab8d08857333226624084d2ada8a567c21ff8ea43fff018ca17c0c
         | 
| 7 | 
            +
              data.tar.gz: 8df13bfa16acea6e9e65b22e20ff3e138791648c654090d1134140fa94b1bea7b8d439ef8544a73c9c7ac5ad8066539e9eca1842e8f152d86e3634b65bb71c73
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. | |
| 5 5 | 
             
            The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
         | 
| 6 6 | 
             
            and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
         | 
| 7 7 |  | 
| 8 | 
            +
            ## [0.3.0] - 2025-10-30
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            ### Added
         | 
| 11 | 
            +
            - Footnote support for academic citations and references
         | 
| 12 | 
            +
              - Ruby backend renderer for footnote processing
         | 
| 13 | 
            +
              - EditorJS inline tool for footnote insertion
         | 
| 14 | 
            +
              - Automatic footnote numbering and deduplication
         | 
| 15 | 
            +
              - Collapsible sources section in rendered output
         | 
| 16 | 
            +
              - Comprehensive footnote documentation
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            ### Fixed
         | 
| 19 | 
            +
            - Disabled caching for blocks containing footnotes to ensure accurate rendering
         | 
| 20 | 
            +
            - Updated test expectations to match footnote behavior
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            ### Changed
         | 
| 23 | 
            +
            - Applied standardrb code style fixes across codebase
         | 
| 24 | 
            +
             | 
| 8 25 | 
             
            ## [0.2.0] - 2025-08-12
         | 
| 9 26 |  | 
| 10 27 | 
             
            ### Added
         | 
| @@ -65,5 +82,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |
| 65 82 | 
             
            - HTML sanitization for security
         | 
| 66 83 | 
             
            - Asset pipeline integration
         | 
| 67 84 |  | 
| 85 | 
            +
            [0.3.0]: https://github.com/tastybamboo/panda-editor/compare/v0.2.1...v0.3.0
         | 
| 68 86 | 
             
            [0.2.0]: https://github.com/tastybamboo/panda-editor/compare/v0.1.0...v0.2.0
         | 
| 69 87 | 
             
            [0.1.0]: https://github.com/tastybamboo/panda-editor/releases/tag/v0.1.0
         | 
    
        data/README.md
    CHANGED
    
    | @@ -90,6 +90,7 @@ Panda::Editor::Engine.config.custom_renderers['custom'] = CustomBlock | |
| 90 90 | 
             
            ## Available Blocks
         | 
| 91 91 |  | 
| 92 92 | 
             
            - **Paragraph**: Standard text content
         | 
| 93 | 
            +
              - Supports inline footnotes with automatic numbering
         | 
| 93 94 | 
             
            - **Header**: H1-H6 headings
         | 
| 94 95 | 
             
            - **List**: Ordered and unordered lists
         | 
| 95 96 | 
             
            - **Quote**: Blockquotes with captions
         | 
| @@ -97,6 +98,106 @@ Panda::Editor::Engine.config.custom_renderers['custom'] = CustomBlock | |
| 97 98 | 
             
            - **Image**: Images with captions and styling options
         | 
| 98 99 | 
             
            - **Alert**: Alert/notification boxes
         | 
| 99 100 |  | 
| 101 | 
            +
            ## Footnotes
         | 
| 102 | 
            +
             | 
| 103 | 
            +
            Panda Editor supports inline footnotes that are automatically collected and rendered in a "Sources/References" section at the end of your content.
         | 
| 104 | 
            +
             | 
| 105 | 
            +
            ### Adding Footnotes to Paragraphs
         | 
| 106 | 
            +
             | 
| 107 | 
            +
            Footnotes are added to paragraph blocks in your EditorJS JSON:
         | 
| 108 | 
            +
             | 
| 109 | 
            +
            ```json
         | 
| 110 | 
            +
            {
         | 
| 111 | 
            +
              "type": "paragraph",
         | 
| 112 | 
            +
              "data": {
         | 
| 113 | 
            +
                "text": "Climate change has accelerated significantly since 1980",
         | 
| 114 | 
            +
                "footnotes": [
         | 
| 115 | 
            +
                  {
         | 
| 116 | 
            +
                    "id": "unique-id-1",
         | 
| 117 | 
            +
                    "content": "IPCC. (2023). Climate Change 2023: Synthesis Report.",
         | 
| 118 | 
            +
                    "position": 55
         | 
| 119 | 
            +
                  }
         | 
| 120 | 
            +
                ]
         | 
| 121 | 
            +
              }
         | 
| 122 | 
            +
            }
         | 
| 123 | 
            +
            ```
         | 
| 124 | 
            +
             | 
| 125 | 
            +
            **Fields:**
         | 
| 126 | 
            +
            - `id`: Unique identifier for the footnote (allows multiple citations of the same source)
         | 
| 127 | 
            +
            - `content`: The footnote text/citation
         | 
| 128 | 
            +
            - `position`: Character position in the text where the footnote marker should appear
         | 
| 129 | 
            +
             | 
| 130 | 
            +
            ### Rendered Output
         | 
| 131 | 
            +
             | 
| 132 | 
            +
            The renderer will:
         | 
| 133 | 
            +
            1. Insert superscript footnote markers (`¹`, `²`, etc.) at specified positions
         | 
| 134 | 
            +
            2. Auto-number footnotes sequentially across the entire document
         | 
| 135 | 
            +
            3. De-duplicate footnotes with the same `id`
         | 
| 136 | 
            +
            4. Generate a collapsible "Sources/References" section at the end
         | 
| 137 | 
            +
             | 
| 138 | 
            +
            Example output:
         | 
| 139 | 
            +
             | 
| 140 | 
            +
            ```html
         | 
| 141 | 
            +
            <p>Climate change has accelerated significantly since 1980<sup id="fnref:1"><a href="#fn:1" class="footnote">1</a></sup></p>
         | 
| 142 | 
            +
             | 
| 143 | 
            +
            <!-- Sources section automatically appended -->
         | 
| 144 | 
            +
            <div class="footnotes-section">
         | 
| 145 | 
            +
              <button class="footnotes-header">
         | 
| 146 | 
            +
                <h3>Sources/References</h3>
         | 
| 147 | 
            +
              </button>
         | 
| 148 | 
            +
              <div class="footnotes-content">
         | 
| 149 | 
            +
                <ol class="footnotes">
         | 
| 150 | 
            +
                  <li id="fn:1">
         | 
| 151 | 
            +
                    <p>
         | 
| 152 | 
            +
                      IPCC. (2023). Climate Change 2023: Synthesis Report.
         | 
| 153 | 
            +
                      <a href="#fnref:1" class="footnote-backref">↩</a>
         | 
| 154 | 
            +
                    </p>
         | 
| 155 | 
            +
                  </li>
         | 
| 156 | 
            +
                </ol>
         | 
| 157 | 
            +
              </div>
         | 
| 158 | 
            +
            </div>
         | 
| 159 | 
            +
            ```
         | 
| 160 | 
            +
             | 
| 161 | 
            +
            ### Frontend Integration
         | 
| 162 | 
            +
             | 
| 163 | 
            +
            The sources section includes data attributes for integration with JavaScript frameworks like Stimulus:
         | 
| 164 | 
            +
             | 
| 165 | 
            +
            - `data-footnotes-target="toggle"` - Toggle button
         | 
| 166 | 
            +
            - `data-footnotes-target="content"` - Collapsible content
         | 
| 167 | 
            +
            - `data-footnotes-target="chevron"` - Chevron icon for rotation
         | 
| 168 | 
            +
             | 
| 169 | 
            +
            See [docs/FOOTNOTES.md](docs/FOOTNOTES.md) for detailed implementation examples.
         | 
| 170 | 
            +
             | 
| 171 | 
            +
            ### Auto-linking URLs
         | 
| 172 | 
            +
             | 
| 173 | 
            +
            Enable automatic URL linking in footnote content:
         | 
| 174 | 
            +
             | 
| 175 | 
            +
            ```ruby
         | 
| 176 | 
            +
            renderer = Panda::Editor::Renderer.new(@content, autolink_urls: true)
         | 
| 177 | 
            +
            html = renderer.render
         | 
| 178 | 
            +
            ```
         | 
| 179 | 
            +
             | 
| 180 | 
            +
            When enabled, plain URLs in footnotes are automatically converted to clickable links:
         | 
| 181 | 
            +
             | 
| 182 | 
            +
            **Input:**
         | 
| 183 | 
            +
            ```
         | 
| 184 | 
            +
            Study by Ward et al. (2021). https://doi.org/10.1111/camh.12471
         | 
| 185 | 
            +
            ```
         | 
| 186 | 
            +
             | 
| 187 | 
            +
            **Output:**
         | 
| 188 | 
            +
            ```html
         | 
| 189 | 
            +
            Study by Ward et al. (2021). <a href="https://doi.org/10.1111/camh.12471" target="_blank" rel="noopener noreferrer">https://doi.org/10.1111/camh.12471</a>
         | 
| 190 | 
            +
            ```
         | 
| 191 | 
            +
             | 
| 192 | 
            +
            Features:
         | 
| 193 | 
            +
            - Opens links in new tab with `target="_blank"`
         | 
| 194 | 
            +
            - Includes `rel="noopener noreferrer"` for security
         | 
| 195 | 
            +
            - Won't double-link URLs already in `<a>` tags
         | 
| 196 | 
            +
            - Supports `http://`, `https://`, `ftp://`, and `www.` URLs
         | 
| 197 | 
            +
            - Handles multiple URLs in the same footnote
         | 
| 198 | 
            +
             | 
| 199 | 
            +
            To enable globally for all content using the `Panda::Editor::Content` concern, pass the option in `generate_cached_content`.
         | 
| 200 | 
            +
             | 
| 100 201 | 
             
            ## Configuration
         | 
| 101 202 |  | 
| 102 203 | 
             
            ```ruby
         | 
| @@ -4,15 +4,23 @@ | |
| 4 4 | 
             
            import { EditorJSInitializer } from "./editor_js_initializer"
         | 
| 5 5 | 
             
            import { EditorJSConfig } from "./editor_js_config"
         | 
| 6 6 | 
             
            import { RichTextEditor } from "./rich_text_editor"
         | 
| 7 | 
            +
            import FootnoteTool from "./tools/footnote_tool"
         | 
| 8 | 
            +
            import ParagraphWithFootnotes from "./tools/paragraph_with_footnotes"
         | 
| 7 9 |  | 
| 8 10 | 
             
            // Export for global access
         | 
| 9 11 | 
             
            window.PandaEditor = {
         | 
| 10 12 | 
             
              EditorJSInitializer,
         | 
| 11 13 | 
             
              EditorJSConfig,
         | 
| 12 14 | 
             
              RichTextEditor,
         | 
| 15 | 
            +
              FootnoteTool,
         | 
| 16 | 
            +
              ParagraphWithFootnotes,
         | 
| 13 17 | 
             
              VERSION: "0.1.0"
         | 
| 14 18 | 
             
            }
         | 
| 15 19 |  | 
| 20 | 
            +
            // Make tools available globally for EditorJS
         | 
| 21 | 
            +
            window.FootnoteTool = FootnoteTool
         | 
| 22 | 
            +
            window.ParagraphWithFootnotes = ParagraphWithFootnotes
         | 
| 23 | 
            +
             | 
| 16 24 | 
             
            // Auto-initialize on DOMContentLoaded
         | 
| 17 25 | 
             
            document.addEventListener("DOMContentLoaded", () => {
         | 
| 18 26 | 
             
              console.log("[Panda Editor] Loaded v" + window.PandaEditor.VERSION)
         | 
| @@ -185,6 +185,30 @@ export const EDITOR_JS_CSS = ` | |
| 185 185 | 
             
                margin: 0 !important;
         | 
| 186 186 | 
             
                padding: 0 !important;
         | 
| 187 187 | 
             
              }
         | 
| 188 | 
            +
             | 
| 189 | 
            +
              /* Footnote marker styles */
         | 
| 190 | 
            +
              .footnote-marker {
         | 
| 191 | 
            +
                display: inline-block;
         | 
| 192 | 
            +
                color: #3b82f6;
         | 
| 193 | 
            +
                font-size: 0.75em;
         | 
| 194 | 
            +
                font-weight: 600;
         | 
| 195 | 
            +
                vertical-align: super;
         | 
| 196 | 
            +
                cursor: pointer;
         | 
| 197 | 
            +
                padding: 0 2px;
         | 
| 198 | 
            +
                user-select: none;
         | 
| 199 | 
            +
                margin-left: 1px;
         | 
| 200 | 
            +
              }
         | 
| 201 | 
            +
             | 
| 202 | 
            +
              .footnote-marker:hover {
         | 
| 203 | 
            +
                color: #2563eb;
         | 
| 204 | 
            +
                text-decoration: underline;
         | 
| 205 | 
            +
              }
         | 
| 206 | 
            +
             | 
| 207 | 
            +
              /* Inline toolbar button for footnote */
         | 
| 208 | 
            +
              .ce-inline-tool--footnote svg {
         | 
| 209 | 
            +
                width: 17px;
         | 
| 210 | 
            +
                height: 17px;
         | 
| 211 | 
            +
              }
         | 
| 188 212 | 
             
            `
         | 
| 189 213 |  | 
| 190 214 | 
             
            export const getEditorConfig = (elementId, previousData, doc = document) => {
         | 
| @@ -230,7 +254,7 @@ export const getEditorConfig = (elementId, previousData, doc = document) => { | |
| 230 254 | 
             
                    }
         | 
| 231 255 | 
             
                  },
         | 
| 232 256 | 
             
                  paragraph: {
         | 
| 233 | 
            -
                    class: win.Paragraph,
         | 
| 257 | 
            +
                    class: win.ParagraphWithFootnotes || win.Paragraph,
         | 
| 234 258 | 
             
                    inlineToolbar: true,
         | 
| 235 259 | 
             
                    config: {
         | 
| 236 260 | 
             
                      placeholder: 'Start writing or press Tab to add content...'
         | 
| @@ -276,6 +300,9 @@ export const getEditorConfig = (elementId, previousData, doc = document) => { | |
| 276 300 | 
             
                        vimeo: true
         | 
| 277 301 | 
             
                      }
         | 
| 278 302 | 
             
                    }
         | 
| 303 | 
            +
                  },
         | 
| 304 | 
            +
                  footnote: {
         | 
| 305 | 
            +
                    class: win.FootnoteTool
         | 
| 279 306 | 
             
                  }
         | 
| 280 307 | 
             
                }
         | 
| 281 308 | 
             
              }
         | 
| @@ -211,7 +211,7 @@ export class EditorJSInitializer { | |
| 211 211 | 
             
                    placeholder: 'Click to start writing...',
         | 
| 212 212 | 
             
                    tools: {
         | 
| 213 213 | 
             
                      paragraph: {
         | 
| 214 | 
            -
                        class: win.Paragraph,
         | 
| 214 | 
            +
                        class: win.ParagraphWithFootnotes || win.Paragraph,
         | 
| 215 215 | 
             
                        inlineToolbar: true,
         | 
| 216 216 | 
             
                        config: {
         | 
| 217 217 | 
             
                          preserveBlank: true,
         | 
| @@ -242,6 +242,9 @@ export class EditorJSInitializer { | |
| 242 242 | 
             
                          quotePlaceholder: 'Enter a quote',
         | 
| 243 243 | 
             
                          captionPlaceholder: 'Quote\'s author'
         | 
| 244 244 | 
             
                        }
         | 
| 245 | 
            +
                      },
         | 
| 246 | 
            +
                      footnote: {
         | 
| 247 | 
            +
                        class: win.FootnoteTool
         | 
| 245 248 | 
             
                      }
         | 
| 246 249 | 
             
                    },
         | 
| 247 250 | 
             
                    onChange: (api, event) => {
         | 
| @@ -5,6 +5,8 @@ import List from "@editorjs/list" | |
| 5 5 | 
             
            import Quote from "@editorjs/quote"
         | 
| 6 6 | 
             
            import Table from "@editorjs/table"
         | 
| 7 7 | 
             
            import NestedList from "@editorjs/nested-list"
         | 
| 8 | 
            +
            import FootnoteTool from "./tools/footnote_tool"
         | 
| 9 | 
            +
            import ParagraphWithFootnotes from "./tools/paragraph_with_footnotes"
         | 
| 8 10 |  | 
| 9 11 | 
             
            export default class RichTextEditor {
         | 
| 10 12 | 
             
              constructor(element, iframe) {
         | 
| @@ -72,7 +74,7 @@ export default class RichTextEditor { | |
| 72 74 | 
             
                    placeholder: "Click to start writing...",
         | 
| 73 75 | 
             
                    tools: {
         | 
| 74 76 | 
             
                      paragraph: {
         | 
| 75 | 
            -
                        class:  | 
| 77 | 
            +
                        class: ParagraphWithFootnotes,
         | 
| 76 78 | 
             
                        inlineToolbar: true
         | 
| 77 79 | 
             
                      },
         | 
| 78 80 | 
             
                      header: {
         | 
| @@ -94,6 +96,9 @@ export default class RichTextEditor { | |
| 94 96 | 
             
                      table: {
         | 
| 95 97 | 
             
                        class: Table,
         | 
| 96 98 | 
             
                        inlineToolbar: true
         | 
| 99 | 
            +
                      },
         | 
| 100 | 
            +
                      footnote: {
         | 
| 101 | 
            +
                        class: FootnoteTool
         | 
| 97 102 | 
             
                      }
         | 
| 98 103 | 
             
                    },
         | 
| 99 104 | 
             
                    onChange: () => {
         | 
| @@ -0,0 +1,392 @@ | |
| 1 | 
            +
            /**
         | 
| 2 | 
            +
             * FootnoteTool - EditorJS Inline Tool for adding footnotes
         | 
| 3 | 
            +
             *
         | 
| 4 | 
            +
             * This tool allows users to add footnotes to text by:
         | 
| 5 | 
            +
             * 1. Selecting text and clicking the footnote button
         | 
| 6 | 
            +
             * 2. Entering the citation content in a modal
         | 
| 7 | 
            +
             * 3. Automatically generating a unique ID
         | 
| 8 | 
            +
             * 4. Storing the footnote data in the paragraph block
         | 
| 9 | 
            +
             * 5. Displaying a visual marker (superscript number) in the editor
         | 
| 10 | 
            +
             */
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            export default class FootnoteTool {
         | 
| 13 | 
            +
              /**
         | 
| 14 | 
            +
               * EditorJS inline tool interface
         | 
| 15 | 
            +
               */
         | 
| 16 | 
            +
              static get isInline() {
         | 
| 17 | 
            +
                return true;
         | 
| 18 | 
            +
              }
         | 
| 19 | 
            +
             | 
| 20 | 
            +
              static get title() {
         | 
| 21 | 
            +
                return 'Footnote';
         | 
| 22 | 
            +
              }
         | 
| 23 | 
            +
             | 
| 24 | 
            +
              /**
         | 
| 25 | 
            +
               * Sanitize config - allow sup tags with our footnote classes
         | 
| 26 | 
            +
               */
         | 
| 27 | 
            +
              static get sanitize() {
         | 
| 28 | 
            +
                return {
         | 
| 29 | 
            +
                  sup: {
         | 
| 30 | 
            +
                    class: 'footnote-marker'
         | 
| 31 | 
            +
                  }
         | 
| 32 | 
            +
                };
         | 
| 33 | 
            +
              }
         | 
| 34 | 
            +
             | 
| 35 | 
            +
              /**
         | 
| 36 | 
            +
               * Constructor
         | 
| 37 | 
            +
               * @param {object} params - Tool parameters from EditorJS
         | 
| 38 | 
            +
               */
         | 
| 39 | 
            +
              constructor({ api }) {
         | 
| 40 | 
            +
                this.api = api;
         | 
| 41 | 
            +
                this.button = null;
         | 
| 42 | 
            +
                this.state = false;
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                // Store reference to active footnotes for this paragraph
         | 
| 45 | 
            +
                this.footnotes = [];
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                // CSS classes
         | 
| 48 | 
            +
                this.CSS = {
         | 
| 49 | 
            +
                  button: 'ce-inline-tool',
         | 
| 50 | 
            +
                  buttonActive: 'ce-inline-tool--active',
         | 
| 51 | 
            +
                  buttonModifier: 'ce-inline-tool--footnote'
         | 
| 52 | 
            +
                };
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                // SVG icon for superscript/footnote
         | 
| 55 | 
            +
                this.iconSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
         | 
| 56 | 
            +
                  <text x="2" y="18" font-size="14" font-weight="bold">fn</text>
         | 
| 57 | 
            +
                  <text x="12" y="8" font-size="8" font-weight="bold">1</text>
         | 
| 58 | 
            +
                </svg>`;
         | 
| 59 | 
            +
              }
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              /**
         | 
| 62 | 
            +
               * Create button for inline toolbar
         | 
| 63 | 
            +
               */
         | 
| 64 | 
            +
              render() {
         | 
| 65 | 
            +
                this.button = document.createElement('button');
         | 
| 66 | 
            +
                this.button.type = 'button';
         | 
| 67 | 
            +
                this.button.classList.add(this.CSS.button, this.CSS.buttonModifier);
         | 
| 68 | 
            +
                this.button.innerHTML = this.iconSVG;
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                return this.button;
         | 
| 71 | 
            +
              }
         | 
| 72 | 
            +
             | 
| 73 | 
            +
              /**
         | 
| 74 | 
            +
               * Handle click on the footnote button
         | 
| 75 | 
            +
               * @param {Range} range - Selected text range
         | 
| 76 | 
            +
               */
         | 
| 77 | 
            +
              surround(range) {
         | 
| 78 | 
            +
                if (this.state) {
         | 
| 79 | 
            +
                  // Remove footnote if already exists
         | 
| 80 | 
            +
                  this.unwrap(range);
         | 
| 81 | 
            +
                  return;
         | 
| 82 | 
            +
                }
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                // Get the selected text position within the paragraph
         | 
| 85 | 
            +
                const position = this.getCaretPosition(range);
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                // Show modal to collect footnote content
         | 
| 88 | 
            +
                this.showFootnoteModal((content) => {
         | 
| 89 | 
            +
                  if (!content || content.trim() === '') {
         | 
| 90 | 
            +
                    return;
         | 
| 91 | 
            +
                  }
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                  // Generate unique ID
         | 
| 94 | 
            +
                  const footnoteId = this.generateFootnoteId();
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                  // Wrap selection with marker (stores content in data attribute)
         | 
| 97 | 
            +
                  this.wrap(range, footnoteId, content.trim());
         | 
| 98 | 
            +
                });
         | 
| 99 | 
            +
              }
         | 
| 100 | 
            +
             | 
| 101 | 
            +
              /**
         | 
| 102 | 
            +
               * Wrap selected text with footnote marker
         | 
| 103 | 
            +
               * @param {Range} range - Selected text range
         | 
| 104 | 
            +
               * @param {string} footnoteId - Unique footnote identifier
         | 
| 105 | 
            +
               * @param {string} content - Footnote content/citation
         | 
| 106 | 
            +
               */
         | 
| 107 | 
            +
              wrap(range, footnoteId, content) {
         | 
| 108 | 
            +
                const marker = document.createElement('sup');
         | 
| 109 | 
            +
                marker.classList.add('footnote-marker');
         | 
| 110 | 
            +
                marker.dataset.footnoteId = footnoteId;
         | 
| 111 | 
            +
                marker.dataset.footnoteContent = content; // Store content in data attribute
         | 
| 112 | 
            +
                marker.contentEditable = false;
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                // Insert marker at the end of selection
         | 
| 115 | 
            +
                range.collapse(false); // Collapse to end
         | 
| 116 | 
            +
                range.insertNode(marker);
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                // Move cursor after marker
         | 
| 119 | 
            +
                range.setStartAfter(marker);
         | 
| 120 | 
            +
                range.collapse(true);
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                // Update selection
         | 
| 123 | 
            +
                const selection = window.getSelection();
         | 
| 124 | 
            +
                selection.removeAllRanges();
         | 
| 125 | 
            +
                selection.addRange(range);
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                // Renumber all footnotes in the document
         | 
| 128 | 
            +
                setTimeout(() => this.renumberAllFootnotes(), 0);
         | 
| 129 | 
            +
              }
         | 
| 130 | 
            +
             | 
| 131 | 
            +
              /**
         | 
| 132 | 
            +
               * Remove footnote marker
         | 
| 133 | 
            +
               * @param {Range} range - Selected text range
         | 
| 134 | 
            +
               */
         | 
| 135 | 
            +
              unwrap(range) {
         | 
| 136 | 
            +
                const marker = this.findMarkerInRange(range);
         | 
| 137 | 
            +
                if (marker) {
         | 
| 138 | 
            +
                  marker.remove();
         | 
| 139 | 
            +
             | 
| 140 | 
            +
                  // Renumber remaining footnotes
         | 
| 141 | 
            +
                  setTimeout(() => this.renumberAllFootnotes(), 0);
         | 
| 142 | 
            +
                }
         | 
| 143 | 
            +
              }
         | 
| 144 | 
            +
             | 
| 145 | 
            +
              /**
         | 
| 146 | 
            +
               * Check if current selection has a footnote
         | 
| 147 | 
            +
               * @param {Range} range - Current selection range
         | 
| 148 | 
            +
               */
         | 
| 149 | 
            +
              checkState(selection) {
         | 
| 150 | 
            +
                if (!selection || !selection.anchorNode) {
         | 
| 151 | 
            +
                  this.state = false;
         | 
| 152 | 
            +
                  return false;
         | 
| 153 | 
            +
                }
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                const marker = this.findMarkerInSelection(selection);
         | 
| 156 | 
            +
                this.state = !!marker;
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                return this.state;
         | 
| 159 | 
            +
              }
         | 
| 160 | 
            +
             | 
| 161 | 
            +
              /**
         | 
| 162 | 
            +
               * Find footnote marker in current selection
         | 
| 163 | 
            +
               * @param {Selection} selection - Current selection
         | 
| 164 | 
            +
               * @returns {Element|null} Footnote marker element if found
         | 
| 165 | 
            +
               */
         | 
| 166 | 
            +
              findMarkerInSelection(selection) {
         | 
| 167 | 
            +
                if (!selection.anchorNode) return null;
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                let node = selection.anchorNode;
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                // Traverse up to find marker
         | 
| 172 | 
            +
                while (node && node !== this.api.blocks.getCurrentBlockIndex()) {
         | 
| 173 | 
            +
                  if (node.nodeType === Node.ELEMENT_NODE &&
         | 
| 174 | 
            +
                      node.tagName === 'SUP' &&
         | 
| 175 | 
            +
                      node.classList.contains('footnote-marker')) {
         | 
| 176 | 
            +
                    return node;
         | 
| 177 | 
            +
                  }
         | 
| 178 | 
            +
                  node = node.parentNode;
         | 
| 179 | 
            +
                }
         | 
| 180 | 
            +
             | 
| 181 | 
            +
                return null;
         | 
| 182 | 
            +
              }
         | 
| 183 | 
            +
             | 
| 184 | 
            +
              /**
         | 
| 185 | 
            +
               * Find footnote marker in a range
         | 
| 186 | 
            +
               * @param {Range} range - Selection range
         | 
| 187 | 
            +
               * @returns {Element|null} Footnote marker element if found
         | 
| 188 | 
            +
               */
         | 
| 189 | 
            +
              findMarkerInRange(range) {
         | 
| 190 | 
            +
                const container = range.commonAncestorContainer;
         | 
| 191 | 
            +
             | 
| 192 | 
            +
                if (container.nodeType === Node.ELEMENT_NODE &&
         | 
| 193 | 
            +
                    container.classList.contains('footnote-marker')) {
         | 
| 194 | 
            +
                  return container;
         | 
| 195 | 
            +
                }
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                const markers = container.querySelectorAll?.('.footnote-marker');
         | 
| 198 | 
            +
                return markers?.[0] || null;
         | 
| 199 | 
            +
              }
         | 
| 200 | 
            +
             | 
| 201 | 
            +
              /**
         | 
| 202 | 
            +
               * Get caret position within the paragraph text
         | 
| 203 | 
            +
               * @param {Range} range - Current selection range
         | 
| 204 | 
            +
               * @returns {number} Character position
         | 
| 205 | 
            +
               */
         | 
| 206 | 
            +
              getCaretPosition(range) {
         | 
| 207 | 
            +
                const block = this.api.blocks.getBlockByIndex(this.api.blocks.getCurrentBlockIndex());
         | 
| 208 | 
            +
                const blockElement = block.holder;
         | 
| 209 | 
            +
                const contentElement = blockElement.querySelector('.ce-paragraph');
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                if (!contentElement) return 0;
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                // Create a range from start of content to current position
         | 
| 214 | 
            +
                const preCaretRange = range.cloneRange();
         | 
| 215 | 
            +
                preCaretRange.selectNodeContents(contentElement);
         | 
| 216 | 
            +
                preCaretRange.setEnd(range.endContainer, range.endOffset);
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                // Get text content length (position)
         | 
| 219 | 
            +
                return preCaretRange.toString().length;
         | 
| 220 | 
            +
              }
         | 
| 221 | 
            +
             | 
| 222 | 
            +
              /**
         | 
| 223 | 
            +
               * Generate a unique footnote ID
         | 
| 224 | 
            +
               * @returns {string} Unique identifier
         | 
| 225 | 
            +
               */
         | 
| 226 | 
            +
              generateFootnoteId() {
         | 
| 227 | 
            +
                return `fn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
         | 
| 228 | 
            +
              }
         | 
| 229 | 
            +
             | 
| 230 | 
            +
              /**
         | 
| 231 | 
            +
               * Renumber all footnotes in the entire document
         | 
| 232 | 
            +
               * Scans all blocks and updates footnote marker numbers sequentially
         | 
| 233 | 
            +
               */
         | 
| 234 | 
            +
              renumberAllFootnotes() {
         | 
| 235 | 
            +
                try {
         | 
| 236 | 
            +
                  const blocksCount = this.api.blocks.getBlocksCount();
         | 
| 237 | 
            +
                  let footnoteNumber = 0;
         | 
| 238 | 
            +
             | 
| 239 | 
            +
                  // Scan through all blocks in order
         | 
| 240 | 
            +
                  for (let i = 0; i < blocksCount; i++) {
         | 
| 241 | 
            +
                    const block = this.api.blocks.getBlockByIndex(i);
         | 
| 242 | 
            +
                    if (!block) continue;
         | 
| 243 | 
            +
             | 
| 244 | 
            +
                    const blockElement = block.holder;
         | 
| 245 | 
            +
                    const markers = blockElement.querySelectorAll('.footnote-marker');
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                    // Update each marker in this block
         | 
| 248 | 
            +
                    markers.forEach(marker => {
         | 
| 249 | 
            +
                      footnoteNumber++;
         | 
| 250 | 
            +
                      marker.textContent = footnoteNumber.toString();
         | 
| 251 | 
            +
                    });
         | 
| 252 | 
            +
                  }
         | 
| 253 | 
            +
             | 
| 254 | 
            +
                  console.debug('[Footnote Tool] Renumbered', footnoteNumber, 'footnotes');
         | 
| 255 | 
            +
                } catch (error) {
         | 
| 256 | 
            +
                  console.error('[Footnote Tool] Error renumbering footnotes:', error);
         | 
| 257 | 
            +
                }
         | 
| 258 | 
            +
              }
         | 
| 259 | 
            +
             | 
| 260 | 
            +
             | 
| 261 | 
            +
              /**
         | 
| 262 | 
            +
               * Show modal dialog to collect footnote content
         | 
| 263 | 
            +
               * @param {Function} onSave - Callback when user saves
         | 
| 264 | 
            +
               */
         | 
| 265 | 
            +
              showFootnoteModal(onSave) {
         | 
| 266 | 
            +
                // Create modal overlay
         | 
| 267 | 
            +
                const overlay = document.createElement('div');
         | 
| 268 | 
            +
                overlay.className = 'footnote-modal-overlay';
         | 
| 269 | 
            +
                overlay.style.cssText = `
         | 
| 270 | 
            +
                  position: fixed;
         | 
| 271 | 
            +
                  top: 0;
         | 
| 272 | 
            +
                  left: 0;
         | 
| 273 | 
            +
                  right: 0;
         | 
| 274 | 
            +
                  bottom: 0;
         | 
| 275 | 
            +
                  background: rgba(0, 0, 0, 0.5);
         | 
| 276 | 
            +
                  display: flex;
         | 
| 277 | 
            +
                  align-items: center;
         | 
| 278 | 
            +
                  justify-content: center;
         | 
| 279 | 
            +
                  z-index: 10000;
         | 
| 280 | 
            +
                `;
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                // Create modal content
         | 
| 283 | 
            +
                const modal = document.createElement('div');
         | 
| 284 | 
            +
                modal.className = 'footnote-modal';
         | 
| 285 | 
            +
                modal.style.cssText = `
         | 
| 286 | 
            +
                  background: white;
         | 
| 287 | 
            +
                  border-radius: 8px;
         | 
| 288 | 
            +
                  padding: 24px;
         | 
| 289 | 
            +
                  max-width: 500px;
         | 
| 290 | 
            +
                  width: 90%;
         | 
| 291 | 
            +
                  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
         | 
| 292 | 
            +
                `;
         | 
| 293 | 
            +
             | 
| 294 | 
            +
                modal.innerHTML = `
         | 
| 295 | 
            +
                  <h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">Add Footnote</h3>
         | 
| 296 | 
            +
                  <div style="margin-bottom: 16px;">
         | 
| 297 | 
            +
                    <label style="display: block; margin-bottom: 8px; font-size: 14px; font-weight: 500;">
         | 
| 298 | 
            +
                      Citation Content
         | 
| 299 | 
            +
                    </label>
         | 
| 300 | 
            +
                    <textarea
         | 
| 301 | 
            +
                      class="footnote-content-input"
         | 
| 302 | 
            +
                      placeholder="Enter citation or reference (e.g., Smith, J. et al. (2023). Study Title. Journal Name.)"
         | 
| 303 | 
            +
                      style="
         | 
| 304 | 
            +
                        width: 100%;
         | 
| 305 | 
            +
                        min-height: 100px;
         | 
| 306 | 
            +
                        padding: 8px 12px;
         | 
| 307 | 
            +
                        border: 1px solid #d1d5db;
         | 
| 308 | 
            +
                        border-radius: 4px;
         | 
| 309 | 
            +
                        font-size: 14px;
         | 
| 310 | 
            +
                        font-family: inherit;
         | 
| 311 | 
            +
                        resize: vertical;
         | 
| 312 | 
            +
                      "
         | 
| 313 | 
            +
                    ></textarea>
         | 
| 314 | 
            +
                    <p style="margin: 8px 0 0 0; font-size: 12px; color: #6b7280;">
         | 
| 315 | 
            +
                      Tip: You can paste URLs directly - they'll be automatically converted to clickable links.
         | 
| 316 | 
            +
                    </p>
         | 
| 317 | 
            +
                  </div>
         | 
| 318 | 
            +
                  <div style="display: flex; gap: 8px; justify-content: flex-end;">
         | 
| 319 | 
            +
                    <button class="footnote-cancel-btn" style="
         | 
| 320 | 
            +
                      padding: 8px 16px;
         | 
| 321 | 
            +
                      border: 1px solid #d1d5db;
         | 
| 322 | 
            +
                      border-radius: 4px;
         | 
| 323 | 
            +
                      background: white;
         | 
| 324 | 
            +
                      color: #374151;
         | 
| 325 | 
            +
                      font-size: 14px;
         | 
| 326 | 
            +
                      font-weight: 500;
         | 
| 327 | 
            +
                      cursor: pointer;
         | 
| 328 | 
            +
                    ">Cancel</button>
         | 
| 329 | 
            +
                    <button class="footnote-save-btn" style="
         | 
| 330 | 
            +
                      padding: 8px 16px;
         | 
| 331 | 
            +
                      border: none;
         | 
| 332 | 
            +
                      border-radius: 4px;
         | 
| 333 | 
            +
                      background: #3b82f6;
         | 
| 334 | 
            +
                      color: white;
         | 
| 335 | 
            +
                      font-size: 14px;
         | 
| 336 | 
            +
                      font-weight: 500;
         | 
| 337 | 
            +
                      cursor: pointer;
         | 
| 338 | 
            +
                    ">Add Footnote</button>
         | 
| 339 | 
            +
                  </div>
         | 
| 340 | 
            +
                `;
         | 
| 341 | 
            +
             | 
| 342 | 
            +
                overlay.appendChild(modal);
         | 
| 343 | 
            +
                document.body.appendChild(overlay);
         | 
| 344 | 
            +
             | 
| 345 | 
            +
                // Get elements
         | 
| 346 | 
            +
                const textarea = modal.querySelector('.footnote-content-input');
         | 
| 347 | 
            +
                const saveBtn = modal.querySelector('.footnote-save-btn');
         | 
| 348 | 
            +
                const cancelBtn = modal.querySelector('.footnote-cancel-btn');
         | 
| 349 | 
            +
             | 
| 350 | 
            +
                // Focus textarea
         | 
| 351 | 
            +
                setTimeout(() => textarea.focus(), 0);
         | 
| 352 | 
            +
             | 
| 353 | 
            +
                // Handle save
         | 
| 354 | 
            +
                const handleSave = () => {
         | 
| 355 | 
            +
                  const content = textarea.value;
         | 
| 356 | 
            +
                  onSave(content);
         | 
| 357 | 
            +
                  overlay.remove();
         | 
| 358 | 
            +
                };
         | 
| 359 | 
            +
             | 
| 360 | 
            +
                // Handle cancel
         | 
| 361 | 
            +
                const handleCancel = () => {
         | 
| 362 | 
            +
                  overlay.remove();
         | 
| 363 | 
            +
                };
         | 
| 364 | 
            +
             | 
| 365 | 
            +
                // Event listeners
         | 
| 366 | 
            +
                saveBtn.addEventListener('click', handleSave);
         | 
| 367 | 
            +
                cancelBtn.addEventListener('click', handleCancel);
         | 
| 368 | 
            +
                overlay.addEventListener('click', (e) => {
         | 
| 369 | 
            +
                  if (e.target === overlay) {
         | 
| 370 | 
            +
                    handleCancel();
         | 
| 371 | 
            +
                  }
         | 
| 372 | 
            +
                });
         | 
| 373 | 
            +
             | 
| 374 | 
            +
                // Handle Enter key (with Shift for new line)
         | 
| 375 | 
            +
                textarea.addEventListener('keydown', (e) => {
         | 
| 376 | 
            +
                  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
         | 
| 377 | 
            +
                    e.preventDefault();
         | 
| 378 | 
            +
                    handleSave();
         | 
| 379 | 
            +
                  } else if (e.key === 'Escape') {
         | 
| 380 | 
            +
                    e.preventDefault();
         | 
| 381 | 
            +
                    handleCancel();
         | 
| 382 | 
            +
                  }
         | 
| 383 | 
            +
                });
         | 
| 384 | 
            +
              }
         | 
| 385 | 
            +
             | 
| 386 | 
            +
              /**
         | 
| 387 | 
            +
               * Optional: Clear tool state
         | 
| 388 | 
            +
               */
         | 
| 389 | 
            +
              clear() {
         | 
| 390 | 
            +
                this.state = false;
         | 
| 391 | 
            +
              }
         | 
| 392 | 
            +
            }
         |