Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Shadow DOM] Enhance range validation to support ranges encapsulated by shadow roots #4721

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/core/main/ts/api/dom/DOMUtils.ts
Expand Up @@ -224,6 +224,7 @@ export interface DOMUtils {
destroy: () => void;
isChildOf: (node: Node, parent: Node) => boolean;
dumpRng: (r: Range) => string;
getTopLevelShadowHost: (node: Node) => Node;
}

/**
Expand Down Expand Up @@ -1198,6 +1199,26 @@ export function DOMUtils(doc: Document, settings: Partial<DOMUtilsSettings> = {}
return false;
};

const getTopLevelShadowHost = (node: Node): Node => {
let topShadowHost = null;
let shadowHost = node;
while (shadowHost = getParentShadowHost(shadowHost)) {
topShadowHost = shadowHost;
}
return topShadowHost;
};

const getElementShadowHost = (element: any): Node => {
return element.host;
};

const getParentShadowHost = (node: Node): Node => {
while (node.parentNode) {
node = node.parentNode;
}
return getElementShadowHost(node);
};

const dumpRng = (r: Range) => {
return (
'startContainer: ' + r.startContainer.nodeName +
Expand Down Expand Up @@ -1891,7 +1912,8 @@ export function DOMUtils(doc: Document, settings: Partial<DOMUtilsSettings> = {}
*/
destroy,
isChildOf,
dumpRng
dumpRng,
getTopLevelShadowHost
};

attrHooks = setupAttrHooks(styles, settings, () => self);
Expand Down
9 changes: 8 additions & 1 deletion src/core/main/ts/api/dom/Selection.ts
Expand Up @@ -42,8 +42,15 @@ const isNativeIeSelection = (rng: any): boolean => {
return !!(<any> rng).select;
};

const isShadowNodeAttachedToDom = function (node: Node): boolean {
const shadowHost = DOMUtils.DOM.getTopLevelShadowHost(node);
return !!shadowHost && Compare.contains(SugarElement.fromDom(node.ownerDocument), SugarElement.fromDom(shadowHost));
};

const isAttachedToDom = function (node: Node): boolean {
return !!(node && node.ownerDocument) && Compare.contains(SugarElement.fromDom(node.ownerDocument), SugarElement.fromDom(node));
return !!(node && node.ownerDocument) &&
(Compare.contains(SugarElement.fromDom(node.ownerDocument), SugarElement.fromDom(node)) ||
isShadowNodeAttachedToDom(node));
};

