Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed
- Fixed the editor turning blank and unresponsive after being moved in the DOM, which happens when it is used inside a modal or dialog that relocates its content, or when Vue re-orders the surrounding elements. The editor is now re-created automatically and keeps its content. #131 #230

## 6.3.0 - 2025-07-31

### Changed
Expand Down
1 change: 1 addition & 0 deletions src/demo/Demo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<router-link :class="{ active: $route.path==='/inline'}" to="/inline">Inline Editor</router-link>
<router-link :class="{ active: $route.path==='/controlled'}" to="/controlled">Controlled component</router-link>
<router-link :class="{ active: $route.path==='/keepalive'}" to="/keepalive">Keep-alive</router-link>
<router-link :class="{ active: $route.path==='/modal'}" to="/modal">Modal</router-link>
<router-link :class="{ active: $route.path==='/refreshable'}" to="/refreshable">Rerender</router-link>
<router-link :class="{ active: $route.path==='/tagged'}" to="/tagged">Tag Change</router-link>
<router-link :class="{ active: $route.path === '/get-editor' }" to="/get-editor">getEditor</router-link>
Expand Down
6 changes: 6 additions & 0 deletions src/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Iframe from '/views/Iframe.vue';
import Inline from '/views/Inline.vue';
import Controlled from '/views/Controlled.vue';
import Keepalive from '/views/KeepAlive.vue';
import Modal from '/views/Modal.vue';
import Refreshable from '/views/Refreshable.vue';
import Tagged from '/views/Tagged.vue';
import GetEditor from '/views/GetEditor.vue';
Expand Down Expand Up @@ -37,6 +38,11 @@ const routes = [
name: 'Keepalive',
component: Keepalive
},
{
path: '/modal',
name: 'Modal',
component: Modal
},
{
path: '/refreshable',
name: 'Refreshable',
Expand Down
89 changes: 89 additions & 0 deletions src/demo/views/Modal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<template>
<div>
<h2>Editor inside a modal</h2>
<p>
Modal and dialog libraries (Bootstrap-Vue, Vuetify, &hellip;) move their
content to another part of the document when they open. That detaches and
re-attaches the editor's <code>&lt;iframe&gt;</code>, which the browser blanks,
historically leaving the editor in an unresponsive &ldquo;zombie&rdquo; state
(<a href="https://github.com/tinymce/tinymce-vue/issues/131" target="_blank" rel="noopener">#131</a>).
The component now detects the re-attach and transparently re-initializes while
preserving content. This demo reproduces the exact condition with a
<code>&lt;Teleport&gt;</code>-based modal &mdash; open and close it repeatedly and
the editor keeps working.
</p>
<button @click="open = true">Open modal</button>

<teleport to="body" :disabled="!open">
<div v-show="open" class="demo-modal-backdrop" @click.self="open = false">
<div class="demo-modal">
<header class="demo-modal-header">
<strong>Compose message</strong>
<button @click="open = false">Close</button>
</header>
<editor :api-key="apiKey" :init="conf" v-model="content" />
<p class="demo-modal-preview"><em>Live v-model:</em> {{ content }}</p>
</div>
</div>
</teleport>
</div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import Editor from "/@/main/ts/index";

const apiKey = "qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc";

export default defineComponent({
name: "Modal",
components: {
Editor
},
setup() {
const open = ref(false);
const content = ref("<p>Edit me, close the modal, then open it again.</p>");
const conf = {
height: 300,
menubar: false
};
return {
apiKey,
open,
content,
conf
};
}
});
</script>

<style scoped>
.demo-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.demo-modal {
background: #fff;
padding: 1rem;
border-radius: 6px;
width: 720px;
max-width: 90vw;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.demo-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.demo-modal-preview {
color: #555;
font-size: 0.85rem;
word-break: break-word;
}
</style>
84 changes: 81 additions & 3 deletions src/main/ts/components/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export const Editor = defineComponent({
let mounting = true;
const initialValue: string = props.initialValue ? props.initialValue : '';
let cache = '';
// Recovery state for when the editor's iframe is moved in the DOM (see watchReattach).
let reattachIframe: HTMLIFrameElement | null = null;
let reattachHandler: (() => void) | null = null;
let reinitializing = false;
// False once the component is torn down, so a queued re-init can bail out.
let active = true;

const getContent = (isMounting: boolean): () => string => modelBind ?
() => (modelValue?.value ? modelValue.value : '') :
Expand All @@ -75,7 +81,19 @@ export const Editor = defineComponent({
setMode(vueEditor, 'readonly');
}

editor.on('init', (e: EditorEvent<any>) => initEditor(e, props, ctx, editor, modelValue, content));
editor.on('init', (e: EditorEvent<any>) => {
initEditor(e, props, ctx, editor, modelValue, content);
watchReattach();
});
if (!modelBind && !inlineEditor) {
// Remember the content as it changes so we can restore it after a re-init;
// the moved iframe is already blank by the time we notice. v-model uses
// modelValue, and inline editors have no iframe, so this is only for the
// uncontrolled initialValue case.
editor.on('change input undo redo SetContent', () => {
cache = editor.getContent();
});
}
if (typeof conf.setup === 'function') {
conf.setup(editor);
}
Expand All @@ -87,6 +105,59 @@ export const Editor = defineComponent({
getTinymce().init(finalInit);
mounting = false;
};
const removeReattachListener = (): void => {
if (reattachIframe !== null && reattachHandler !== null) {
reattachIframe.removeEventListener('load', reattachHandler);
}
reattachIframe = null;
reattachHandler = null;
};
const reinitializeEditor = (): void => {
if (vueEditor === null || reinitializing) {
return;
}
reinitializing = true;
// Don't read the editor here: its iframe is already blank. cache/modelValue hold the content.
removeReattachListener();
getTinymce()?.remove(vueEditor);
// The Vue docs state you can either use the callback form or await it. Ref: https://vuejs.org/api/general.html#nexttick
// eslint-disable-next-line @typescript-eslint/no-floating-promises
nextTick(() => {
try {
// Skip if the component was torn down while this was queued.
if (active) {
initWrapper();
}
} finally {
// Always clear the guard so a failed init doesn't disable recovery.
reinitializing = false;
}
});
};
// Recover when the editor's iframe is moved in the DOM, e.g. by a modal that
// relocates its content or by Vue re-ordering the surrounding elements. Moving an
// iframe blanks it, and Vue doesn't unmount the component, so nothing else fixes
// it. The iframe fires `load` again on each insertion, so a `load` after the first
// means it was moved and must be re-created. Inline editors have no iframe.
// See #131 and #230.
const watchReattach = (): void => {
removeReattachListener();
if (inlineEditor || vueEditor === null) {
return;
}
// iframeElement/removed are typed from v5 on; the null/falsy guards keep this safe on v4.
const iframe = vueEditor.iframeElement;
if (isNullOrUndefined(iframe)) {
return;
}
reattachIframe = iframe;
reattachHandler = () => {
if (vueEditor !== null && !vueEditor.removed && !reinitializing) {
reinitializeEditor();
}
};
iframe.addEventListener('load', reattachHandler);
};
watch(readonly, (isReadonly) => {
if (vueEditor !== null) {
setMode(vueEditor, isReadonly ? 'readonly' : 'design');
Expand All @@ -102,10 +173,11 @@ export const Editor = defineComponent({
}
});
watch(tagName, (_) => {
if (vueEditor) {
if (vueEditor && !vueEditor.removed && !reinitializing) {
if (!modelBind) {
cache = vueEditor.getContent();
}
removeReattachListener();
getTinymce()?.remove(vueEditor);
// The Vue docs state you can either use the callback form or await it. Ref: https://vuejs.org/api/general.html#nexttick
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Expand All @@ -129,18 +201,23 @@ export const Editor = defineComponent({
}
});
onBeforeUnmount(() => {
active = false;
removeReattachListener();
if (getTinymce() !== null) {
getTinymce().remove(vueEditor);
}
});
if (!inlineEditor) {
onActivated(() => {
active = true;
if (!mounting) {
initWrapper();
}
});
onDeactivated(() => {
if (vueEditor) {
active = false;
removeReattachListener();
if (vueEditor && !vueEditor.removed) {
if (!modelBind) {
cache = vueEditor.getContent();
}
Expand All @@ -151,6 +228,7 @@ export const Editor = defineComponent({
const rerender = (init: EditorOptions) => {
if (vueEditor) {
cache = vueEditor.getContent();
removeReattachListener();
getTinymce()?.remove(vueEditor);
conf = { ...conf, ...init, ...defaultInitValues };

Expand Down
Loading