Browse Source

Merge ec434402eb into 70e3177c2f

pull/6811/merge
Garvish Panchal 4 weeks ago
committed by GitHub
parent
commit
13aa7fdb73
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      packages/core/src/asset_manager/view/AssetImageView.ts
  2. 4
      packages/core/src/asset_manager/view/AssetsView.ts
  3. 3
      packages/core/src/asset_manager/view/FileUploader.ts
  4. 13
      packages/core/src/canvas/view/CanvasView.ts
  5. 9
      packages/core/src/canvas/view/FrameView.ts
  6. 10
      packages/core/src/canvas/view/FrameWrapView.ts
  7. 4
      packages/core/src/commands/view/Preview.ts
  8. 4
      packages/core/src/css_composer/view/CssRuleView.ts
  9. 4
      packages/core/src/css_composer/view/CssRulesView.ts
  10. 2
      packages/core/src/device_manager/view/DevicesView.ts
  11. 6
      packages/core/src/dom_components/view/ComponentFrameView.ts
  12. 16
      packages/core/src/editor/config/config.ts
  13. 4
      packages/core/src/modal_dialog/view/ModalView.ts
  14. 9
      packages/core/src/navigator/view/ItemView.ts
  15. 8
      packages/core/src/parser/model/BrowserParserCss.ts
  16. 2
      packages/core/src/parser/model/ParserCss.ts
  17. 6
      packages/core/src/rich_text_editor/model/RichTextEditor.ts
  18. 4
      packages/core/src/selector_manager/view/ClassTagsView.ts
  19. 13
      packages/core/src/style_manager/view/LayerView.ts
  20. 4
      packages/core/src/style_manager/view/PropertyFileView.ts
  21. 11
      packages/core/src/style_manager/view/PropertySelectView.ts
  22. 10
      packages/core/src/style_manager/view/PropertyView.ts
  23. 8
      packages/core/src/styles/scss/_gjs_category_general.scss
  24. 1
      packages/core/src/styles/scss/spectrum.scss
  25. 14
      packages/core/src/trait_manager/view/TraitSelectView.ts
  26. 24
      packages/core/src/utils/ColorPicker.ts
  27. 83
      packages/core/src/utils/dom.ts
  28. 1
      packages/core/test/specs/commands/view/Preview.ts
  29. 109
      packages/core/test/specs/editor/CspNonce.ts
  30. 5
      packages/core/test/specs/style_manager/view/PropertySelectView.ts
  31. 62
      packages/core/test/specs/utils/noInlineStyleAttributes.ts
  32. 63
      packages/core/test/specs/utils/setStyleText.ts

15
packages/core/src/asset_manager/view/AssetImageView.ts

