Skip to content

issue #5 Event driven architecture#54

Open
sushilbang wants to merge 47 commits intosohzm:masterfrom
sushilbang:event-driven-arc
Open

issue #5 Event driven architecture#54
sushilbang wants to merge 47 commits intosohzm:masterfrom
sushilbang:event-driven-arc

Conversation

@sushilbang
Copy link
Contributor

@sushilbang sushilbang commented Dec 11, 2025

Summary by CodeRabbit

  • New Features

    • Sharing controls (add/remove users, public/database toggles), paste‑link menu, richer link‑preview & embed blocks, quick page+link creation, Project Documentation template, editor font‑size option, expanded code editor language support.
  • Improvements

    • Unified, safer paste/copy (HTML/MD/images) with sanitization, event‑driven sync with timestamped, queued saves, enhanced drag/drop (touch, keyboard, Escape), numbered‑list renumbering, virtual‑keyboard scrolling.
  • Style

    • Minor CSS/layout and cursor refinements.

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link

vercel bot commented Dec 11, 2025

@sushilbang is attempting to deploy a commit to the sz47's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Dec 11, 2025

📝 Walkthrough

Walkthrough

Introduces an event-driven sync pipeline with timestamped change events and queued persistence; replaces plain-text paste handling with a comprehensive WiskPasteHandler; adds paste-link UI, new link/embed components and page-creation flow; expands plugin UI, touch/drag and virtual-keyboard handling; adds templates and editor font-size theming.

Changes

Cohort / File(s) Summary
Sync core & persistence
js/sync/sync.js
Adds eventLog and wisk.sync.newChange, applyEvent, saveModification, enqueueSave; batched, timestamped event application and durable persistence with rollback semantics.
Editor API & mutation pipeline
js/editor.js
Refactors element create/update/delete flows; emits timestamped newChange events for config/plugin/data/access mutations; adds public mutators (addUserAccess, removeUserAccess, setPublicStatus, setDatabaseProp, savePluginData); block creation now includes lastUpdated; some operations now async.
Paste subsystem & handler
js/paste-handler.js, index.html
Replaces PlainTextPasteHandler with WiskPasteHandler: unified parsing for WISK clipboard, HTML, Markdown, plain text, images; sanitization, list/table/code extraction, and index.html now includes paste-handler script.
Paste-link UI & text plugin
js/plugins/code/base-text-element.js, js/plugins/code/text-element.js
Adds paste-link menu state/UI, keyboard navigation, copy serialization (handleCopy), and methods (showPasteLinkMenu, hidePasteLinkMenu, updatePasteLinkMenuSelection, handlePasteLinkChoice, stripProtocol); injects menu markup/CSS.
Link, preview & embed components
js/plugins/code/embed-element.js, js/plugins/code/link-element.js, js/plugins/code/link-preview-element.js
Reimplements EmbedElement lifecycle (input + sandboxed iframe); introduces LinkElement (shadow DOM, editable title/icon, internal fetch/update); refactors link-preview to LinkPreviewElement with two-pane preview, metadata fetch and robust status handling.
Selector & page creation
js/elements/selector-element.js
Makes selectButton async and adds createPageAndLink() to create a page record, insert a link-element in the parent, persist, and navigate when selecting "Page".
Plugins metadata & defaults
js/plugins/plugin-data.json, js/plugins/plugins.js
Adds page-element and link-element entries; updates link-preview-element metadata; includes link-element in defaultPlugins and preloads page-element.
Clipboard safety & serialization
js/plugins/code/base-text-element.js, js/editor.js
Adds HTML sanitize logic for clipboard payloads, base64-encoded internal clipboard JSON, safer link href handling, and immediate newChange emission on config/plugin data mutations.
Drag/touch, lists, renumbering & UI tweaks
js/plugins/code/tier-list.js, js/plugins/code/list-element.js, js/plugins/code/numbered-list-element.js, js/left-sidebar.js, style.css
Touch drag support for TierList, drag-handle CSS and global dragging cursor rules, debounced RAF sidebar resize, numbered-list renumbering after moves/deletes, and minor list-dot alignment/style adjustments.
Code editor & plugin refinements
js/plugins/code/code-element.js, js/plugins/code/table-element.js, js/plugins/code/super-checkbox.js, js/plugins/code/main-element.js
Expanded CodeMirror language/mode support and token styling, autofocus behavior; table markdown generation delegated to getMarkdownText(); checkbox listener binding stabilized; small UI style tweaks.
Font-size theming & options
js/plugins/code/options-component.js, js/theme/variables.css, js/wisk.js
Adds --editor-font-size variable, Options UI to choose/apply editor font size (persisted to localStorage), and startup application of saved font size.
Keyboard & command updates
js/elements/command-palette.js, various plugins
Replaces numeric keyCode checks with e.key comparisons across keyboard handlers for clearer semantics.
Templates & misc UI/theme
js/templates/*, js/theme/theme.js, css/pages.css, index.html
Adds "Project Documentation" template and JSON; reuses theme/favicons to avoid duplicates; records theme config changes via addConfigChange; updates CSS variable usage; includes paste-handler script.

Sequence Diagram

sequenceDiagram
    participant UI as Editor UI / Plugins
    participant Editor as js/editor.js
    participant Sync as wisk.sync
    participant Doc as In-memory Document
    participant DB as wisk.db
    participant Ext as External metadata service

    UI->>Editor: user action (paste / create / edit / delete / paste-link)
    Editor->>Sync: wisk.sync.newChange(event{path, value, agent, timestamp})
    Sync->>Sync: validate & populate metadata
    Sync->>Sync: append event to eventLog
    alt apply immediately / enqueue save
        Sync->>Doc: applyEvent(document, event) — mutate target paths
        Doc-->>Sync: document updated (lastUpdated, syncLogs)
        Sync->>DB: enqueueSave / wisk.db.setPage(document)
        DB-->>Sync: persist OK / error
        Sync-->>Editor: persistence result
    end
    opt external metadata needed (link-preview/embed)
        UI->>Ext: fetch metadata
        Ext-->>UI: metadata
        UI->>Editor: create newChange for metadata
        Editor->>Sync: wisk.sync.newChange(metadataEvent)
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I nibble bytes and stitch them quick and neat,
Events hop with timestamps keeping time and beat,
Pages sprout links, embeds peek in their frame,
Paste menus flutter, touch-drag joins the game,
I logged the changes and hopped off to eat.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'issue #5 Event driven architecture' accurately summarizes the main architectural refactor introducing an event-driven system for managing document changes, mutations, and synchronization throughout the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
js/plugins/code/link-preview-element.js (2)

193-258: Dead code referencing undefined this.editable.

These methods (handleSpecialKeys, handleBackspace, onValueUpdated, bindEvents) reference this.editable, but:

  1. The editable element is commented out in render() (lines 407-413)
  2. The this.editable assignment is commented out in connectedCallback() (line 17)
  3. bindEvents() is never called

If this component is now purely a display/preview element (non-editable), remove these dead methods to avoid confusion. If editing functionality is planned, the editable element and bindings need to be restored.

#!/bin/bash
# Check if these methods are referenced anywhere
rg -n "handleSpecialKeys|onValueUpdated" --type js -g "!link-preview-element.js"

421-434: bindEvents references non-existent elements.

This method references .open button and this.editable, both of which are commented out or undefined. Since bindEvents() is not called in connectedCallback, this is dead code that should be removed.

js/plugins/code/base-text-element.js (1)

878-889: Missing paste-link-menu element in render template.

The code references this.pasteLinkMenu in connectedCallback (line 352) and uses it throughout the paste link methods, but the render template doesn't include the HTML for .paste-link-menu. This will cause the paste link menu feature to silently fail.

Add the paste link menu markup to the render template:

             <div class="emoji-suggestions"></div>
+            <div class="paste-link-menu" style="display: none; position: absolute; z-index: 1000;">
+                <div class="paste-link-option selected" data-type="url">
+                    <span>🔗</span> Paste as link
+                </div>
+                <div class="paste-link-option" data-type="bookmark">
+                    <span>📑</span> Create bookmark
+                </div>
+                <div class="paste-link-option" data-type="embed">
+                    <span>📦</span> Create embed
+                </div>
+            </div>
         `;

Also add corresponding styles for .paste-link-menu and .paste-link-option in the <style> section.

🧹 Nitpick comments (12)
style.css (1)

24-24: Consider using CSS variable instead of hardcoded value.

Changing from var(--gap-2) to a hardcoded 2px breaks consistency with the design token system. If this specific value is intentional, consider defining a new CSS variable or documenting why the deviation is needed.

-    gap: 2px;
+    gap: var(--gap-2);
js/sync/sync.js (2)

180-190: Redundant path length check.

The condition pathParts.length === 2 at line 180 is redundant since event.path === 'data.elements' already guarantees exactly 2 path parts. This could be simplified.

-    if (event.path === 'data.elements' && pathParts.length === 2) {
+    if (event.path === 'data.elements') {

276-280: Consider bounding syncLogs growth.

Events are appended to syncLogs indefinitely. For long-lived documents with frequent edits, this array could grow very large, impacting IndexedDB storage and document load times. Consider implementing a retention policy or compaction strategy.

js/plugins/plugins.js (1)

38-38: Consider expanding the comment for clarity.

The comment "page-element is handled specially in selector" is helpful, but could be more descriptive about what "specially" means. This would help future maintainers understand why it's pre-populated.

-    loadedPlugins: ['page-element'], // page-element is handled specially in selector
+    loadedPlugins: ['page-element'], // Pre-loaded: page-element uses link-element component but bypasses normal plugin loading for selector page creation workflow
js/elements/selector-element.js (2)

63-70: Magic string check for "Page" type.

The condition newDetail.title === 'Page' relies on a string literal. Consider using a dedicated property (e.g., newDetail.isPageType or checking newDetail.component === 'page-element') to make this more robust against title changes in the plugin data.

-        if (newDetail.title === 'Page') {
+        if (newDetail.component === 'page-element' || newDetail.title === 'Page') {

125-137: Remove or track commented-out code.

The commented createChildPage method appears to be dead code. Consider either removing it or creating an issue to track if this alternative implementation is needed later.

js/plugins/code/link-element.js (2)

57-69: Consider extracting hardcoded domain to a constant.

The https://app.wisk.cc string is hardcoded. If this domain could change or differs between environments (staging/production), consider extracting it to a configuration constant.

The empty catch block on line 66 is acceptable here since invalid URLs should simply return false for isInternal.


466-470: Auto-refresh timing may need adjustment.

The 500ms delay for refreshing internal links on DOMContentLoaded is arbitrary. If pages load slowly or IndexedDB access is delayed, this might execute before all elements are ready. Consider using a more robust trigger (e.g., waiting for wisk.db to be ready, or listening for a custom "editor-ready" event).

js/editor.js (3)

345-348: Add error handling for async operation in setTimeout.

The await wisk.sync.saveModification() inside setTimeout can throw, but errors won't be caught or logged.

         // Save immediately (critical event)
         setTimeout(async () => {
-            await wisk.sync.saveModification();
+            try {
+                await wisk.sync.saveModification();
+            } catch (err) {
+                console.error('Failed to save block creation:', err);
+            }
         }, 0);

The same pattern applies to lines 730-732 and 1072-1073.


186-212: Consider adding email validation for addUserAccess.

The function accepts any string as email without validation. While server-side validation should exist, client-side validation improves UX by providing immediate feedback.

 wisk.editor.addUserAccess = async function (email) {
     const timestamp = Date.now();
+    
+    // Basic email format validation
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    if (!emailRegex.test(email)) {
+        console.warn('Invalid email format:', email);
+        return;
+    }

     // Initialize access array if it doesn't exist

2646-2651: Address TODO: Consider flushing pending updates on page unload.

The comment correctly identifies that pending updates could be lost if the user closes the tab before the debounce triggers. Consider adding a beforeunload handler.

// Add near initEditor or initializeRectangleSelection
window.addEventListener('beforeunload', async (e) => {
    if (elementUpdatesNeeded.size > 0) {
        clearTimeout(debounceTimer);
        // Synchronously flush updates - use sendBeacon or synchronous approach
        // Note: async operations may not complete during unload
    }
});

Would you like me to provide a more complete implementation using navigator.sendBeacon for reliable unload persistence?

js/plugins/code/base-text-element.js (1)

1033-1044: Consider guarding against component unmount during timeout.

If the element is removed from DOM between showPasteLinkMenu call and the setTimeout callback, the listener may be added to a detached element or cause issues.

         // Close on outside click
         setTimeout(() => {
+            // Guard against component being unmounted
+            if (!this.isConnected) return;
+            
             this._closePasteMenuHandler = (e) => {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1825148 and dce3a41.

⛔ Files ignored due to path filters (1)
  • js/plugins/icons/page.svg is excluded by !**/*.svg
📒 Files selected for processing (13)
  • js/editor.js (18 hunks)
  • js/elements/selector-element.js (1 hunks)
  • js/plugins/code/base-text-element.js (17 hunks)
  • js/plugins/code/embed-element.js (1 hunks)
  • js/plugins/code/link-element.js (1 hunks)
  • js/plugins/code/link-preview-element.js (7 hunks)
  • js/plugins/code/list-element.js (1 hunks)
  • js/plugins/code/numbered-list-element.js (2 hunks)
  • js/plugins/code/text-element.js (4 hunks)
  • js/plugins/plugin-data.json (1 hunks)
  • js/plugins/plugins.js (2 hunks)
  • js/sync/sync.js (2 hunks)
  • style.css (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
js/plugins/code/link-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (3)
  • index (1008-1008)
  • index (1018-1018)
  • wrapper (85-85)
js/elements/selector-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/sync/sync.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (7)
  • event (1731-1731)
  • event (1801-1801)
  • pathParts (518-518)
  • pathParts (605-605)
  • i (767-767)
  • i (901-901)
  • i (1273-1273)
js/editor.js (1)
js/sync/sync.js (3)
  • elementId (199-199)
  • pathParts (154-154)
  • element (200-200)
js/plugins/code/link-preview-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/numbered-list-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/embed-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
  • prevElement (308-308)
  • nextElement (1459-1459)
🪛 Biome (2.1.2)
js/editor.js

[error] 968-968: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

js/plugins/code/base-text-element.js

[error] 1153-1153: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🔇 Additional comments (22)
style.css (1)

198-201: LGTM!

The touch and selection disabling properties are appropriate for interactive hover elements, preventing unwanted text selection and touch callouts during drag/interaction operations.

js/plugins/code/list-element.js (1)

145-152: LGTM!

The top: 0.6em positioning aligns the bullet point better with the text baseline, which is a cleaner approach than the previous translateY(-50%) centering. This works well with the line-height: 1.5 on the editable element.

js/plugins/plugin-data.json (1)

170-213: Verify inconsistent textual property between page-element and link-element.

Both plugins use the same component: "link-element", but page-element has "textual": false (line 185) while link-element has "textual": true (line 208). This could affect how the editor handles text-related operations (e.g., backspace merging, content appending) differently for what appears to be the same underlying component.

Please verify this is intentional behavior.

js/plugins/plugins.js (1)

7-7: LGTM!

Adding link-element to the default plugins list ensures it's available for the new page/link creation workflows introduced in this PR.

js/plugins/code/text-element.js (2)

15-31: Style improvements look good.

The increased min-height and padding for the editable area, along with the accent color for links (including visited state), improve visual consistency.


114-157: Paste-link menu UI integration is properly implemented.

The styling and HTML structure for the paste-link menu are well-defined with the menu options (URL, Bookmark, Embed) correctly wired to the base-text-element's event handlers. All three required handlers (showPasteLinkMenu, hidePasteLinkMenu, handlePasteLinkChoice) are properly implemented in BaseTextElement and fully functional.

js/plugins/code/numbered-list-element.js (2)

119-157: Improved emptiness detection logic.

The new combined check (innerText.trim().length === 0 && this.editable.children.length === 0) is more robust than a simple text check, properly handling cases where the element might contain non-text children like inline elements.

Note: The indentation in this method appears inconsistent (the function body starts at column 0 instead of being indented within the class). This might be a formatting artifact, but verify it matches the project's code style.


211-216: Number positioning adjustment is appropriate.

Switching from vertical centering to top alignment with matching line-height: 1.5 ensures the list number aligns with the first line of text content, which is the expected behavior for numbered lists.

js/plugins/code/link-preview-element.js (1)

16-32: Click handler implementation is good.

The click handler properly normalizes URLs without protocol prefix before opening. Making the entire preview clickable provides good UX.

js/plugins/code/link-element.js (2)

1-39: Well-structured custom element implementation.

The component follows good patterns:

  • Proper use of observedAttributes and attributeChangedCallback
  • Clean separation of connectedCallback and disconnectedCallback
  • Conditional re-render only when connected and value changed

261-278: This pattern is consistent with existing codebase architecture.

The updateLinkedPageTitle method correctly uses wisk.db.setPage() directly because it updates a different page, not the currently-edited document. This matches the pattern used throughout the codebase for updating other pages (see database-element.js, selector-element.js, and sync.js itself).

The sync system's newChange() is intentionally limited to the current document (wisk.editor.document), while direct setPage() calls handle updates to other pages. Unless linked pages should participate in the event-driven sync system—which the current architecture doesn't support—no changes are needed.

js/editor.js (3)

546-565: Potential double mutation in deletion handler.

The code manually filters wisk.editor.document.data.elements on lines 559-560, then calls wisk.sync.applyEvent on line 562 which likely also modifies the document state. This could cause inconsistencies.

Consider whether the manual array mutation is needed if applyEvent already handles it, or move the applyEvent call before the manual mutation:

         if (!deletedElements.includes(deletedId)) {
             deletedElements.push(deletedId);
             const element = document.getElementById(`div-${deletedId}`);
             if (element) {
                 document.getElementById('editor').removeChild(element);
             }
+            wisk.sync.applyEvent(wisk.editor.document, event);
             wisk.editor.document.data.elements =
                 wisk.editor.document.data.elements.filter(e => e.id !== deletedId);
-
-            wisk.sync.applyEvent(wisk.editor.document, event);
         }

2268-2299: Good defensive sanitization for clipboard HTML.

The sanitizeHtmlForClipboard function properly cleans anchor attributes to prevent JSON corruption. The approach of removing all attributes then re-adding only clean ones is robust.


304-306: Good addition of lastUpdated timestamp for conflict resolution.

Adding lastUpdated to block objects enables proper last-writer-wins conflict resolution in the event-driven sync model.

js/plugins/code/base-text-element.js (5)

1066-1133: Paste link choice handling is well-structured.

Each case properly handles URL insertion/conversion with appropriate block wrapping. The inline link-element insertion correctly adds a trailing space and updates cursor position.


1806-1838: Link conversion sanitization looks correct.

The convertLinksToElements helper properly sanitizes hrefs and converts anchors to atomic link-element components with appropriate attributes.


970-992: URL validation utilities are adequate for the use case.

The isUrl function correctly rejects strings with spaces/newlines and uses URL constructor validation. isInternalUrl properly identifies internal wisk pages. The empty catch blocks are acceptable here since invalid URLs should simply return false.


1599-1608: Improved placeholder detection for child elements.

The updated updatePlaceholder correctly considers both text content and child elements (like link-element), preventing the placeholder from showing when inline elements are present.


1893-1957: Well-designed recursive list processing with proper indentation.

The processListRecursively function correctly:

  • Maintains separate number counters per indent level
  • Extracts block elements (tables, code) embedded within list items
  • Marks processed nodes to prevent duplicates
js/plugins/code/embed-element.js (3)

64-111: URL conversion logic is sound for common embed services.

The convertToEmbedUrl function correctly handles:

  • YouTube watch and short URLs → embed format
  • Google Drive file view → preview format
  • Protocol normalization for all URLs

The iframe sandbox attribute (line 339) provides appropriate security restrictions.


31-56: setValue correctly extracts URLs from iframe embed code.

The extractSrcFromIframe helper properly parses iframe HTML and extracts the src attribute. The setValue method handles both direct URLs and pasted iframe embed codes.


339-339: Appropriate iframe sandbox configuration.

The sandbox attribute permits necessary capabilities (scripts, forms, popups) while maintaining isolation. The allow-presentation enables fullscreen video playback which is important for YouTube embeds.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (10)
js/sync/sync.js (1)

131-151: Avoid mutating caller-owned event + trim noisy logging
newChange currently mutates event.value (timestamp/agent) and logs full eventLog every time, which can be expensive and leak document data into consoles. Consider cloning and logging only counts/ids.

 wisk.sync.newChange = function(event) {
@@
-    if(!event.value.timestamp) {
-        event.value.timestamp = Date.now();
-    }
-    if(!event.value.agent) {
-        event.value.agent = wisk.sync.agent;
-    }
+    event = {
+        ...event,
+        value: {
+            ...event.value,
+            timestamp: event.value.timestamp ?? Date.now(),
+            agent: event.value.agent ?? wisk.sync.agent,
+        },
+    };

     // add to event log
     wisk.sync.eventLog.push(event);

-    console.log('New change event logged:', event);
-    console.log('Current event log:', wisk.sync.eventLog);
+    console.log('New change event logged:', event.path);
+    console.log('Current event log size:', wisk.sync.eventLog.length);
 }
js/elements/selector-element.js (2)

56-74: Consider awaiting changeBlockType() for non-Page selections too
selectButton is now async, but the non-Page path doesn’t await wisk.editor.changeBlockType(...), so failures/async saves can be dropped.

-        wisk.editor.changeBlockType(this.elementId, element.getValue(), newDetail.component);
+        await wisk.editor.changeBlockType(this.elementId, element.getValue(), newDetail.component);
         this.hide();

76-123: Page ID generation: prefer crypto over Math.random() + minor log text
Math.random() IDs can collide; using crypto.randomUUID() (or crypto.getRandomValues) is safer. Also the catch message says “child page” but the function is createPageAndLink.

-            const randomId = Math.random().toString(36).substring(2, 12).toUpperCase();
+            const randomId = (crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)).replace(/-/g, '').slice(0, 10).toUpperCase();
@@
-            console.error('Error creating child page:', error);
+            console.error('Error creating page:', error);
js/plugins/code/embed-element.js (1)

167-174: Consider tightening iframe sandbox / adding referrer policy
Current sandbox allows scripts + same-origin + popups; for arbitrary embeds this is a high-trust posture. Consider least-privilege per supported providers, and add referrerpolicy.

-        <iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" allowfullscreen></iframe>
+        <iframe
+          sandbox="allow-scripts allow-same-origin allow-presentation"
+          referrerpolicy="strict-origin-when-cross-origin"
+          allowfullscreen
+        ></iframe>
js/plugins/code/base-text-element.js (1)

1032-1056: Ensure outside-click handler is removed on disconnect
If the component is removed while the paste menu is open, _closePasteMenuHandler stays on document. Add cleanup in your disconnect path.

         this.disconnectedCallback = () => {
             observer.disconnect();
             this.shadowRoot.removeEventListener('selectionchange', handleSelectionChange);
+            if (this._closePasteMenuHandler) {
+                document.removeEventListener('mousedown', this._closePasteMenuHandler);
+                this._closePasteMenuHandler = null;
+            }
         };

Also applies to: 527-531

js/editor.js (5)

345-348: Unhandled promise rejection risk in setTimeout.

The setTimeout(async () => { await wisk.sync.saveModification(); }, 0) pattern wraps an async function but doesn't handle potential rejections. If saveModification throws, the error is silently swallowed.

Consider using .catch() or a try-catch block:

-        setTimeout(async () => {
-            await wisk.sync.saveModification();
-        }, 0);
+        setTimeout(() => {
+            wisk.sync.saveModification().catch(err => {
+                console.error('Failed to save block creation:', err);
+            });
+        }, 0);

546-565: Element deletion order inconsistency: array filtered before applyEvent.

At lines 559-560, the element is filtered out of the data array, then applyEvent is called at line 562. If applyEvent expects the element to exist in the array (e.g., for logging or validation), this ordering causes issues.

Consider calling applyEvent before modifying the local state:

             if (element) {
                 document.getElementById('editor').removeChild(element);
             }
+            wisk.sync.applyEvent(wisk.editor.document, event);
             wisk.editor.document.data.elements =
                 wisk.editor.document.data.elements.filter(e => e.id !== deletedId);
-
-            wisk.sync.applyEvent(wisk.editor.document, event);
         }

1130-1155: Consider consolidating multiple events for block type change.

Three separate events are emitted for a single logical operation (component, value, lastUpdated). This increases event log size and could cause partial application issues if events are processed individually.

Consider emitting a single compound event or ensuring the sync layer treats these as atomic:

+    // Single event for all changes
+    wisk.sync.newChange({
+        path: `data.elements.${elementId}`,
+        value: {
+            data: {
+                component: newType,
+                value: value,
+                lastUpdated: timestamp
+            },
+            timestamp: timestamp,
+            agent: wisk.sync.agent
+        }
+    });
-    wisk.sync.newChange({
-        path: `data.elements.${elementId}.component`,
-        ...
-    });
-    wisk.sync.newChange({
-        path: `data.elements.${elementId}.value`,
-        ...
-    });
-    wisk.sync.newChange({
-        path: `data.elements.${elementId}.lastUpdated`,
-        ...
-    });

2647-2651: Data loss risk if user closes tab during debounce window.

The comment at line 2647 correctly identifies that changes may be lost if the user closes the tab before the 1000ms debounce triggers. Consider adding a beforeunload event listener to flush pending updates:

window.addEventListener('beforeunload', async (event) => {
    if (elementUpdatesNeeded.size > 0) {
        // Trigger immediate save
        clearTimeout(debounceTimer);
        // Note: async operations in beforeunload are unreliable
        // but we can at least try
        await wisk.sync.saveModification();
    }
});

Alternatively, reduce the debounce timer or use sendBeacon API for more reliable unload persistence.


186-212: Direct state mutation before event emission may cause inconsistency.

Lines 190-192, 217-219, and 254-256 directly mutate wisk.editor.document.data.config before emitting the change event. If wisk.sync.applyEvent (called elsewhere for incoming events) also attempts to set this value, the state is already modified. This could lead to subtle bugs with conflict resolution or event replay.

Consider deferring the initialization to the sync layer or ensuring applyEvent handles the case where the property already exists.

#!/bin/bash
# Check how applyEvent handles these paths in sync.js
rg -nA 10 'applyEvent' --type=js | head -60
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dce3a41 and 24f0f6e.

