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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4aceae2a319683face212a6ab1cb17cf0796ac0d0ce5f10ccfe4adaf1ff3a76
4
- data.tar.gz: 440839d76811382db14f860ed4529ea6672148a815459c817423ef734bc0a81d
3
+ metadata.gz: b3b8232a60a069137aa8a04c6611f0583877cf036ba6c62c82bef72584c5216d
4
+ data.tar.gz: 975f2cb5e0fb8ce6eaa5ae8eb80e3135e1ff44126c102271dd318b148b40b47a
5
5
  SHA512:
6
- metadata.gz: ab408175021676d37abd0a62b37181cda6830c5e09324056d4bd112bee796bca5a61f5ae3c8dfdc64e9ea5134e55729b16b8343b0a57cb9b78ecb2229104d0cd
7
- data.tar.gz: d6142f7cd742a6efb0f3fd810a517361cfcd5446ae8014c1699376a857b65acb388635f40f1063116baa3ab659b2cdac8e60f23c57cb19bacd8ef27287b8b43e
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: Paragraph,
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
+ }