const isValidRange = function (rng: Range) {
Expand Down
57 changes: 57 additions & 0 deletions src/core/test/ts/browser/api/dom/DOMUtilsTest.ts
@@ -0,0 +1,57 @@
import { Assertions, Logger, Pipeline, Step } from '@ephox/agar';
import DOMUtils from 'tinymce/core/api/dom/DOMUtils';
import ViewBlock from '../../../module/test/ViewBlock';
import { UnitTest } from '@ephox/bedrock';
import { document } from '@ephox/dom-globals';

UnitTest.asynctest('browser.tinymce.core.api.dom.DOMUtilsTest', function () {
const success = arguments[arguments.length - 2];
const failure = arguments[arguments.length - 1];

const DOM = DOMUtils.DOM;

const viewBlock = ViewBlock();

const sTestNotShadowHost = Logger.t('No shadow host returns null', Step.sync(function () {
viewBlock.update('<div></div>');
Assertions.assertEq('label', null, DOM.getTopLevelShadowHost(viewBlock.get()));
}));

const sTestSingleShadowHost = Logger.t('Single level returns shadow host', Step.sync(function () {
viewBlock.update('<div></div>');
const div = viewBlock.get().firstElementChild;
if (div.attachShadow) {
const shadow = div.attachShadow({mode: 'open'});
const para = document.createElement('p');
shadow.appendChild(para);

Assertions.assertEq('label', div, DOM.getTopLevelShadowHost(para));
}
}));

const sTestMultiShadowHost = Logger.t('Multi level returns shadow host', Step.sync(function () {
viewBlock.update('<div></div>');
const div = viewBlock.get().firstElementChild;
if (div.attachShadow) {
const shadow = div.attachShadow({mode: 'open'});
const para = document.createElement('p');
shadow.appendChild(para);

const shadow2 = para.attachShadow({mode: 'open'});
const para2 = document.createElement('p');
shadow2.appendChild(para2);

Assertions.assertEq('label', div, DOM.getTopLevelShadowHost(para2));
}
}));

viewBlock.attach();
Pipeline.async({}, [
sTestNotShadowHost,
sTestSingleShadowHost,
sTestMultiShadowHost,
], function () {
viewBlock.detach();
success();
}, failure);
});
86 changes: 86 additions & 0 deletions src/core/test/ts/browser/api/dom/SelectionRangeTest.ts
@@ -0,0 +1,86 @@
import { Assertions, Logger, Pipeline, Step } from '@ephox/agar';
import { TinyLoader } from '@ephox/mcagar';
import { Selection } from 'tinymce/core/api/dom/Selection';
import Theme from 'tinymce/themes/modern/Theme';
import DOMUtils from 'tinymce/core/api/dom/DOMUtils';
import ViewBlock from '../../../module/test/ViewBlock';
import { UnitTest } from '@ephox/bedrock';
import { document } from '@ephox/dom-globals';

UnitTest.asynctest('browser.tinymce.core.api.dom.SelectionRangeTest', function () {
const success = arguments[arguments.length - 2];
const failure = arguments[arguments.length - 1];

const validateRangeTest = function (root, shouldSetRange, editor) {

const selection = Selection(DOM, DOM.win, null, editor);
const rng = document.createRange();
rng.setStartBefore(root.firstElementChild.firstElementChild);
rng.setEndAfter(root.firstElementChild.firstElementChild);

selection.setRng(rng);
const selected = selection.getRng();
Assertions.assertEq('range set', shouldSetRange, root.firstElementChild === selected.startContainer);
};

const innerHtml = '<p><span>Blue</span><span>Orange</span></p>';

Theme();

const DOM = DOMUtils.DOM;
const viewBlock = ViewBlock();

TinyLoader.setup(function (editor, onSuccess, onFailure) {

const sTestSimpleDocumentRange = Logger.t('Sets simple range in document', Step.sync(function () {
viewBlock.update(innerHtml);
const div = viewBlock.get();

validateRangeTest(div, true, editor);
}));

const sTestSimpleDocumentRangeNotAttached = Logger.t('Unattached simple range not set', Step.sync(function () {
const div = document.createElement('div');
DOMUtils.DOM.setHTML(div, innerHtml);

validateRangeTest(div, false, editor);
}));

const sTestShadowRootRange = Logger.t('Sets shadow range in document', Step.sync(function () {
viewBlock.update('<div></div>');
const div = viewBlock.get().firstElementChild;

if (div.attachShadow) {
const shadow = div.attachShadow({mode: 'open'});
DOMUtils.DOM.setHTML(shadow, innerHtml);

validateRangeTest(shadow, true, editor);
}
}));

const sTestShadowRootRangeNotAttached = Logger.t('Unattached shadow range not set', Step.sync(function () {
const div = document.createElement('div');
if (div.attachShadow) {
const shadow = div.attachShadow({mode: 'open'});
DOMUtils.DOM.setHTML(shadow, innerHtml);

validateRangeTest(shadow, false, editor);
}
}));

viewBlock.attach();

Pipeline.async({}, [
sTestSimpleDocumentRange,
sTestSimpleDocumentRangeNotAttached,
sTestShadowRootRange,
sTestShadowRootRangeNotAttached
], onSuccess, onFailure);
}, {
skin_url: '/project/js/tinymce/skins/lightgray',
inline: true
}, function () {
viewBlock.detach();
success();
}, failure);
});