@ -5,14 +5,23 @@ import html from '../../utils/html';
export default class AssetImageView extends AssetView<AssetImage> {
getPreview() {
const { pfx, ppfx, model } = this;
const src = model.get('src');
const { pfx, ppfx } = this;
return html`
<div class="${pfx}preview" style="background-image: url('${src}');"></div>
<div class="${pfx}preview" data-preview></div>
<div class="${pfx}preview-bg ${ppfx}checker-bg"></div>
`;
}
render() {
super.render();
// Set through the CSSOM, a `style` attribute would be blocked by a strict
// `style-src-attr` policy
const previewEl = this.el.querySelector('[data-preview]') as HTMLElement;
const src = this.model.get('src');
previewEl && previewEl.style.setProperty('background-image', src ? `url(${JSON.stringify(src)})` : '');
return this;
}
getInfo() {
const { pfx, model } = this;
let name = model.get('name');

4
packages/core/src/asset_manager/view/AssetsView.ts

@ -20,7 +20,7 @@ export default class AssetsView extends View {
<input placeholder="${em?.t('assetManager.inputPlh')}"/>
</div>
<button class="${ppfx}btn-prim">${em?.t('assetManager.addButton')}</button>
<div style="clear:both"></div>
<div class="${ppfx}clear-float"></div>
</form>
`;
}
@ -31,7 +31,7 @@ export default class AssetsView extends View {
${form}
</div>
<div class="${pfx}assets" data-el="assets"></div>
<div style="clear:both"></div>
<div class="${ppfx}clear-float"></div>
</div>
`;
}

3
packages/core/src/asset_manager/view/FileUploader.ts

@ -48,6 +48,7 @@ export default class FileUploaderView extends View {
uploadForm?: HTMLFormElement | null;
template({ pfx, title, uploadId, disabled, multiUpload }: FileUploaderTemplateProps) {
const { ppfx } = this;
return html`
<form>
<div id="${pfx}title">${title}</div>
@ -60,7 +61,7 @@ export default class FileUploaderView extends View {
${disabled ? 'disabled' : ''}
${multiUpload ? 'multiple' : ''}
/>
<div style="clear:both;"></div>
<div class="${ppfx}clear-float"></div>
</form>
`;
}

13
packages/core/src/canvas/view/CanvasView.ts

@ -5,6 +5,7 @@ import Component from '../../dom_components/model/Component';
import ComponentView from '../../dom_components/view/ComponentView';
import {
createEl,
createStyleEl,
getDocumentScroll,
getElRect,
getKeyChar,
@ -59,7 +60,6 @@ export default class CanvasView extends ModuleView<Canvas> {
<div class="${pfx}canvas__spots" data-spots></div>
</div>
<div id="${pfx}tools" class="${pfx}canvas__tools" data-tools></div>
<style data-canvas-style></style>
`;
}
/*get className(){
@ -666,16 +666,16 @@ export default class CanvasView extends ModuleView<Canvas> {
const toolsWrp = $el.find('[data-tools]');
this.toolsWrapper = toolsWrp.get(0);
toolsWrp.append(`
<div class="${ppfx}tools ${ppfx}tools-gl" style="pointer-events:none">
<div class="${ppfx}tools ${ppfx}tools-gl ${ppfx}no-pointer-events">
<div class="${ppfx}placeholder">
<div class="${ppfx}placeholder-int"></div>
</div>
</div>
<div id="${ppfx}tools" style="pointer-events:none">
<div id="${ppfx}tools" class="${ppfx}no-pointer-events">
${config.extHl ? `<div class="${ppfx}highlighter-sel"></div>` : ''}
<div class="${ppfx}badge"></div>
<div class="${ppfx}ghost"></div>
<div class="${ppfx}toolbar" style="pointer-events:all"></div>
<div class="${ppfx}toolbar ${ppfx}pointer-events-all"></div>
<div class="${ppfx}resizer"></div>
<div class="${ppfx}offset-v"></div>
<div class="${ppfx}offset-fixed-v"></div>
@ -692,7 +692,10 @@ export default class CanvasView extends ModuleView<Canvas> {
this.fixedOffsetEl = el.querySelector(`.${ppfx}offset-fixed-v`)!;
this.toolsGlobEl = el.querySelector(`.${ppfx}tools-gl`)!;
this.spotsEl = el.querySelector('[data-spots]')!;
this.cvStyle = el.querySelector('[data-canvas-style]')!;
// Created here instead of in the template, so that the CSP nonce is in
// place before the element enters the document
this.cvStyle = createStyleEl('', em.getConfig().cspNonce, { 'data-canvas-style': '' });
el.appendChild(this.cvStyle);
el.className = getUiClass(em, this.className);
if (config.scrollableCanvas === true) {
el.style.overflow = 'auto';

9
packages/core/src/canvas/view/FrameView.ts

@ -7,7 +7,7 @@ import ComponentView from '../../dom_components/view/ComponentView';
import ComponentWrapperView from '../../dom_components/view/ComponentWrapperView';
import AutoScroller from '../../utils/AutoScroller';
import Droppable from '../../utils/Droppable';
import { append, appendVNodes, createCustomEvent, createEl, motionsEv, off, on } from '../../utils/dom';
import { append, appendVNodes, createCustomEvent, createEl, createStyleEl, motionsEv, off, on } from '../../utils/dom';
import { hasDnd, setViewEl } from '../../utils/mixins';
import Canvas from '../model/Canvas';
import Frame from '../model/Frame';
@ -374,7 +374,8 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
append(
body,
`<style>
createStyleEl(
`
${conf.baseCss || config.frameStyle || ''}
${hasAutoHeight ? 'body { overflow: hidden }' : ''}
@ -444,7 +445,9 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
${conf.canvasCss || ''}
${conf.protectedCss || ''}
</style>`,
`,
conf.cspNonce,
),
);
const { root } = model;
const { view } = this.getComponentView(root);

10
packages/core/src/canvas/view/FrameWrapView.ts

@ -204,7 +204,7 @@ export default class FrameWrapView extends ModuleView<Frame> {
${model.get('name') || ''}
</div>
<div class="${ppfx}frame-wrapper__top-r">
<div class="${ppfx}frame-wrapper__icon" data-action-remove style="display: none">
<div class="${ppfx}frame-wrapper__icon ${ppfx}hidden" data-action-remove>
<svg viewBox="0 0 24 24"><path d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"></path></svg>
</div>
</div>
@ -218,8 +218,7 @@ export default class FrameWrapView extends ModuleView<Frame> {
const elTools = createEl(
'div',
{
class: `${ppfx}tools`,
style: 'pointer-events:none; display: none',
class: `${ppfx}tools ${ppfx}no-pointer-events`,
},
`
<div class="${ppfx}highlighter" data-hl></div>
@ -228,7 +227,7 @@ export default class FrameWrapView extends ModuleView<Frame> {
<div class="${ppfx}placeholder-int"></div>
</div>
<div class="${ppfx}ghost"></div>
<div class="${ppfx}toolbar" style="pointer-events:all"></div>
<div class="${ppfx}toolbar ${ppfx}pointer-events-all"></div>
<div class="${ppfx}resizer"></div>
<div class="${ppfx}offset-v" data-offset>
<div class="gjs-marginName" data-offset-m>
@ -247,6 +246,9 @@ export default class FrameWrapView extends ModuleView<Frame> {
<div class="${ppfx}offset-fixed-v"></div>
`,
);
// Kept on the CSSOM instead of a class, `toggleToolsEl` shows it back by
// resetting the inline display
elTools.style.display = 'none';
this.elTools = elTools;
const twrp = cv?.toolsWrapper;
twrp && twrp.appendChild(elTools); // TODO remove on frame remove

4
packages/core/src/commands/view/Preview.ts

@ -102,7 +102,9 @@ export default class CommandPreview extends CommandAbstract {
panels.forEach((panel) => panel.set('visible', true));
const canvas = editor.Canvas.getElement();
canvas.setAttribute('style', '');
// Removing beats writing an empty `style`, which a strict `style-src-attr`
// policy would still report
canvas.removeAttribute('style');
selected && editor.select(selected);
delete this.selected;

4
packages/core/src/css_composer/view/CssRuleView.ts

@ -1,6 +1,7 @@
import FrameView from '../../canvas/view/FrameView';
import { View } from '../../common';
import EditorModel from '../../editor/model/Editor';
import { setNonce } from '../../utils/dom';
import CssRule from '../model/CssRule';
import { CssEvents } from '../types';
@ -46,6 +47,9 @@ export default class CssRuleView extends View<CssRule> {
const css = model.toCSS({ important });
const mountProps = { rule: model, ruleView: this, css };
em?.trigger(CssEvents.mountBefore, mountProps);
// Has to be set before the style block is filled, otherwise a strict
// `style-src` policy blocks the rule
setNonce(el as HTMLElement, em?.getConfig().cspNonce);
el.innerHTML = mountProps.css;
em?.trigger(CssEvents.mount, mountProps);
return this;

4
packages/core/src/css_composer/view/CssRulesView.ts

@ -1,7 +1,7 @@
import { bindAll } from 'underscore';
import { View } from '../../common';
import { createEl } from '../../utils/dom';
import { createEl, createStyleEl } from '../../utils/dom';
import CssRuleView from './CssRuleView';
import CssGroupRuleView from './CssGroupRuleView';
import EditorModel from '../../editor/model/Editor';
@ -66,7 +66,7 @@ export default class CssRulesView extends View {
let atRuleEl = this.atRules[atRule];
if (!atRuleEl) {
const styleEl = document.createElement('style');
const styleEl = createStyleEl('', this.em?.getConfig().cspNonce);
atRuleEl = document.createTextNode('');
styleEl.appendChild(document.createTextNode(`${atRule}{`));
styleEl.appendChild(atRuleEl);

2
packages/core/src/device_manager/view/DevicesView.ts

@ -25,7 +25,7 @@ export default class DevicesView extends View {
<div class="${ppfx}d-s-arrow"></div>
</div>
</div>
<button style="display:none" class="${ppfx}add-trasp" data-add-trasp>+</button>
<button class="${ppfx}add-trasp ${ppfx}hidden" data-add-trasp>+</button>
`;
}

6
packages/core/src/dom_components/view/ComponentFrameView.ts

@ -1,5 +1,5 @@
import ComponentView from './ComponentView';
import { createEl, find, attrUp } from '../../utils/dom';
import { createEl, find, attrUp, setStyleText } from '../../utils/dom';
import ComponentFrame from '../model/ComponentFrame';
export default class ComponentFrameView extends ComponentView<ComponentFrame> {
@ -21,9 +21,11 @@ export default class ComponentFrameView extends ComponentView<ComponentFrame> {
super.render();
const frame = createEl('iframe', {
class: `${this.ppfx}no-pointer`,
style: 'width: 100%; height: 100%; border: none',
src: this.__getSrc(),
});
// Set through the CSSOM, a `style` attribute would be blocked by a strict
// `style-src-attr` policy
setStyleText(frame, 'width: 100%; height: 100%; border: none');
this.el.appendChild(frame);
return this;
}

16
packages/core/src/editor/config/config.ts

@ -244,6 +244,21 @@ export interface EditorConfig {
*/
avoidInlineStyle?: boolean;
/**
* CSP nonce to set on every `<style>` element created by the editor.
* Required when the page is served with a strict `style-src`/`style-src-elem`
* policy, as the editor injects stylesheets at runtime (canvas styles, frame
* base styles and one `<style>` per CSS rule).
*
* The value has to match the `nonce-...` source of the policy sent with the
* document. Note that it does not cover inline `style` attributes, which are
* governed by `style-src-attr`.
* @default undefined
* @example
* cspNonce: 'r4nd0m'
*/
cspNonce?: string;
/**
* Avoid default properties from storable JSON data, like `components` and `styles`.
* With this option enabled your data will be smaller (usefull if need to
@ -467,6 +482,7 @@ const config: () => EditorConfig = () => ({
optsHtml: {},
optsCss: {},
avoidInlineStyle: true,
cspNonce: undefined,
avoidDefaults: true,
clearStyles: false,
listenToEl: [],

4
packages/core/src/modal_dialog/view/ModalView.ts

@ -10,10 +10,10 @@ export default class ModalView extends ModuleView<Modal> {
</div>
<div class="${pfx}content">
<div id="${pfx}c">${content}</div>
<div style="clear:both"></div>
<div class="${ppfx}clear-float"></div>
</div>
</div>
<div class="${pfx}collector" style="display: none"></div>`;
<div class="${pfx}collector ${ppfx}hidden"></div>`;
}
events() {

9
packages/core/src/navigator/view/ItemView.ts

@ -49,8 +49,6 @@ export default class ItemView extends View {
const clsTitle = `${this.clsTitle} ${addClass}`;
const clsTitleC = `${this.clsTitleC}`;
const clsInput = `${this.inputNameCls} ${clsNoEdit} ${ppfx}no-app`;
const level = opt.level || 0;
const gut = `${level * 10}px`;
const name = model.getName();
const icon = model.getIcon();
const clsBase = `${pfx}layer`;
@ -69,7 +67,7 @@ export default class ItemView extends View {
: ''
}
<div class="${clsTitleC}">
<div class="${clsTitle}" style="padding-left: ${gut}">
<div class="${clsTitle}" data-title-indent>
<div class="${pfx}layer-title-inn" title="${name}">
<i class="${this.clsCaret}" data-toggle-open>${chevron}</i>
${icon ? `<span class="${clsBase}__icon">${icon}</span>` : ''}
@ -434,6 +432,11 @@ export default class ItemView extends View {
el.find(`.${this.clsChildren}`).append(children);
}
// Set through the CSSOM, a `style` attribute would be blocked by a strict
// `style-src-attr` policy
const titleEl = this.el.querySelector('[data-title-indent]') as HTMLElement;
titleEl && (titleEl.style.paddingLeft = `${(opt.level || 0) * 10}px`);
!module.isVisible(model) && (this.className += ` ${pfx}hide`);
hidden && (this.className += ` ${ppfx}hidden`);
el.attr('class', this.className!);

8
packages/core/src/parser/model/BrowserParserCss.ts

@ -1,6 +1,7 @@
import { keys } from 'underscore';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import { ObjectStrings } from '../../common';
import { createStyleEl } from '../../utils/dom';
/** @see https://developer.mozilla.org/en-US/docs/Web/API/CSSRule/type */
const CSS_RULE_TYPES = {
@ -271,11 +272,12 @@ export const parseNode = (el: CSSStyleSheet | CSSRule) => {
/**
* Parse CSS string and return the array of objects
* @param {String} str CSS string
* @param {String} [nonce] CSP nonce, required under a strict `style-src` policy,
* otherwise the style block is blocked and `el.sheet` stays empty
* @return {Array<Object>} Array of objects for the definition of CSSRules
*/
export default (str: string) => {
const el = document.createElement('style');
el.innerHTML = str;
export default (str: string, nonce?: string) => {
const el = createStyleEl(str, nonce);
// There is no .sheet before adding it to the <head>
document.head.appendChild(el);

2
packages/core/src/parser/model/ParserCss.ts

@ -29,7 +29,7 @@ export default class ParserCss {
const { input } = inputOptions;
try {
nodes = parserCss ? parserCss(input, editor!) : BrowserCssParser(input);
nodes = parserCss ? parserCss(input, editor!) : BrowserCssParser(input, this.em?.getConfig().cspNonce);
} catch (err) {
error = err;
if (opts.throwOnError) throw err;

6
packages/core/src/rich_text_editor/model/RichTextEditor.ts

@ -4,7 +4,7 @@
import { isString } from 'underscore';
import RichTextEditorModule from '..';
import EditorModel from '../../editor/model/Editor';
import { getPointerEvent, off, on } from '../../utils/dom';
import { getPointerEvent, off, on, setStyleText } from '../../utils/dom';
import { getComponentModel } from '../../utils/mixins';
export interface RichTextEditorAction {
@ -369,7 +369,9 @@ export default class RichTextEditor {
action.btn = btn;
for (let key in attr) {
btn.setAttribute(key, attr[key]);
// `style` goes through the CSSOM, writing the attribute would be
// blocked by a strict `style-src-attr` policy
key === 'style' ? setStyleText(btn, attr[key]) : btn.setAttribute(key, attr[key]);
}
if (typeof icon == 'string') {

4
packages/core/src/selector_manager/view/ClassTagsView.ts

@ -32,7 +32,7 @@ export default class ClassTagsView extends View<Selector> {
<div id="${pfx}tags-c" data-selectors></div>
<input id="${pfx}new" data-input />
<span id="${pfx}add-tag" class="${pfx}tags-btn ${pfx}tags-btn__add" data-add> $${iconAdd} </span>
<span class="${pfx}tags-btn ${pfx}tags-btn__sync" style="display: none" data-sync-style> $${iconSync} </span>
<span class="${pfx}tags-btn ${pfx}tags-btn__sync" data-sync-style> $${iconSync} </span>
</div>
<div class="${pfx}sels-info">
<div class="${pfx}label-sel">${labelInfo}:</div>
@ -436,6 +436,8 @@ export default class ClassTagsView extends View<Selector> {
this.$classes = $el.find('#' + pfx + 'tags-c');
this.$btnSyncEl = $el.find('[data-sync-style]');
this.$input.hide();
// Hidden through the CSSOM, `updateSelector` brings it back with `show()`
this.$btnSyncEl.hide();
this.renderStates();
this.renderClasses();
$el.attr('class', `${this.className} ${ppfx}one-bg ${ppfx}two-color`);

13
packages/core/src/style_manager/view/LayerView.ts

@ -36,7 +36,7 @@ export default class LayerView extends View<Layer> {
${iconMove}
</div>
<div id="${pfx}label" data-label></div>
<div id="${pfx}preview-box" class="${pfx}layer-preview" style="display: none" data-preview-box>
<div id="${pfx}preview-box" class="${pfx}layer-preview" data-preview-box>
<div id="${pfx}preview" class="${pfx}layer-preview-cnt" data-preview></div>
</div>
<div id="${pfx}close-layer" class="${pfx}btn-close" data-close-layer>
@ -104,10 +104,8 @@ export default class LayerView extends View<Layer> {
if (model.hasPreview()) {
const prvEl = this.getPreviewEl();
const style = model.getStylePreview({ number: { min: -3, max: 3 } });
const styleStr = keys(style)
.map((k) => `${k}:${style[k]}`)
.join(';');
prvEl.setAttribute('style', styleStr);
prvEl.removeAttribute('style');
keys(style).forEach((k) => prvEl.style.setProperty(k, `${style[k]}`));
}
}
@ -124,9 +122,8 @@ export default class LayerView extends View<Layer> {
const { el, pfx, model } = this;
el.innerHTML = this.template();
el.className = `${pfx}layer`;
if (model.hasPreview()) {
(el.querySelector('[data-preview-box]') as HTMLElement).style.display = '';
}
const previewBoxEl = el.querySelector('[data-preview-box]') as HTMLElement;
previewBoxEl.style.display = model.hasPreview() ? '' : 'none';
this.updateLabel();
this.updateVisibility();
return this;

4
packages/core/src/style_manager/view/PropertyFileView.ts

@ -11,7 +11,7 @@ export default class PropertyFileView extends PropertyView {
}
templateInput() {
const { pfx, em } = this;
const { pfx, ppfx, em } = this;
const icons = this.em?.getConfig().icons;
const iconClose = icons?.close;
@ -23,7 +23,7 @@ export default class PropertyFileView extends PropertyView {
${em.t('styleManager.fileButton')}
</button>
</div>
<div style="clear:both;"></div>
<div class="${ppfx}clear-float"></div>
</div>
<div id="${pfx}preview-box" class="${pfx}preview-file" data-preview-box>
<div id="${pfx}preview-file" class="${pfx}preview-file-cnt" data-preview></div>

11
packages/core/src/style_manager/view/PropertySelectView.ts

@ -1,3 +1,4 @@
import { setStyleText } from '../../utils/dom';
import PropertySelect from '../model/PropertySelect';
import PropertyView from './PropertyView';
@ -31,19 +32,23 @@ export default class PropertySelectView extends PropertyView {
if (!this.input) {
const optionsRes: string[] = [];
const optionsStyle: string[] = [];
options.forEach((option) => {
const id = model.getOptionId(option);
const name = model.getOptionLabel(id);
const style = option.style ? option.style.replace(/"/g, '&quot;') : '';
const styleAttr = style ? `style="${style}"` : '';
const value = id.replace(/"/g, '&quot;');
optionsRes.push(`<option value="${value}" ${styleAttr}>${name}</option>`);
optionsStyle.push(option.style || '');
optionsRes.push(`<option value="${value}">${name}</option>`);
});
const inputH = this.el.querySelector(`#${pfx}input-holder`)!;
inputH.innerHTML = `<select>${optionsRes.join('')}</select>`;
this.input = inputH.firstChild as HTMLInputElement;
// Option styles are applied through the CSSOM, a `style` attribute would
// be blocked by a strict `style-src-attr` policy
const optionEls = this.input.querySelectorAll('option');
optionsStyle.forEach((style, i) => style && setStyleText(optionEls[i] as HTMLElement, style));
}
}

10
packages/core/src/style_manager/view/PropertyView.ts

@ -82,7 +82,7 @@ export default class PropertyView extends View<Property> {
}
templateLabel(model: Property) {
const { pfx, em } = this;
const { pfx, ppfx, em } = this;
const { parent } = model;
const { icon = '', info = '' } = model.attributes;
const icons = em?.getConfig().icons;
@ -92,7 +92,7 @@ export default class PropertyView extends View<Property> {
<span class="${pfx}icon ${icon}" title="${info}">
${model.getLabel()}
</span>
${!parent ? `<div class="${pfx}clear" style="display: none" ${clearProp}>${iconClose}</div>` : ''}
${!parent ? `<div class="${pfx}clear ${ppfx}hidden" ${clearProp}>${iconClose}</div>` : ''}
`;
}
@ -123,13 +123,13 @@ export default class PropertyView extends View<Property> {
const computedCls = `${ppfx}color-warn`;
const labelEl = this.$el.children(`.${pfx}label`);
const clearStyleEl = this.getClearEl();
const clearStyle = clearStyleEl ? clearStyleEl.style : ({} as CSSStyleDeclaration);
const hiddenCls = `${ppfx}hidden`;
labelEl.removeClass(`${updatedCls} ${computedCls}`);
clearStyle.display = 'none';
clearStyleEl?.classList.add(hiddenCls);
if (model.hasValue({ noParent: true }) && config.highlightChanged) {
labelEl.addClass(updatedCls);
config.clearProperties && (clearStyle.display = '');
config.clearProperties && clearStyleEl?.classList.remove(hiddenCls);
} else if (model.hasValue() && config.highlightComputed) {
labelEl.addClass(computedCls);
}

8
packages/core/src/styles/scss/_gjs_category_general.scss

@ -57,6 +57,14 @@
pointer-events: none;
}
.#{gjs_vars.$app-prefix}pointer-events-all {
pointer-events: all;
}
.#{gjs_vars.$app-prefix}clear-float {
clear: both;
}
.no-select {
@include gjs_main_mixins.user-select(none);
}

1
packages/core/src/styles/scss/spectrum.scss

@ -603,6 +603,7 @@ See http://bgrins.github.io/spectrum/themes/ for instructions.
}
.sp-clear-display {
background-color: transparent;
background-repeat: no-repeat;
background-position: center;
background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==);

14
packages/core/src/trait_manager/view/TraitSelectView.ts

@ -1,5 +1,6 @@
import { isString, isUndefined } from 'underscore';
import { $ } from '../../common';
import { setStyleText } from '../../utils/dom';
import TraitView from './TraitView';
export default class TraitSelectView extends TraitView {
@ -31,8 +32,9 @@ export default class TraitSelectView extends TraitView {
const values: string[] = [];
let input = '<select>';
const styles: string[] = [];
opts.forEach((el) => {
let attrs = '';
let name, value, style;
if (isString(el)) {
@ -41,16 +43,20 @@ export default class TraitSelectView extends TraitView {
} else {
name = el.name || el.label || el.value;
value = `${isUndefined(el.value) ? el.id : el.value}`.replace(/"/g, '&quot;');
style = el.style ? (el.style as string).replace(/"/g, '&quot;') : '';
attrs += style ? ` style="${style}"` : '';
style = el.style as string;
}
const resultName = em.t(`traitManager.traits.options.${propName}.${value}`) || name;
input += `<option value="${value}"${attrs}>${resultName}</option>`;
styles.push(style || '');
input += `<option value="${value}">${resultName}</option>`;
values.push(value);
});
input += '</select>';
this.$input = $(input);
// Option styles are applied through the CSSOM, a `style` attribute would
// be blocked by a strict `style-src-attr` policy
const optionEls = this.$input!.get(0)!.querySelectorAll('option');
styles.forEach((style, i) => style && setStyleText(optionEls[i] as HTMLElement, style));
const val = model.getTargetValue();
const valResult = values.indexOf(val) >= 0 ? val : model.get('default');
!isUndefined(valResult) && this.$input!.val(valResult);

24
packages/core/src/utils/ColorPicker.ts

@ -5,6 +5,7 @@
// https://github.com/bgrins/spectrum
// Author: Brian Grinstead
// License: MIT
import { setStyleText } from './dom';
import { hasWin } from './mixins';
export interface ColorPickerOptions {
@ -159,6 +160,16 @@ export default function ($, undefined?: any) {
].join('');
})();
// Swatch colors are known only at runtime, so they are carried by a data
// attribute and moved to the CSSOM once in the DOM: writing them as a `style`
// attribute would be blocked by a strict `style-src-attr` policy.
function applySwatchStyles($container) {
$container.find('[data-swatch-style]').each(function (i, el) {
setStyleText(el, el.getAttribute('data-swatch-style'));
el.removeAttribute('data-swatch-style');
});
}
function paletteTemplate(p, color, className, opts) {
var html = [];
for (var i = 0; i < p.length; i++) {
@ -176,20 +187,15 @@ export default function ($, undefined?: any) {
tiny.toRgbString() +
'" class="' +
c +
'"><span class="sp-thumb-inner" style="' +
'"><span class="sp-thumb-inner" data-swatch-style="' +
swatchStyle +
';"></span></span>',
'"></span></span>',
);
} else {
var cls = 'sp-clear-display';
html.push(
$('<div />')
.append(
$('<span data-color="" style="background-color:transparent;" class="' + cls + '"></span>').attr(
'title',
opts.noColorSelectedText,
),
)
.append($('<span data-color="" class="' + cls + '"></span>').attr('title', opts.noColorSelectedText))
.html(),
);
}
@ -598,6 +604,7 @@ export default function ($, undefined?: any) {
}
paletteContainer.html(html.join(''));
applySwatchStyles(paletteContainer);
}
function drawInitial() {
@ -605,6 +612,7 @@ export default function ($, undefined?: any) {
var initial = colorOnShow;
var current = get();
initialColorContainer.html(paletteTemplate([initial, current], current, 'sp-palette-row-initial', opts));
applySwatchStyles(initialColorContainer);
}
}

83
packages/core/src/utils/dom.ts

@ -94,6 +94,89 @@ export const createEl = (tag: string, attrs: ObjectAny = {}, child?: ChildHTML)
export const createText = (str: string) => document.createTextNode(str);
/**
* Set the CSP nonce on an element.
* The nonce has to be in place before the element is inserted in the document,
* otherwise a strict `style-src`/`script-src` policy blocks it.
*/
export const setNonce = <T extends HTMLElement>(el: T, nonce?: string) => {
nonce && el.setAttribute('nonce', nonce);
return el;
};
/**
* Create a detached `<style>` element, optionally carrying a CSP nonce.
* Attributes (nonce included) are set while the element is still detached, so
* the style block is already trusted by the time the caller appends it.
*/
export const createStyleEl = (css = '', nonce?: string, attributes: ObjectAny = {}) => {
const el = createEl('style', attributes) as HTMLStyleElement;
setNonce(el, nonce);
if (css) el.innerHTML = css;
return el;
};
/**
* Split a CSS declaration string on the top-level `;`, ignoring the ones nested
* in functions or strings (eg. `background: url(data:image/png;base64,...)`).
*/
const splitDeclarations = (style: string) => {
const result: string[] = [];
let current = '';
let depth = 0;
let quote = '';
for (let i = 0; i < style.length; i++) {
const char = style[i];
if (quote) {
char === quote && (quote = '');
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === '(') {
depth++;
} else if (char === ')') {
depth = Math.max(0, depth - 1);
} else if (char === ';' && !depth) {
result.push(current);
current = '';
continue;
}
current += char;
}
result.push(current);
return result;
};
const IMPORTANT_RE = /!\s*important\s*$/i;
/**
* Apply a CSS declaration string to an element through the CSSOM, replacing any
* style previously set on it.
* Unlike writing the `style` attribute, CSSOM updates are not subject to the
* `style-src-attr` CSP directive, so this keeps the editor usable on pages
* served with a strict policy.
*/
export const setStyleText = <T extends HTMLElement>(el: T, style?: string) => {
el.removeAttribute('style');
splitDeclarations(style || '').forEach((declaration) => {
const index = declaration.indexOf(':');
if (index < 0) return;
const prop = declaration.slice(0, index).trim();
if (!prop) return;
let value = declaration.slice(index + 1).trim();
const important = IMPORTANT_RE.test(value);
important && (value = value.replace(IMPORTANT_RE, '').trim());
el.style.setProperty(prop, value, important ? 'important' : '');
});
return el;
};
// Unfortunately just creating `KeyboardEvent(e.type, e)` is not enough,
// the keyCode/which will be always `0`. Even if it's an old/deprecated
// property keymaster (and many others) still use it... using `defineProperty`

1
packages/core/test/specs/commands/view/Preview.ts

@ -43,6 +43,7 @@ describe('Preview command', () => {
getElement: jest.fn().mockReturnValue({
style: {},
setAttribute: jest.fn(),
removeAttribute: jest.fn(),
}),
},

109
packages/core/test/specs/editor/CspNonce.ts

@ -0,0 +1,109 @@
import Editor from '../../../src/editor';
import EditorModel from '../../../src/editor/model/Editor';
import BrowserParserCss from '../../../src/parser/model/BrowserParserCss';
import { setupTestEditor } from '../../common';
const NONCE = 'test-nonce-123';
const getStyleEls = (em: EditorModel) => {
const docs = [document, em.Canvas.getDocument()].filter(Boolean) as Document[];
return docs.reduce<HTMLStyleElement[]>((res, doc) => res.concat(Array.from(doc.querySelectorAll('style'))), []);
};
describe('CSP nonce', () => {
let editor: Editor;
let em: EditorModel;
const setup = (cspNonce?: string) => {
({ editor, em } = setupTestEditor({ withCanvas: true, config: { cspNonce } }));
editor.setComponents('<div class="cmp">Hello</div>');
editor.setStyle('.cmp { color: red; } @media (max-width: 480px) { .cmp { color: blue } }');
em.Css.addRules('@keyframes anim { from { opacity: 0 } to { opacity: 1 } }');
};
afterEach(() => {
em?.destroy();
});
describe('with cspNonce set', () => {
beforeEach(() => setup(NONCE));
test('every style element created by the editor carries the nonce', () => {
const els = getStyleEls(em);
expect(els.length).toBeGreaterThan(0);
els.forEach((el) => expect(el.getAttribute('nonce')).toBe(NONCE));
});
test('canvas style element carries the nonce', () => {
const el = document.querySelector('[data-canvas-style]');
expect(el).toBeTruthy();
expect(el!.getAttribute('nonce')).toBe(NONCE);
});
test('frame base styles carry the nonce', () => {
const doc = em.Canvas.getDocument()!;
const el = doc.body.querySelector('style');
expect(el).toBeTruthy();
expect(el!.getAttribute('nonce')).toBe(NONCE);
});
test('each CSS rule style element carries the nonce', () => {
const doc = em.Canvas.getDocument()!;
const els = Array.from(doc.querySelectorAll('style')).filter((el) => el.innerHTML.includes('.cmp'));
expect(els.length).toBeGreaterThan(0);
els.forEach((el) => expect(el.getAttribute('nonce')).toBe(NONCE));
});
test('the CSS parser sets the nonce on its temporary style element', () => {
const nonces: (string | null)[] = [];
const appendChild = jest.spyOn(document.head, 'appendChild').mockImplementation(<T extends Node>(node: T) => {
nonces.push((node as unknown as HTMLElement).getAttribute?.('nonce') ?? null);
return node;
});
const removeChild = jest.spyOn(document.head, 'removeChild').mockImplementation(<T extends Node>(n: T) => n);
em.Parser.parseCss('.parsed { color: green }');
appendChild.mockRestore();
removeChild.mockRestore();
expect(nonces).toEqual([NONCE]);
});
});
describe('without cspNonce', () => {
beforeEach(() => setup());
test('no style element gets a nonce attribute', () => {
const els = getStyleEls(em);
expect(els.length).toBeGreaterThan(0);
els.forEach((el) => expect(el.hasAttribute('nonce')).toBe(false));
});
test('canvas style element is still created', () => {
expect(document.querySelector('[data-canvas-style]')).toBeTruthy();
});
});
describe('BrowserParserCss', () => {
test('parses CSS and applies the nonce to the temporary style element', () => {
const create = jest.spyOn(document, 'createElement');
const res = BrowserParserCss('.a { color: red }', NONCE);
const el = create.mock.results.find((r) => (r.value as HTMLElement).tagName === 'STYLE')!
.value as HTMLStyleElement;
create.mockRestore();
expect(el.getAttribute('nonce')).toBe(NONCE);
expect(res).toEqual([expect.objectContaining({ selectors: ['a'] })]);
});
test('omits the nonce attribute when none is given', () => {
const create = jest.spyOn(document, 'createElement');
BrowserParserCss('.a { color: red }');
const el = create.mock.results.find((r) => (r.value as HTMLElement).tagName === 'STYLE')!
.value as HTMLStyleElement;
create.mockRestore();
expect(el.hasAttribute('nonce')).toBe(false);
});
});
});

5
packages/core/test/specs/style_manager/view/PropertySelectView.ts

@ -16,7 +16,7 @@ describe('PropertySelectView', () => {
const propValue = 'test1value';
const defValue = 'test2value';
let options: any = [
{ id: 'test1value', style: 'test:style' },
{ id: 'test1value', style: 'color: red' },
{ id: 'test2', value: 'test2value' },
];
@ -69,7 +69,8 @@ describe('PropertySelectView', () => {
expect((children[1] as any).value).toEqual(options[1].id);
expect(children[0].textContent).toEqual(options[0].id);
expect(children[1].textContent).toEqual(options[1].id);
expect(children[0].getAttribute('style')).toEqual(options[0].style);
// Applied through the CSSOM, never written as a `style` attribute
expect((children[0] as HTMLElement).style.color).toEqual('red');
expect(children[1].getAttribute('style')).toEqual(null);
});

62
packages/core/test/specs/utils/noInlineStyleAttributes.ts

@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
/**
* The editor renders most of its chrome by assigning HTML strings, so a
* `style="..."` literal in a template ends up parsed as an inline style
* attribute, which a strict `style-src-attr` CSP blocks. The same goes for
* `setAttribute('style', ...)`.
*
* CSSOM writes (`el.style.prop = value`, `setStyleText`) are not covered by CSP
* and are the supported way to apply runtime values.
*/
const SRC_DIR = path.join(__dirname, '../../../src');
// Files allowed to keep an inline style, with the reason why
const ALLOWED: Record<string, string> = {
'dom_components/model/ComponentImage.ts':
'SVG placeholder serialized to a base64 data URL and used as `img` src, so it is a separate document governed by `img-src`',
'dom_components/view/ComponentView.ts':
'writes the style attribute of a user component, which is the content the editor exists to author (and is off by default via `avoidInlineStyle`)',
};
const PATTERNS = [
{ name: 'style attribute in markup', re: /(^|[^-\w])style\s*=\s*["'`]/ },
{ name: "setAttribute('style')", re: /setAttribute\(\s*['"`]style['"`]/ },
];
const stripComments = (code: string) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
const walk = (dir: string): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).reduce<string[]>((res, entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return res.concat(walk(full));
return entry.name.endsWith('.ts') ? res.concat(full) : res;
}, []);
describe('No inline style attributes in editor markup', () => {
test('src is free of `style=` and `setAttribute("style")`, except the documented cases', () => {
const found: string[] = [];
walk(SRC_DIR).forEach((file) => {
const relative = path.relative(SRC_DIR, file).split(path.sep).join('/');
if (ALLOWED[relative]) return;
stripComments(fs.readFileSync(file, 'utf8'))
.split('\n')
.forEach((line, i) => {
PATTERNS.forEach(({ name, re }) => {
re.test(line) && found.push(`${relative}:${i + 1} (${name}) ${line.trim()}`);
});
});
});
expect(found).toEqual([]);
});
test('the allowlist only names files that exist', () => {
Object.keys(ALLOWED).forEach((relative) => {
expect(fs.existsSync(path.join(SRC_DIR, relative))).toBe(true);
});
});
});

63
packages/core/test/specs/utils/setStyleText.ts

@ -0,0 +1,63 @@
import { setStyleText } from '../../../src/utils/dom';
describe('setStyleText', () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement('div');
});
test('applies a single declaration', () => {
setStyleText(el, 'color: red');
expect(el.style.color).toBe('red');
});
test('applies multiple declarations', () => {
setStyleText(el, 'color: red; padding-left: 10px');
expect(el.style.color).toBe('red');
expect(el.style.paddingLeft).toBe('10px');
});
test('replaces any style previously set', () => {
setStyleText(el, 'color: red; width: 10px');
setStyleText(el, 'color: blue');
expect(el.style.color).toBe('blue');
expect(el.style.width).toBe('');
});
test('keeps `;` nested in functions', () => {
const url = 'data:image/gif;base64,R0lGODlh';
setStyleText(el, `background-image: url(${url}); color: red`);
// jsdom re-serializes the url with quotes, what matters is that the
// `;` inside it did not split the declaration
expect(el.style.backgroundImage).toContain(url);
expect(el.style.color).toBe('red');
});
test('keeps `;` nested in strings', () => {
setStyleText(el, `content: "a;b"; color: red`);
expect(el.style.color).toBe('red');
});
test('supports !important', () => {
setStyleText(el, 'color: red !important');
expect(el.style.getPropertyPriority('color')).toBe('important');
expect(el.style.color).toBe('red');
});
test('supports custom properties', () => {
setStyleText(el, '--my-var: 10px');
expect(el.style.getPropertyValue('--my-var')).toBe('10px');
});
test('tolerates empty, partial and trailing declarations', () => {
setStyleText(el, ';; color: red ;; padding ;');
expect(el.style.color).toBe('red');
});
test('clears the style with an empty input', () => {
setStyleText(el, 'color: red');
setStyleText(el);
expect(el.getAttribute('style')).toBe(null);
});
});
Loading…
Cancel
Save