📒 Files selected for processing (6)
  • js/editor.js (18 hunks)
  • js/elements/selector-element.js (1 hunks)
  • js/plugins/code/base-text-element.js (17 hunks)
  • js/plugins/code/embed-element.js (1 hunks)
  • js/plugins/code/link-element.js (1 hunks)
  • js/sync/sync.js (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
js/plugins/code/base-text-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/link-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (1)
  • wrapper (85-85)
js/elements/selector-element.js (2)
js/editor.js (12)
  • element (522-522)
  • element (555-555)
  • element (768-768)
  • element (902-902)
  • element (1056-1056)
  • element (1086-1086)
  • element (1099-1099)
  • element (1119-1119)
  • element (1189-1189)
  • element (1277-1277)
  • element (1551-1551)
  • element (1696-1696)
js/wisk.js (1)
  • wisk (3-3)
js/sync/sync.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
js/sync/sync.js (3)
  • elementId (199-199)
  • pathParts (154-154)
  • element (200-200)
js/wisk.js (1)
  • wisk (3-3)
🔇 Additional comments (7)
js/sync/sync.js (1)

298-300: Public API exposure looks consistent
Exporting saveModification + applyEvent on wisk.sync matches the intended event-driven flow.

js/plugins/code/link-element.js (1)

1-48: Static instance registry is a solid fix for shadow-DOM visibility
LinkElement.instances + refreshAllInternalLinks() addresses the “document.querySelectorAll can’t see into shadow roots” limitation.

Also applies to: 139-147

js/plugins/code/base-text-element.js (1)

1151-1156: Good: switch-case scoping for Enter
Wrapping the Enter case in a block avoids switch-clause scope leakage.

js/editor.js (4)

54-64: LGTM! Touch event handling improvements.

The passive: false option is correctly added to allow preventDefault(), and the guard against triggering select during drag prevents unintended behavior.


123-184: LGTM! Event-driven config changes are well-structured.

Each switch case is properly scoped with blocks, the event payload structure is consistent, and persistence is correctly awaited.


270-284: LGTM! Plugin data persistence follows the critical event pattern.

Correctly marks plugin data changes as critical and saves immediately.


2269-2300: LGTM! Clipboard sanitization properly handles link security.

The function removes all attributes and re-adds only safe ones (href, contenteditable, target), preventing potential XSS from malformed clipboard data.

Comment on lines +2697 to +2711
if(elementId === wisk.editor.document.data.elements[0].id) {
const textContent = domElement.getTextContent().text || '';

if(textContent) {
document.title = textContent;
wisk.sync.newChange({
path: 'data.config.name',
value: {
data: textContent,
timestamp: timestamp,
agent: wisk.sync.agent
}
});
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential null reference when accessing getTextContent().

At line 2698, domElement.getTextContent().text is accessed without checking if getTextContent() returns a valid object. If getTextContent() is undefined or returns an object without text, this will throw.

             if(elementId === wisk.editor.document.data.elements[0].id) {
-                const textContent = domElement.getTextContent().text || '';
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';
 
                 if(textContent) {
🤖 Prompt for AI Agents
In js/editor.js around lines 2697 to 2711, the call to
domElement.getTextContent().text may throw if getTextContent() returns undefined
or lacks a text property; guard that access by first retrieving the result into
a variable, check that it is an object and has a non-empty text string (or use
optional chaining and a default), then proceed to set document.title and call
wisk.sync.newChange only when text is present; ensure you replace direct chained
access with a safe check (e.g., const tc = domElement.getTextContent(); const
textContent = tc && tc.text ? tc.text : '') and keep the existing behavior when
textContent is falsy.

js/sync/sync.js Outdated
Comment on lines +153 to +178
function applyEvent(document, event) {
const pathParts = event.path.split('.');
let current = document;
let i = 0;

if (event.path === 'data.elementOrder') {
const newOrder = event.value.data;

// Store the order
if (!document.data) document.data = {};
document.data.elementOrder = newOrder;

// Reorder elements array to match
if (document.data.elements && Array.isArray(document.data.elements)) {
const orderedElements = [];
newOrder.forEach(id => {
const elem = document.data.elements.find(e => e.id === id);
if (elem) orderedElements.push(elem);
});
document.data.elements = orderedElements;
}

document.lastUpdated = event.value.timestamp;
console.log('Applied element order event');
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

data.elementOrder reordering can drop elements (data loss risk)
If newOrder is missing ids, document.data.elements becomes “only the found ones”. Safer to append leftovers (or at least warn) to avoid silent loss.

         if (document.data.elements && Array.isArray(document.data.elements)) {
             const orderedElements = [];
+            const seen = new Set();
             newOrder.forEach(id => {
                 const elem = document.data.elements.find(e => e.id === id);
-                if (elem) orderedElements.push(elem);
+                if (elem) {
+                    orderedElements.push(elem);
+                    seen.add(id);
+                }
             });
+            // Keep any elements not present in newOrder (avoid accidental drops)
+            document.data.elements.forEach(e => {
+                if (e?.id && !seen.has(e.id)) orderedElements.push(e);
+            });
             document.data.elements = orderedElements;
         }


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

In js/sync/sync.js around lines 153 to 178, the handler for event.path ===
'data.elementOrder' builds orderedElements only from IDs present in newOrder
which will drop any existing elements whose IDs are missing from newOrder;
change this to track which IDs were added (use a Set), push matching elements
into orderedElements while adding their IDs to the Set, then iterate the
original document.data.elements and append any elements whose id is not in the
Set to preserve leftovers; finally assign document.data.elements =
orderedElements and optionally log a warning if newOrder omitted any IDs so the
operation does not silently lose data.


</details>

<!-- fingerprinting:phantom:medusa:falcon -->

<!-- This is an auto-generated comment by CodeRabbit -->

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
js/editor.js (4)

534-542: Component change still doesn't reapply width classes.

This is the same issue flagged in previous reviews. When changing a block's component type, the new DOM element is created without checking if the plugin's fullWidth property requires adding/removing the rndr-full-width class on the container.

Apply this fix to handle width classes on component change:

     } else if (property === 'component') {
         const newType = event.value.data;
         const newDomElement = document.createElement(newType);
         newDomElement.id = elementId;
         domElement.replaceWith(newDomElement);
+        
+        // Update container width class based on new plugin type
+        const container = document.getElementById(`div-${elementId}`);
+        if (container) {
+            const pluginDetail = wisk.plugins.getPluginDetail(newType);
+            if (pluginDetail.width === 'max') {
+                container.classList.add('rndr-full-width');
+            } else {
+                container.classList.remove('rndr-full-width');
+            }
+        }
+        
         setTimeout(() => {
             newDomElement.setValue('', element.value);
         }, 0);
     }

1063-1063: Still updating unused deletedElementsLeft array.

As noted in previous reviews, this array is declared (line 4) and updated but never read in active code. It should be removed.

Remove the declaration at line 4 and this push statement to eliminate dead code.


2526-2593: Remove commented-out legacy code.

As flagged in previous reviews, this commented-out justUpdates implementation should be removed now that the new event-driven version is in place.

Delete lines 2526-2593 to clean up the codebase.


2698-2698: Potential null reference accessing getTextContent().

As noted in previous reviews, this line accesses getTextContent().text without checking if getTextContent() returns a valid object.

Apply this fix:

-                const textContent = domElement.getTextContent().text || '';
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 24f0f6e and 0e9badc.

📒 Files selected for processing (1)
  • js/editor.js (18 hunks)
🔇 Additional comments (8)
js/editor.js (8)

123-184: LGTM! Event-driven config changes are well-structured.

The function correctly wraps each switch case in blocks (addressing scoping), creates timestamped events, and immediately persists changes via saveModification. The async/await pattern is properly applied.


186-268: LGTM! New public APIs follow consistent event-driven pattern.

The four new APIs (addUserAccess, removeUserAccess, setPublicStatus, setDatabaseProp) consistently create events with timestamps/agent metadata and immediately persist via saveModification. Defensive initialization and duplicate-checking are appropriate.


412-487: LGTM! Event processing logic is well-structured.

The priority-ordered processing (deletions → creations → updates → config/plugins → order) prevents common edge cases. Grouping events by type before processing is efficient and clear.


1429-1456: LGTM! Inline markdown processing order is well-documented.

The explicit ordering (citations → code → formatting → links → references) with inline comments prevents conflicts between different markdown patterns.


54-64: LGTM! Touch event handling improved.

The touchend handler properly distinguishes between tap (select) and hold-drag interactions. Setting { passive: false } on touchstart is necessary for preventDefault() to work.


964-974: LGTM! Switch case properly wrapped in block.

The sup case is now wrapped in a block to prevent scope leakage of const declarations to other cases, addressing the previous review comment.


2269-2300: The aggressive attribute removal is appropriate for this clipboard context.

The function removes all attributes before re-adding only href, contenteditable, and target. This is correct behavior for clipboard operations because:

  1. Clipboard data should not include application-internal state (data-* attributes)
  2. Security attributes like rel="noopener noreferrer" are UI concerns, not clipboard data
  3. Custom attributes would corrupt the clipboard JSON (which is why Base64 encoding is used elsewhere in this change)

No custom attributes need to be preserved in clipboard text.


345-347: Review the setTimeout with async callback pattern.

Using setTimeout with an async callback for critical persistence operations can lead to race conditions or lost updates if the user navigates away before the timeout fires. Consider either:

  • Awaiting saveModification directly (blocking)
  • Using a non-blocking queue that guarantees eventual persistence
  • Documenting why the delay is necessary

js/editor.js Outdated
Comment on lines +2647 to +2722
// debounce: creating events when typing stops. (what if the user closes the tab before debounce triggers???)

clearTimeout(debounceTimer);

debounceTimer = setTimeout(async () => {
const changed = elementUpdatesLeft
.map(elementId => {
const element = wisk.editor.getElement(elementId);
if (element) {
return {
path: 'document.elements',
values: {
id: element.id,
value: element.value,
lastEdited: element.lastEdited,
component: element.component,
},
};
console.log('typing stopped, creating events for: ', elementUpdatesNeeded.size, 'elements');

const timestamp = Date.now();

// create events for all elements needing update
elementUpdatesNeeded.forEach(elementId => {
const domElement = document.getElementById(elementId);
if(!domElement) {
return;
}

const elementValue = domElement.getValue();
const componentName = domElement.tagName.toLowerCase();

// create event
wisk.sync.newChange({
path: `data.elements.${elementId}.value`,
value: {
data: elementValue,
timestamp: timestamp,
agent: wisk.sync.agent
}
return null;
})
.filter(Boolean);
});

const elementIds = wisk.editor.document.data.elements.map(e => e.id);
// component because they can be changed (changeBlockType)

await wisk.sync.saveUpdates();
wisk.sync.newChange({
path: `data.elements.${elementId}.component`,
value: {
data: componentName,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

wisk.sync.newChange({
path: `data.elements.${elementId}.lastUpdated`,
value: {
data: timestamp,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

elementUpdatesLeft = [];
deletedElementsLeft = [];
}, elementSyncTimer); // should it be less? to voice your opinion, join our discord server: https://discord.gg/D8tQCvgDhu
// special case: first element
if(elementId === wisk.editor.document.data.elements[0].id) {
const textContent = domElement.getTextContent().text || '';

if(textContent) {
document.title = textContent;
wisk.sync.newChange({
path: 'data.config.name',
value: {
data: textContent,
timestamp: timestamp,
agent: wisk.sync.agent
}
});
}
}
});

// save to indexedDB locally
await wisk.sync.saveModification();

// TODO: Send only the events to server (future step)
// await sendEventsToServer(wisk.sync.eventLog);

// Clear the set
elementUpdatesNeeded.clear();
}, 300);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Debounce timing creates data loss risk.

The 300ms debounce means changes aren't persisted until typing stops. If a user closes the tab or loses connection before the debounce fires, updates are lost. The inline comment (line 2647) acknowledges this concern.

Consider:

  • Reducing debounce to 100-150ms
  • Using beforeunload to flush pending updates
  • Implementing a visibility change handler to persist on tab switch

Would you like me to generate a visibility change handler to address this risk?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/editor.js (1)

1414-1441: Critical XSS/URL-injection risk in convertInlineMarkdown()
You’re constructing HTML strings with user-controlled content (<a href="$2">, <code>$1</code>, <cite-element reference-id="...">) without escaping/sanitizing. This enables javascript: URLs and attribute-breaking payloads.

 function convertInlineMarkdown(text) {
+    const escapeHtml = (s) =>
+        String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+    const escapeAttr = escapeHtml;
+    const sanitizeHref = (raw) => {
+        try {
+            const u = new URL(raw, window.location.origin);
+            if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:') return u.href;
+        } catch {}
+        return '';
+    };

     // Citation elements (must be before links to avoid conflicts)
-    text = text.replace(/--citation-element--([^-]+)--/g, (match, referenceId) => {
-        return `<cite-element reference-id="${referenceId}"></cite-element>`;
-    });
+    text = text.replace(/--citation-element--([^-]+)--/g, (_m, referenceId) => {
+        const safeId = String(referenceId).replace(/[^a-zA-Z0-9_-]/g, '');
+        return safeId ? `<cite-element reference-id="${escapeAttr(safeId)}"></cite-element>` : '';
+    });

     // Inline code (must be before other formatting to preserve code content)
-    text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
+    text = text.replace(/`([^`]+)`/g, (_m, code) => `<code>${escapeHtml(code)}</code>`);

     ...
     // Links (must be before reference numbers)
-    text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" contenteditable="false" target="_blank">$1</a>');
+    text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, href) => {
+        const safeHref = sanitizeHref(href);
+        const safeLabel = escapeHtml(label);
+        if (!safeHref) return safeLabel; // fallback to text if URL is unsafe
+        return `<a href="${escapeAttr(safeHref)}" contenteditable="false" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
+    });
♻️ Duplicate comments (3)
js/editor.js (3)

1013-1056: deletedElementsLeft still looks unused
This variable is written in deleteBlock() (deletedElementsLeft.push(...)) but I don’t see a corresponding read in the provided context; if it’s truly unused, remove it to avoid misleading state.


2511-2637: Guard getTextContent() access inside justUpdates()
domElement.getTextContent().text will throw if getTextContent is missing or returns nullish; a defensive read avoids breaking event generation for the “main element” title.

-                const textContent = domElement.getTextContent().text || '';
+                const tc = domElement.getTextContent?.();
+                const textContent = tc?.text || '';

501-528: Remote component updates must also update rndr-full-width class
changeBlockType() updates the container width class, but handleElementUpdate() (event-driven path) does not—so remote type changes can leave stale layout.

  } else if (property === 'component') {
    const newType = event.value.data;
    const newDomElement = document.createElement(newType);
    newDomElement.id = elementId;
    domElement.replaceWith(newDomElement);
+
+    // Keep width class consistent with new plugin type
+    const container = document.getElementById(`div-${elementId}`);
+    if (container) {
+      const pluginDetail = wisk.plugins.getPluginDetail(newType);
+      if (pluginDetail?.width === 'max') container.classList.add('rndr-full-width');
+      else container.classList.remove('rndr-full-width');
+    }
+
    setTimeout(() => {
      newDomElement.setValue('', element.value);
    }, 0);
  }
🧹 Nitpick comments (5)
js/plugins/code/embed-element.js (2)

16-23: Avoid accumulating duplicate listeners on re-connect
bindEvents() uses addEventListener each connectedCallback(); if the element is detached/reattached, listeners can stack. Consider guarding with a flag or removing in disconnectedCallback().


65-90: Remove or gate debug logging
The console.log calls in setValue() will be noisy in production. Consider a wisk.debug flag or removing them before merge.

js/plugins/code/link-element.js (2)

31-37: Prevent render storms from setValue() / attribute changes
setValue() calls multiple setAttribute()s, and attributeChangedCallback() re-renders each time → up to 4 renders per update. Consider batching via a microtask (queueMicrotask) or a _suspendRenders flag while applying attributes.

Also applies to: 138-148


1-21: Check consistency with the BaseTextElement contract used across js/plugins/code/*
This is a standalone HTMLElement with its own shadow DOM and event wiring; if it’s intended to behave like other editor “text-ish” plugins, consider whether any shared focus/value/render contract belongs in a base class. Based on learnings, keep base-vs-derived responsibilities consistent across plugin elements.

Also applies to: 193-275

js/editor.js (1)

2224-2227: Consider replacing escape/unescape base64 UTF-8 conversions
decodeURIComponent(escape(atob(...))) and btoa(unescape(encodeURIComponent(...))) are legacy patterns and can be brittle with Unicode. A TextEncoder/TextDecoder path would be more robust.

Also applies to: 2335-2338

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0e9badc and 4631d8c.

📒 Files selected for processing (3)
  • js/editor.js (18 hunks)
  • js/plugins/code/embed-element.js (1 hunks)
  • js/plugins/code/link-element.js (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/embed-element.js
  • js/plugins/code/link-element.js
🧬 Code graph analysis (1)
js/plugins/code/link-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (6)
  • wrapper (85-85)
  • prevElement (308-308)
  • nextElement (1445-1445)
  • event (1717-1717)
  • event (1787-1787)
  • img (75-75)
🪛 Biome (2.1.2)
js/plugins/code/embed-element.js

[error] 39-39: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 47-47: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

js/plugins/code/link-element.js

[error] 291-291: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 299-299: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🔇 Additional comments (4)
js/plugins/code/embed-element.js (1)

176-225: Sandbox is a good baseline, but double-check required permissions
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" is fairly permissive (especially allow-same-origin). Make sure each capability is actually needed, otherwise tighten to reduce embed risk.

js/plugins/code/link-element.js (1)

347-510: Icon rendering looks safe (no innerHTML injection)
Building emoji via textContent and images via img.src properties is the right direction for avoiding XSS.

js/editor.js (2)

412-472: Event grouping in handleChanges() looks sensible
Clear separation of creations/updates/deletions/order/config/pluginData improves determinism and keeps DOM work localized.


1092-1165: Nice: changeBlockType() now updates width class and persists via events
The DOM swap + rndr-full-width class sync makes component changes behave consistently.

Comment on lines +277 to +305
handleBlockKeyDown(event) {
switch (event.key) {
case 'Backspace':
case 'Delete':
event.preventDefault();
wisk.editor.deleteBlock(this.id);
break;
case 'Enter':
event.preventDefault();
wisk.editor.createNewBlock(this.id, 'text-element', { textContent: '' }, { x: 0 });
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
const prevElement = wisk.editor.prevElement(this.id);
if (prevElement) {
wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
}
break;
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
const nextElement = wisk.editor.nextElement(this.id);
if (nextElement) {
wisk.editor.focusBlock(nextElement.id, { x: 0 });
}
break;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix switch case declarations (noSwitchDeclarations)
Same Biome issue as embed-element: wrap the declaration cases in {} blocks.

  handleBlockKeyDown(event) {
    switch (event.key) {
      ...
      case 'ArrowUp':
      case 'ArrowLeft':
-        event.preventDefault();
-        const prevElement = wisk.editor.prevElement(this.id);
-        if (prevElement) {
-          wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
-        }
-        break;
+        {
+          event.preventDefault();
+          const prevElement = wisk.editor.prevElement(this.id);
+          if (prevElement) {
+            wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
+          }
+          break;
+        }
      case 'ArrowDown':
      case 'ArrowRight':
-        event.preventDefault();
-        const nextElement = wisk.editor.nextElement(this.id);
-        if (nextElement) {
-          wisk.editor.focusBlock(nextElement.id, { x: 0 });
-        }
-        break;
+        {
+          event.preventDefault();
+          const nextElement = wisk.editor.nextElement(this.id);
+          if (nextElement) {
+            wisk.editor.focusBlock(nextElement.id, { x: 0 });
+          }
+          break;
+        }
    }
  }
🧰 Tools
🪛 Biome (2.1.2)

[error] 291-291: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 299-299: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
In js/plugins/code/link-element.js around lines 277 to 305, the switch has const
declarations inside case clauses which violates the noSwitchDeclarations rule;
wrap the ArrowUp/ArrowLeft case body and the ArrowDown/ArrowRight case body in
their own block braces ({ ... }) so the const prevElement and const nextElement
are block-scoped, keeping event.preventDefault() and the existing logic inside
those new blocks.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (3)
js/plugins/code/link-element.js (1)

277-305: Fix Biome noSwitchDeclarations in handleBlockKeyDown (wrap cases in blocks).
This is the same issue previously flagged for switch-case scoping; it still exists here and will fail Biome.

   handleBlockKeyDown(event) {
     switch (event.key) {
@@
       case 'ArrowUp':
       case 'ArrowLeft':
-        event.preventDefault();
-        const prevElement = wisk.editor.prevElement(this.id);
-        if (prevElement) {
-          wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
-        }
-        break;
+        {
+          event.preventDefault();
+          const prevElement = wisk.editor.prevElement(this.id);
+          if (prevElement) {
+            wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
+          }
+          break;
+        }
       case 'ArrowDown':
       case 'ArrowRight':
-        event.preventDefault();
-        const nextElement = wisk.editor.nextElement(this.id);
-        if (nextElement) {
-          wisk.editor.focusBlock(nextElement.id, { x: 0 });
-        }
-        break;
+        {
+          event.preventDefault();
+          const nextElement = wisk.editor.nextElement(this.id);
+          if (nextElement) {
+            wisk.editor.focusBlock(nextElement.id, { x: 0 });
+          }
+          break;
+        }
     }
   }
js/sync/sync.js (1)

153-169: Data-loss risk: data.elementOrder can drop elements not listed in newOrder.
If newOrder is partial (or stale), document.data.elements becomes only “found ones”. Append leftovers (or at least warn) to avoid silent deletion.

js/editor.js (1)

2498-2622: Guard getTextContent() access in justUpdates (can throw) + consider unload flush for debounce.
This line can throw if a component doesn’t implement getTextContent() or returns nullish:
domElement.getTextContent().text

Suggested minimal hardening:

- const textContent = domElement.getTextContent().text || '';
+ const tc = domElement.getTextContent?.();
+ const textContent = tc?.text || '';

Also: the comment about “flush pending updates on unload” is real—worth implementing visibilitychange/pagehide flushing so debounced updates aren’t lost.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4631d8c and c019c47.

📒 Files selected for processing (6)
  • js/editor.js (18 hunks)
  • js/plugins/code/embed-element.js (1 hunks)
  • js/plugins/code/link-element.js (1 hunks)
  • js/plugins/code/link-preview-element.js (4 hunks)
  • js/plugins/plugin-data.json (2 hunks)
  • js/sync/sync.js (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/plugins/plugin-data.json
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/link-element.js
  • js/plugins/code/link-preview-element.js
  • js/plugins/code/embed-element.js
🧬 Code graph analysis (5)
js/sync/sync.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (22)
  • pathParts (502-502)
  • pathParts (589-589)
  • i (723-723)
  • i (854-854)
  • i (1225-1225)
  • elementId (364-364)
  • elementId (378-378)
  • elementId (398-398)
  • elementId (503-503)
  • elementId (2512-2512)
  • element (506-506)
  • element (539-539)
  • element (724-724)
  • element (855-855)
  • element (1007-1007)
  • element (1035-1035)
  • element (1048-1048)
  • element (1068-1068)
  • element (1140-1140)
  • element (1228-1228)
  • element (1502-1502)
  • element (1647-1647)
js/plugins/code/link-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
  • prevElement (308-308)
  • nextElement (1411-1411)
js/editor.js (1)
js/sync/sync.js (4)
  • elementId (189-189)
  • key (184-184)
  • pathParts (149-149)
  • element (190-190)
js/plugins/code/link-preview-element.js (2)
js/editor.js (2)
  • prevElement (308-308)
  • nextElement (1411-1411)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/embed-element.js (2)
js/editor.js (4)
  • event (1683-1683)
  • event (1753-1753)
  • prevElement (308-308)
  • nextElement (1411-1411)
js/wisk.js (1)
  • wisk (3-3)
🪛 Biome (2.1.2)
js/plugins/code/link-element.js

[error] 291-291: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 299-299: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🔇 Additional comments (3)
js/plugins/code/link-element.js (1)

1-29: Good: static instance registry fixes shadow-DOM discovery for refreshAllInternalLinks.
This resolves the “document.querySelectorAll('link-element') misses shadow DOM” class of bugs by tracking instances in connectedCallback/disconnectedCallback.

js/plugins/code/embed-element.js (1)

143-205: Embed URL normalization + view toggling looks consistent.
convertToEmbedUrl() + updateView() is straightforward and avoids showing the iframe for empty input.

js/editor.js (1)

55-64: Good hardening: sanitizing link protocols in clipboard HTML.
The safe-protocol allowlist + removal of dangerous href values addresses the javascript:/data: footgun for pasted/copied HTML.

Also applies to: 2220-2270

Comment on lines +411 to 471
wisk.editor.handleChanges = async function (eventPackage) {
if (!eventPackage) return;

const events = Array.isArray(eventPackage.events) ? eventPackage.events : [];

// Group events by type for efficient processing
const elementValueUpdates = [];
const elementComponentUpdates = [];
const elementCreations = [];
const elementDeletions = [];
const elementOrderChanges = [];
const configChanges = [];
const pluginDataChanges = [];

events.forEach(event => {
if (event.path.startsWith('data.elements.')) {
if (event.path.includes('.value')) {
elementValueUpdates.push(event);
} else if (event.path.includes('.component')) {
elementComponentUpdates.push(event);
}
}
if (change.path.startsWith('document.config.plugins')) {
if (change.path.includes('add')) {
wisk.plugins.loadPlugin(change.values.plugin);
}
if (change.path.includes('remove')) {
window.location.reload();
}
else if (event.path === 'data.elements') {
elementCreations.push(event);
}
if (change.path.startsWith('document.config.theme')) {
wisk.theme.setTheme(change.values.theme);
else if (event.path === 'data.deletedElements') {
elementDeletions.push(event);
}
if (change.path.startsWith('document.plugin')) {
if (change.values.data) {
document.getElementById(change.path.replace('document.plugin.', '')).loadData(change.values.data);
}
else if (event.path === 'data.elementOrder') {
elementOrderChanges.push(event);
}
}
else if (event.path.startsWith('data.config')) {
configChanges.push(event);
}
else if (event.path.startsWith('data.pluginData')) {
pluginDataChanges.push(event);
}
});

// Handle reordering only if necessary
if (allElements.length > 0) {
smartReorderElements(allElements);
await handleElementDeletions(elementDeletions);
for (const event of elementCreations) {
await handleElementCreation(event);
}
for (const event of elementValueUpdates) {
await handleElementUpdate(event);
}
for (const event of elementComponentUpdates) {
await handleElementUpdate(event);
}
for (const event of configChanges) {
handleConfigChange(event);
}
for (const event of pluginDataChanges) {
handlePluginDataChange(event);
}
if (elementOrderChanges.length > 0) {
const lastOrderEvent = elementOrderChanges[elementOrderChanges.length - 1];
smartReorderElements(lastOrderEvent.value.data);
wisk.sync.applyEvent(wisk.editor.document, lastOrderEvent);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remote apply path misses data.elements.<id>.lastUpdated events.
handleChanges() only routes .value and .component under data.elements.*; lastUpdated events are currently ignored. If those timestamps matter for conflict resolution or UI, they should either be applied via wisk.sync.applyEvent(...) in this path or explicitly handled.

🤖 Prompt for AI Agents
In js/editor.js around lines 411 to 471, the event routing ignores
data.elements.<id>.lastUpdated events so those timestamp updates are never
applied; detect events whose path startsWith 'data.elements.' and includes
'.lastUpdated' and ensure they are applied to the local document (either route
them to the same handler as value/component updates or call
wisk.sync.applyEvent(wisk.editor.document, event) directly). Update the forEach
grouping logic to push '.lastUpdated' events to an appropriate list (or call
applyEvent immediately) and then apply them in the processing phase so
lastUpdated timestamps are reflected locally.

Comment on lines +55 to +67
get isInternal() {
if (!this.url) return false;
if (this.url.startsWith('https://app.wisk.cc')) return true;

try {
const urlObj = new URL(this.url);
const currentOrigin = window.location.origin;

if (urlObj.origin === currentOrigin && urlObj.searchParams.has('id')) return true;
} catch {}

return false;
}
Copy link

@coderabbitai coderabbitai bot Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isInternal / URL parsing likely breaks for relative internal URLs; also fix window.open tabnabbing.

  • new URL(this.url) throws for relative URLs (e.g. ?id=... / /page?...), so isInternal becomes false and openLink() will incorrectly prefix https:// and open an invalid external URL.
  • window.open(url, '_blank') should include noopener (and ideally noreferrer) to prevent window.opener attacks.

Proposed diff (use a base URL + noopener):

   get isInternal() {
     if (!this.url) return false;
     if (this.url.startsWith('https://app.wisk.cc')) return true;
     
     try {
-      const urlObj = new URL(this.url);
+      const urlObj = new URL(this.url, window.location.origin);
       const currentOrigin = window.location.origin;
       
       if (urlObj.origin === currentOrigin && urlObj.searchParams.has('id')) return true;
     } catch {}
     
     return false;
   }

   async fetchInternalPageTitle() {
@@
     try {
-      const urlObj = new URL(this.url);
+      const urlObj = new URL(this.url, window.location.origin);
       const pageId = urlObj.searchParams.get('id');
@@
   async updateLinkedPageTitle(newTitle) {
     try {
-      const urlObj = new URL(this.url);
+      const urlObj = new URL(this.url, window.location.origin);
       const pageId = urlObj.searchParams.get('id');
@@
   openLink() {
@@
     } else {
       let url = this.url;
       if (!url.startsWith('http://') && !url.startsWith('https://')) {
         url = 'https://' + url;
       }
-      window.open(url, '_blank');
+      window.open(url, '_blank', 'noopener,noreferrer');
     }
   }

Also applies to: 69-112, 326-339

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naah

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sushilbang, understood! I'll leave this as-is. Let me know if you'd like me to look at anything else in the PR.

  (\(\ 
  ( -.-)
  o_(")(")

🧠 Learnings used
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In js/plugins/code/, BaseTextElement provides shared state and logic while concrete elements like TextElement extend it and override render() to add UI markup. The base class's connectedCallback queries for elements that child classes inject via their render methods. This is an intentional inheritance pattern used throughout the plugin system.

Comment on lines +39 to +46
this.handleClick = (event) => {
if (this.link && !event.target.closest('.input-dialog')) {
let url = this.link;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
window.open(url, '_blank');
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden external navigation + fetch reliability (noopener + timeout).

  • Add noopener,noreferrer to window.open.
  • Consider an AbortController timeout for fetch() so “loading” can’t hang forever.
- window.open(url, '_blank');
+ window.open(url, '_blank', 'noopener,noreferrer');

(And add an abort timeout around the metadata fetch.)

Also applies to: 290-331

🤖 Prompt for AI Agents
In js/plugins/code/link-preview-element.js around lines 39-46 (and similarly
around 290-331), external navigation uses window.open without rel flags and the
metadata fetch can hang; update the window.open call to include the feature
string 'noopener,noreferrer' to prevent the new window from accessing the
opener, and wrap metadata fetches in an AbortController with a short timeout:
create an AbortController, pass controller.signal to fetch, start a setTimeout
that calls controller.abort() after the timeout, handle the abort (ignore or
surface a timeout error) and clear the timer on success or component teardown to
avoid leaks; apply these changes to both occurrences noted.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
js/plugins/code/link-preview-element.js (1)

301-342: Add timeout to metadata fetch to prevent indefinite loading state.

The fetch() call lacks an AbortController timeout, so if the request to render.wisk.cc hangs, the preview remains in the "Loading preview..." state indefinitely. Consider adding a 5-10 second timeout.

 async updateLinkPreview() {
     if (!this.link || !this.link.trim() || this.metadata) return;
     if (this.status === 'loading' || this.status === 'ok' || this.status === 'error') return;

     this.status = 'loading';
     this.showLoadingState();
     this.sendUpdates();

+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 8000);
+
     try {
         let url = this.link;
         if (!url.startsWith('http://') && !url.startsWith('https://')) {
             url = 'https://' + url;
         }

         const response = await fetch('https://render.wisk.cc/fetch-metadata', {
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
             body: JSON.stringify({ url }),
+            signal: controller.signal,
         });

+        clearTimeout(timeoutId);
+
         if (!response.ok) {
             throw new Error('Failed to fetch metadata');
         }

         const metadata = await response.json();

         if (metadata.error) {
             throw new Error(metadata.error);
         }

         this.metadata = metadata;
         this.status = 'ok';
         this.updatePreviewWithMetadata(metadata);
         this.sendUpdates();
     } catch (error) {
-        console.error('Error fetching metadata:', error);
+        clearTimeout(timeoutId);
+        console.error('Error fetching metadata:', error);
         this.metadata = null;
         this.status = 'error';
         this.showErrorState();
         this.sendUpdates();
     }
 }
js/sync/sync.js (1)

176-192: Element reordering can still drop elements not in newOrder.

The current logic only includes elements whose IDs appear in newOrder (lines 182-186). Any existing element missing from newOrder will be silently dropped from document.data.elements, causing data loss.

Apply this fix to preserve elements not in newOrder:

     if (event.path === 'data.elementOrder') {
         const newOrder = event.value.data;
         if (!document.data) document.data = {};
         document.data.elementOrder = newOrder;
         if (document.data.elements && Array.isArray(document.data.elements)) {
             const orderedElements = [];
+            const seen = new Set();
             newOrder.forEach(id => {
                 const elem = document.data.elements.find(e => e.id === id);
-                if (elem) orderedElements.push(elem);
+                if (elem) {
+                    orderedElements.push(elem);
+                    seen.add(id);
+                }
             });
+            // Preserve elements not in newOrder to avoid data loss
+            document.data.elements.forEach(e => {
+                if (e?.id && !seen.has(e.id)) {
+                    orderedElements.push(e);
+                    console.warn(`Element ${e.id} not in newOrder, appending to end`);
+                }
+            });
             document.data.elements = orderedElements;
         }

         document.lastUpdated = event.value.timestamp;
         console.log('Applied element order event');
         return;
     }
js/editor.js (2)

417-602: Remote lastUpdated events are not applied locally.

The event routing in handleChanges (lines 431-454) only groups events matching .value or .component. Events with path data.elements.<id>.lastUpdated don't match either condition and are ignored. Similarly, handleElementUpdate (lines 507-533) only handles 'value' and 'component' properties.

To fix, add lastUpdated handling:

     events.forEach(event => {
         if (event.path.startsWith('data.elements.')) {
             if (event.path.includes('.value')) {
                 elementValueUpdates.push(event);
             } else if (event.path.includes('.component')) {
                 elementComponentUpdates.push(event);
+            } else if (event.path.includes('.lastUpdated')) {
+                // Apply timestamp updates via applyEvent
+                wisk.sync.applyEvent(wisk.editor.document, event);
             }
         }
         // ...
     });

2596-2611: Guard getTextContent() call against undefined return value.

At line 2597, domElement.getTextContent().text assumes getTextContent() returns an object with a text property. If the method returns undefined or an object without text, this will throw a runtime error.

Apply safe access:

             // special case: first element
             if(elementId === wisk.editor.document.data.elements[0].id) {
-                const textContent = domElement.getTextContent().text || '';
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';

                 if(textContent) {
                     document.title = textContent;
                     wisk.sync.newChange({
                         path: 'data.config.name',
                         value: {
                             data: textContent, 
                             timestamp: timestamp,
                             agent: wisk.sync.agent
                         }
                     });
                 }
             }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c019c47 and d0cf13a.

📒 Files selected for processing (4)
  • js/editor.js (18 hunks)
  • js/plugins/code/embed-element.js (1 hunks)
  • js/plugins/code/link-preview-element.js (4 hunks)
  • js/sync/sync.js (2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/link-preview-element.js
  • js/plugins/code/embed-element.js
🧬 Code graph analysis (2)
js/sync/sync.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (7)
  • event (1683-1683)
  • event (1753-1753)
  • pathParts (508-508)
  • pathParts (595-595)
  • i (723-723)
  • i (854-854)
  • i (1225-1225)
js/editor.js (2)
js/sync/sync.js (4)
  • elementId (212-212)
  • key (207-207)
  • pathParts (172-172)
  • element (213-213)
js/wisk.js (1)
  • wisk (3-3)
🔇 Additional comments (12)
js/plugins/code/link-preview-element.js (3)

27-108: LGTM! Event listener leak issue resolved.

The refactored event binding now stores handlers as properties (this._onOuterClick, this._onUrlInputKeyDown, etc.) and properly removes them in _removeAllListeners() and disconnectedCallback(). This addresses the previous concern about anonymous handlers stacking on detach/reattach.


177-217: LGTM! setValue logic correctly handles append and full updates.

The implementation properly:

  • Concatenates text for value.append path
  • Resets metadata/status when the link changes (old metadata becomes invalid)
  • Guards fetch until _hasConnected is true (avoids premature network calls)
  • Syncs status to 'ok' when metadata is present

252-299: LGTM! Metadata rendering is robust with proper guards.

The function safely handles partial or missing metadata:

  • All DOM elements are checked before updating
  • Missing description/meta information is hidden (display: 'none')
  • Favicon load failures are caught with onerror handler
  • Date formatting uses standard Date constructor
js/plugins/code/embed-element.js (4)

9-83: LGTM! Lifecycle and event cleanup properly implemented.

The component now:

  • Queries DOM elements in connectedCallback
  • Stores event handlers as properties for later removal
  • Implements symmetric add/remove via _removeAllListeners
  • Calls cleanup in disconnectedCallback

This resolves the previously flagged event listener leak issue.


94-124: LGTM! Switch case declarations now properly scoped.

The block scoping for prevElement and nextElement (lines 106-113 and 115-122) addresses the previous noSwitchDeclarations lint error. Variables are now isolated to their respective cases.


126-206: LGTM! URL extraction and transformation logic is sound.

The implementation correctly:

  • Parses iframe HTML with DOMParser (safe)
  • Extracts video IDs from YouTube watch and short URLs
  • Transforms Google Drive file URLs to preview format
  • Adds protocol prefix where missing
  • Provides safe fallback for unknown URL types

208-229: LGTM! View toggle logic correctly manages two-pane UI.

The function:

  • Guards against missing DOM elements
  • Properly toggles between input dialog and iframe display
  • Strips protocol and transforms URL before setting iframe.src
  • Prevents setting invalid URLs (line 219 guard)
js/sync/sync.js (2)

8-29: LGTM! Save queue correctly serializes persistence operations.

The enqueueSave implementation:

  • Uses a saveScheduled flag to coalesce rapid requests
  • Chains saves via Promise to ensure sequential execution
  • Catches errors without breaking the queue

This addresses the previously flagged risk of concurrent saveModification() calls overlapping and losing events.


260-306: LGTM! Snapshot-and-drain pattern prevents event loss during persistence.

The implementation correctly:

  • Snapshots eventLog and clears it upfront (lines 276-277)
  • Applies only the snapshot, avoiding interference from new events during await
  • Restores events to the queue on persistence failure (line 303)
  • Wraps persistence in try/catch to handle errors gracefully

This addresses the previously flagged risk of concurrent saveModification() calls losing events.

js/editor.js (3)

250-274: LGTM! Key sanitization prevents path injection.

The validation at line 252 correctly rejects keys containing ., [, or ], preventing malicious keys from mutating unintended nested paths. This addresses the previously flagged path injection risk.


302-361: LGTM! Block creation now uses event system and queued persistence.

The refactored implementation:

  • Adds lastUpdated timestamp to new blocks (line 318)
  • Emits structured newChange events for data.elements and data.elementOrder
  • Uses enqueueSave('block-creation') instead of ad-hoc setTimeout (line 352)

This addresses the previously flagged persistence serialization risk.


2220-2270: LGTM! Protocol sanitization prevents malicious link injection.

The sanitizeHtmlForClipboard function now:

  • Allowlists safe protocols (http:, https:, mailto:, tel:)
  • Explicitly rejects dangerous protocols (javascript:, data:, vbscript:)
  • Strips all attributes and re-adds only clean ones

This addresses the previously flagged security risk when pasting HTML with links.

js/editor.js Outdated
Comment on lines +2546 to +2621
// debounce: creating events when typing stops. (what if the user closes the tab before debounce triggers???) -> flush pending updates using `unload`

clearTimeout(debounceTimer);

debounceTimer = setTimeout(async () => {
const changed = elementUpdatesLeft
.map(elementId => {
const element = wisk.editor.getElement(elementId);
if (element) {
return {
path: 'document.elements',
values: {
id: element.id,
value: element.value,
lastEdited: element.lastEdited,
component: element.component,
},
};
console.log('typing stopped, creating events for: ', elementUpdatesNeeded.size, 'elements');

const timestamp = Date.now();

// create events for all elements needing update
elementUpdatesNeeded.forEach(elementId => {
const domElement = document.getElementById(elementId);
if(!domElement) {
return;
}

const elementValue = domElement.getValue();
const componentName = domElement.tagName.toLowerCase();

// create event
wisk.sync.newChange({
path: `data.elements.${elementId}.value`,
value: {
data: elementValue,
timestamp: timestamp,
agent: wisk.sync.agent
}
return null;
})
.filter(Boolean);
});

// component because they can be changed (changeBlockType)

wisk.sync.newChange({
path: `data.elements.${elementId}.component`,
value: {
data: componentName,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

wisk.sync.newChange({
path: `data.elements.${elementId}.lastUpdated`,
value: {
data: timestamp,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

// special case: first element
if(elementId === wisk.editor.document.data.elements[0].id) {
const textContent = domElement.getTextContent().text || '';

if(textContent) {
document.title = textContent;
wisk.sync.newChange({
path: 'data.config.name',
value: {
data: textContent,
timestamp: timestamp,
agent: wisk.sync.agent
}
});
}
}
});

const elementIds = wisk.editor.document.data.elements.map(e => e.id);
// save to indexedDB locally
await wisk.sync.saveModification();

await wisk.sync.saveUpdates();
// TODO: Send only the events to server (future step)
// await sendEventsToServer(wisk.sync.eventLog);

elementUpdatesLeft = [];
deletedElementsLeft = [];
}, elementSyncTimer); // should it be less? to voice your opinion, join our discord server: https://discord.gg/D8tQCvgDhu
// Clear the set
elementUpdatesNeeded.clear();
}, 300);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider reducing debounce window or adding beforeunload flush.

The 300ms debounce (line 2550) means changes aren't persisted until typing stops. As noted in the inline comment (line 2546), if a user closes the tab within this window, updates are lost.

Consider these mitigations:

  1. Reduce debounce to 100-150ms to narrow the loss window
  2. Add a beforeunload handler to flush pending updates:
window.addEventListener('beforeunload', (e) => {
    if (elementUpdatesNeeded.size > 0) {
        // Synchronously create events before unload
        const timestamp = Date.now();
        elementUpdatesNeeded.forEach(elementId => {
            const domElement = document.getElementById(elementId);
            if (domElement) {
                const elementValue = domElement.getValue();
                wisk.sync.newChange({
                    path: `data.elements.${elementId}.value`,
                    value: { data: elementValue, timestamp, agent: wisk.sync.agent }
                });
            }
        });
        // Note: Can't reliably await async saves in beforeunload
        // Consider using sendBeacon or synchronous storage API
    }
});
  1. Flush on visibilitychange when the tab becomes hidden

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
js/plugins/code/tier-list.js (1)

181-184: Event listener removal is broken due to .bind() creating a new function reference.

this.handleDragEnd.bind(this) returns a new function each time it's called, so the removeEventListener call will not remove the listener added in setupDragAndDrop. The listener will persist even after the element is disconnected.

🔎 Proposed fix: Store bound references
 setupDragAndDrop() {
-    window.addEventListener('dragend', this.handleDragEnd.bind(this));
-    this.addEventListener('dragstart', this.handleDragStart.bind(this));
-    this.addEventListener('dragover', this.handleDragOver.bind(this));
-    this.addEventListener('dragleave', this.handleDragLeave.bind(this));
-    this.addEventListener('drop', this.handleDrop.bind(this));
-    this.addEventListener('touchstart', this.handleTouchStart.bind(this));
+    this._boundHandleDragEnd = this.handleDragEnd.bind(this);
+    window.addEventListener('dragend', this._boundHandleDragEnd);
+    this.addEventListener('dragstart', this.handleDragStart.bind(this));
+    this.addEventListener('dragover', this.handleDragOver.bind(this));
+    this.addEventListener('dragleave', this.handleDragLeave.bind(this));
+    this.addEventListener('drop', this.handleDrop.bind(this));
+    this.addEventListener('touchstart', this.handleTouchStart.bind(this));
 }

 disconnectedCallback() {
     super.disconnectedCallback();
-    window.removeEventListener('dragend', this.handleDragEnd.bind(this));
+    window.removeEventListener('dragend', this._boundHandleDragEnd);
+    // Clean up document listeners if touch was in progress
+    if (this._boundTouchMove) {
+        document.removeEventListener('touchmove', this._boundTouchMove);
+        document.removeEventListener('touchend', this._boundTouchEnd);
+        document.removeEventListener('touchcancel', this._boundTouchEnd);
+    }
 }
js/plugins/code/code-element.js (1)

147-180: Fix the setCursor line index calculation to use lineCount() - 1

At line 247, setCursor(this.editor.lineCount(), 0) attempts to set the cursor at an invalid line index. Since lineCount() returns the total count and lines are 0-indexed, use this.editor.setCursor(this.editor.lineCount() - 1, 0) to position the cursor at the end of the last line.

The text/typescript MIME type is correctly registered by CodeMirror's built-in JavaScript mode and does not require a separate TypeScript mode file or verification.

♻️ Duplicate comments (4)
js/plugins/code/base-text-element.js (1)

1613-1623: Placeholder check still treats structural children as “non‑empty”

updatePlaceholder() now distinguishes text vs child elements, but hasChildElements = this.editable.children.length > 0; will often be true even when the editor is visually empty (browsers insert <br> or similar structural nodes into contenteditables), preventing the placeholder from showing.

Consider filtering out purely structural children such as <br>:

const hasChildElements = Array.from(this.editable.children).some(
    el => el.tagName !== 'BR'
);

This matches the earlier suggestion you already received here.

js/editor.js (3)

417-477: .lastUpdated element events are still not applied in remote handler

handleChanges() groups data.elements.* events into value vs component updates only:

if (event.path.startsWith('data.elements.')) {
    if (event.path.includes('.value')) {  }
    else if (event.path.includes('.component')) {  }
}

Events on data.elements.<id>.lastUpdated are ignored here (never pushed to any list, and never passed to wisk.sync.applyEvent), so remote .lastUpdated updates won’t be reflected in the local document, even though you now emit them in changeBlockType and justUpdates.

You should either:

  • Treat .lastUpdated as a third branch routed to handleElementUpdate, or
  • At least call wisk.sync.applyEvent(wisk.editor.document, event) for those events so the in‑memory document’s timestamps stay correct.

506-533: Remote component changes still don’t update full‑width container class

In handleElementUpdate, when property === 'component' you replace the DOM node with a new custom element and then set its value, but you don’t update the surrounding container’s rndr-full-width class based on the new plugin’s width:

} else if (property === 'component') {
    const newType = event.value.data;
    const newDomElement = document.createElement(newType);
    newDomElement.id = elementId;
    domElement.replaceWith(newDomElement);
    setTimeout(() => {
        newDomElement.setValue('', element.value);
    }, 0);
}

Local changeBlockType() does adjust the container class using wisk.plugins.getPluginDetail(newType), so remote component changes will visually diverge (full‑width vs normal).

You can mirror the same container‑class update here after replaceWith(newDomElement).


2506-2631: justUpdates debounce is cleaner but still risks losing edits on abrupt tab close

The new justUpdates:

  • Queues element IDs in elementUpdatesNeeded.
  • Debounces for 300ms, then batches events for value, component, and lastUpdated for each element.
  • Special‑cases the first element to update data.config.name from domElement.getTextContent().text.
  • Persists via a single saveModification() and clears the set.

This is a solid event‑creation pipeline, but the existing concern remains:

  1. The 300ms debounce window still means typed changes can be lost if the user closes the tab or navigates away before the timer fires.
  2. domElement.getTextContent().text is accessed without guarding against getTextContent() being missing or returning an object without text, which can throw for non‑textual plugins.

Consider:

  • Adding a beforeunload/visibilitychange handler that flushes pending elementUpdatesNeeded synchronously (creating events without waiting on async I/O).
  • Safely reading the text with optional chaining:
const tc = domElement.getTextContent?.();
const textContent = tc?.text || '';
🧹 Nitpick comments (2)
js/plugins/code/tier-list.js (1)

296-341: Consider guarding against empty changedTouches.

The implementation correctly mirrors the mouse drop logic and properly cleans up styles and event listeners. However, e.changedTouches[0] could be undefined in edge cases (though rare). Consider a guard clause for defensive coding.

🔎 Optional defensive check
 handleTouchEndEvent(e) {
     if (!this.touchDraggedImage) return;

     const touch = e.changedTouches[0];
+    if (!touch) {
+        this.cleanupTouchState();
+        return;
+    }

     this.touchDraggedImage.style.display = 'none';

You could extract the cleanup to a helper method to avoid duplication.

js/plugins/code/base-text-element.js (1)

1788-1993: Variable redeclarations in Markdown paste path (Biome errors)

In the markdown paste branch of handlePaste(), inx, lastId, and i are redeclared with var after having already been declared earlier in the function, which Biome flags as suspicious and can be confusing to readers.

You can switch these to let and avoid redeclaration by reusing the outer variables, or scope them in a small block:

-                    if (parsedElements.length > 0) {
-                        var inx = 0;
-                        var lastId = this.id;
+                    if (parsedElements.length > 0) {
+                        let inx = 0;
+                        let lastId = this.id;
@@
-                        for (var i = inx; i < parsedElements.length; i++) {
+                        for (let i = inx; i < parsedElements.length; i++) {

This keeps the control flow clearer and appeases the linter.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf13a and e9522ce.

📒 Files selected for processing (6)
  • index.html
  • js/editor.js
  • js/paste-handler.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/code-element.js
  • js/plugins/code/tier-list.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/tier-list.js
  • js/plugins/code/code-element.js
  • js/plugins/code/base-text-element.js
🧬 Code graph analysis (3)
js/plugins/code/base-text-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (3)
js/sync/sync.js (4)
  • elementId (212-212)
  • key (207-207)
  • pathParts (172-172)
  • element (213-213)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/plugins.js (1)
  • nav (167-167)
js/paste-handler.js (2)
js/editor.js (20)
  • element (512-512)
  • element (545-545)
  • element (724-724)
  • element (855-855)
  • element (1015-1015)
  • element (1043-1043)
  • element (1056-1056)
  • element (1076-1076)
  • element (1148-1148)
  • element (1236-1236)
  • element (1510-1510)
  • element (1655-1655)
  • elementId (370-370)
  • elementId (384-384)
  • elementId (404-404)
  • elementId (509-509)
  • elementId (2522-2522)
  • i (723-723)
  • i (854-854)
  • i (1233-1233)
js/wisk.js (1)
  • wisk (3-3)
🪛 Biome (2.1.2)
js/plugins/code/base-text-element.js

[error] 2646-2646: Shouldn't redeclare 'inx'. Consider to delete it or rename it.

'inx' is defined here:

(lint/suspicious/noRedeclare)


[error] 2647-2647: Shouldn't redeclare 'lastId'. Consider to delete it or rename it.

'lastId' is defined here:

(lint/suspicious/noRedeclare)


[error] 2658-2658: Shouldn't redeclare 'i'. Consider to delete it or rename it.

'i' is defined here:

(lint/suspicious/noRedeclare)

🔇 Additional comments (15)
js/plugins/code/tier-list.js (3)

156-159: LGTM!

Touch state fields are properly initialized in the constructor, consistent with their usage in the touch event handlers.


256-273: LGTM!

Touch start handler correctly initializes state, applies visual feedback with opacity, and stores bound references for proper cleanup. The pattern of adding document-level listeners for move/end events is appropriate for touch drag operations.


275-294: LGTM!

Movement detection with a 10px threshold prevents accidental drags. The e.preventDefault() call correctly prevents page scrolling during touch drag. Fixed positioning with high z-index ensures the dragged image stays visible above other content.

js/plugins/code/base-text-element.js (2)

614-699: Numbered‑list Markdown detection and numbering logic are solid

The extended handleMarkdown() handling for ########, */>/``` ````, dividers, checkbox patterns, and the new ^(\d+)[.)]$ detection for numbered lists is coherent. Carrying the parsed number into `val.number` and then into `changeBlockType` aligns with the list element model.


976-1143: Paste‑link menu UX wiring looks correct

The URL detection (isUrl/isInternalUrl), menu positioning near the cursor, outside‑click closing with composedPath(), and keyboard handling inside handlePasteLinkChoice/handleKeyDown form a coherent flow. The three options (inline URL link, bookmark/link‑preview block, embed block) are clearly separated and dispatch the expected changeBlockType/inline‑link behaviors.

index.html (1)

269-276: Paste handler script ordering is appropriate

Loading /js/paste-handler.js before editor.js ensures WiskPasteHandler is available for handlePasteEvent and other editor code, and the placement alongside other core scripts keeps bootstrapping straightforward.

js/editor.js (9)

123-185: Config mutation helpers correctly emit events and persist

addConfigChange, addUserAccess, removeUserAccess, setPublicStatus, and setDatabaseProp all:

  • Build a timestamped value envelope { data, timestamp, agent }.
  • Use safe, dotted paths (data.config.*, data.config.databaseProps.${key}) with input sanitization for keys.
  • Call await wisk.sync.saveModification() to persist.

This aligns well with the event‑driven model and keeps config mutations centralized.


250-274: Good defensive checks on setDatabaseProp / savePluginData identifiers

Both setDatabaseProp and savePluginData now validate their identifiers against [.\[\]] before embedding them into dotted paths, which prevents accidental path injection via . or bracket syntax. That’s exactly the right direction for the new event paths.


316-353: Element creation events integrate cleanly with event log

createBlockBase() now generates a timestamped element object (including lastUpdated) and, for local creations, emits:

  • data.elements with the new object, and
  • data.elementOrder with the updated ID list,

then enqueues a block-creation save. This keeps document state, order, and persistence in sync with the event log model without touching the remote path (isRemote guard).


989-1034: Delete‑block flow correctly emits events and updates DOM/doc

The new async deleteBlock:

  • Guards the special root element (abcdxyz).
  • Emits a data.deletedElements event with full element data, timestamp, and agent.
  • Removes the corresponding div-<id> from the DOM.
  • Filters the element out of wisk.editor.document.data.elements.
  • Fires a block-deleted custom event.
  • Calls saveModification() when rec is undefined.

This is a clean, consistent implementation for element deletion in the event‑driven model.


1068-1139: changeBlockType event emission and DOM update path look solid

changeBlockType now:

  • Updates the in‑memory element’s component, value, and lastUpdated.
  • Emits three events for component, value, and lastUpdated with consistent timestamps.
  • Replaces the DOM node, updates the container’s rndr-full-width class via plugin metadata, and re‑applies the value.
  • Dispatches a block-changed event and persists when rec is undefined.

This aligns well with the event‑driven design and keeps DOM, document model, and persisted log in sync.


1660-1717: Drag‑and‑drop cancelation and cleanup are robust

The added handleDragKeydown / cleanupDrag wiring:

  • Cancels drags on Escape or window blur.
  • Clears the auto‑scroll timer, hides the drop indicator, removes the clone node, and unregisters all drag listeners.

This significantly tightens drag lifecycle handling and prevents stuck drag state or stray clones.


2001-2155: Rectangle selection implementation is thoughtful and well‑bounded

The rectangle selection flow:

  • Starts only in the editor area and skips UI chrome / disableSelection plugins.
  • Uses editor‑relative coordinates and throttles updates with requestAnimationFrame.
  • Tracks intersecting .rndr containers, adds overlays, and falls back to native selection when a single element is hit.

Combined with overlay updates on scroll/resize and a mutation observer, this is a solid, user‑friendly implementation.


2201-2213: WISK clipboard intercept using WiskPasteHandler is correctly scoped

handlePasteEvent delegates to WiskPasteHandler.handleWiskClipboardPaste(event) and, when WISK data is present, prevents default, stops propagation, restores elementClipboard, and calls pasteElements().

This cleanly separates internal clipboard (multi‑element) paste from standard browser paste behavior and leverages the new centralized paste handler.


2230-2280: Clipboard HTML sanitization and base64 wrapper for WISK format are well‑designed

  • sanitizeHtmlForClipboard() strips dangerous elements, removes all attributes from <a>, and then re‑applies only a cleaned href (allowlisting http/https/mailto/tel and rejecting javascript: / data: etc.), plus safe contenteditable=false/target=_blank. This is a good defense‑in‑depth measure.
  • copySelectedElements() base64‑encodes the JSON clipboard payload (__WISK_CLIPBOARD__...__WISK_CLIPBOARD_END__) to avoid HTML entity corruption, while still providing a plain‑text representation for interoperability.

Together, these changes strongly improve clipboard robustness and safety.

Also applies to: 2288-2334

Comment on lines +11 to +37
static parseWiskClipboard(htmlData) {
if(!htmlData) return null;

const match = htmlData.match(/__WISK_CLIPBOARD__(.+?)__WISK_CLIPBOARD_END__/);
if(!match) return null;

try {
// Try base64 decoding first (new format)
const jsonString = decodeURIComponent(escape(atob(match[1])));
const wiskData = JSON.parse(jsonString);
if(wiskData.__wisk_elements__ && wiskData.elements) {
return wiskData.elements;
}
} catch (error) {
// Fallback: try direct JSON parse (old format)
try {
const wiskData = JSON.parse(match[1]);
if(wiskData.__wisk_elements__ && wiskData.elements) {
return wiskData.elements;
}
} catch (fallbackError) {
console.error('Failed to parse wisk clipboard data: ', error);
}
}

return null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify error logging in parseWiskClipboard’s fallback path

In the inner catch (fallbackError), the log still prints the outer error rather than fallbackError, which will make diagnosing JSON‑vs‑base64 issues harder:

} catch (fallbackError) {
    console.error('Failed to parse wisk clipboard data: ', error);
}

You likely want to log fallbackError (and optionally the original) so you can distinguish which stage failed.

🤖 Prompt for AI Agents
In js/paste-handler.js around lines 11 to 37, the inner catch block logs the
outer `error` instead of the `fallbackError`, obscuring which decode attempt
failed; update the inner catch to log the correct error (`fallbackError`) and
include contextual information (and optionally include the original `error` as
well) so the log distinguishes base64/decode failure from raw-JSON parse
failure.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (9)
js/editor.js (7)

1-6: Remove unused deletedElementsLeft tracking.

deletedElementsLeft is declared and updated in deleteBlock but never read anywhere in the current implementation, so it’s dead state.

Suggested cleanup
-var deletedElementsLeft = [];
...
-            deletedElements.push(elementId);
-            deletedElementsLeft.push(elementId);
+            deletedElements.push(elementId);

989-1034: Delete-block event emission is correct, but the extra deletedElementsLeft push is noise.

The async deleteBlock now:

  • Emits a data.deletedElements event with the full element and timestamp.
  • Removes the DOM node and updates wisk.editor.document.data.elements.
  • Pushes to both deletedElements and deletedElementsLeft, but only deletedElements is used.
  • Saves immediately when rec is undefined.

Functionally fine; just drop deletedElementsLeft as noted earlier.


2233-2283: sanitizeHtmlForClipboard robustly cleans link href protocols.

The sanitizer:

  • Normalizes escaped characters in href.
  • Validates protocol against a whitelist (http:, https:, mailto:, tel:), falling back to manual checks on parse failure.
  • Strips all attributes and reapplies only safe ones, plus contenteditable="false"/target="_blank".

This closes the earlier javascript:/data: risk in clipboard HTML.


417-477: handleChanges still ignores .lastUpdated element events.

In the data.elements.* branch you only dispatch to:

  • elementValueUpdates (paths containing .value)
  • elementComponentUpdates (paths containing .component)

Events for data.elements.<id>.lastUpdated are neither grouped nor passed to wisk.sync.applyEvent, so remote timestamp updates are effectively dropped on the client.

Suggested adjustment
-    events.forEach(event => {
-        if (event.path.startsWith('data.elements.')) {
-            if (event.path.includes('.value')) {
-                elementValueUpdates.push(event);
-            } else if (event.path.includes('.component')) {
-                elementComponentUpdates.push(event);
-            }
-        }
+    events.forEach(event => {
+        if (event.path.startsWith('data.elements.')) {
+            if (event.path.includes('.value')) {
+                elementValueUpdates.push(event);
+            } else if (event.path.includes('.component')) {
+                elementComponentUpdates.push(event);
+            } else if (event.path.includes('.lastUpdated')) {
+                // Apply directly; no DOM changes needed
+                wisk.sync.applyEvent(wisk.editor.document, event);
+            }
+        }

480-533: Remote component changes don’t update container width class.

handleElementUpdate’s component branch replaces the inner element but doesn’t update the containing div-<id> to add/remove rndr-full-width based on the new plugin’s width, unlike changeBlockType.

Blocks that change from full-width to regular (or vice versa) via remote events will leave the container with stale width styling.

Suggested fix
    } else if (property === 'component') {
        const newType = event.value.data;
        const newDomElement = document.createElement(newType);
        newDomElement.id = elementId;
        domElement.replaceWith(newDomElement);
-        setTimeout(() => {
-            newDomElement.setValue('', element.value);
-        }, 0);
+        const container = document.getElementById(`div-${elementId}`);
+        if (container) {
+            const pluginDetail = wisk.plugins.getPluginDetail(newType);
+            if (pluginDetail.width === 'max') {
+                container.classList.add('rndr-full-width');
+            } else {
+                container.classList.remove('rndr-full-width');
+            }
+        }
+        setTimeout(() => {
+            newDomElement.setValue('', element.value);
+        }, 0);
    }

2608-2623: Still fragile: assumes getTextContent().text always exists on first element.

In the first-element special case you still do:

const textContent = domElement.getTextContent().text || '';

If getTextContent is missing or returns something without .text, this will throw and abort the whole debounce batch.

Suggested guard
-            if(elementId === wisk.editor.document.data.elements[0].id) {
-                const textContent = domElement.getTextContent().text || '';
+            if (elementId === wisk.editor.document.data.elements[0].id) {
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';

2509-2635: Debounced persistence gap remains; consider a flush on unload/visibilitychange.

The 300 ms debounce in justUpdates still means recent edits can be lost if the tab is closed or hidden before the timeout fires, as acknowledged in the inline comment.

Adding a beforeunload and/or visibilitychange handler to synchronously flush elementUpdatesNeeded (emit events and call saveModification once) would mitigate this without changing the steady-state debounce.

js/paste-handler.js (1)

10-31: Log the correct error in the fallback JSON parse catch.

The inner catch (fallbackError) still logs the outer error, which makes it hard to see whether base64 decode or raw JSON parse failed:

} catch (fallbackError) {
    console.error('Failed to parse wisk clipboard data: ', error);
}

Log fallbackError (and optionally both errors) so you can distinguish the failure stage.

Suggested fix
-        } catch (error) {
-            try {
-                const wiskData = JSON.parse(match[1]);
-                if (wiskData.__wisk_elements__ && wiskData.elements) {
-                    return wiskData.elements;
-                }
-            } catch (fallbackError) {
-                console.error('Failed to parse wisk clipboard data: ', error);
-            }
-        }
+        } catch (error) {
+            try {
+                const wiskData = JSON.parse(match[1]);
+                if (wiskData.__wisk_elements__ && wiskData.elements) {
+                    return wiskData.elements;
+                }
+            } catch (fallbackError) {
+                console.error('Failed to parse wisk clipboard data (fallback JSON parse): ', fallbackError, {
+                    originalError: error,
+                });
+            }
+        }
js/plugins/code/base-text-element.js (1)

1598-1603: Placeholder empty-check still treats structural <br> as content.

hasChildElements uses this.editable.childElementCount > 0, so an auto-inserted <br> keeps isEmpty false and the placeholder hidden for visually empty blocks:

const hasText = this.editable.innerText.trim() !== '';
const hasChildElements = this.editable.childElementCount > 0;
const isEmpty = !hasText && !hasChildElements;

To avoid this, filter out purely structural children like <br> when computing hasChildElements.

Suggested tweak
-        if (this.editable) {
-            const hasText = this.editable.innerText.trim() !== '';
-            const hasChildElements = this.editable.childElementCount > 0;
-            const isEmpty = !hasText && !hasChildElements;
+        if (this.editable) {
+            const hasText = this.editable.innerText.trim() !== '';
+            const hasChildElements = Array.from(this.editable.children).some(
+                el => el.tagName !== 'BR'
+            );
+            const isEmpty = !hasText && !hasChildElements;
🧹 Nitpick comments (5)
js/paste-handler.js (1)

536-564: Simplify or fix isMarkdownText threshold logic.

The final return condition:

return markdownPatterns > 0 && (markdownPatterns >= 1 || markdownPatterns / lines.length >= 0.2);

reduces to markdownPatterns > 0 — the ratio check is never used. Either simplify to markdownPatterns > 0 or restore the intended threshold (e.g., markdownPatterns / lines.length >= 0.2) so detection behavior is explicit.

js/plugins/code/base-text-element.js (2)

972-1054: Paste-link menu insertion and teardown look correct.

The paste-link workflow (insert temporary <link-element>, position .paste-link-menu relative to the link, wire option clicks, and clean up with _closePasteMenuHandler) is internally consistent:

  • Selection/range handling gracefully falls back to end-of-block.
  • Menu is closed on outside click and when a choice is made.
  • Bookmark/embed choices remove the inline link and transform the block via changeBlockType.

Just ensure that text-element’s CSS keeps .paste-link-menu absolutely positioned relative to the same coordinate space as this.editable so the computed left/top offsets line up visually.


1156-1173: Guard suggestion debounce when paste menu is open.

handleKeyDown correctly routes ArrowUp/Down/Enter/Escape to the paste-link menu when showingPasteLinkMenu is true, but the rest of the method (emoji handling, suggestion discard, navigation handlers) still runs for other keys.

If you ever see interactions between the paste-link menu and emoji/autocomplete flows, consider short‑circuiting the rest of handleKeyDown whenever showingPasteLinkMenu is true (except for keys you explicitly want to bubble).

js/plugins/code/code-element.js (1)

245-249: Auto-focus on initialization is generally helpful but might surprise in some contexts.

Calling this.editor.focus() and moving the cursor to the end right after initialization makes sense when a new code block is created interactively. If this element ever appears off-screen or inside a non-focused panel, you may want a guard flag to skip autofocus in those contexts.

js/editor.js (1)

1781-1799: Drag-drop reinsert deletes + recreates blocks; consider event-level moves later.

handleDrop currently implements reordering by deleting the original block and creating a new one below the target, using the captured originalValue and originalComponent. This is consistent with your event model (a delete + create sequence), though a future enhancement could use a pure elementOrder change for more compact histories.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e9522ce and 85360e2.

📒 Files selected for processing (5)
  • js/editor.js
  • js/paste-handler.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/code-element.js
  • js/plugins/code/numbered-list-element.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/base-text-element.js
  • js/plugins/code/numbered-list-element.js
  • js/plugins/code/code-element.js
🧬 Code graph analysis (3)
js/plugins/code/base-text-element.js (2)
js/editor.js (8)
  • newType (525-525)
  • el (1484-1484)
  • i (723-723)
  • i (854-854)
  • i (1236-1236)
  • event (1725-1725)
  • event (1782-1782)
  • tempDiv (2238-2238)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/numbered-list-element.js (2)
js/editor.js (2)
  • event (1725-1725)
  • event (1782-1782)
js/wisk.js (1)
  • wisk (3-3)
js/paste-handler.js (1)
js/wisk.js (1)
  • wisk (3-3)
🔇 Additional comments (25)
js/plugins/code/base-text-element.js (4)

348-355: Paste-link menu DOM lookup matches inheritance pattern.

Querying .paste-link-menu in connectedCallback and storing it on this.pasteLinkMenu is consistent with the pattern where concrete elements (e.g., text-element) render the menu HTML while BaseTextElement owns the behavior/state. No issues here.

Based on learnings, this aligns with the intended base/derived separation.


1353-1377: Backspace removal of adjacent link-element is well-scoped.

The new handleBackspace logic around selection/range checks cleanly removes a preceding LINK-ELEMENT when backspacing at its boundary (both for text and element containers) and falls through to existing block-merge behavior otherwise. No correctness issues spotted.


1760-1796: Copy handler for link-element<a> looks good.

handleCopy clones the current selection, rewrites each link-element to a plain <a> with data-wisk-* metadata, and writes both HTML and text variants to the clipboard. That should preserve Wisk semantics while keeping external pastes interoperable. No issues seen.


1798-1834: Text-element paste integration with WiskPasteHandler is consistent.

The paste handler correctly:

  • Special-cases standalone URLs into block‑level link elements / paste-link menu when the block is empty.
  • Defers Wisk clipboard format to the document-level handler (handlePasteEvent) by early-returning.
  • Uses WiskPasteHandler.handleTextElementPaste for HTML/Markdown/plain-text, and calls sendUpdates() when that handler reports handled.

This wiring matches the new unified paste pipeline; no additional issues.

js/plugins/code/numbered-list-element.js (4)

20-35: Revised numbering styles per indent level are coherent.

Mapping:

  • Level 0 → 1, 2, 3...
  • Level 1 → a, b, c...
  • Level 2 → i, ii, iii...
  • Level 3 → 1, 2, 3...
  • Level 4 → a, b, c...

via styles[level % styles.length] is straightforward and keeps nested numbering predictable.


107-117: Indent + number reset on leading space/Tab is reasonable.

On beforeinput space at the start and on Tab at focus 0 you now:

  • Increment this.indent
  • Reset this.number = 1
  • Update indent and send updates

That matches the behavior of starting a new nested ordered list whose first item should be 1.. No functional issues.


120-158: Enter behavior correctly handles “split vs exit list”.

The new handleEnterKey:

  • Splits content at the caret into beforeContainer and afterContainer.
  • Leaves current item with beforeContainer.innerHTML.
  • If the current item is empty (no text and no children), converts this block to a text-element with the trailing content.
  • Otherwise, creates a new numbered-list-element with same indent and number + 1, populated with the trailing content.

This mirrors typical OL behavior (“Enter on empty item exits the list”) and keeps numbering consistent.


187-197: Tab-at-start resetting number to 1 is consistent with space-at-start.

In handleTab, when at focus 0 you increment indent, reset number to 1, and update. That keeps behavior consistent with the space-at-start handler and avoids surprising carryover numbers when promoting an item into a deeper level.

js/plugins/code/code-element.js (4)

4-138: CodeMirror styling and selection/cursor tweaks look safe.

The CSS changes (host margin, select sizing/opacity behavior, CodeMirror padding/line-height, selection colors, cursor width, token colors) are purely visual and don’t affect behavior. They should improve readability without functional impact.


148-181: Expanded supportedLanguages is consistent and backward compatible.

New entries (Rust, Scala, SCSS, XML, JSON, YAML, Bash/Shell/PowerShell, Dockerfile, Plain Text) extend the dropdown without changing existing keys. Since selectedLang still defaults to 'javascript', existing documents remain unaffected.


274-305: getModeForLanguage mapping covers the new languages and has a safe fallback.

Mappings for new languages (e.g., typescript → 'text/typescript', rust → 'rust', scala → 'text/x-scala', scss → 'text/x-scss', json → 'application/json', yaml → 'yaml', bash/shell → 'shell', powershell → 'powershell', dockerfile → 'dockerfile', plaintext → 'text/plain') look correct, with an explicit 'text/plain' fallback for unknown keys.


188-208: Incomplete language support: code does not load modes for all languages in supportedLanguages.

The code loads only 16 language modes (javascript, xml, css, python, clike, markdown, go, sql, php, ruby, swift, rust, yaml, shell, powershell, dockerfile, sass), but supportedLanguages exposes at least 25 languages including java, kotlin, scala, html, json, csharp, and scss. Users can select these languages from the dropdown but will receive no syntax highlighting. Additionally, SCSS is listed in supportedLanguages but the code loads Sass instead. TypeScript is also in supportedLanguages but relies on the javascript mode variant, requiring explicit MIME type configuration (e.g., mode: "text/typescript") rather than a dedicated mode import.

Likely an incorrect or invalid review comment.

js/editor.js (13)

123-184: Config change → event emission looks correct and centralized.

addConfigChange now consistently emits timestamped events for name, theme, and plugin add/remove, then calls wisk.sync.saveModification(). Name and theme side effects (document.title, wisk.theme.setTheme) remain in place. This aligns config updates with the new event-driven sync path.


186-296: Access/public/database/pluginData setters align with event-driven config/plugin updates.

addUserAccess, removeUserAccess, setPublicStatus, setDatabaseProp, and savePluginData:

  • Sanitize keys/identifiers to avoid path injection.
  • Build new arrays/objects rather than mutating in place.
  • Emit data.config.* / data.pluginData.* events with timestamps and agents.
  • Immediately await wisk.sync.saveModification().

This is a solid pattern for config/plugin mutations.


302-353: Element creation events and order updates are correctly emitted for local blocks.

createBlockBase now:

  • Adds lastUpdated to the element object.
  • For non-remote blocks, emits data.elements (new element object) and data.elementOrder (current ID list) events with timestamps/agent.
  • Uses wisk.sync.enqueueSave('block-creation') instead of ad-hoc setTimeout(saveModification).

This matches the intent of the new event-driven architecture.


536-555: Element deletion handler is consistent with the event log.

handleElementDeletions applies each data.deletedElements event by:

  • Avoiding double-processing via the deletedElements array.
  • Removing the corresponding DOM container and updating wisk.editor.document.data.elements.
  • Calling wisk.sync.applyEvent to keep the document model aligned.

This is a good fit for the new event package format.


557-589: Config and pluginData change handlers correctly bridge events to UI.

handleConfigChange and handlePluginDataChange:

  • Call wisk.sync.applyEvent to update the document.
  • Apply side effects (theme set, document title, reload on plugins change, dispatch of custom visibility/access/database-props events, and plugin loadData calls).

This wiring makes config/plugin data reactive without duplicating state.


683-693: Block move now emits data.elementOrder and enqueues a save.

moveBlock updates wisk.editor.document.data.elements, reorders DOM nodes, then:

  • Emits a data.elementOrder event with the new ID order and timestamp.
  • Calls wisk.sync.enqueueSave('block-move').

This is consistent with creation/deletion flows and avoids fire‑and‑forget setTimeout(saveModification).


875-949: htmlToMarkdown/convertInlineMarkdown citation and reference handling looks correct.

The updated conversion logic:

  • Encodes <cite-element> as --citation-element--id-- during markdown generation and decodes it back when parsing inline markdown.
  • Handles reference-number links both in <sup><a class="reference-number"> and <span .reference-number> cases.
  • Keeps other inline formatting (bold/italic/strike/code/links) intact.

No obvious edge-case regressions.


1068-1142: changeBlockType event emission and DOM replacement are solid.

The updated changeBlockType:

  • Updates element.component, element.value, and element.lastUpdated.
  • Emits separate events for .component, .value, and .lastUpdated.
  • Replaces the DOM element with a new instance of newType, updates the container’s width class, and sets its value, focusing it afterward.
  • Persists via saveModification() when rec is undefined.

This fits well with the event-driven model and fixes the width class issue for local type changes.


1209-1378: Markdown-to-elements conversion is well-structured and consistent with new code-element behavior.

convertMarkdownToElements handles:

  • Headings, fenced code blocks (with language), blockquotes, checkboxes, numbered/unordered lists (with indentation), horizontal rules, and plain text.
  • Uses convertInlineMarkdown for inline formatting, which now also supports citations and reference numbers.

No correctness issues observed; behavior appears compatible with the new code-element language support.


1763-1775: Auto-scroll during drag is properly cleaned up via cleanupDrag.

cleanupDrag now stops autoScroll, hides the drop indicator, removes the clone, and unregisters all drag-related listeners (including the new blur/keydown handlers). This prevents stuck scroll timers and lingering listeners after a drag ends or is canceled.


2205-2215: Wisk clipboard paste handler is correctly isolated.

handlePasteEvent intercepts document-level pastes, calls WiskPasteHandler.handleWiskClipboardPaste, and when it returns elements:

  • Prevents default and stops propagation.
  • Restores elementClipboard and calls pasteElements().

This keeps Wisk’s internal multi-block clipboard separate from normal paste flows.


2288-2337: Wisk clipboard serialization (base64 __WISK_CLIPBOARD__ marker) is sound.

copySelectedElements now:

  • Deep-clones each element’s value and sanitizes textContent via sanitizeHtmlForClipboard.
  • Builds a wiskClipboardData object with a __wisk_elements__ marker and elements array.
  • Base64‑encodes the JSON and wraps it in __WISK_CLIPBOARD__...__WISK_CLIPBOARD_END__.
  • Writes both plain text and HTML (with a <meta name="wisk-clipboard"> marker) to the system clipboard.

This scheme is compatible with parseWiskClipboard in paste-handler.js.


2637-2668: Virtual keyboard detection helper is straightforward and self-contained.

initKeyboardDetection wires both visualViewport and navigator.virtualKeyboard (where available) to emit a virtual-keyboard-visible custom event with isVisible and height. No external dependencies; behavior looks correct.

Comment on lines +2511 to +2634
wisk.editor.justUpdates = async function (elementIdOrEvent) {
console.log('justUpdates called with:', elementIdOrEvent);

// handle event objects passed directly
if(typeof elementIdOrEvent === 'object') {
wisk.sync.newChange(elementIdOrEvent);

console.log('critical event, saving immediately');
await wisk.sync.saveModification();

// TODO: Send event to server here
return;
}

// Handle nested elements with IDs containing hyphens
if (elementId && elementId.includes('-')) {
const elementId = elementIdOrEvent;
// handle nested elements
if(elementId && elementId.includes('-')) {
const eid = elementId.split('-')[0];
document.getElementById(eid).editor.justUpdates(elementId);
const parentElement = document.getElementById(eid);
if(parentElement && parentElement.editor) {
parentElement.editor.justUpdates(elementId);
}
return;
}

window.dispatchEvent(new CustomEvent('something-updated', { detail: { id: elementId } }));
if(!elementId) {
return;
}

if (elementId) {
if (elementId === wisk.editor.document.data.elements[0].id) {
document.title = byQuery('#' + elementId).getTextContent().text;
wisk.editor.document.data.config.name = document.title;
wisk.sync.newChange({
action: 'config',
key: 'name',
value: document.title,
});
}
// mark element as needing update (not creating events yet)
elementUpdatesNeeded.add(elementId);

const element = wisk.editor.getElement(elementId);
if (element) {
const domElement = document.getElementById(elementId);
if (domElement) {
element.value = domElement.getValue();
element.lastEdited = Math.floor(Date.now() / 1000);
element.component = domElement.tagName.toLowerCase();
document.getElementById('nav').classList.add('nav-disappear');
document.getElementById('getting-started').style.display = 'none';

if (!elementUpdatesLeft.includes(elementId)) {
elementUpdatesLeft.push(elementId);
}
}
}
// UI updates
const nav = document.getElementById('nav');
if(nav) {
nav.classList.add('nav-disappear');
}

const gettingStarted = document.getElementById('getting-started');
if(gettingStarted) {
gettingStarted.style.display = 'none';
}

// dispatch event
window.dispatchEvent(new CustomEvent('something-updates', {
detail: { id: elementId }
}));

// debounce: creating events when typing stops. (what if the user closes the tab before debounce triggers???) -> flush pending updates using `unload`

clearTimeout(debounceTimer);

debounceTimer = setTimeout(async () => {
const changed = elementUpdatesLeft
.map(elementId => {
const element = wisk.editor.getElement(elementId);
if (element) {
return {
path: 'document.elements',
values: {
id: element.id,
value: element.value,
lastEdited: element.lastEdited,
component: element.component,
},
};
console.log('typing stopped, creating events for: ', elementUpdatesNeeded.size, 'elements');

const timestamp = Date.now();

// create events for all elements needing update
elementUpdatesNeeded.forEach(elementId => {
const domElement = document.getElementById(elementId);
if(!domElement) {
return;
}

const elementValue = domElement.getValue();
const componentName = domElement.tagName.toLowerCase();

// create event
wisk.sync.newChange({
path: `data.elements.${elementId}.value`,
value: {
data: elementValue,
timestamp: timestamp,
agent: wisk.sync.agent
}
return null;
})
.filter(Boolean);
});

// component because they can be changed (changeBlockType)

wisk.sync.newChange({
path: `data.elements.${elementId}.component`,
value: {
data: componentName,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

wisk.sync.newChange({
path: `data.elements.${elementId}.lastUpdated`,
value: {
data: timestamp,
timestamp: timestamp,
agent: wisk.sync.agent
}
});

// special case: first element
if(elementId === wisk.editor.document.data.elements[0].id) {
const textContent = domElement.getTextContent().text || '';

if(textContent) {
document.title = textContent;
wisk.sync.newChange({
path: 'data.config.name',
value: {
data: textContent,
timestamp: timestamp,
agent: wisk.sync.agent
}
});
}
}
});

const elementIds = wisk.editor.document.data.elements.map(e => e.id);
// save to indexedDB locally
await wisk.sync.saveModification();

await wisk.sync.saveUpdates();
// TODO: Send only the events to server (future step)
// await sendEventsToServer(wisk.sync.eventLog);

elementUpdatesLeft = [];
deletedElementsLeft = [];
}, elementSyncTimer); // should it be less? to voice your opinion, join our discord server: https://discord.gg/D8tQCvgDhu
// Clear the set
elementUpdatesNeeded.clear();
}, 300);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

justUpdates() with no argument no longer persists template or global changes.

The new justUpdates implementation treats only:

  • Event objects (immediately sent via wisk.sync.newChange + saveModification), or
  • A specific elementId (debounced aggregation via elementUpdatesNeeded).

A call like wisk.editor.justUpdates() with no arguments now returns immediately:

if (!elementId) {
    return;
}

But useTemplate() still calls wisk.editor.justUpdates(); after replacing wisk.editor.document.data.elements and setting values, so those changes will not emit any events or be saved.

Possible fixes
  1. Teach justUpdates() to handle “all elements” when no ID is provided:
-    if(!elementId) {
-        return;
-    }
+    if (!elementId) {
+        // enqueue all elements for update
+        wisk.editor.document.data.elements.forEach(e => {
+            if (e.id) elementUpdatesNeeded.add(e.id);
+        });
+    } else {
+        elementUpdatesNeeded.add(elementId);
+    }
  1. Or: change useTemplate() to explicitly emit events (e.g., loop over template.elements and call justUpdates(element.id) or a dedicated helper).

Comment on lines +566 to +593
static parseInlineMarkdown(text) {
if (!text) return '';

return active;
let result = text;
// Escape HTML entities first (but preserve existing HTML tags we want to keep)
result = result.replace(/&/g, '&amp;');
// Don't escape < and > for HTML tags we want to preserve
result = result.replace(/<(?!(b|i|u|strike|code|a|br|span|strong|em)\b)/g, '&lt;');
result = result.replace(/(?<!\b(b|i|u|strike|code|a|br|span|strong|em))>/g, '&gt;');
// Bold and italic combined (***text*** or ___text___)
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<b><i>$1</i></b>');
result = result.replace(/___(.+?)___/g, '<b><i>$1</i></b>');
// Bold (**text** or __text__)
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
result = result.replace(/__(.+?)__/g, '<b>$1</b>');
// Italic (*text* or _text_) - be careful not to match inside words
result = result.replace(/\*([^*]+)\*/g, '<i>$1</i>');
result = result.replace(/(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g, '<i>$1</i>');
// Strikethrough (~~text~~)
result = result.replace(/~~(.+?)~~/g, '<strike>$1</strike>');
// Inline code (`code`)
result = result.replace(/`([^`]+)`/g, '<code style="background: var(--bg-2); padding: 2px 4px; border-radius: 3px; font-family: var(--font-mono);">$1</code>');
// Links [text](url) - convert to link-element
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<link-element url="$2" display="inline" title="$1" contenteditable="false"></link-element>');
// Images ![alt](url) - convert to text representation
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[Image: $1]');

return result;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize protocols for Markdown links ([text](url)) as well.

parseInlineMarkdown converts [text](url) into <link-element url="..."> without checking the protocol, so javascript: / data: URLs can slip through this path, unlike HTML paste which is sanitized by sanitizeAndConvertLinks.

Reusing the same safe-protocol logic (or calling a shared helper) for Markdown URLs would align behavior and close a potential injection vector.

One possible approach
-        // Links [text](url) - convert to link-element
-        result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<link-element url="$2" display="inline" title="$1" contenteditable="false"></link-element>');
+        // Links [text](url) - convert to link-element, but only for safe protocols
+        result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
+            const safe = WiskPasteHandler.normalizeUrl
+                ? WiskPasteHandler.normalizeUrl(url)
+                : url.trim();
+            // Optionally reuse the same safeProtocols check as sanitizeAndConvertLinks
+            const safeProtocols = /^(https?:|mailto:|tel:|\/|#)/i;
+            if (!safeProtocols.test(safe)) return text; // degrade to plain text
+            return `<link-element url="${safe}" display="inline" title="${text}" contenteditable="false"></link-element>`;
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static parseInlineMarkdown(text) {
if (!text) return '';
return active;
let result = text;
// Escape HTML entities first (but preserve existing HTML tags we want to keep)
result = result.replace(/&/g, '&amp;');
// Don't escape < and > for HTML tags we want to preserve
result = result.replace(/<(?!(b|i|u|strike|code|a|br|span|strong|em)\b)/g, '&lt;');
result = result.replace(/(?<!\b(b|i|u|strike|code|a|br|span|strong|em))>/g, '&gt;');
// Bold and italic combined (***text*** or ___text___)
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<b><i>$1</i></b>');
result = result.replace(/___(.+?)___/g, '<b><i>$1</i></b>');
// Bold (**text** or __text__)
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
result = result.replace(/__(.+?)__/g, '<b>$1</b>');
// Italic (*text* or _text_) - be careful not to match inside words
result = result.replace(/\*([^*]+)\*/g, '<i>$1</i>');
result = result.replace(/(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g, '<i>$1</i>');
// Strikethrough (~~text~~)
result = result.replace(/~~(.+?)~~/g, '<strike>$1</strike>');
// Inline code (`code`)
result = result.replace(/`([^`]+)`/g, '<code style="background: var(--bg-2); padding: 2px 4px; border-radius: 3px; font-family: var(--font-mono);">$1</code>');
// Links [text](url) - convert to link-element
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<link-element url="$2" display="inline" title="$1" contenteditable="false"></link-element>');
// Images ![alt](url) - convert to text representation
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[Image: $1]');
return result;
static parseInlineMarkdown(text) {
if (!text) return '';
let result = text;
// Escape HTML entities first (but preserve existing HTML tags we want to keep)
result = result.replace(/&/g, '&amp;');
// Don't escape < and > for HTML tags we want to preserve
result = result.replace(/<(?!(b|i|u|strike|code|a|br|span|strong|em)\b)/g, '&lt;');
result = result.replace(/(?<!\b(b|i|u|strike|code|a|br|span|strong|em))>/g, '&gt;');
// Bold and italic combined (***text*** or ___text___)
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<b><i>$1</i></b>');
result = result.replace(/___(.+?)___/g, '<b><i>$1</i></b>');
// Bold (**text** or __text__)
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
result = result.replace(/__(.+?)__/g, '<b>$1</b>');
// Italic (*text* or _text_) - be careful not to match inside words
result = result.replace(/\*([^*]+)\*/g, '<i>$1</i>');
result = result.replace(/(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g, '<i>$1</i>');
// Strikethrough (~~text~~)
result = result.replace(/~~(.+?)~~/g, '<strike>$1</strike>');
// Inline code (`code`)
result = result.replace(/`([^`]+)`/g, '<code style="background: var(--bg-2); padding: 2px 4px; border-radius: 3px; font-family: var(--font-mono);">$1</code>');
// Links [text](url) - convert to link-element, but only for safe protocols
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const safe = WiskPasteHandler.normalizeUrl
? WiskPasteHandler.normalizeUrl(url)
: url.trim();
// Optionally reuse the same safeProtocols check as sanitizeAndConvertLinks
const safeProtocols = /^(https?:|mailto:|tel:|\/|#)/i;
if (!safeProtocols.test(safe)) return text; // degrade to plain text
return `<link-element url="${safe}" display="inline" title="${text}" contenteditable="false"></link-element>`;
});
// Images ![alt](url) - convert to text representation
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[Image: $1]');
return result;
🤖 Prompt for AI Agents
In js/paste-handler.js around lines 566 to 593, parseInlineMarkdown currently
converts Markdown links ([text](url)) straight to a <link-element> without
validating the URL scheme, allowing javascript:/data: injection; update the
function to validate/sanitize link protocols before creating the link-element —
either call the existing sanitizeAndConvertLinks/shared helper that enforces an
allowed-schemes whitelist (http, https, mailto, maybe ftp) or implement the same
safe-protocol check: parse the URL, normalize/trim it, allow only whitelisted
protocols (treat protocol-less/relative URLs as safe or resolve them to https),
and if the protocol is not allowed, do not emit a link-element (render as
escaped text or a safe placeholder) so untrusted protocols are never injected
into the url attribute of the link-element.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
js/plugins/code/main-element.js (1)

1300-1306: Remove duplicate getTextContent() method definition.

The getTextContent() method is defined twice (lines 1300-1306 and 1332-1338) with different implementations. JavaScript will use the second definition, which means the emoji is not included in the exported text content.

Please clarify which implementation is intended and remove the duplicate.

🔎 Proposed resolution

If the emoji should be included in text exports, keep the first implementation and remove the second:

-    getTextContent() {
-        return {
-            html: this.editable.innerHTML,
-            text: this.editable.innerText,
-            markdown: '# ' + wisk.editor.htmlToMarkdown(this.editable.innerHTML),
-        };
-    }

If the emoji should NOT be included, remove the first implementation:

-    getTextContent() {
-        return {
-            html: `<h1>${this.emoji} ${this.editable.innerHTML}</h1>`,
-            text: `${this.emoji} ${this.editable.innerText}`,
-            markdown: `# ${this.emoji} ${this.editable.innerText}`,
-        };
-    }
-

Also applies to: 1332-1338

js/plugins/code/numbered-list-element.js (1)

142-157: isEmpty check should ignore structural children like <br>

Using this.editable.children.length === 0 in isEmpty means a visually empty list item that contains only a browser‑inserted <br> will be treated as non‑empty, so Enter won’t convert it back to a text block. Consider aligning with the placeholder logic pattern by treating only non‑BR children as content, e.g.:

const hasNonBrChildren = Array.from(this.editable.children).some(
    el => el.tagName !== 'BR'
);
const isEmpty = this.editable.innerText.trim().length === 0 && !hasNonBrChildren;

This keeps behavior consistent even when the browser injects structural nodes.

js/plugins/code/base-text-element.js (1)

1394-1418: convertInlineMarkdown should share the same safe-URL rules as paste handlers

convertInlineMarkdown now maps [text](url) into raw <a href="$2" ...> without validating protocols:

text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
  '<a href="$2" contenteditable="false" target="_blank">$1</a>');

For parity with sanitizeHtmlForClipboard and WiskPasteHandler.sanitizeAndConvertLinks, this path should also enforce an allowed-schemes whitelist (http/https/mailto/tel/relative) and drop or neutralize anything else. That keeps markdown imports from reintroducing javascript: / data: links even when they bypass the HTML sanitizer.

You can either call into the shared sanitizer or run the same protocol check before emitting <a>.

js/plugins/code/link-preview-element.js (1)

316-357: Add timeout to metadata fetch to prevent indefinite loading state.

While the noopener,noreferrer security issue (Line 39) has been addressed from previous reviews, the fetch operation still lacks a timeout. If the external API at render.wisk.cc is slow or unresponsive, the component remains in "loading" state indefinitely with no user recovery option.

🔎 Recommended fix with AbortController timeout
 async updateLinkPreview() {
     if (!this.link || !this.link.trim() || this.metadata) return;
     if (this.status === 'loading' || this.status === 'ok' || this.status === 'error') return;
 
     this.status = 'loading';
     this.showLoadingState();
     this.sendUpdates();
 
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
+
     try {
         let url = this.link;
         if (!url.startsWith('http://') && !url.startsWith('https://')) {
             url = 'https://' + url;
         }
 
         const response = await fetch('https://render.wisk.cc/fetch-metadata', {
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
             body: JSON.stringify({ url }),
+            signal: controller.signal,
         });
 
         if (!response.ok) {
             throw new Error('Failed to fetch metadata');
         }
 
         const metadata = await response.json();
 
         if (metadata.error) {
             throw new Error(metadata.error);
         }
 
+        clearTimeout(timeoutId);
         this.metadata = metadata;
         this.status = 'ok';
         this.updatePreviewWithMetadata(metadata);
         this.sendUpdates();
     } catch (error) {
+        clearTimeout(timeoutId);
-        console.error('Error fetching metadata:', error);
+        const message = error.name === 'AbortError' ? 'Request timed out' : error.message;
+        console.error('Error fetching metadata:', message);
         this.metadata = null;
         this.status = 'error';
         this.showErrorState();
         this.sendUpdates();
     }
 }
♻️ Duplicate comments (6)
js/editor.js (4)

991-1034: deletedElementsLeft write is dead code

deleteBlock now pushes into both deletedElements and deletedElementsLeft:

deletedElements.push(elementId);
deletedElementsLeft.push(elementId);

But deletedElementsLeft is never read anywhere in the current file; its previous uses were tied to the old batch-sync path.

You can safely drop this array and the associated push to reduce noise.


419-479: handleChanges still ignores data.elements.*.lastUpdated events

The new handleChanges groups only .value and .component under data.elements.*:

if (event.path.startsWith('data.elements.')) {
    if (event.path.includes('.value')) { ... }
    else if (event.path.includes('.component')) { ... }
}

Events on data.elements.<id>.lastUpdated are neither added to any list nor passed to wisk.sync.applyEvent, so remote lastUpdated timestamps are dropped on the floor and the local document never reflects them.

At minimum, those events should still be applied to wisk.editor.document (even if the DOM doesn’t care), e.g. by:

  • Detecting .lastUpdated in the same branch and calling wisk.sync.applyEvent, or
  • Falling back to a generic applyEvent for any unclassified data.elements.* paths.

508-535: Remote component changes don’t update container width class

In handleElementUpdate, a remote data.elements.<id>.component event replaces the inner custom element but doesn’t touch the .rndr-full-width class on its container:

} else if (property === 'component') {
    const newType = event.value.data;
    const newDomElement = document.createElement(newType);
    newDomElement.id = elementId;
    domElement.replaceWith(newDomElement);
    setTimeout(() => {
        newDomElement.setValue('', element.value);
    }, 0);
}

Local changeBlockType does update the container based on pluginDetail.width, but remote viewers of that change will keep whatever width class the old component had.

Consider mirroring the changeBlockType container-class logic here by:

  • Looking up wisk.plugins.getPluginDetail(newType) and
  • Adding/removing rndr-full-width on #div-${elementId} accordingly.

2582-2707: Guard getTextContent() when updating title/name from the first element

In justUpdates, the “first element” special case still does:

if (elementId === wisk.editor.document.data.elements[0].id) {
    const textContent = domElement.getTextContent().text || '';
    ...
}

If domElement.getTextContent is undefined or returns a non-object, this will throw and break the debounced save for all pending elements.

Use a defensive read instead:

const tc = domElement.getTextContent?.();
const textContent = tc?.text || '';

and only update document.title/data.config.name if textContent is non-empty.

js/paste-handler.js (2)

16-31: Log the correct error in the WISK clipboard fallback catch

In parseWiskClipboard, the inner catch (fallbackError) still logs the outer error, which hides whether the base64 decode or the raw‑JSON fallback failed:

} catch (fallbackError) {
    console.error('Failed to parse wisk clipboard data: ', error);
}

This should log fallbackError (and optionally the original error) to distinguish which stage broke and simplify debugging.


566-593: Markdown links still bypass protocol sanitization

parseInlineMarkdown converts [text](url) directly into <link-element url="..."> without validating the URL scheme:

result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
  '<link-element url="$2" display="inline" title="$1" contenteditable="false"></link-element>');

This reopens the same javascript: / data: injection vector that sanitizeAndConvertLinks explicitly guards against for HTML anchors. Please reuse the same safe‑protocol logic here (or call a shared helper), e.g.:

result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
    const raw = url.trim();
    const safe = WiskPasteHandler.normalizeUrl
        ? WiskPasteHandler.normalizeUrl(raw)
        : raw;
    const safeProtocols = /^(https?:|mailto:|tel:|\/|#)/i;
    if (!safeProtocols.test(safe)) return text; // degrade to plain text
    return `<link-element url="${safe}" display="inline" title="${text}" contenteditable="false"></link-element>`;
});
🧹 Nitpick comments (3)
js/plugins/code/main-element.js (1)

1253-1253: Simplify the event name selection logic.

The array .find() pattern on line 1253 is unnecessarily complex. This can be simplified to a direct ternary expression for better readability.

🔎 Proposed simplification
-            const eventName = ['input', 'change'].find(e => e === (/^(text|number|url|email|phone)$/.test(prop.type) ? 'input' : 'change'));
+            const eventName = /^(text|number|url|email|phone)$/.test(prop.type) ? 'input' : 'change';
js/plugins/code/link-preview-element.js (2)

48-79: Consider simplifying nested conditions in arrow-key navigation.

The keyboard navigation logic is functionally correct, but lines 63 and 71 contain redundant nested checks (the outer if already ensures selectionStart === 0 for ArrowLeft at line 62, and selectionStart === value.length for ArrowRight at line 70).

🔎 Proposed simplification
 } else if (event.key === 'ArrowUp' || (event.key === 'ArrowLeft' && this.urlInput.selectionStart === 0)) {
-    if (this.urlInput.selectionStart === 0) {
-        event.preventDefault();
-        const prevElement = wisk.editor.prevElement(this.id);
-        if (prevElement) {
-            wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
-        }
+    event.preventDefault();
+    const prevElement = wisk.editor.prevElement(this.id);
+    if (prevElement) {
+        wisk.editor.focusBlock(prevElement.id, { x: prevElement.value?.textContent?.length || 0 });
     }
 } else if (event.key === 'ArrowDown' || (event.key === 'ArrowRight' && this.urlInput.selectionStart === this.urlInput.value.length)) {
-    if (this.urlInput.selectionStart === this.urlInput.value.length) {
-        event.preventDefault();
-        const nextElement = wisk.editor.nextElement(this.id);
-        if (nextElement) {
-            wisk.editor.focusBlock(nextElement.id, { x: 0 });
-        }
+    event.preventDefault();
+    const nextElement = wisk.editor.nextElement(this.id);
+    if (nextElement) {
+        wisk.editor.focusBlock(nextElement.id, { x: 0 });
     }
 }

117-138: Consider renaming normalized and adding URL validation.

Line 121 assigns the trimmed URL to a variable named normalized, but no actual normalization occurs. This is misleading.

Additionally, there's no URL format validation before accepting the input. While invalid URLs are handled gracefully during fetch (lines 336-356), early validation could improve UX.

🔎 Proposed improvements
 submitUrl() {
     const url = this.urlInput.value.trim();
     if (!url) return;
 
-    const normalized = url;
+    const newUrl = url;
 
-    if (normalized !== this.link) {
-        this.link = normalized;
+    if (newUrl !== this.link) {
+        this.link = newUrl;
         this.metadata = null;
         this.status = 'idle';
     } else {
         if (!this.metadata) this.status = 'idle';
     }

For optional URL validation, you could add a helper method:

_isValidUrl(str) {
    try {
        const url = new URL(str.startsWith('http') ? str : 'https://' + str);
        return url.protocol === 'http:' || url.protocol === 'https:';
    } catch {
        return false;
    }
}

Then validate before assignment.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 85360e2 and 8234f4a.

📒 Files selected for processing (10)
  • js/editor.js
  • js/paste-handler.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/embed-element.js
  • js/plugins/code/link-preview-element.js
  • js/plugins/code/list-element.js
  • js/plugins/code/main-element.js
  • js/plugins/code/numbered-list-element.js
  • js/plugins/code/text-element.js
  • style.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/plugins/code/text-element.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/list-element.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/embed-element.js
  • js/plugins/code/link-preview-element.js
  • js/plugins/code/numbered-list-element.js
  • js/plugins/code/main-element.js
🧬 Code graph analysis (6)
js/plugins/code/list-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
  • prevElement (322-322)
  • prevElement (2277-2277)
js/plugins/code/base-text-element.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/link-preview-element.js (2)
js/editor.js (7)
  • prevElement (322-322)
  • prevElement (2277-2277)
  • nextElement (1424-1424)
  • nextElement (2279-2279)
  • elementToFocus (637-637)
  • elementToFocus (747-747)
  • elementToFocus (2275-2275)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (3)
js/sync/sync.js (5)
  • elementId (212-212)
  • key (207-207)
  • pathParts (172-172)
  • element (213-213)
  • i (174-174)
js/wisk.js (1)
  • wisk (3-3)
js/storage/db.js (1)
  • data (272-272)
js/paste-handler.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/plugins/code/numbered-list-element.js (1)
js/editor.js (2)
  • prevElement (322-322)
  • prevElement (2277-2277)
🔇 Additional comments (13)
js/plugins/code/list-element.js (1)

105-117: Scoped prev-element lookup and dot alignment look correct

Moving prevDomElement inside the prevElement guard avoids accidental use when no previous block exists, and the dot’s vertical offset change is purely visual with no behavioral impact. Nothing blocking here.

Also applies to: 145-152

style.css (1)

24-24: Drag affordances and touch tweaks are consistent with JS drag state

The added .drag-handle / body.is-dragging cursors and touch-related flags on .hover-image line up with the drag state management in js/editor.js and should improve DnD UX on both mouse and touch. No functional issues spotted.

Also applies to: 190-202, 208-223

js/plugins/code/base-text-element.js (1)

973-1107: Paste-link menu, copy, and paste integration look consistent

The new paste-link flow (URL detection in handlePaste, inline link-element placeholder + .paste-link-menu, and conversion to link-preview-element / embed-element) hangs together well:

  • Menu state is localized (showingPasteLinkMenu, pendingPasteUrl, _pendingLinkElement) and cleaned up via hidePasteLinkMenu and a scoped mousedown listener.
  • Keyboard handling (arrows/Enter/Escape) is blocked early in handleKeyDown while the menu is open, which prevents interference with normal editing.
  • handleCopy’s translation of <link-element> to <a> with data-wisk-* attributes provides good interop with external apps while keeping round‑trip metadata.
  • Delegating rich paste flows to WiskPasteHandler.handleTextElementPaste keeps the base element lean.

No blocking issues here.

Also applies to: 1763-1837

js/editor.js (1)

125-186: Config, access, public status, and plugin-data mutators align with event-driven sync

The refactored addConfigChange plus the new helpers (addUserAccess, removeUserAccess, setPublicStatus, setDatabaseProp, savePluginData) consistently:

  • Emit timestamped wisk.sync.newChange events on the appropriate data.config.* / data.pluginData.* paths.
  • Guard keys/identifiers against ./[] to avoid path-injection.
  • Call wisk.sync.saveModification() immediately for config and plugin data, which makes these mutations durable.

This matches the new event-log architecture and looks solid.

Also applies to: 188-276, 278-298

js/plugins/code/embed-element.js (1)

9-190: EmbedElement lifecycle and URL handling look solid

The revised EmbedElement:

  • Properly caches shadow DOM elements in connectedCallback, binds/unbinds listeners, and calls updateView() so early setValue calls aren’t lost.
  • Normalizes user input and pasted values via extractSrcFromIframe and convertToEmbedUrl, forcing https:// where needed and handling common providers (YouTube, Maps, Drive, Gist).
  • Integrates cleanly with the editor: Backspace/Delete remove the block and move focus, Enter inserts a text block, and Arrow navigation respects prev/next blocks.
  • Uses focus() to route focus either to the container (when embedded) or to the URL input (when showing the dialog), which matches expected UX.

No blocking issues here.

Also applies to: 223-259, 260-369

js/plugins/code/link-preview-element.js (8)

1-25: LGTM! Constructor and lifecycle setup is well-structured.

The initialization sequence is sound: the constructor sets up initial state and renders, while connectedCallback properly queries DOM elements, binds events, and conditionally triggers metadata fetch. The _hasConnected flag is a good guard for deferred initialization.


27-115: LGTM! Event listener lifecycle is now correctly managed.

The refactor to store handlers as properties (_onOuterClick, _onUrlInputKeyDown, etc.) and systematically remove them in _removeAllListeners successfully addresses the previous event leak concern. The pattern is solid: remove old listeners before rebinding (line 28), guard against missing elements, and clean up on disconnect.


33-41: LGTM! External navigation is now properly secured.

The addition of 'noopener,noreferrer' on Line 39 correctly addresses the previous security concern. URL normalization with protocol prepending is appropriate for this use case.


140-178: LGTM! Keyboard navigation for the preview state is well-implemented.

The handleKeyDown method correctly implements block-level navigation (arrows), deletion (Backspace/Delete), and new block creation (Enter). The logic properly prevents defaults, focuses appropriate blocks with correct cursor positions, and follows the editor's navigation patterns.


267-414: LGTM! Metadata rendering and state management are robust.

The updatePreviewWithMetadata, showLoadingState, and showErrorState methods handle edge cases well:

  • Null checks prevent errors on missing DOM elements (lines 273, 277, 286, 298, etc.)
  • Favicon onerror handler (lines 290-292) prevents broken images
  • Error state displays the URL (lines 399-401) as a helpful fallback
  • Loading and error states correctly toggle display properties

243-265: LGTM! State machine logic in updateView is well-structured.

The updateView method correctly implements the two-pane toggle and status-based rendering:

  • Empty link → input dialog (lines 261-264)
  • Link with metadata → preview with metadata (lines 250-253)
  • Link with error status → error state (lines 255-258)
  • Link without metadata → loading state (line 260)

The state machine transitions are clear and complete.


422-564: LGTM! Rendering and registration are correctly implemented.

The render method properly sets up the shadow DOM with:

  • Well-structured CSS using CSS custom properties for theming consistency
  • Two-pane HTML structure (input-dialog and preview-content) matching the architectural design
  • Appropriate use of semantic elements and CSS classes

Custom element registration (line 564) is correct.


192-233: The guards in updateLinkPreview are sufficient; no race condition exists.

Lines 230-232 and the guard at line 318 prevent multiple fetches. When updateLinkPreview is called, it immediately sets status = 'loading' (line 320) before any async operations begin. Any subsequent setValue call, even if made in rapid succession, will fail the guard check at line 318 (if (this.status === 'loading'...) return;) and exit early. Since the status transition happens synchronously with no gap between the guard check and the assignment, multiple fetch requests cannot be queued.

Likely an incorrect or invalid review comment.

Comment on lines +1810 to 1849
wisk.editor.renumberNumberedLists = function() {
const elements = wisk.editor.document.data.elements;
const counters = [0]; // Stack for tracking numbers at each indent level

for (let i = 0; i < elements.length; i++) {
const el = elements[i];
if (el.component === 'numbered-list-element') {
const domEl = document.getElementById(el.id);
if (!domEl) continue;

const indent = domEl.indent || 0;

// Adjust counter stack for current indent level
while (counters.length <= indent) {
counters.push(0);
}
while (counters.length > indent + 1) {
counters.pop();
}

// Increment counter for current level
counters[indent]++;

// Update the element's number
const newNumber = counters[indent];
if (domEl.number !== newNumber) {
domEl.number = newNumber;
domEl.updateNumber();
el.value.number = newNumber;
}
} else {
// Reset counters when encountering non-numbered-list element
counters.length = 1;
counters[0] = 0;
}
}

// Save modifications
wisk.editor.justUpdates(elements[0]?.id);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

renumberNumberedLists doesn’t persist updated numbers for all items

renumberNumberedLists correctly recomputes number based on indentation and updates both domEl.number and el.value.number for each numbered-list-element, but at the end it only calls:

wisk.editor.justUpdates(elements[0]?.id);

justUpdates creates events by reading domElement.getValue() for each element in elementUpdatesNeeded. Since only the first element ID is enqueued here, renumbered items at other positions will have their updated number reflected in the DOM but not in the persisted document or remote copies.

A safer pattern is to enqueue every list element whose number actually changed, for example:

const changedIds = new Set();
// inside the loop when domEl.number !== newNumber:
changedIds.add(el.id);
// after the loop:
changedIds.forEach(id => wisk.editor.justUpdates(id));

This ensures all renumbered bullets persist and synchronize correctly.

🤖 Prompt for AI Agents
In js/editor.js around lines 1810 to 1849, renumberNumberedLists updates
domEl.number and el.value.number but only calls
wisk.editor.justUpdates(elements[0]?.id) so only the first changed element is
persisted; collect the IDs of every element whose number actually changed (e.g.
use a Set changedIds and add el.id when domEl.number !== newNumber), and after
the loop iterate changedIds calling wisk.editor.justUpdates(id) for each so all
renumbered items are enqueued for persistence/sync.

Comment on lines +493 to +506
} else if (element.elementName === 'image-element') {
flattenedElements.push({
elementName: element.elementName,
value: {
imageUrl: element.value,
textContent: '',
},
});
} else if (element.elementName === 'table-element') {
flattenedElements.push({
elementName: element.elementName,
value: element.value,
});
} else if (element.elementName === 'code-element') {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

flattenElements mishandles image-element values

processNode('img') and other flows set element.value to an object { imageUrl, textContent }, but flattenElements treats it as a scalar:

} else if (element.elementName === 'image-element') {
    flattenedElements.push({
        elementName: element.elementName,
        value: {
            imageUrl: element.value,
            textContent: '',
        },
    });
}

This produces imageUrl: [object Object] and drops any existing textContent. You likely want to preserve the object:

} else if (element.elementName === 'image-element') {
    const val = element.value || {};
    flattenedElements.push({
        elementName: element.elementName,
        value: {
            imageUrl: val.imageUrl || '',
            textContent: val.textContent || '',
        },
    });
}
🤖 Prompt for AI Agents
In js/paste-handler.js around lines 493 to 506, flattenElements treats
element.value for 'image-element' as a scalar and wraps it into imageUrl,
causing imageUrl to become "[object Object]" and dropping any existing
textContent; update the branch to read element.value as an object (guarding for
null/undefined), extract imageUrl and textContent from it (defaulting to empty
strings), and push an object with those two fields so existing imageUrl and
textContent are preserved.

Comment on lines 1601 to 1606
updatePlaceholder() {
if (this.editable) {
const isEmpty = this.editable.innerText.trim() === '';
const hasText = this.editable.innerText.trim() !== '';
const hasChildElements = this.editable.childElementCount > 0;
const isEmpty = !hasText && !hasChildElements;
this.editable.classList.toggle('empty', isEmpty);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Placeholder detection will treat structural children as non-empty

updatePlaceholder now uses:

const hasText = this.editable.innerText.trim() !== '';
const hasChildElements = this.editable.childElementCount > 0;
const isEmpty = !hasText && !hasChildElements;

Browsers often insert <br> or similar structural nodes into an otherwise empty contenteditable, so childElementCount > 0 can make isEmpty false even when the block looks blank. That can prevent the placeholder from ever appearing.

Consider following the earlier pattern of ignoring “structural only” children, e.g.:

const hasChildElements = Array.from(this.editable.children).some(
    el => el.tagName !== 'BR'
);
const isEmpty = !hasText && !hasChildElements;

This keeps placeholders aligned with what users see.

🤖 Prompt for AI Agents
In js/plugins/code/base-text-element.js around lines 1601 to 1606, the
placeholder detection currently treats any child element (e.g. browser-inserted
<br>) as non-empty which prevents the placeholder from showing; change the child
check to ignore structural-only children by checking this.editable.children and
treating elements like BR (and other known structural-only tags if needed) as
non-counting, then compute isEmpty = !hasText && !hasChildElements so that only
meaningful child elements mark the node as non-empty.

Comment on lines 669 to +676
.empty-emoji {
background: var(--bg-2) !important;
opacity: 0;
padding: 8px 12px;
}
.header-container:hover .empty-emoji {
opacity: 1;
background: var(--bg-2)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix missing semicolon in CSS.

Line 675 is missing a semicolon after var(--bg-2), which is a CSS syntax error.

🔎 Proposed fix
 .empty-emoji {
     opacity: 0;
     padding: 8px 12px;
 }
 .header-container:hover .empty-emoji {
     opacity: 1;
-    background: var(--bg-2)
+    background: var(--bg-2);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.empty-emoji {
background: var(--bg-2) !important;
opacity: 0;
padding: 8px 12px;
}
.header-container:hover .empty-emoji {
opacity: 1;
background: var(--bg-2)
}
.empty-emoji {
opacity: 0;
padding: 8px 12px;
}
.header-container:hover .empty-emoji {
opacity: 1;
background: var(--bg-2);
}
🤖 Prompt for AI Agents
In js/plugins/code/main-element.js around lines 669 to 676, the CSS rule
".header-container:hover .empty-emoji { background: var(--bg-2) }" is missing a
trailing semicolon; add a semicolon after var(--bg-2) (i.e., "background:
var(--bg-2);") to fix the syntax error and ensure proper CSS parsing.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/plugins/code/table-element.js (1)

133-139: Remove duplicate CSS declarations.

Lines 133-135 declare max-width, min-width, and width for td, but lines 137-139 immediately redeclare the same properties with different values. The second declaration overrides the first, making lines 133-135 ineffective and confusing.

🔎 Proposed fix
 td {
     height: 40px;
-    max-width: 400px;
-    min-width: 100px;
-    width: auto;
-
     min-width: 200px;
     max-width: 400px;
     width: auto;
 }
♻️ Duplicate comments (5)
js/editor.js (5)

992-1037: Remove unused deletedElementsLeft variable.

The deleteBlock function correctly emits deletion events and persists changes, but line 1024 still pushes to deletedElementsLeft, which was flagged in a previous review as unused. This variable (declared at line 4) should be removed along with all references to clean up dead code.


1204-1204: Template application won't persist without element IDs.

At line 1204, useTemplate calls wisk.editor.justUpdates() with no arguments after loading template elements. The new justUpdates implementation (line 2620-2622) returns early when elementId is undefined, so template changes won't be persisted.

Consider either:

  1. Looping over template elements and calling justUpdates(element.id) for each, or
  2. Detecting the no-argument case and enqueuing all elements:
if (!elementId) {
    // enqueue all elements for update
    wisk.editor.document.data.elements.forEach(e => {
        if (e.id) elementUpdatesNeeded.add(e.id);
    });
    return;
}

1821-1860: Only first renumbered element is persisted.

The renumberNumberedLists function updates all list elements' numbers correctly, but line 1859 only calls wisk.editor.justUpdates(elements[0]?.id). This means only the first element's changes are enqueued for persistence. As noted in a previous review, you should collect all changed element IDs and call justUpdates for each:

const changedIds = new Set();
// Inside the loop when updating:
if (domEl.number !== newNumber) {
    domEl.number = newNumber;
    domEl.updateNumber();
    el.value.number = newNumber;
    changedIds.add(el.id);
}
// After the loop:
changedIds.forEach(id => wisk.editor.justUpdates(id));

2647-2719: Consider beforeunload handler to prevent data loss.

The 300ms debounce (line 2647) creates a window where pending updates in elementUpdatesNeeded could be lost if the user closes the tab or navigates away. While the inline comment (line 2643) acknowledges this, adding a beforeunload handler to flush pending updates would reduce the risk:

window.addEventListener('beforeunload', (e) => {
    if (elementUpdatesNeeded.size > 0) {
        clearTimeout(debounceTimer);
        const timestamp = Date.now();
        elementUpdatesNeeded.forEach(elementId => {
            const domElement = document.getElementById(elementId);
            if (domElement) {
                wisk.sync.newChange({
                    path: `data.elements.${elementId}.value`,
                    value: { 
                        data: domElement.getValue(), 
                        timestamp, 
                        agent: wisk.sync.agent 
                    }
                });
            }
        });
        // Note: Can't reliably await in beforeunload
    }
});

Alternatively, consider reducing the debounce to 100-150ms to narrow the loss window.


2693-2707: Guard against null getTextContent() for first element.

Line 2694 accesses domElement.getTextContent().text without checking if getTextContent() returns a valid object. If the method is undefined or returns null, this will throw. Use optional chaining:

const textContentObj = domElement.getTextContent?.();
const textContent = textContentObj?.text || '';

if (textContent) {
    document.title = textContent;
    wisk.sync.newChange({
        path: 'data.config.name',
        value: {
            data: textContent,
            timestamp: timestamp,
            agent: wisk.sync.agent
        }
    });
}
🧹 Nitpick comments (1)
js/theme/theme.js (1)

54-62: Consider revoking old favicon blob URLs to prevent memory leaks.

Each theme change creates a new blob URL without revoking the previous one. While theme changes are infrequent, storing the previous URL and calling URL.revokeObjectURL() before creating a new one would prevent gradual memory accumulation in long-running sessions.

🔎 Proposed enhancement

Track and revoke the previous blob URL:

+let currentFaviconBlobUrl = null;
+
 wisk.theme.setTheme = async function (themeName) {
     // ... existing code ...
 
     let favicon = document.querySelector('link[rel="icon"]');
     if (!favicon) {
         favicon = document.createElement('link');
         favicon.rel = 'icon';
         favicon.type = 'image/svg+xml';
         document.head.appendChild(favicon);
     }
+    if (currentFaviconBlobUrl) {
+        URL.revokeObjectURL(currentFaviconBlobUrl);
+    }
-    favicon.href = createThemedFaviconSVG(textColor, bgColor);
+    currentFaviconBlobUrl = createThemedFaviconSVG(textColor, bgColor);
+    favicon.href = currentFaviconBlobUrl;
 
     // ... rest of function ...
 };
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8234f4a and 249b767.

📒 Files selected for processing (8)
  • css/pages.css
  • js/editor.js
  • js/elements/command-palette.js
  • js/left-sidebar.js
  • js/plugins/code/super-checkbox.js
  • js/plugins/code/table-element.js
  • js/theme/theme.js
  • style.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • style.css
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/table-element.js
  • js/plugins/code/super-checkbox.js
🧬 Code graph analysis (3)
js/left-sidebar.js (2)
js/right-sidebar.js (7)
  • startX (28-28)
  • startWidth (29-29)
  • sidebar (15-15)
  • sidebar (64-64)
  • sidebar (83-83)
  • sidebar (92-92)
  • sidebar (164-164)
script.js (1)
  • sidebar (10-10)
js/theme/theme.js (1)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
js/sync/sync.js (4)
  • elementId (212-212)
  • pathParts (172-172)
  • element (213-213)
  • i (174-174)
js/left-sidebar.js (1)
  • latestClientX (31-31)
🔇 Additional comments (27)
js/plugins/code/table-element.js (2)

199-199: LGTM! Good refactoring to eliminate code duplication.

Extracting the inline Markdown generation logic into the getMarkdownText() method improves maintainability and follows the DRY principle.


203-248: LGTM! Well-implemented Markdown table generation.

The getMarkdownText() method correctly handles:

  • Edge cases with empty headers/rows (lines 206-208)
  • Null/undefined cell values with the ?? operator (lines 214, 224, 238)
  • Proper escaping of Markdown special characters
  • Column width calculation and alignment
css/pages.css (1)

12-13: LGTM! CSS variable migration enables theming.

The migration to CSS variables with fallback values is well-executed and aligns with the broader theme customization work in this PR.

js/left-sidebar.js (2)

30-31: LGTM! RAF throttling pattern aligns with project-wide improvements.

The introduction of resizeScheduled and latestClientX implements proper requestAnimationFrame throttling to prevent excessive reflows during resize operations, consistent with similar patterns in js/editor.js.


47-60: LGTM! RAF-throttled resize correctly batches DOM updates.

The resize function properly accumulates mouse position and applies width updates in a single requestAnimationFrame callback. The calculation from the original startX throughout the drag session is correct—it ensures the width reflects the total displacement from the drag start point.

js/elements/command-palette.js (1)

13-13: LGTM! Migration to modern key property improves cross-browser compatibility.

The replacement of deprecated keyCode checks with e.key string comparisons is correct and improves code readability. All key mappings are accurate (80→'p', 27→'Escape', 13→'Enter', 38→'ArrowUp', 40→'ArrowDown').

Also applies to: 49-49, 54-54, 58-58, 68-68, 76-76

js/plugins/code/super-checkbox.js (1)

14-14: LGTM! Bound handler pattern ensures proper lifecycle management.

Pre-binding onCheckboxChange in the constructor and using the same reference for addEventListener/removeEventListener ensures proper cleanup. The guard check in disconnectedCallback prevents errors if the checkbox wasn't initialized.

Also applies to: 36-36, 43-45

js/editor.js (17)

24-24: LGTM! Drag state management and touch handling improvements.

The addition of dragUpdateScheduled for RAF throttling and enhanced touch event handling with proper passive settings and drag state guards are well-implemented. The !dragState check on touchend correctly prevents accidental clicks when a drag operation is in progress.

Also applies to: 41-41, 58-67


126-187: LGTM! Config mutations now emit timestamped events.

The refactored addConfigChange properly wraps each case in a block (preventing scope leakage), generates timestamps, and emits events through the new event-driven architecture before persisting. The switch cases now correctly handle config.name, config.theme, and config.plugins mutations.


189-277: LGTM! New config mutators with proper validation and event emission.

The new functions (addUserAccess, removeUserAccess, setPublicStatus, setDatabaseProp) follow a consistent pattern: validate input, initialize data structures if needed, emit timestamped events, and persist immediately. The key validation in setDatabaseProp (line 255) correctly prevents path injection by rejecting keys containing ., [, or ].


279-299: LGTM! Plugin data mutations sanitized and event-driven.

savePluginData correctly validates the identifier (line 281-284) to prevent path injection, emits timestamped events, and persists immediately. The validation pattern matches setDatabaseProp and effectively blocks malicious path traversal.


320-356: LGTM! Block creation now emits events and uses queued persistence.

The addition of lastUpdated timestamp (line 321) and the event emission for element creation and order changes (lines 337-356) integrate well with the event-driven architecture. The use of wisk.sync.enqueueSave('block-creation') instead of direct saveModification addresses the previous concern about overlapping saves by centralizing persistence through a queue.


420-480: LGTM! Event-driven architecture with specialized handlers.

The refactored handleChanges properly groups events by type and delegates to specialized handlers (handleElementCreation, handleElementUpdate, handleElementDeletions, etc.). This provides clear separation of concerns and makes the event processing flow easier to follow.


483-508: LGTM! Specialized event handlers properly apply mutations.

handleElementCreation, handleElementDeletions, handleConfigChange, and handlePluginDataChange correctly apply events to the document and perform corresponding DOM/UI updates. The duplication check in handleElementCreation (line 486) prevents double-creation.

Also applies to: 539-605


686-695: LGTM! Block moves now emit order events and use queued persistence.

The moveBlock function correctly emits a data.elementOrder event with timestamps and uses enqueueSave('block-move') to queue the persistence, avoiding overlapping saves.


1071-1145: LGTM! Block type changes emit events and update width classes.

The refactored changeBlockType function now:

  1. Emits three timestamped events (component, value, lastUpdated)
  2. Correctly updates the container's rndr-full-width class based on the new plugin's width property (lines 1120-1128)
  3. Persists changes asynchronously

This addresses the previous review concern about missing width class updates on component changes.


1669-1729: LGTM! Enhanced drag lifecycle with proper cleanup.

The drag handling now includes:

  • Escape key to cancel drag (lines 1700-1704)
  • Comprehensive cleanup in cleanupDrag that removes listeners, clears intervals, and resets state
  • Proper event listener management for blur/keydown/mouse/touch events

This ensures drag operations can be cleanly cancelled and don't leak resources.


1731-1757: LGTM! RAF-throttled drag updates prevent performance issues.

The handleDrag function correctly implements requestAnimationFrame throttling: it accumulates the latest mouse position in dragState, guards against multiple RAF calls with dragUpdateScheduled, and performs all DOM updates in a single RAF callback. This pattern matches the improvements in left-sidebar.js and reduces reflows during drag operations.


2075-2097: LGTM! Shadow root handling correctly isolates editable content.

The getTextNodes helper (lines 2084-2089) properly queries for the #editable element within shadow roots, ensuring selection overlays only highlight actual editable content rather than the entire shadow tree (e.g., buttons, controls). This aligns with the component structure described in the retrieved learnings about BaseTextElement.


2317-2367: LGTM! HTML sanitization blocks dangerous protocols.

The sanitizeHtmlForClipboard function correctly:

  1. Strips all link attributes and rebuilds them safely
  2. Validates href protocols against a whitelist (http:, https:, mailto:, tel:)
  3. Rejects dangerous protocols (javascript:, data:, vbscript:) that could enable XSS
  4. Handles URL parsing errors gracefully

This addresses the previous security concern about unsanitized clipboard HTML.


2382-2420: LGTM! Clipboard serialization prevents corruption.

The use of sanitizeHtmlForClipboard (lines 2385-2387) and Base64 encoding (lines 2417-2420) prevents both XSS attacks and JSON corruption from HTML entities. The encoding pattern btoa(unescape(encodeURIComponent(jsonString))) correctly handles UTF-8 characters.


901-948: LGTM! Switch case blocks prevent variable scope leakage.

All switch cases that declare variables (cite-element, a, sup, span, etc.) are now properly wrapped in block scopes. This prevents const/let declarations from leaking to other cases, addressing the previous review concern about the sup case (lines 928-936).


1395-1421: LGTM! Markdown conversion order prevents conflicts.

The reordering of inline markdown transformations is well-documented and correct:

  1. Citations first (to avoid link regex interference)
  2. Code spans second (to preserve literal content)
  3. Formatting (bold/italic/strike)
  4. Links (before reference markers)
  5. Reference numbers last

This sequence prevents regex conflicts and ensures proper nesting of elements.


2593-2622: LGTM! Event routing and nested element handling.

The new justUpdates signature correctly handles:

  1. Event objects passed directly (critical path with immediate save)
  2. Nested element IDs (delegates to parent editor)
  3. Invalid inputs (early return)

The pattern of adding to elementUpdatesNeeded Set and deferring event creation to the debounce callback is a clean separation.

js/theme/theme.js (3)

35-40: Good improvement: Prevents duplicate style elements.

Reusing the existing style element avoids accumulating multiple identical <style> tags in the DOM on repeated theme changes.


54-60: Good improvement: Reuses existing favicon element.

This prevents duplicate <link rel="icon"> elements from being appended on repeated theme changes.


136-142: LGTM: Proper async handling in theme initialization.

The loop correctly uses a local themeName variable to avoid closure issues, and the registered callbacks properly await both setTheme and addConfigChange.

Comment on lines +510 to 536
const handleElementUpdate = async (event) => {
const pathParts = event.path.split('.');
const elementId = pathParts[2];
const property = pathParts[3]; // 'value', 'component', 'lastUpdated'

if (domElement.tagName.toLowerCase() !== updatedElement.component) {
const prevElement = wisk.editor.prevElement(updatedElement.id);
if (prevElement) {
wisk.editor.changeBlockType(updatedElement.id, updatedElement.value, updatedElement.component, rec);
}
} else {
const element = wisk.editor.getElement(elementId);
const domElement = document.getElementById(elementId);

if (!element || !domElement) {
console.warn(`Element ${elementId} not found for update`);
return;
}
wisk.sync.applyEvent(wisk.editor.document, event);
if (property === 'value') {
setTimeout(() => {
domElement.setValue('', event.value.data);
}, 0);
} else if (property === 'component') {
const newType = event.value.data;
const newDomElement = document.createElement(newType);
newDomElement.id = elementId;
domElement.replaceWith(newDomElement);
setTimeout(() => {
domElement.setValue('', updatedElement.value);
newDomElement.setValue('', element.value);
}, 0);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing width class update on component change.

When handleElementUpdate processes a component change (line 527-535), it replaces the DOM element but doesn't update the container's rndr-full-width class based on the new plugin's width property. This is the same issue flagged in a previous review for changeBlockType (lines 1114-1137), which was reportedly fixed.

A past review comment indicated this should be addressed, but the current code still lacks the width class logic here. Consider adding:

} else if (property === 'component') {
    const newType = event.value.data;
    const newDomElement = document.createElement(newType);
    newDomElement.id = elementId;
    domElement.replaceWith(newDomElement);
    
    // Update container width class
    const container = document.getElementById(`div-${elementId}`);
    if (container) {
        const pluginDetail = wisk.plugins.getPluginDetail(newType);
        if (pluginDetail.width === 'max') {
            container.classList.add('rndr-full-width');
        } else {
            container.classList.remove('rndr-full-width');
        }
    }
    
    setTimeout(() => {
        newDomElement.setValue('', element.value);
    }, 0);
}
🤖 Prompt for AI Agents
In js/editor.js around lines 510 to 536, when handling a component change the
code replaces the DOM element but omits updating the container's rndr-full-width
class based on the new plugin's width; add logic after creating/replacing the
new element to find the container (id `div-<elementId>`), fetch plugin details
via wisk.plugins.getPluginDetail(newType), and add or remove the rndr-full-width
class depending on whether pluginDetail.width === 'max' before calling setValue
so the container width reflects the new component type.

Comment on lines +105 to +113
const themeName = themeData.name;
wisk.editor.registerCommand(themeName, '', 'Theme', async () => {
wisk.theme.setTheme(themeName);
await wisk.editor.addConfigChange('document.config.theme', themeName);
}, '');

console.log('Theme "' + themeData.name + '" added successfully');
wisk.theme.setTheme(themeData.name);
console.log('Theme "' + themeName + '" added successfully');
wisk.theme.setTheme(themeName);
wisk.editor.addConfigChange('document.config.theme', themeName);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing await on async operations causes race condition.

Lines 112-113 call setTheme and addConfigChange without awaiting them, even though:

  • setTheme is declared async (line 3)
  • addConfigChange is awaited in the registered command callbacks (lines 108, 113, 140)
  • initTheme properly awaits setTheme (line 143)

This inconsistency can cause the config change to be lost or not persisted before addTheme returns, leading to data inconsistency.

🔎 Proposed fix
-wisk.theme.addTheme = function (themeData) {
+wisk.theme.addTheme = async function (themeData) {
     try {
         themeData = JSON.parse(themeData);
     } catch (error) {
@@ -109,8 +109,8 @@
     }, '');
 
     console.log('Theme "' + themeName + '" added successfully');
-    wisk.theme.setTheme(themeName);
-    wisk.editor.addConfigChange('document.config.theme', themeName);
+    await wisk.theme.setTheme(themeName);
+    await wisk.editor.addConfigChange('document.config.theme', themeName);
 
     return true;
 };
🤖 Prompt for AI Agents
In js/theme/theme.js around lines 105 to 113, the calls to
wisk.theme.setTheme(themeName) and
wisk.editor.addConfigChange('document.config.theme', themeName) are invoked
without awaiting them, which can cause a race where the config change is not
persisted; change these calls to await wisk.theme.setTheme(themeName) and await
wisk.editor.addConfigChange('document.config.theme', themeName), ensure the
enclosing function is declared async if it isn’t already, and keep the
console.log after the awaited calls so it only runs once the operations
complete.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/editor.js (1)

1206-1206: Template changes won't persist due to empty justUpdates() call.

Line 1206 calls wisk.editor.justUpdates() with no arguments after replacing document elements with template data. With the new implementation, this returns early (line 2625 in justUpdates), so template changes are never persisted to the event log or saved.

This was flagged in a previous review but remains unresolved.

🔎 Possible fixes

Option 1: Loop through template elements

     wisk.theme.setTheme(template.theme);
     wisk.editor.document.data.elements = template.elements;
 
     await wisk.editor.addConfigChange('document.config.theme', template.theme);
 
-    wisk.editor.justUpdates();
+    // Persist all template elements
+    template.elements.forEach(element => {
+        if (element.id) {
+            wisk.editor.justUpdates(element.id);
+        }
+    });

Option 2: Teach justUpdates to handle "all elements"

     if (!elementId) {
-        return;
+        // Enqueue all elements for update
+        wisk.editor.document.data.elements.forEach(e => {
+            if (e.id) elementUpdatesNeeded.add(e.id);
+        });
+        // Don't return, let debounce handle it
     }
♻️ Duplicate comments (4)
js/plugins/code/base-text-element.js (1)

1615-1622: The placeholder detection still treats structural children as non-empty.

The updatePlaceholder() logic uses childElementCount > 0 to detect whether the contenteditable element has child elements. However, browsers insert structural nodes like <br> into empty contenteditable elements, which causes this check to incorrectly mark the element as non-empty.

Commit 85360e2 refactored when updatePlaceholder() is called, but did not address the underlying logic. The method still lacks filtering for structural children.

Consider filtering out structural elements:

Suggested fix
-const hasChildElements = this.editable.childElementCount > 0;
+const hasChildElements = Array.from(this.editable.children).some(
+    el => el.tagName !== 'BR'
+);
js/editor.js (3)

2697-2711: Potential null reference accessing getTextContent().

Line 2698 calls domElement.getTextContent().text without checking if getTextContent() returns null or an object without the text property. This was flagged in a previous review but remains unaddressed.

🔎 Proposed fix
             // special case: first element
             if(elementId === wisk.editor.document.data.elements[0].id) {
-                const textContent = domElement.getTextContent().text || '';
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';
 
                 if(textContent) {

1825-1864: Still only persisting first element in renumberNumberedLists.

The function correctly renumbers all list elements (lines 1850-1853), but line 1863 only calls wisk.editor.justUpdates(elements[0]?.id). This means only the first element's updated number is persisted, while other renumbered items won't sync to the document or remote copies.

This issue was previously flagged but remains unresolved.

🔎 Recommended fix
+    const changedIds = new Set();
     for (let i = 0; i < elements.length; i++) {
         const el = elements[i];
         if (el.component === 'numbered-list-element') {
             const domEl = document.getElementById(el.id);
             if (!domEl) continue;
 
             const indent = domEl.indent || 0;
 
             // ... counter logic ...
 
             // Update the element's number
             const newNumber = counters[indent];
             if (domEl.number !== newNumber) {
+                changedIds.add(el.id);
                 domEl.number = newNumber;
                 domEl.updateNumber();
                 el.value.number = newNumber;
             }
         } else {
             // Reset counters when encountering non-numbered-list element
             counters.length = 1;
             counters[0] = 0;
         }
     }
 
     // Save modifications
-    wisk.editor.justUpdates(elements[0]?.id);
+    changedIds.forEach(id => wisk.editor.justUpdates(id));
 }

512-538: Missing container width class update on component change.

When handling a component change (lines 529-537), the code replaces the DOM element but doesn't update the parent container's rndr-full-width class based on the new plugin's width property. This was previously flagged and reportedly fixed for changeBlockType (lines 1116-1139), but the same logic is missing here in handleElementUpdate.

🔎 Proposed fix
     } else if (property === 'component') {
         const newType = event.value.data;
         const newDomElement = document.createElement(newType);
         newDomElement.id = elementId;
         domElement.replaceWith(newDomElement);
+        
+        // Update container width class
+        const container = document.getElementById(`div-${elementId}`);
+        if (container) {
+            const pluginDetail = wisk.plugins.getPluginDetail(newType);
+            if (pluginDetail.width === 'max') {
+                container.classList.add('rndr-full-width');
+            } else {
+                container.classList.remove('rndr-full-width');
+            }
+        }
+        
         setTimeout(() => {
             newDomElement.setValue('', element.value);
         }, 0);
     }
🧹 Nitpick comments (4)
js/plugins/code/options-component.js (1)

1131-1136: Consider adding defensive error handling.

If an invalid font-size value is stored in localStorage (e.g., from manual editing or a future code change), option will be undefined and the CSS variable won't be set. Consider adding a fallback:

🔎 Optional: Add fallback for invalid values
 applyFontSize(sizeValue) {
     const option = this.fontSizeOptions.find(opt => opt.value === sizeValue);
     if (option) {
         document.documentElement.style.setProperty('--editor-font-size', option.size);
+    } else {
+        // Fallback to default if invalid value
+        const defaultOption = this.fontSizeOptions.find(opt => opt.value === 'default');
+        if (defaultOption) {
+            document.documentElement.style.setProperty('--editor-font-size', defaultOption.size);
+        }
     }
 }
js/editor.js (3)

128-189: Consider adding error handling for saveModification.

The await wisk.sync.saveModification() at line 188 could throw an error if the save operation fails. While the function is properly awaited, there's no try-catch to handle potential failures gracefully.

🔎 Optional improvement
         }
     }
-    await wisk.sync.saveModification();
+    try {
+        await wisk.sync.saveModification();
+    } catch (error) {
+        console.error('Failed to save config change:', error);
+        wisk.utils.showToast?.('Failed to save changes', 2000);
+    }
 };

191-217: Consider validating email format in addUserAccess.

The function accepts any string as an email without format validation. While this may be intentional for flexibility, invalid email formats could lead to issues downstream.

🔎 Optional email validation
 wisk.editor.addUserAccess = async function (email) {
+    // Basic email format validation
+    if (!email || typeof email !== 'string' || !email.includes('@')) {
+        console.error('Invalid email format:', email);
+        return;
+    }
+    
     const timestamp = Date.now();
 
     // Initialize access array if it doesn't exist

1396-1421: Consider link protocol validation in markdown conversion.

Line 1417 creates links with href="$2" without protocol validation. While markdown input is typically trusted, if user-provided markdown is ever processed, this could allow javascript: or data: URLs.

🔎 Optional safety improvement
     // Links (must be before reference numbers)
-    text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" contenteditable="false" target="_blank">$1</a>');
+    text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
+        // Only allow safe protocols
+        if (href && !/^(https?|mailto|tel):/i.test(href) && !href.startsWith('/') && !href.startsWith('#')) {
+            // Treat as relative URL
+        }
+        return `<a href="${href}" contenteditable="false" target="_blank">${text}</a>`;
+    });
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 249b767 and 4aaa9ed.

📒 Files selected for processing (26)
  • css/pages.css
  • js/editor.js
  • js/elements/home-element.js
  • js/plugins/code/accordion-element.js
  • js/plugins/code/base-layout-element.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/callout-element.js
  • js/plugins/code/checkbox-element.js
  • js/plugins/code/columns-element.js
  • js/plugins/code/heading1-element.js
  • js/plugins/code/heading2-element.js
  • js/plugins/code/heading3-element.js
  • js/plugins/code/heading4-element.js
  • js/plugins/code/heading5-element.js
  • js/plugins/code/image-element.js
  • js/plugins/code/list-element.js
  • js/plugins/code/main-element.js
  • js/plugins/code/numbered-list-element.js
  • js/plugins/code/options-component.js
  • js/plugins/code/quote-element.js
  • js/plugins/code/super-checkbox.js
  • js/plugins/code/text-element.js
  • js/plugins/code/video-element.js
  • js/theme/variables.css
  • js/wisk.js
  • style.css
✅ Files skipped from review due to trivial changes (4)
  • js/plugins/code/accordion-element.js
  • js/plugins/code/heading5-element.js
  • js/plugins/code/video-element.js
  • js/plugins/code/base-layout-element.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • js/plugins/code/main-element.js
  • js/plugins/code/numbered-list-element.js
  • style.css
  • css/pages.css
  • js/plugins/code/super-checkbox.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/columns-element.js
  • js/plugins/code/checkbox-element.js
  • js/plugins/code/callout-element.js
  • js/plugins/code/heading4-element.js
  • js/plugins/code/image-element.js
  • js/plugins/code/heading3-element.js
  • js/plugins/code/heading1-element.js
  • js/plugins/code/options-component.js
  • js/plugins/code/base-text-element.js
  • js/plugins/code/list-element.js
  • js/plugins/code/quote-element.js
  • js/plugins/code/heading2-element.js
  • js/plugins/code/text-element.js
🧬 Code graph analysis (2)
js/plugins/code/list-element.js (2)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (2)
  • prevElement (325-325)
  • prevElement (2292-2292)
js/editor.js (4)
js/sync/sync.js (5)
  • elementId (212-212)
  • key (207-207)
  • pathParts (172-172)
  • element (213-213)
  • i (174-174)
js/storage/db.js (1)
  • data (272-272)
js/left-sidebar.js (1)
  • latestClientX (31-31)
script.js (1)
  • editor (76-76)
🔇 Additional comments (47)
js/elements/home-element.js (2)

529-532: LGTM! Improved hover behavior for tree icons.

The addition of :has(.arrow) properly constrains the hover-hide behavior to only items with children, ensuring that leaf nodes keep their emoji/icon visible on hover since they have no arrow to display.


1295-1295: LGTM! Fixes template duplication.

Using slice(3) correctly shows only the remaining templates in the expanded section, avoiding duplication of the first 3 templates already displayed above.

js/plugins/code/options-component.js (4)

526-532: LGTM: Styling accommodates the new font-size control.

The CSS adjustments ensure the new jalebi-select component fits properly within the options layout and labels don't wrap awkwardly.


718-718: LGTM: Property correctly declared.

The editorFontSize property is properly typed and will trigger re-renders when updated.


737-747: LGTM: Font-size initialization is well-structured.

The font-size options provide a good range, and the initialization properly reads from localStorage with a sensible default. Applying the font size immediately on construction ensures consistency on page load.


1589-1594: LGTM: UI control is properly wired.

The font-size selector is correctly integrated into the main options view with proper event binding and state management.

js/plugins/code/heading4-element.js (1)

20-20: LGTM: Font-size scales correctly with editor font-size.

The heading4 element now responds to user font-size preferences with a 10% increase over the base editor font size. The fallback value matches the default in variables.css.

js/plugins/code/heading1-element.js (1)

20-20: LGTM: Font-size scaling maintains heading hierarchy.

The heading1 element uses a 2x multiplier, which maintains proper visual hierarchy with other headings (heading2 at 1.5x, heading3 at 1.25x, heading4 at 1.1x). The implementation correctly responds to user font-size preferences.

js/theme/variables.css (1)

49-49: LGTM: CSS variable provides global font-size baseline.

The --editor-font-size variable is correctly defined in :root with a sensible default of 17px, which matches the 'default' option in the UI controls.

js/plugins/code/columns-element.js (1)

821-821: LGTM: Padding now uses design token.

The context menu item padding has been updated to use the CSS variable var(--padding-3). Note that --padding-3 is defined as 8px in variables.css, which means both vertical and horizontal padding are now 8px (previously the horizontal padding was 10px). This improves consistency across the codebase by using design tokens.

js/plugins/code/heading3-element.js (1)

20-20: LGTM! Font-size now scales with the editor configuration.

The change to use calc(var(--editor-font-size, 17px) * 1.25) aligns heading3 with the global font-size system introduced in this PR, enabling users to customize typography.

js/plugins/code/checkbox-element.js (1)

397-397: LGTM! Font-size now uses the configurable editor font.

The editable area now references var(--editor-font-size, 17px), allowing checkbox text to scale with user preferences.

js/plugins/code/callout-element.js (1)

108-108: LGTM! Callout font-size now matches the editor standard.

The change to var(--editor-font-size, 17px) integrates with the global font-size system and updates the fallback from 16px to 17px.

js/plugins/code/heading2-element.js (1)

20-20: LGTM! Heading2 font-size now scales proportionally.

The calc(var(--editor-font-size, 17px) * 1.5) change establishes a proper heading hierarchy (h2 at 1.5x, h3 at 1.25x) while supporting user font customization.

js/plugins/code/image-element.js (1)

649-649: LGTM! Image caption font-size now scales with editor settings.

The change to calc(var(--editor-font-size, 17px) * 0.85) makes the caption proportional to the base font while maintaining appropriate sizing (slightly smaller than body text).

js/plugins/code/quote-element.js (1)

13-13: LGTM! Dynamic font sizing enhances consistency.

The shift from fixed 1.5rem to calc(var(--editor-font-size, 17px) * 1.2) ensures quotes scale with the editor-wide font size setting while maintaining appropriate visual hierarchy.

js/wisk.js (1)

86-93: LGTM! Font size initialization is properly defensive.

The self-invoking function safely initializes --editor-font-size from localStorage with appropriate fallbacks. The semantic-to-pixel mapping is clear and aligns with the default of 17px used throughout the codebase.

js/plugins/code/list-element.js (2)

105-107: LGTM! Defensive access prevents potential null reference error.

Moving prevDomElement retrieval inside the if (prevElement) block ensures it's only accessed when prevElement exists, preventing a possible runtime exception.


147-147: LGTM! Styling updates align with editor-wide font sizing.

The dot positioning change (from centering transform to top: 0.6em) simplifies alignment, and the dynamic font-size adoption ensures consistency with the centralized --editor-font-size variable.

Also applies to: 158-158

js/plugins/code/text-element.js (3)

15-17: LGTM! Editable area refinements improve usability.

The increased min-height (1em → 1.5em), added vertical padding, and dynamic font sizing enhance both clickability and visual consistency with the editor-wide font system.


27-32: LGTM! Link styling maintains accent color consistency.

The shift from --fg-blue to --fg-accent aligns with the theme system, and the visited link rule ensures consistent accent coloring across link states.


115-167: LGTM! Paste-link menu UI integrates cleanly with base class logic.

The CSS provides proper layering, smooth transitions, and interactive states, while the markup (URL/Bookmark/Embed options) aligns with the base-text-element methods that query and control this menu. The initial hidden state (opacity: 0, pointer-events: none) prevents premature interaction.

Based on learnings, this inheritance pattern is intentional: BaseTextElement provides state management and logic, while TextElement overrides render() to inject the UI markup.

js/plugins/code/base-text-element.js (10)

58-62: LGTM! Paste-link state properly initialized and bound.

The paste-link menu state fields and DOM binding in connectedCallback follow the established inheritance pattern where the base class queries for elements rendered by child classes.

Based on learnings, this is the expected pattern for this plugin system.

Also applies to: 351-351


454-460: LGTM! Mobile UX enhancement with scroll-into-view.

The delayed scrollIntoView calls on virtual keyboard devices improve mobile usability by ensuring focused elements remain visible above the keyboard. The 150ms delay allows the keyboard animation to settle.

Also applies to: 1560-1565


628-674: LGTM! Switch cases properly scoped and numbered list support added.

The block wrapping for switch cases (lines 629-674) addresses the scope leakage concern from past reviews. The numbered list pattern matching (/^(\d+)[.)]$/) is a nice markdown shortcut enhancement.

Also applies to: 690-693


984-1052: LGTM! Paste-link menu flow is well-structured.

The showPasteLinkMenu() implementation:

  • Creates a placeholder link-element at the cursor
  • Positions the menu below the link with proper transitions
  • Wires up click handlers and outside-click cleanup
  • Properly tracks and cleans up _closePasteMenuHandler in hidePasteLinkMenu()

The logic handles selection edge cases and maintains proper state throughout the flow.


1074-1114: LGTM! Paste-link choices properly transform blocks.

The handlePasteLinkChoice() logic correctly handles the three cases:

  • URL: Keeps inline link, focuses editor
  • Bookmark: Removes placeholder, transforms to link-preview-element
  • Embed: Removes placeholder, transforms to embed-element

Trailing space cleanup prevents orphaned text nodes.


1117-1140: LGTM! Paste-link keyboard navigation is intuitive.

Arrow keys navigate options, Enter selects, Escape cancels—standard and accessible. Early return when the menu is showing prevents key event conflicts with other handlers.


1362-1388: LGTM! Backspace correctly detects and removes link-elements.

The logic handles both cases where the cursor precedes a link-element:

  1. When cursor is at offset 0 in a text node with a LINK-ELEMENT previous sibling
  2. When cursor is in an element node with a LINK-ELEMENT previous child

This ensures clean deletion of inline link components.


1393-1393: LGTM! Defensive access consistent with list-element fix.

Moving prevDomElement retrieval inside the conditional (after checking prevElement exists) prevents potential null reference errors, matching the fix in list-element.js line 107.


1777-1813: LGTM! Copy handler serializes link-elements for interoperability.

The handleCopy() implementation:

  • Clones the selection
  • Converts link-element custom elements to standard <a> tags with data attributes
  • Sets both HTML and plain text formats on the clipboard

This enables pasting into external applications while preserving wisk-specific metadata for internal pastes.


1815-1851: Verify WiskPasteHandler is defined and accessible.

The handlePaste() implementation delegates URL detection and paste handling to WiskPasteHandler static methods (isURL(), normalizeUrl(), isInternalUrl(), isWiskClipboardFormat(), handleTextElementPaste()), but this handler is referenced without import or definition check.

Run the following script to verify WiskPasteHandler is defined before this file loads:

#!/bin/bash
# Check if WiskPasteHandler is defined in the codebase
rg -n "class WiskPasteHandler" --type=js

# Check if paste-handler.js or similar is loaded before base-text-element.js
rg -n "paste-handler" index.html editor.html --type=html
js/editor.js (15)

281-301: Good sanitization added for plugin data.

The identifier validation at lines 283-286 properly prevents path injection attacks by rejecting keys containing ., [, or ]. This addresses the security concern raised in previous reviews.


307-366: Excellent improvements to block creation.

The addition of lastUpdated timestamp (line 323) and the use of wisk.sync.enqueueSave('block-creation') (line 357) properly address previous concerns about data tracking and save serialization. The conditional event creation for non-remote blocks (line 340) prevents duplicate events during synchronization.


688-697: Good use of enqueueSave for block moves.

The move operation correctly uses wisk.sync.enqueueSave('block-move') instead of the old setTimeout pattern, ensuring proper save serialization.


903-950: Scope leakage properly addressed.

All switch cases now use block scope (curly braces), including the previously flagged 'sup' case (lines 930-938). This prevents variable declarations from leaking to other cases.


1073-1147: Width class update properly implemented.

The container width class is now correctly updated (lines 1122-1130) when changing block types, addressing the previous review concern. The implementation properly checks pluginDetail.width === 'max' and adds or removes the rndr-full-width class accordingly.


1698-1731: Excellent drag state management.

The addition of Escape key handling (lines 1702-1706) and window blur cleanup (line 1698) prevents stuck drag states. The comprehensive cleanupDrag function properly removes all listeners, clears intervals, removes DOM elements, and resets state.


1733-1759: Good drag performance optimization.

The drag throttling using requestAnimationFrame (lines 1743-1758) with the dragUpdateScheduled flag properly limits updates to one per frame, preventing performance issues during rapid mouse movements.


1761-1799: Smart keyboard-aware drag scrolling.

The scroll handling correctly accounts for virtual keyboard height (lines 1766-1767, 1781, 1783), preventing unwanted autoscroll when dragging near the keyboard on mobile devices.


2079-2101: Proper shadow DOM handling for selection.

The text node traversal correctly handles shadow DOM by specifically targeting the #editable element (lines 2088-2092), preventing selection of non-editable UI elements within custom components.


2272-2282: Clean integration with WiskPasteHandler.

The paste event handling properly delegates to WiskPasteHandler for detecting and parsing Wisk's custom clipboard format, then restores the internal clipboard and pastes elements.


2321-2371: Excellent link protocol sanitization.

The href sanitization (lines 2335-2356) properly implements a safe protocol allowlist and blocks dangerous protocols like javascript:, data:, and vbscript:. The approach of removing all attributes first (lines 2358-2361) and then setting only clean ones provides defense-in-depth. This addresses the security concern from previous reviews.


2386-2391: Good sanitization in copy operation.

The clipboard copy correctly sanitizes textContent (line 2390) before adding to the clipboard, preventing malicious link protocols from being copied and potentially pasted elsewhere.


2421-2424: Proper UTF-8 safe base64 encoding.

The base64 encoding pattern btoa(unescape(encodeURIComponent(jsonString))) at line 2423 correctly handles UTF-8 characters that would otherwise break standard btoa(). This prevents data corruption when clipboard content passes through HTML.


2725-2789: Comprehensive virtual keyboard handling.

The implementation properly handles virtual keyboards using both the Visual Viewport API (better browser support) and the newer Virtual Keyboard API (lines 2725-2755). The integration with drag scrolling via currentKeyboardHeight (line 2763) and the smart shadow DOM focus detection (lines 2774-2776) provide excellent mobile UX.


2311-2318: Potential null reference when focusing after deletion.

Lines 2312-2317 access elementToFocus.component without checking if elementToFocus is null. If all non-abcdxyz elements are deleted, elementToFocus will be null and this will throw.

🔎 Proposed fix
     clearSelection();
 
-    if (elementToFocus) {
+    if (elementToFocus && elementToFocus.id) {
         const componentDetail = wisk.plugins.getPluginDetail(elementToFocus.component);
         if (componentDetail && componentDetail.textual) {
             wisk.editor.focusBlock(elementToFocus.id, { x: elementToFocus.value?.textContent?.length || 0 });

Likely an incorrect or invalid review comment.

wisk.editor.justUpdates();
wisk.editor.document.data.elements = wisk.editor.document.data.elements.filter(e => e.id !== elementId);
deletedElements.push(elementId);
deletedElementsLeft.push(elementId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

deletedElementsLeft still present despite past review.

Line 1026 still updates deletedElementsLeft.push(elementId), even though a previous review comment (marked as addressed in commit 4631d8c) indicated this unused array should be removed. The variable is declared at line 4 but never read in active code.

Based on learnings, this was flagged for removal but remains in the codebase.

🤖 Prompt for AI Agents
In js/editor.js around line 1026 (declaration at line 4), the code still calls
deletedElementsLeft.push(elementId) even though deletedElementsLeft is unused;
remove the push call at line 1026 and also remove the deletedElementsLeft
variable declaration at line 4 (or delete the entire unused variable if not
referenced elsewhere), search the file for any remaining references to
deletedElementsLeft to ensure no other usages exist, and run linters/tests to
confirm no regressions.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/scripts/home-sidebar.js (1)

66-77: Remove the redundant setAttribute('value', currentTheme) call on line 77.

The code sets option.selected = true on line 72 and then calls setAttribute('value', currentTheme) on line 77. However, other usage of jalebi-select in the codebase (specifically main-element.js) demonstrates that setting option.selected alone is sufficient—no setAttribute call is made there. This indicates the setAttribute call on line 77 is redundant and can be safely removed.

🤖 Fix all issues with AI agents
In @js/paste-handler.js:
- Around line 460-527: flattenElements() mishandles image-element by assigning
imageUrl: element.value (the whole object) instead of the URL string created in
processNode(); update the image-element branch in flattenElements() to pull the
URL string (e.g., element.value.imageUrl) and preserve textContent (e.g.,
element.value.textContent) so flattened image elements match the shape produced
by processNode().
- Around line 44-91: sanitizeAndConvertLinks leaves dangerous href attributes in
place because when an href fails the safeProtocols test you only set href = null
but don’t remove or overwrite the original attribute; also treating "/" as safe
allows protocol-relative URLs. Fix sanitizeAndConvertLinks by: after computing
href and validating against a stricter safeProtocols (remove leading "/" and
allow only explicit https?:, mailto:, tel:, and # anchors), if the href is
invalid explicitly remove the href attribute from the original <a> element (or
set it to a safe empty value like ""), and ensure you also remove
protocol-relative URLs (those starting with "//"); then proceed with replacement
or fallback attributes on the element as currently done (refer to
sanitizeAndConvertLinks, tempDiv, links, and the safeProtocols check).
- Around line 254-458: The img handling in processNode accepts node.src without
protocol validation—mirror the href safeProtocols check used elsewhere and only
accept image URLs matching safe protocols (e.g., http(s):, root-relative "/",
fragment "#", or data:image/*) before creating an image-element; also adjust
TEXT_NODE handling in processNode to not blindly use node.textContent.trim()
(which drops spaces between inline elements) — instead check for any
non-whitespace and preserve/normalize internal spacing (e.g., collapse
consecutive whitespace to single spaces) when building the text-element so
spaces between inline siblings are kept.
- Around line 573-601: parseInlineMarkdown emits unescaped capture groups into a
link-element tag and uses lookbehinds that break older browsers; this lets
crafted markdown like [text"onclick="x](url) inject attributes when
base-text-element.js setValue() inserts innerHTML. Fix by avoiding raw string
interpolation into HTML: construct links using DOM APIs
(createElement('link-element') + setAttribute('url','...') +
setAttribute('title','...')) or explicitly escape attribute values (e.g.,
replace quotes and angle brackets in $1/$2) before embedding, and replace
unsupported lookbehind patterns (used in the italic regexes) with compatible
alternatives (e.g., boundary-aware regex without (?<!) or manual checks) so
parsing works in older runtimes.

In @js/scripts/home-sidebar.js:
- Around line 79-82: Replace the unnecessary defensive extraction in the change
handler for themeSelect: inside the themeSelect.addEventListener('change', ...)
callback stop using the conditional e.detail?.value ?? e.target.value and
instead pass e.target.value directly to wisk.theme.setTheme, so the handler
always uses the event target's value (i.e., call wisk.theme.setTheme with
e.target.value).
🧹 Nitpick comments (2)
js/paste-handler.js (2)

235-253: Default code language shouldn’t be javascript.

Pasted code blocks without an explicit language will be mislabeled.

Proposed tweak
- let language = 'javascript';
+ let language = 'plaintext';

937-943: document.execCommand(...) is deprecated; double-check target browser support and editor behavior.

This is used for both plain text insertion and HTML insertion. If you support modern contenteditable flows, consider migrating to Selection/Range APIs (or ensure a centralized editor insertion API is used consistently).

Also applies to: 1058-1074

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a9e0c4 and 8965fcc.

📒 Files selected for processing (2)
  • js/paste-handler.js
  • js/scripts/home-sidebar.js
🧰 Additional context used
🧬 Code graph analysis (1)
js/scripts/home-sidebar.js (2)
js/editor.js (1)
  • e (2762-2762)
js/wisk.js (1)
  • wisk (3-3)
🔇 Additional comments (2)
js/scripts/home-sidebar.js (1)

29-31: No action needed. The <jalebi-select> custom element is properly loaded before this code executes via synchronous script inclusion in index.html (line 316), which runs before home-sidebar.js (line 324). No registration timing issue exists.

Likely an incorrect or invalid review comment.

js/paste-handler.js (1)

1049-1124: Paste flow return flags look good; validate sanitization invariants end-to-end.

The “handoff” flags (isWiskFormat, isStandaloneUrl) are a clean separation. Main concern is ensuring any HTML inserted (insertHTML, textContent fields that render as HTML) is sanitized consistently for both HTML and Markdown paths.

Comment on lines +6 to +43
static isWiskClipboardFormat(htmlData) {
return htmlData && htmlData.includes('__WISK_CLIPBOARD__');
}

static parseWiskClipboard(htmlData) {
if (!htmlData) return null;

const match = htmlData.match(/__WISK_CLIPBOARD__(.+?)__WISK_CLIPBOARD_END__/);
if (!match) return null;

try {
const jsonString = decodeURIComponent(escape(atob(match[1])));
const wiskData = JSON.parse(jsonString);
if (wiskData.__wisk_elements__ && wiskData.elements) {
return wiskData.elements;
}
} catch (error) {
try {
const wiskData = JSON.parse(match[1]);
if (wiskData.__wisk_elements__ && wiskData.elements) {
return wiskData.elements;
}
} catch (fallbackError) {
console.error('Failed to parse wisk clipboard data: ', error);
}
}

return null;
}

static handleWiskClipboardPaste(event) {
const clipboardData = event.clipboardData;
if (!clipboardData) return null;

const html = clipboardData.getData('text/html');
return WiskPasteHandler.parseWiskClipboard(html);
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden WISK clipboard parsing (regex + UTF-8 decode).

  • __WISK_CLIPBOARD__(.+?)__WISK_CLIPBOARD_END__ won’t match newlines; clipboard payloads can contain them.
  • decodeURIComponent(escape(atob(...))) uses deprecated escape() and is brittle for UTF-8.
Proposed fix
 static parseWiskClipboard(htmlData) {
     if (!htmlData) return null;

-    const match = htmlData.match(/__WISK_CLIPBOARD__(.+?)__WISK_CLIPBOARD_END__/);
+    const match = htmlData.match(/__WISK_CLIPBOARD__([\s\S]+?)__WISK_CLIPBOARD_END__/);
     if (!match) return null;

     try {
-        const jsonString = decodeURIComponent(escape(atob(match[1])));
+        const bytes = Uint8Array.from(atob(match[1].trim()), c => c.charCodeAt(0));
+        const jsonString = new TextDecoder('utf-8').decode(bytes);
         const wiskData = JSON.parse(jsonString);
         if (wiskData.__wisk_elements__ && wiskData.elements) {
             return wiskData.elements;
         }

Comment on lines +44 to +91
static sanitizeAndConvertLinks(htmlString) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

tempDiv.querySelectorAll('script, style, iframe, object, embed, form, input, button, meta, link, base').forEach(el => el.remove());

tempDiv.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on')) {
el.removeAttribute(attr.name);
}
});
});

const links = tempDiv.querySelectorAll('a');
links.forEach(link => {
let href = link.getAttribute('href');

if (href) {
href = href.replace(/\\/g, '').replace(/&quot;/g, '').replace(/&amp;/g, '&').trim();
const safeProtocols = /^(https?:|mailto:|tel:|\/|#)/i;
if (!safeProtocols.test(href)) {
href = null;
}
}

if (href) {
const linkText = link.textContent || '';
const wiskDisplay = link.getAttribute('data-wisk-display') || 'inline';
const linkElement = document.createElement('link-element');
linkElement.setAttribute('url', href);
linkElement.setAttribute('display', wiskDisplay);
linkElement.setAttribute('contenteditable', 'false');

if (linkText) {
linkElement.setAttribute('title', linkText);
}

link.parentNode.replaceChild(linkElement, link);
} else {
link.setAttribute('contenteditable', 'false');
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}
});

return tempDiv.innerHTML;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: unsafe <a href> is not neutralized (XSS / “javascript:” clickthrough).

When href is deemed unsafe you set href = null, but you never remove/overwrite the element’s existing href attribute—so the original dangerous URL remains clickable.

Also, allowing / as “safe” permits protocol-relative URLs like //evil.com.

Proposed fix
 const links = tempDiv.querySelectorAll('a');
 links.forEach(link => {
     let href = link.getAttribute('href');

     if (href) {
         href = href.replace(/\\/g, '').replace(/&quot;/g, '').replace(/&amp;/g, '&').trim();
         const safeProtocols = /^(https?:|mailto:|tel:|\/|#)/i;
-        if (!safeProtocols.test(href)) {
+        if (!safeProtocols.test(href) || href.startsWith('//')) {
             href = null;
         }
     }

     if (href) {
         const linkText = link.textContent || '';
         const wiskDisplay = link.getAttribute('data-wisk-display') || 'inline';
         const linkElement = document.createElement('link-element');
         linkElement.setAttribute('url', href);
         linkElement.setAttribute('display', wiskDisplay);
         linkElement.setAttribute('contenteditable', 'false');
@@
         link.parentNode.replaceChild(linkElement, link);
     } else {
+        // Ensure any existing href is removed/neutralized
+        link.removeAttribute('href');
+        link.setAttribute('href', '#');
         link.setAttribute('contenteditable', 'false');
         link.setAttribute('target', '_blank');
         link.setAttribute('rel', 'noopener noreferrer');
     }
 });
🤖 Prompt for AI Agents
In @js/paste-handler.js around lines 44 - 91, sanitizeAndConvertLinks leaves
dangerous href attributes in place because when an href fails the safeProtocols
test you only set href = null but don’t remove or overwrite the original
attribute; also treating "/" as safe allows protocol-relative URLs. Fix
sanitizeAndConvertLinks by: after computing href and validating against a
stricter safeProtocols (remove leading "/" and allow only explicit https?:,
mailto:, tel:, and # anchors), if the href is invalid explicitly remove the href
attribute from the original <a> element (or set it to a safe empty value like
""), and ensure you also remove protocol-relative URLs (those starting with
"//"); then proceed with replacement or fallback attributes on the element as
currently done (refer to sanitizeAndConvertLinks, tempDiv, links, and the
safeProtocols check).

Comment on lines +93 to +201
static getIndentLevel(element) {
let indent = 0;
let parent = element.parentElement;
while (parent) {
if (parent.tagName === 'UL' || parent.tagName === 'OL') {
indent++;
}
parent = parent.parentElement;
}
return Math.max(0, indent - 1);
}

static isPartOfProcessedList(node) {
let parent = node.parentElement;
while (parent) {
if (parent._processed) return true;
parent = parent.parentElement;
}
return false;
}

static getDirectChildrenLi(listNode) {
return Array.from(listNode.children).filter(child => child.tagName === 'LI');
}

static extractContentFromLi(li) {
const clone = li.cloneNode(true);
clone.querySelectorAll('ul, ol').forEach(list => list.remove());
clone.querySelectorAll('input').forEach(input => input.remove());
const blockElements = [];
clone.querySelectorAll('table').forEach(table => {
blockElements.push({ type: 'table', node: table.cloneNode(true) });
table.remove();
});
clone.querySelectorAll('pre').forEach(pre => {
blockElements.push({ type: 'code', node: pre.cloneNode(true) });
pre.remove();
});
const paragraphs = clone.querySelectorAll('p');
let text = '';

if (paragraphs.length > 0) {
text = Array.from(paragraphs).map(p => p.innerHTML.trim()).filter(t => t).join('<br>');
} else {
text = clone.innerHTML.trim();
}
text = text.replace(/^\[[\sx]\]\s*/, '');

return { text, blockElements };
}

static processListRecursively(listNode, baseIndent = 0, numberCounters = {}, isCheckboxList = false) {
const results = [];
const directChildren = WiskPasteHandler.getDirectChildrenLi(listNode);
const isNumbered = listNode.tagName === 'OL';

// Clean the text:
// 1. Replace all whitespace characters (including newlines, tabs) with single spaces
// 2. Trim any leading/trailing whitespace
text = text.replace(/\s+/g, ' ').trim();
if (text) {
document.execCommand('insertText', false, text);
if (!numberCounters[baseIndent]) {
numberCounters[baseIndent] = 1;
}

directChildren.forEach((li) => {
li._processed = true;
li.querySelectorAll('*').forEach(child => {
child._processed = true;
});
const { text, blockElements } = WiskPasteHandler.extractContentFromLi(li);
const item = {
type: 'list-item',
text: WiskPasteHandler.sanitizeAndConvertLinks(text),
indent: baseIndent,
};

if (isNumbered) {
item.number = numberCounters[baseIndent];
numberCounters[baseIndent]++;
}

if (isCheckboxList) {
item.checked = li.textContent.startsWith('[x]') || li.querySelector('input[type="checkbox"]')?.checked || false;
}

results.push(item);
blockElements.forEach(block => {
results.push({
type: 'block-element',
blockType: block.type,
node: block.node,
indent: baseIndent,
});
});
const nestedList = Array.from(li.children).find(child =>
child.tagName === 'UL' || child.tagName === 'OL'
);

if (nestedList) {
nestedList._processed = true;
numberCounters[baseIndent + 1] = 1;
const nestedDirectChildren = WiskPasteHandler.getDirectChildrenLi(nestedList);
const isNestedCheckboxList = nestedDirectChildren.some(
li => li.textContent.startsWith('[ ]') || li.textContent.startsWith('[x]') || li.querySelector('input[type="checkbox"]')
);

const nestedResults = WiskPasteHandler.processListRecursively(nestedList, baseIndent + 1, numberCounters, isNestedCheckboxList);
results.push(...nestedResults);
}
});

return results;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

List parsing: checkbox detection should be case-insensitive + marker stripping inconsistent.

  • startsWith('[x]') misses [X] (and stripping uses /^\[[\sx]\]\s*/ which doesn’t match X either).
  • Indent computation assumes list nesting only; pasted markdown lists often use spaces/tabs, so HTML vs markdown indents will differ—worth validating UX.
Targeted tweak
- item.checked = li.textContent.startsWith('[x]') || li.querySelector('input[type="checkbox"]')?.checked || false;
+ item.checked = /^\s*\[[xX]\]/.test(li.textContent) || li.querySelector('input[type="checkbox"]')?.checked || false;
- text = text.replace(/^\[[\sx]\]\s*/, '');
+ text = text.replace(/^\s*\[[\s xX]\]\s*/, '');

Comment on lines +254 to 458
static processNode(node, structuredElements) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text) {
structuredElements.push({
elementName: 'text-element',
value: text,
});
}
return;
}

if (node.nodeType !== Node.ELEMENT_NODE) return;

let element = null;
let skipChildren = false;

switch (node.tagName.toLowerCase()) {
case 'h1': {
if (node.textContent.trim()) {
element = { elementName: 'heading1-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'h2': {
if (node.textContent.trim()) {
element = { elementName: 'heading2-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'h3': {
if (node.textContent.trim()) {
element = { elementName: 'heading3-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'h4': {
if (node.textContent.trim()) {
element = { elementName: 'heading4-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'h5': {
if (node.textContent.trim()) {
element = { elementName: 'heading5-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'ul':
case 'ol': {
if (!node._processed) {
node._processed = true;
const directLiChildren = WiskPasteHandler.getDirectChildrenLi(node);
const isCheckboxList = directLiChildren.some(
li => li.textContent.startsWith('[ ]') || li.textContent.startsWith('[x]') || li.querySelector('input[type="checkbox"]')
);
let elementName;
if (isCheckboxList && node.tagName.toLowerCase() === 'ul') {
elementName = 'checkbox-element';
} else {
elementName = node.tagName.toLowerCase() === 'ul' ? 'list-element' : 'numbered-list-element';
}
const results = WiskPasteHandler.processListRecursively(node, 0, {}, isCheckboxList);
const listItems = results.filter(r => r.type === 'list-item');
const blockElements = results.filter(r => r.type === 'block-element');
element = {
elementName: elementName,
value: listItems.map(item => {
const itemValue = { text: item.text, indent: item.indent };
if (item.number !== undefined) {
itemValue.number = item.number;
}
if (item.checked !== undefined) {
itemValue.checked = item.checked;
}
return itemValue;
}),
};
blockElements.forEach(blockEl => {
if (blockEl.blockType === 'table') {
const { headers, rows } = WiskPasteHandler.parseTableNode(blockEl.node);
if (headers.length > 0 || rows.length > 0) {
structuredElements.push({
elementName: 'table-element',
value: {
tableContent: {
headers: headers.length > 0 ? headers : ['Column 1'],
rows: rows.length > 0 ? rows : [['']],
}
}
});
}
} else if (blockEl.blockType === 'code') {
const codeData = WiskPasteHandler.parseCodeNode(blockEl.node);
structuredElements.push({
elementName: 'code-element',
value: codeData
});
}
});

skipChildren = true;
}
break;
}
case 'li': {
if (!WiskPasteHandler.isPartOfProcessedList(node) && !node._processed) {
const isCheckbox =
node.textContent.startsWith('[ ]') ||
node.textContent.startsWith('[x]') ||
node.querySelector('input[type="checkbox"]');

if (isCheckbox) {
element = {
elementName: 'checkbox-element',
value: [
{
text: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.replace(/^\[[\sx]\]\s*/, '').trim()),
checked: node.textContent.startsWith('[x]') || node.querySelector('input[type="checkbox"]')?.checked,
indent: WiskPasteHandler.getIndentLevel(node),
},
],
};
} else {
element = {
elementName: 'list-element',
value: [
{
text: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()),
indent: WiskPasteHandler.getIndentLevel(node),
},
],
};
}
skipChildren = true;
}
},
true
);
break;
}
case 'blockquote':
element = { elementName: 'quote-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
skipChildren = true;
break;
case 'pre':
case 'code':
element = {
elementName: 'code-element',
value: WiskPasteHandler.parseCodeNode(node)
};
skipChildren = true;
break;
case 'hr': {
element = { elementName: 'divider-element', value: '' };
skipChildren = true;
break;
}
case 'img': {
if (node.src) {
element = {
elementName: 'image-element',
value: { imageUrl: node.src, textContent: '' },
};
}
skipChildren = true;
break;
}
case 'p': {
if (node.textContent.trim()) {
element = { elementName: 'text-element', value: WiskPasteHandler.sanitizeAndConvertLinks(node.innerHTML.trim()) };
}
skipChildren = true;
break;
}
case 'table': {
const { headers, rows } = WiskPasteHandler.parseTableNode(node);
if (headers.length > 0 || rows.length > 0) {
element = {
elementName: 'table-element',
value: {
tableContent: {
headers: headers.length > 0 ? headers : ['Column 1'],
rows: rows.length > 0 ? rows : [['']],
}
}
};
}
skipChildren = true;
break;
}
}

if (element) {
structuredElements.push(element);
}

if (!skipChildren) {
node.childNodes.forEach(childNode => {
WiskPasteHandler.processNode(childNode, structuredElements);
});
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the complete file to understand context and find tests
wc -l js/paste-handler.js

Repository: sohzm/wisk

Length of output: 78


🏁 Script executed:

# Search for how structuredElements is used and what happens downstream
rg "structuredElements" js/paste-handler.js -B 2 -A 2

Repository: sohzm/wisk

Length of output: 1844


🏁 Script executed:

# Look for image src validation or protocol checking
rg "img.*src|protocol|http" js/paste-handler.js -i

Repository: sohzm/wisk

Length of output: 433


🏁 Script executed:

# Search for any tests related to paste handler
fd -t f ".*test.*" -path "*/paste*"

Repository: sohzm/wisk

Length of output: 222


🏁 Script executed:

# Look for how span and other inline elements are handled
rg "<span>|inline|whitespace" js/paste-handler.js -i

Repository: sohzm/wisk

Length of output: 1118


🏁 Script executed:

# Look for sanitizeAndConvertLinks implementation
rg "sanitizeAndConvertLinks" js/paste-handler.js -A 15

Repository: sohzm/wisk

Length of output: 5806


🏁 Script executed:

# Check the img element handling more carefully - does it validate src?
rg "case 'img':" js/paste-handler.js -A 10

Repository: sohzm/wisk

Length of output: 408


🏁 Script executed:

# Search for any src validation logic
rg "\.src\s*=" js/paste-handler.js -B 2 -A 2

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# Look for test files
fd "test\|spec" --type f

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# Check how inline elements like span, strong, em are handled
rg "case 'span':|case 'strong':|case 'em':|case 'b':|case 'i':" js/paste-handler.js -A 3

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# Check the full sanitizeAndConvertLinks to see if it handles img elements
rg "sanitizeAndConvertLinks" js/paste-handler.js -A 30 | head -50

Repository: sohzm/wisk

Length of output: 1990


🏁 Script executed:

# Verify if img elements get processed through sanitizeAndConvertLinks anywhere
rg "img" js/paste-handler.js -B 3 -A 3

Repository: sohzm/wisk

Length of output: 560


🏁 Script executed:

# Check if there's any downstream validation of imageUrl
rg "imageUrl|image-element" js/paste-handler.js -B 2 -A 2

Repository: sohzm/wisk

Length of output: 1334


Validate image URL protocol to prevent XSS with javascript: or data: URIs.

The img case accepts node.src without any protocol validation, while <a> href uses safeProtocols check (/^(https?:|mailto:|tel:|\/|#)/i). Apply the same validation to image URLs:

case 'img': {
    if (node.src) {
        const safeProtocols = /^(https?:|\/|#|data:image\/)/i;
        if (safeProtocols.test(node.src)) {
            element = {
                elementName: 'image-element',
                value: { imageUrl: node.src, textContent: '' },
            };
        }
    }
    skipChildren = true;
    break;
}

Also, node.textContent.trim() in TEXT_NODE handling drops whitespace between unblocked inline elements (e.g., <span>Hello</span> <span>world</span> becomes two separate elements with no space), though this is less common with Google Docs/Office HTML which typically wraps in <p> tags.

🤖 Prompt for AI Agents
In @js/paste-handler.js around lines 254 - 458, The img handling in processNode
accepts node.src without protocol validation—mirror the href safeProtocols check
used elsewhere and only accept image URLs matching safe protocols (e.g.,
http(s):, root-relative "/", fragment "#", or data:image/*) before creating an
image-element; also adjust TEXT_NODE handling in processNode to not blindly use
node.textContent.trim() (which drops spaces between inline elements) — instead
check for any non-whitespace and preserve/normalize internal spacing (e.g.,
collapse consecutive whitespace to single spaces) when building the text-element
so spaces between inline siblings are kept.

Comment on lines +460 to +527
static flattenElements(structuredElements) {
const flattenedElements = [];

structuredElements.forEach((element, idx) => {
if (Array.isArray(element.value)) {
element.value.forEach((item, index) => {
if (typeof item === 'object') {
const newElement = {
elementName: element.elementName,
value: {
textContent: item.text || '',
indent: typeof item.indent === 'number' ? item.indent : 0,
},
};

while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
if (element.elementName === 'checkbox-element') {
newElement.value.checked = !!item.checked;
}

if (element.elementName === 'numbered-list-element') {
newElement.value.number = item.number !== undefined ? item.number : index + 1;
}

flattenedElements.push(newElement);
} else {
flattenedElements.push({
elementName: element.elementName,
value: {
textContent: item,
},
});
}
});
} else if (element.elementName === 'image-element') {
flattenedElements.push({
elementName: element.elementName,
value: {
imageUrl: element.value,
textContent: '',
},
});
} else if (element.elementName === 'table-element') {
flattenedElements.push({
elementName: element.elementName,
value: element.value,
});
} else if (element.elementName === 'code-element') {
flattenedElements.push({
elementName: element.elementName,
value: element.value,
});
} else if (element.elementName === 'latex-element') {
flattenedElements.push({
elementName: element.elementName,
value: element.value,
});
} else {
flattenedElements.push({
elementName: element.elementName,
value: {
textContent: element.value,
},
});
}
});

return flattenedElements;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: flattenElements() breaks image-element values.

processNode() creates image-element with value: { imageUrl, textContent }, but flattenElements() wraps it as imageUrl: element.value (object), not the URL string.

Proposed fix
 } else if (element.elementName === 'image-element') {
+    const imageUrl =
+        typeof element.value === 'string'
+            ? element.value
+            : (element.value && element.value.imageUrl) || '';
     flattenedElements.push({
         elementName: element.elementName,
         value: {
-            imageUrl: element.value,
-            textContent: '',
+            imageUrl,
+            textContent: (element.value && element.value.textContent) || '',
         },
     });
🤖 Prompt for AI Agents
In @js/paste-handler.js around lines 460 - 527, flattenElements() mishandles
image-element by assigning imageUrl: element.value (the whole object) instead of
the URL string created in processNode(); update the image-element branch in
flattenElements() to pull the URL string (e.g., element.value.imageUrl) and
preserve textContent (e.g., element.value.textContent) so flattened image
elements match the shape produced by processNode().

Comment on lines +573 to 601
static parseInlineMarkdown(text) {
if (!text) return '';

let result = text;
// Escape HTML entities first (but preserve existing HTML tags we want to keep)
result = result.replace(/&/g, '&amp;');
// Don't escape < and > for HTML tags we want to preserve
result = result.replace(/<(?!(b|i|u|strike|code|a|br|span|strong|em)\b)/g, '&lt;');
result = result.replace(/(?<!\b(b|i|u|strike|code|a|br|span|strong|em))>/g, '&gt;');
// Bold and italic combined (***text*** or ___text___)
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<b><i>$1</i></b>');
result = result.replace(/___(.+?)___/g, '<b><i>$1</i></b>');
// Bold (**text** or __text__)
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
result = result.replace(/__(.+?)__/g, '<b>$1</b>');
// Italic (*text* or _text_) - be careful not to match inside words
result = result.replace(/\*([^*]+)\*/g, '<i>$1</i>');
result = result.replace(/(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g, '<i>$1</i>');
// Strikethrough (~~text~~)
result = result.replace(/~~(.+?)~~/g, '<strike>$1</strike>');
// Inline code (`code`)
result = result.replace(/`([^`]+)`/g, '<code style="background: var(--bg-2); padding: 2px 4px; border-radius: 3px; font-family: var(--font-mono);">$1</code>');
// Links [text](url) - convert to link-element
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<link-element url="$2" display="inline" title="$1" contenteditable="false"></link-element>');
// Images ![alt](url) - convert to text representation
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[Image: $1]');

return active;
return result;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file size and then examine the relevant sections
wc -l js/paste-handler.js

Repository: sohzm/wisk

Length of output: 78


🏁 Script executed:

# Read the parseInlineMarkdown function and surrounding context
sed -n '570,610p' js/paste-handler.js

Repository: sohzm/wisk

Length of output: 2190


🏁 Script executed:

# Also check the lines mentioned in "Also applies to: 620-930"
sed -n '620,650p' js/paste-handler.js

Repository: sohzm/wisk

Length of output: 1156


🏁 Script executed:

# Search for sanitizeAndConvertLinks function
rg "sanitizeAndConvertLinks" js/paste-handler.js

Repository: sohzm/wisk

Length of output: 1377


🏁 Script executed:

# Search for any HTML sanitization or escaping functions
rg "sanitize|escape|DOMPurify" .

Repository: sohzm/wisk

Length of output: 50370


🏁 Script executed:

# Find and read the sanitizeAndConvertLinks function
rg -A 30 "static sanitizeAndConvertLinks" js/paste-handler.js

Repository: sohzm/wisk

Length of output: 1344


🏁 Script executed:

# Check the lines 620-930 that the review mentions
sed -n '620,700p' js/paste-handler.js

Repository: sohzm/wisk

Length of output: 3004


🏁 Script executed:

# Check if there's any HTML escaping happening for attributes
rg "escapeHtml|escapeAttribute|htmlEscape" .

Repository: sohzm/wisk

Length of output: 509


🏁 Script executed:

# Check how parseInlineMarkdown is used and if its output is passed through sanitizeAndConvertLinks
rg "parseInlineMarkdown" js/paste-handler.js -B 2 -A 2

Repository: sohzm/wisk

Length of output: 2047


🏁 Script executed:

# Check browser support for lookbehind assertions
rg "browserslist|target|es20" package.json .babelrc .browserslistrc 2>/dev/null | head -20

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# See the complete sanitizeAndConvertLinks function
rg -A 50 "static sanitizeAndConvertLinks" js/paste-handler.js

Repository: sohzm/wisk

Length of output: 1995


🏁 Script executed:

# Check if link-element component has any sanitization
rg -A 20 "class.*link-element" js/plugins/code/link-element.js | head -40

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# Verify how the textContent from parseInlineMarkdown is used in elements
rg "textContent.*parseInlineMarkdown|parseInlineMarkdown.*textContent" js/paste-handler.js -A 1 -B 1 | head -30

Repository: sohzm/wisk

Length of output: 1428


🏁 Script executed:

# Find the link-element implementation
fd "link-element" --type f

Repository: sohzm/wisk

Length of output: 85


🏁 Script executed:

# Check how textContent is rendered in the UI (check the element processor)
rg "link-element|textContent" js/plugins/code/ -l

Repository: sohzm/wisk

Length of output: 1144


🏁 Script executed:

# Look for where these elements are rendered (innerHTML vs textContent vs setAttribute)
rg "innerHTML.*textContent|textContent.*innerHTML|\.textContent\s*=" js/ -B 2 -A 2 | head -40

Repository: sohzm/wisk

Length of output: 1707


🏁 Script executed:

# Check how text-element renders its textContent
rg -A 30 "class.*text-element|customElements.define.*text-element" js/plugins/code/ | head -60

Repository: sohzm/wisk

Length of output: 238


🏁 Script executed:

# Check base-text-element which might handle rendering
cat js/plugins/code/base-text-element.js | head -100

Repository: sohzm/wisk

Length of output: 3836


🏁 Script executed:

# Check the render method in BaseTextElement
rg -A 50 "render\(\)" js/plugins/code/base-text-element.js | head -70

Repository: sohzm/wisk

Length of output: 2634


🏁 Script executed:

# Check text-element specifically
cat js/plugins/code/text-element.js | head -150

Repository: sohzm/wisk

Length of output: 5170


🏁 Script executed:

# Find where element values are actually rendered/loaded into the DOM
rg "value\.textContent|\.textContent\s*=" js/plugins/code/text-element.js -B 3 -A 3

Repository: sohzm/wisk

Length of output: 36


🏁 Script executed:

# Check how elements are loaded/hydrated from saved data
rg "loadValue|setValue|hydrate|connectedCallback" js/plugins/code/base-text-element.js -A 10 | head -50

Repository: sohzm/wisk

Length of output: 1157


🏁 Script executed:

# Search for where innerHTML is set with element content
rg "innerHTML\s*=" js/plugins/code/text-element.js -B 2 -A 2

Repository: sohzm/wisk

Length of output: 143


🏁 Script executed:

# Confirm the issue by checking setValue is called with parseInlineMarkdown output
rg "setValue|value\.textContent" js/editor.js -B 5 -A 5 | head -40

Repository: sohzm/wisk

Length of output: 1309


🏁 Script executed:

# Final confirmation: see complete flow from parseMarkdownText to setValue
rg "parseMarkdownText|parseInlineMarkdown" js/editor.js -B 2 -A 5 | head -30

Repository: sohzm/wisk

Length of output: 36


Markdown link handling creates injection vulnerability; regex lookbehind breaks in older browsers.

  • parseInlineMarkdown() emits <link-element url="$2" ... title="$1" ...> without escaping regex captures. A markdown URL like [text"onclick="alert(1)](x) breaks attribute boundaries and allows HTML injection, since the output is later set via innerHTML in base-text-element.js setValue() without sanitization.
  • Regexes use lookbehind assertions ((?<!\b...), (?<![a-zA-Z])) which are ES2018+ and will fail silently in older browsers, breaking markdown parsing.

Recommend: escape attribute values (e.g., $1.replace(/"/g, '&quot;')) before insertion, or build link-element via DOM API (createElement + setAttribute). Alternatively, run sanitizeAndConvertLinks() on the textContent before setValue() processes it.

🤖 Prompt for AI Agents
In @js/paste-handler.js around lines 573 - 601, parseInlineMarkdown emits
unescaped capture groups into a link-element tag and uses lookbehinds that break
older browsers; this lets crafted markdown like [text"onclick="x](url) inject
attributes when base-text-element.js setValue() inserts innerHTML. Fix by
avoiding raw string interpolation into HTML: construct links using DOM APIs
(createElement('link-element') + setAttribute('url','...') +
setAttribute('title','...')) or explicitly escape attribute values (e.g.,
replace quotes and angle brackets in $1/$2) before embedding, and replace
unsupported lookbehind patterns (used in the italic regexes) with compatible
alternatives (e.g., boundary-aware regex without (?<!) or manual checks) so
parsing works in older runtimes.

Comment on lines +993 to +1021
static async handleImagePaste(event) {
const clipboardData = event.clipboardData;
if (!clipboardData || !clipboardData.items) return null;

for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const file = item.getAsFile();
if (!file) return null;

return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve({
elementName: 'image-element',
value: {
imageUrl: e.target.result,
textContent: '',
},
});
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
}

return null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Image paste paths are inconsistent (data URL vs asset storage) + sanitize file extension.

  • handleImagePaste() returns a data URL block, but handleTextElementPaste() stores to wisk.db.setAsset() and inserts an image-element with a synthetic filename—these likely produce different downstream rendering.
  • file.type.split('/')[1] can include svg+xml; better to normalize to [a-z0-9] to avoid odd filenames.
Proposed tweak
- const extension = file.type.split('/')[1] || 'png';
+ const extensionRaw = file.type.split('/')[1] || 'png';
+ const extension = extensionRaw.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'png';

Also applies to: 1023-1047

Comment on lines 79 to 82
themeSelect.addEventListener('change', e => {
wisk.theme.setTheme(e.target.value);
const value = e.detail?.value ?? e.target.value;
wisk.theme.setTheme(value);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find where jalebi-select is defined and used
find . -type f -name "*.js" -o -name "*.html" -o -name "*.ts" | head -20

Repository: sohzm/wisk

Length of output: 593


🏁 Script executed:

# Search for jalebi-select definition and event patterns
rg -i "jalebi-select|themeSelect" --type js --type html -C 3

Repository: sohzm/wisk

Length of output: 7270


🏁 Script executed:

# Look for CustomEvent dispatch patterns and change events
rg "dispatchEvent|new CustomEvent|new Event" --type js -C 4 | head -100

Repository: sohzm/wisk

Length of output: 4825


🏁 Script executed:

# Check the actual element definition in home-sidebar.js
rg -n "themeSelect" js/scripts/home-sidebar.js -B 5 -A 10

Repository: sohzm/wisk

Length of output: 1471


Remove unnecessary defensive pattern; use e.target.value consistently.

The defensive pattern e.detail?.value ?? e.target.value is unnecessary. Other parts of the codebase using jalebi-select (e.g., database-element.js) use e.target.value directly without checking detail.value. Standard web component behavior dispatches change events with the value on target, not detail. Simplify to:

themeSelect.addEventListener('change', e => {
    wisk.theme.setTheme(e.target.value);
});
🤖 Prompt for AI Agents
In @js/scripts/home-sidebar.js around lines 79 - 82, Replace the unnecessary
defensive extraction in the change handler for themeSelect: inside the
themeSelect.addEventListener('change', ...) callback stop using the conditional
e.detail?.value ?? e.target.value and instead pass e.target.value directly to
wisk.theme.setTheme, so the handler always uses the event target's value (i.e.,
call wisk.theme.setTheme with e.target.value).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
js/sync/sync.js (2)

22-95: Path-based mutation logic is sound, but note subtle loop counter modification.

The applyEvent function handles restore, reordering, element creation, and generic path traversal correctly. However, Line 65 uses const elementId = pathParts[++i], which increments the loop counter inline to consume two path segments ('elements' + ID). While correct, this pattern is subtle and could confuse future maintainers.

💡 Optional: Add clarifying comment
        // 'elements' array uses ID lookup, not index
        if (key === 'elements' && Array.isArray(current.elements)) {
+           // Consume next path part as element ID (incrementing i)
            const elementId = pathParts[++i];

98-127: Redundant initialization on Line 116.

Line 115 already initializes wisk.editor.document.data.sync with syncLogs: [], making line 116's nullish coalescing operator redundant.

♻️ Remove redundant line
    // Store in syncLogs for future server sync
    wisk.editor.document.data.sync ??= { syncLogs: [], isPushed: false, lastSync: 0 };
-   wisk.editor.document.data.sync.syncLogs ??= [];
    wisk.editor.document.data.sync.syncLogs.push(...eventsToSave);
js/plugins/code/options-component.js (1)

1138-1143: Add fallback for invalid font size values.

If localStorage contains an invalid value (e.g., manually edited), the find() returns undefined and the CSS variable won't be set. Consider adding a fallback to the default size.

🔧 Proposed fix with fallback
    applyFontSize(sizeValue) {
        const option = this.fontSizeOptions.find(opt => opt.value === sizeValue);
        if (option) {
            document.documentElement.style.setProperty('--editor-font-size', option.size);
+       } else {
+           // Fallback to default if invalid value
+           const defaultOption = this.fontSizeOptions.find(opt => opt.value === 'default');
+           document.documentElement.style.setProperty('--editor-font-size', defaultOption.size);
        }
    }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8965fcc and cc52e84.

📒 Files selected for processing (3)
  • js/plugins/code/options-component.js
  • js/sync/sync.js
  • js/wisk.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • js/wisk.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T14:14:36.148Z
Learnt from: sushilbang
Repo: sohzm/wisk PR: 54
File: js/plugins/code/base-text-element.js:58-62
Timestamp: 2025-12-12T14:14:36.148Z
Learning: In the js/plugins/code directory, ensure that BaseTextElement serves as the shared base class with common state/logic, while concrete elements (e.g., TextElement) extend it and override render() to provide UI markup. The base class's connectedCallback should query for child elements that are injected by their render methods. This inheritance pattern should be consistently applied across the plugin system; review new or existing elements to maintain a clean base-vs-extension separation, avoid tight coupling, and verify that the render contracts and DOM querying align between base and derived classes.

Applied to files:

  • js/plugins/code/options-component.js
🧬 Code graph analysis (1)
js/plugins/code/options-component.js (1)
js/wisk.js (1)
  • wisk (3-3)
🔇 Additional comments (12)
js/sync/sync.js (6)

1-7: LGTM: Clean state initialization.

The Promise-based queue and scheduling flag provide a solid foundation for sequential, debounced saves. The eventLog initialization is straightforward.


10-20: LGTM: Proper event validation and enqueueing.

The validation ensures required fields exist, and the nullish coalescing operators provide sensible defaults for timestamp and agent metadata.


129-145: LGTM: Debounced async queue implementation.

The promise-chaining pattern with the saveScheduled flag correctly ensures sequential, non-overlapping saves. Note that errors are logged but not re-thrown, so the returned promise always resolves successfully. This appears intentional for fire-and-forget semantics.


148-209: LGTM: WebSocket cleanup and modernization.

The changes improve code clarity with arrow functions, add safety with optional chaining, and enhance debugging with better logging. No functional issues identified.


212-261: LGTM: Page initialization with noted TODOs.

The sync and live functions are logically sound. Note the TODO on Line 232 regarding wisk.site integration cleanup, and the commented WebSocket initialization on lines 252-253.


260-264: LGTM: Network status listeners and API exports.

The online/offline event listeners provide basic network awareness, and exporting saveModification and applyEvent to wisk.sync correctly exposes the event-driven API surface to the rest of the application.

js/plugins/code/options-component.js (6)

526-532: LGTM: Appropriate styling for font size selector.

The min/max width constraints on jalebi-select and the nowrap styling on labels ensure the font size UI renders consistently across different viewport sizes.


718-718: LGTM: Property declaration follows Lit conventions.

The editorFontSize property with type String is correctly declared for reactive updates.


738-747: LGTM: Font size options and initialization.

The font size options span a reasonable range (14px to 20px), and the initialization correctly reads from localStorage with a sensible default and applies the setting immediately.


1145-1151: LGTM: Font size change handler.

The event handler correctly updates state, persists to localStorage, applies the CSS variable, and triggers a re-render in the proper sequence.


596-601: LGTM: Font size UI integration.

The markup correctly binds the select element to the changeFontSize handler, maps options from the fontSizeOptions array, and manages the selected state.


1091-1108: LGTM: Snapshot restoration now uses event-driven system.

The restore flow correctly uses wisk.sync.newChange with a restore event and enqueueSave to persist the change before reloading. Note that snapshotId in the event value is not currently consumed by applyEvent but could be useful for future logging or audit trails.

Based on learnings: The event-driven approach aligns with the PR-wide architectural changes to centralize document mutations through the sync system.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@js/editor.js`:
- Around line 560-567: When replacing the DOM node for a component change inside
handleElementPatch, also update the container's rndr-full-width class just like
handleElementUpdate does: locate the container (use the same lookup used in
handleElementUpdate — e.g., the element's parent container or the id-based
container derived from elementId), then add or remove the 'rndr-full-width'
class according to the patch/fullWidth value or existing element state so the
container width state stays in sync after domElement is replaced.

In `@js/sync/sync.js`:
- Around line 209-215: sendAuth currently assumes
document.querySelector('auth-component') returns an element and calling
getUserInfo() will succeed; if the element is missing the await will throw.
Update sendAuth to first query for the element (auth-component), check for null,
and handle that case (either return early or retry/wait until present), then
call getUserInfo() on the found element and only then call sendMessage with
wisk.editor.pageId and the retrieved token; ensure any errors from getUserInfo()
are caught and logged/handled so sendMessage is not invoked with undefined data.
- Around line 189-197: The error/close handlers on socket currently call alert()
then location.reload(), which discards unsaved entries in wisk.sync.eventLog;
update both socket.addEventListener('error') and 'close' handlers to first flush
pending changes by invoking (or implementing) an async flush/save function on
the sync module (e.g. wisk.sync.flushPending() or wisk.sync.savePendingEvents()
that iterates wisk.sync.eventLog and persists entries and returns a Promise),
await that operation (with a timeout/fallback), then show a non-blocking user
notice and finally call location.reload() only after the flush completes or the
fallback triggers. Ensure you reference socket, wisk.sync.eventLog, and the
new/used flush function (wisk.sync.flushPending or wisk.sync.savePendingEvents)
so reviewers can locate the change.
♻️ Duplicate comments (6)
js/sync/sync.js (1)

46-58: Element reordering may still drop elements not in newOrder.

A past review flagged that if newOrder is missing element IDs, those elements are silently dropped from document.data.elements. The current implementation at line 53 uses .filter(Boolean) which removes undefined entries (elements not in newOrder). This could cause data loss if the order array is incomplete.

#!/bin/bash
# Check how elementOrder events are generated to ensure they always include all element IDs
rg -n "data.elementOrder" --type js -B 2 -A 5 | head -60
js/editor.js (5)

2-4: Unused variable deletedElementsLeft still present.

A past review noted this variable is declared but never read. It's still being pushed to at line 1065 but never used elsewhere.

#!/bin/bash
# Verify deletedElementsLeft is never read
rg -n "deletedElementsLeft" --type js -B 1 -A 1

533-541: Missing width class update on component change in handleElementUpdate.

When the component type changes via remote event, the container's rndr-full-width class is not updated based on the new plugin's width property. This was flagged in a past review and the fix was reportedly applied to changeBlockType, but handleElementUpdate still lacks this logic.

🐛 Proposed fix
     } else if (property === 'component') {
         const newType = event.value.data;
         const newDomElement = document.createElement(newType);
         newDomElement.id = elementId;
         domElement.replaceWith(newDomElement);
+        
+        // Update container width class
+        const container = document.getElementById(`div-${elementId}`);
+        if (container) {
+            const pluginDetail = wisk.plugins.getPluginDetail(newType);
+            if (pluginDetail?.width === 'max') {
+                container.classList.add('rndr-full-width');
+            } else {
+                container.classList.remove('rndr-full-width');
+            }
+        }
+        
         setTimeout(() => {
             newDomElement.setValue('', element.value);
         }, 0);
     }

1870-1885: Only first element persisted after renumbering.

A past review flagged that renumberNumberedLists only calls justUpdates for the first element (line 1884), so renumbered items at other positions won't have their updated number persisted.

🐛 Proposed fix
+    const changedIds = new Set();
+
     for (let i = 0; i < elements.length; i++) {
         const el = elements[i];
         if (el.component === 'numbered-list-element') {
             // ... existing logic ...
             
             // Update the element's number
             const newNumber = counters[indent];
             if (domEl.number !== newNumber) {
                 domEl.number = newNumber;
                 domEl.updateNumber();
                 el.value.number = newNumber;
+                changedIds.add(el.id);
             }
         } else {
             // Reset counters when encountering non-numbered-list element
             counters.length = 1;
             counters[0] = 0;
         }
     }
 
     // Save modifications
-    wisk.editor.justUpdates(elements[0]?.id);
+    changedIds.forEach(id => wisk.editor.justUpdates(id));
 }

2644-2646: justUpdates() with no argument is a no-op.

Calling justUpdates() without an elementId returns immediately (line 2645). This means useTemplate() at line 1227 which calls wisk.editor.justUpdates() won't persist the template changes.

This was flagged in a past review. The template elements are set directly into wisk.editor.document.data.elements at line 1223 but never persisted through the event system.


2693-2708: Potential null reference accessing getTextContent().text.

At line 2695, domElement.getTextContent().text is accessed without checking if getTextContent() returns a valid object. This was flagged in a past review.

🐛 Proposed fix
             // special case: first element
             if(elementId === wisk.editor.document.data.elements[0].id) {
-                const textContent = domElement.getTextContent().text || '';
+                const textContentObj = domElement.getTextContent?.();
+                const textContent = textContentObj?.text || '';
 
                 if(textContent) {
🧹 Nitpick comments (4)
js/elements/selector-element.js (2)

120-122: Silent error handling may confuse users.

When page creation fails, the error is only logged to console. The user won't know why their action didn't complete. Consider showing a toast notification.

💡 Proposed improvement
         } catch (error) {
             console.error('Error creating child page:', error);
+            wisk.utils.showToast?.('Failed to create page. Please try again.', 3000);
         }

259-263: Fragile selector for first visible button.

The selector :not([style*="display: none"]) depends on the exact style string format. If display:none (without space) is ever set, this won't match.

💡 Suggested improvement
-        // Set focus on first visible button
-        const firstVisible = this.shadowRoot.querySelector('.selector-button:not([style*="display: none"])');
-        if (firstVisible) {
-            firstVisible.classList.add('selector-button-focused');
-        }
+        // Set focus on first visible button
+        const firstVisible = Array.from(this.shadowRoot.querySelectorAll('.selector-button'))
+            .find(btn => btn.style.display !== 'none');
+        if (firstVisible) {
+            firstVisible.classList.add('selector-button-focused');
+        }
js/sync/sync.js (1)

103-106: Minor: Inconsistent indentation.

Line 105 has extra leading whitespace compared to surrounding code.

🧹 Fix indentation
         // check if the next object is present so a deep path traversal wont crash
         if(current[key] === null || typeof current[key] !== "object") current[key] = {};
-                current = current[key];
+        current = current[key];
     }
js/editor.js (1)

1063-1066: Dead code: deletedElementsLeft.push(elementId).

This line adds to deletedElementsLeft which is never read anywhere in the codebase. It should be removed along with the variable declaration at line 4.

🧹 Remove dead code
             wisk.editor.document.data.elements = wisk.editor.document.data.elements.filter(e => e.id !== elementId);
             deletedElements.push(elementId);
-            deletedElementsLeft.push(elementId);
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cc52e84 and 3511dca.

📒 Files selected for processing (3)
  • js/editor.js
  • js/elements/selector-element.js
  • js/sync/sync.js
🧰 Additional context used
🧬 Code graph analysis (3)
js/elements/selector-element.js (3)
js/editor.js (14)
  • element (521-521)
  • element (549-549)
  • element (591-591)
  • element (768-768)
  • element (899-899)
  • element (1059-1059)
  • element (1087-1087)
  • element (1100-1100)
  • element (1119-1119)
  • element (1177-1177)
  • element (1265-1265)
  • element (1540-1540)
  • element (1687-1687)
  • e (2758-2758)
js/sync/sync.js (2)
  • element (66-66)
  • element (95-95)
js/wisk.js (1)
  • wisk (3-3)
js/sync/sync.js (3)
js/wisk.js (1)
  • wisk (3-3)
js/editor.js (11)
  • event (1757-1757)
  • event (1826-1826)
  • pathParts (437-437)
  • pathParts (517-517)
  • pathParts (545-545)
  • pathParts (639-639)
  • e (2758-2758)
  • i (767-767)
  • i (898-898)
  • i (1262-1262)
  • i (1850-1850)
js/storage/db.js (1)
  • data (272-272)
js/editor.js (2)
js/sync/sync.js (6)
  • elementId (62-62)
  • elementId (94-94)
  • pathParts (26-26)
  • element (66-66)
  • element (95-95)
  • i (89-89)
script.js (1)
  • editor (76-76)
🔇 Additional comments (13)
js/elements/selector-element.js (3)

56-74: LGTM on async selectButton implementation.

The method correctly handles the "Page" item by awaiting createPageAndLink() and returning early, while preserving the original flow for non-Page items. The commented-out code (lines 67-70) should be cleaned up eventually.


159-191: LGTM on visible-button navigation.

The navigation logic correctly filters hidden buttons and handles wrap-around at list boundaries. Focus management and smooth scrolling are properly implemented.


274-282: LGTM on show() implementation.

Recreating buttons on each show ensures plugin state changes are reflected. The flow of createAllButtons() followed by renderButtons('') is correct.

js/sync/sync.js (4)

1-8: LGTM on state initialization.

The save serialization state (saveQueue, saveScheduled) and event log are properly initialized. The promise-based queue pattern is a good approach for serializing saves.


120-138: LGTM on save queue serialization.

The promise-chaining pattern correctly serializes saves and handles events arriving during persistence by recursively enqueueing follow-up saves.


150-163: syncLogs entries not rolled back on persistence failure.

When wisk.db.setPage fails, wisk.editor.document is restored from docBackup (line 161). However, eventsToSave were already pushed to document.data.sync.syncLogs at line 153. Since the backup was taken before applying events, restoring it correctly reverts syncLogs too.

Wait - actually the backup is taken at line 145 before events are applied, so the restore at line 161 does revert the syncLogs changes. This is correct.


11-15: Validation order may throw on missing event.value.

If event.value is undefined, the check event.value.data === undefined will throw a TypeError before the validation can reject the event. The !event.value check should short-circuit, but due to operator precedence, event.value.data === undefined is evaluated when event.value is falsy-but-defined (e.g., null).

🐛 Proposed fix
 wisk.sync.newChange = function (event) {
-    if(!event || !event.path || !event.value || event.value.data === undefined) {
+    if(!event || !event.path || !event.value || !('data' in event.value)) {
         console.error("Invalid event: ", event);
         return;
     }

Likely an incorrect or invalid review comment.

js/editor.js (6)

255-279: LGTM on setDatabaseProp key sanitization.

The key validation at line 257 correctly rejects keys containing ., [, or ] to prevent path injection attacks. This addresses the past review concern.


338-357: LGTM on block creation event emission.

The block creation now properly emits data.elements and data.elementOrder events with timestamps and uses enqueueSave for serialized persistence. This addresses past concerns about overlapping saves.


1112-1168: LGTM on changeBlockType implementation.

The function correctly emits a patch event, updates the element state, replaces the DOM element, and properly handles the container's rndr-full-width class based on the new plugin's width property. Focus restoration is a good UX touch.


2342-2392: LGTM on clipboard HTML sanitization.

The function properly sanitizes link protocols, only allowing http:, https:, mailto:, and tel:. Dangerous protocols like javascript: and data: are correctly blocked. The approach of removing all attributes then adding back only clean ones is secure.


2755-2785: LGTM on virtual keyboard handling.

The implementation correctly adjusts editor padding when the keyboard is visible and scrolls the focused element into view. The shadow DOM focus detection (lines 2771-2772) properly handles custom elements.


1719-1752: LGTM on drag cleanup implementation.

The cleanupDrag function properly removes all event listeners and resets state. The Escape key handler and blur event listener provide good UX for canceling drags.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +560 to +567
// Handle component change (must be done before value update)
if (patch.component && patch.component !== domElement.tagName.toLowerCase()) {
const newType = patch.component;
const newDomElement = document.createElement(newType);
newDomElement.id = elementId;
domElement.replaceWith(newDomElement);
domElement = newDomElement; // update reference for value setting
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing width class update in handleElementPatch for component changes.

Same issue as handleElementUpdate: when a patch changes the component type, the container's rndr-full-width class should be updated.

🐛 Proposed fix
     // Handle component change (must be done before value update)
     if (patch.component && patch.component !== domElement.tagName.toLowerCase()) {
         const newType = patch.component;
         const newDomElement = document.createElement(newType);
         newDomElement.id = elementId;
         domElement.replaceWith(newDomElement);
         domElement = newDomElement; // update reference for value setting
+        
+        // Update container width class
+        const container = document.getElementById(`div-${elementId}`);
+        if (container) {
+            const pluginDetail = wisk.plugins.getPluginDetail(newType);
+            if (pluginDetail?.width === 'max') {
+                container.classList.add('rndr-full-width');
+            } else {
+                container.classList.remove('rndr-full-width');
+            }
+        }
     }
🤖 Prompt for AI Agents
In `@js/editor.js` around lines 560 - 567, When replacing the DOM node for a
component change inside handleElementPatch, also update the container's
rndr-full-width class just like handleElementUpdate does: locate the container
(use the same lookup used in handleElementUpdate — e.g., the element's parent
container or the id-based container derived from elementId), then add or remove
the 'rndr-full-width' class according to the patch/fullWidth value or existing
element state so the container width state stays in sync after domElement is
replaced.

Comment on lines +189 to 197
socket.addEventListener('error', () => {
alert('Connection with server failed. Click OK to reload the page.');
location.reload();
});

socket.addEventListener('close', event => {
socket.addEventListener('close', () => {
alert('Connection with server closed. Click OK to reload the page.');
location.reload();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

WebSocket error/close handlers force reload without save.

If the WebSocket closes unexpectedly, pending changes in wisk.sync.eventLog may be lost since alert() blocks and then location.reload() discards unsaved state. Consider flushing pending saves before reload.

💡 Suggested improvement
-        socket.addEventListener('error', () => {
-            alert('Connection with server failed. Click OK to reload the page.');
-            location.reload();
+        socket.addEventListener('error', async () => {
+            try {
+                if (wisk.sync.eventLog?.length > 0) {
+                    await saveModification();
+                }
+            } catch (e) {
+                console.error('Failed to save before reload:', e);
+            }
+            alert('Connection with server failed. Click OK to reload the page.');
+            location.reload();
         });
🤖 Prompt for AI Agents
In `@js/sync/sync.js` around lines 189 - 197, The error/close handlers on socket
currently call alert() then location.reload(), which discards unsaved entries in
wisk.sync.eventLog; update both socket.addEventListener('error') and 'close'
handlers to first flush pending changes by invoking (or implementing) an async
flush/save function on the sync module (e.g. wisk.sync.flushPending() or
wisk.sync.savePendingEvents() that iterates wisk.sync.eventLog and persists
entries and returns a Promise), await that operation (with a timeout/fallback),
then show a non-blocking user notice and finally call location.reload() only
after the flush completes or the fallback triggers. Ensure you reference socket,
wisk.sync.eventLog, and the new/used flush function (wisk.sync.flushPending or
wisk.sync.savePendingEvents) so reviewers can locate the change.

Comment on lines +209 to +215
async function sendAuth() {
const user = await document.querySelector('auth-component').getUserInfo();
sendMessage(JSON.stringify({
id: wisk.editor.pageId,
token: user.token,
}));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

sendAuth may send message before user info is ready.

The function awaits getUserInfo() but if auth-component doesn't exist, querySelector returns null and the await will throw.

🐛 Proposed fix
 async function sendAuth() {
-    const user = await document.querySelector('auth-component').getUserInfo();
+    const authComponent = document.querySelector('auth-component');
+    if (!authComponent) {
+        console.warn('Auth component not found, skipping auth');
+        return;
+    }
+    const user = await authComponent.getUserInfo();
     sendMessage(JSON.stringify({
         id: wisk.editor.pageId,
         token: user.token,
     }));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function sendAuth() {
const user = await document.querySelector('auth-component').getUserInfo();
sendMessage(JSON.stringify({
id: wisk.editor.pageId,
token: user.token,
}));
}
async function sendAuth() {
const authComponent = document.querySelector('auth-component');
if (!authComponent) {
console.warn('Auth component not found, skipping auth');
return;
}
const user = await authComponent.getUserInfo();
sendMessage(JSON.stringify({
id: wisk.editor.pageId,
token: user.token,
}));
}
🤖 Prompt for AI Agents
In `@js/sync/sync.js` around lines 209 - 215, sendAuth currently assumes
document.querySelector('auth-component') returns an element and calling
getUserInfo() will succeed; if the element is missing the await will throw.
Update sendAuth to first query for the element (auth-component), check for null,
and handle that case (either return early or retry/wait until present), then
call getUserInfo() on the found element and only then call sendMessage with
wisk.editor.pageId and the retrieved token; ensure any errors from getUserInfo()
are caught and logged/handled so sendMessage is not invoked with undefined data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant