Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 183 additions & 58 deletions src/components/TextEditor/components/CodeBlockComponent.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<template>
<node-view-wrapper>
<div class="code-block-container">
<pre><code><node-view-content /></code></pre>
<select
v-if="isEditable"
class="language-selector form-select py-0"
contenteditable="false"
v-model="selectedLanguage"
Expand All @@ -16,7 +18,9 @@
{{ language.label }}
</option>
</select>
<pre><code><node-view-content /></code></pre>
<span v-else class="language-label">{{
selectedLanguage || 'auto'
}}</span>
</div>
</node-view-wrapper>
</template>
Expand All @@ -30,6 +34,16 @@ export default {
NodeViewContent,
},
props: nodeViewProps,
data() {
return {
isEditable: this.editor.isEditable,
}
},
mounted() {
this.editor.on('update', () => {
this.isEditable = this.editor.isEditable
})
},
Comment on lines +42 to +46
Copy link
Copy Markdown

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

🌐 Web query:

Does TipTap editor.on() return an unbind function?

💡 Result:

No, TipTap's editor.on does not return an unbind function. It follows a standard EventEmitter pattern where editor.on(event, callback) adds the listener but returns the editor instance (this) for chaining. To unbind, use editor.off(event, callback) with the exact same callback reference. This is shown in the official documentation with examples like: const onUpdate = => { ... }; editor.on('update', onUpdate); editor.off('update', onUpdate);

Citations:


Missing event listener cleanup — potential memory leak.

The editor.on('update', ...) listener is registered in mounted() but never removed. This will leak memory if the component is mounted/unmounted multiple times. TipTap uses a standard EventEmitter pattern: to unbind, call editor.off(event, callback) with the same callback reference in beforeUnmount().

🐛 Proposed fix: store callback and call editor.off() on unmount
   data() {
     return {
       isEditable: this.editor.isEditable,
+      onUpdate: null,
     }
   },
   mounted() {
-    this.editor.on('update', () => {
+    this.onUpdate = () => {
       this.isEditable = this.editor.isEditable
-    })
+    }
+    this.editor.on('update', this.onUpdate)
   },
+  beforeUnmount() {
+    if (this.onUpdate) {
+      this.editor.off('update', this.onUpdate)
+    }
+  },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/TextEditor/components/CodeBlockComponent.vue` around lines 42
- 46, The mounted() hook attaches an anonymous listener via
this.editor.on('update', ...) but never removes it, causing a memory leak;
refactor to store the callback in a stable reference (e.g.
this.handleEditorUpdate) and use that same function to unsubscribe in
beforeUnmount() by calling this.editor.off('update', this.handleEditorUpdate),
ensuring you update this.isEditable inside the handler (keep the reference name
consistent with the component methods/fields such as mounted, beforeUnmount, and
isEditable).

computed: {
selectedLanguage: {
get() {
Expand Down Expand Up @@ -71,98 +85,209 @@ export default {
right: 0.25rem;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
z-index: 10;
transition-property: opacity;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
transition-delay: 0s;
pointer-events: none;
}

.code-block-container:hover .language-selector {
opacity: 1;
transition-delay: 0s;
pointer-events: auto;
}

.language-selector:focus-within {
opacity: 1;
transition-delay: 0s;
pointer-events: auto;
}

/* When mouse leaves the code block, delay the hiding */
.code-block-container:not(:hover) .language-selector:not(:focus-within) {
transition-delay: 1.5s;
.language-label {
position: absolute;
top: 0.5rem;
right: 0.75rem;
font-size: 12px;
color: var(--ink-gray-4);
pointer-events: none;
user-select: none;
}

/* ── pre shell ────────────────────────────────────────────────── */
.ProseMirror pre {
background: #0d0d0d;
color: #fff;
font-family: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
background: #f6f8fa;
color: #24292e;
font-family:
ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Mono', 'Source Code Pro', 'Fira Mono',
'Droid Sans Mono', 'Consolas', 'Courier New', monospace;
font-size: 12px;
line-height: 1.7;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
caret-color: #fff;
border: 1px solid var(--outline-gray-2);
caret-color: #24292e;
}

.ProseMirror pre code {
color: inherit;
padding: 0;
background: none;
font-size: 12px;
font-size: inherit;
}

.ProseMirror pre .hljs-comment,
.ProseMirror pre .hljs-quote {
color: #999;
/* ── highlight.js — GitHub light ─────────────────────────────── */
.ProseMirror pre .hljs-doctag,
.ProseMirror pre .hljs-keyword,
.ProseMirror pre .hljs-meta .hljs-keyword,
.ProseMirror pre .hljs-template-tag,
.ProseMirror pre .hljs-template-variable,
.ProseMirror pre .hljs-type,
.ProseMirror pre .hljs-variable.language_ {
color: #d73a49;
}

.ProseMirror pre .hljs-variable,
.ProseMirror pre .hljs-template-variable,
.ProseMirror pre .hljs-attribute,
.ProseMirror pre .hljs-tag,
.ProseMirror pre .hljs-name,
.ProseMirror pre .hljs-regexp,
.ProseMirror pre .hljs-link,
.ProseMirror pre .hljs-selector-id,
.ProseMirror pre .hljs-selector-class {
color: #f2777a;
.ProseMirror pre .hljs-title,
.ProseMirror pre .hljs-title.class_,
.ProseMirror pre .hljs-title.class_.inherited__,
.ProseMirror pre .hljs-title.function_ {
color: #6f42c1;
}

.ProseMirror pre .hljs-number,
.ProseMirror pre .hljs-meta,
.ProseMirror pre .hljs-built_in,
.ProseMirror pre .hljs-builtin-name,
.ProseMirror pre .hljs-attr,
.ProseMirror pre .hljs-attribute,
.ProseMirror pre .hljs-literal,
.ProseMirror pre .hljs-type,
.ProseMirror pre .hljs-params {
color: #f99157;
.ProseMirror pre .hljs-meta,
.ProseMirror pre .hljs-number,
.ProseMirror pre .hljs-operator,
.ProseMirror pre .hljs-variable,
.ProseMirror pre .hljs-selector-attr,
.ProseMirror pre .hljs-selector-class,
.ProseMirror pre .hljs-selector-id {
color: #005cc5;
}

.ProseMirror pre .hljs-regexp,
.ProseMirror pre .hljs-string,
.ProseMirror pre .hljs-symbol,
.ProseMirror pre .hljs-bullet {
color: #99cc99;
.ProseMirror pre .hljs-meta .hljs-string {
color: #032f62;
}

.ProseMirror pre .hljs-title,
.ProseMirror pre .hljs-section {
color: #ffcc66;
.ProseMirror pre .hljs-built_in,
.ProseMirror pre .hljs-symbol {
color: #e36209;
}

.ProseMirror pre .hljs-keyword,
.ProseMirror pre .hljs-selector-tag {
color: #6196cc;
.ProseMirror pre .hljs-comment,
.ProseMirror pre .hljs-code,
.ProseMirror pre .hljs-formula {
color: #6a737d;
}

.ProseMirror pre .hljs-name,
.ProseMirror pre .hljs-quote,
.ProseMirror pre .hljs-selector-tag,
.ProseMirror pre .hljs-selector-pseudo {
color: #22863a;
}

.ProseMirror pre .hljs-subst {
color: #24292e;
}
.ProseMirror pre .hljs-section {
color: #005cc5;
font-weight: bold;
}
.ProseMirror pre .hljs-bullet {
color: #735c0f;
}
.ProseMirror pre .hljs-emphasis {
color: #24292e;
font-style: italic;
}

.ProseMirror pre .hljs-strong {
color: #24292e;
font-weight: 700;
}
.ProseMirror pre .hljs-addition {
color: #22863a;
background-color: #f0fff4;
}
.ProseMirror pre .hljs-deletion {
color: #b31d28;
background-color: #ffeef0;
}

/* ── pre shell — dark mode ───────────────────────────────────── */
[data-theme='dark'] .ProseMirror pre {
background: #0d1117;
color: #c9d1d9;
caret-color: #c9d1d9;
}

/* ── highlight.js — GitHub dark ──────────────────────────────── */
[data-theme='dark'] .ProseMirror pre .hljs-doctag,
[data-theme='dark'] .ProseMirror pre .hljs-keyword,
[data-theme='dark'] .ProseMirror pre .hljs-meta .hljs-keyword,
[data-theme='dark'] .ProseMirror pre .hljs-template-tag,
[data-theme='dark'] .ProseMirror pre .hljs-template-variable,
[data-theme='dark'] .ProseMirror pre .hljs-type,
[data-theme='dark'] .ProseMirror pre .hljs-variable.language_ {
color: #ff7b72;
}

[data-theme='dark'] .ProseMirror pre .hljs-title,
[data-theme='dark'] .ProseMirror pre .hljs-title.class_,
[data-theme='dark'] .ProseMirror pre .hljs-title.class_.inherited__,
[data-theme='dark'] .ProseMirror pre .hljs-title.function_ {
color: #d2a8ff;
}

[data-theme='dark'] .ProseMirror pre .hljs-attr,
[data-theme='dark'] .ProseMirror pre .hljs-attribute,
[data-theme='dark'] .ProseMirror pre .hljs-literal,
[data-theme='dark'] .ProseMirror pre .hljs-meta,
[data-theme='dark'] .ProseMirror pre .hljs-number,
[data-theme='dark'] .ProseMirror pre .hljs-operator,
[data-theme='dark'] .ProseMirror pre .hljs-variable,
[data-theme='dark'] .ProseMirror pre .hljs-selector-attr,
[data-theme='dark'] .ProseMirror pre .hljs-selector-class,
[data-theme='dark'] .ProseMirror pre .hljs-selector-id {
color: #79c0ff;
}

[data-theme='dark'] .ProseMirror pre .hljs-regexp,
[data-theme='dark'] .ProseMirror pre .hljs-string,
[data-theme='dark'] .ProseMirror pre .hljs-meta .hljs-string {
color: #a5d6ff;
}

[data-theme='dark'] .ProseMirror pre .hljs-built_in,
[data-theme='dark'] .ProseMirror pre .hljs-symbol {
color: #ffa657;
}

[data-theme='dark'] .ProseMirror pre .hljs-comment,
[data-theme='dark'] .ProseMirror pre .hljs-code,
[data-theme='dark'] .ProseMirror pre .hljs-formula {
color: #8b949e;
}

[data-theme='dark'] .ProseMirror pre .hljs-name,
[data-theme='dark'] .ProseMirror pre .hljs-quote,
[data-theme='dark'] .ProseMirror pre .hljs-selector-tag,
[data-theme='dark'] .ProseMirror pre .hljs-selector-pseudo {
color: #7ee787;
}

[data-theme='dark'] .ProseMirror pre .hljs-subst {
color: #c9d1d9;
}
[data-theme='dark'] .ProseMirror pre .hljs-section {
color: #1f6feb;
font-weight: bold;
}
[data-theme='dark'] .ProseMirror pre .hljs-bullet {
color: #f2cc60;
}
[data-theme='dark'] .ProseMirror pre .hljs-emphasis {
color: #c9d1d9;
font-style: italic;
}
[data-theme='dark'] .ProseMirror pre .hljs-strong {
color: #c9d1d9;
font-weight: 700;
}
[data-theme='dark'] .ProseMirror pre .hljs-addition {
color: #aff5b4;
background-color: #033a16;
}
[data-theme='dark'] .ProseMirror pre .hljs-deletion {
color: #ffdcd7;
background-color: #67060c;
}
</style>
17 changes: 17 additions & 0 deletions src/components/TextEditor/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,20 @@ img.ProseMirror-selectednode {
margin: 2.25em 0;
}
}

/* Empty paragraph height in prose-v3.
ProseMirror always renders empty paragraphs as <p><br class="ProseMirror-trailingBreak"></p>
even in readonly mode, so p:empty never matches.
Edit mode: no override — natural 14px × 1.7 = 23.8px, avoids jump when typing
Readonly mode: scoped via [contenteditable=false] + ProseMirror-trailingBreak class
to precisely target empty paragraphs (not inline <br> in content) */
.ProseMirror[contenteditable='false'] .prose-v3 p:has(> br.ProseMirror-trailingBreak:only-child),
.prose-v3.ProseMirror[contenteditable='false'] p:has(> br.ProseMirror-trailingBreak:only-child) {
line-height: 20px;
}

/* prose-v3 blockquote: 2px left border — overrides Tailwind Typography's 0.25rem
using element specificity (0,1,1) to beat the plugin's :where() (0,1,0) */
.prose-v3 blockquote {
border-inline-start-width: 2px;
}
Loading
Loading