diff --git a/src/components/tw-fonts-modal/add-system-font.jsx b/src/components/tw-fonts-modal/add-system-font.jsx
index c06fd83784e..0618668ab77 100644
--- a/src/components/tw-fonts-modal/add-system-font.jsx
+++ b/src/components/tw-fonts-modal/add-system-font.jsx
@@ -64,24 +64,13 @@ class AddSystemFont extends React.Component {
/>
- {/* TODO: datalist is pretty bad at this. we should try our own dropdown? */}
diff --git a/src/components/tw-fonts-modal/font-dropdown-item.jsx b/src/components/tw-fonts-modal/font-dropdown-item.jsx
new file mode 100644
index 00000000000..316ce390b33
--- /dev/null
+++ b/src/components/tw-fonts-modal/font-dropdown-item.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import bindAll from 'lodash.bindall';
+import styles from './fonts-modal.css';
+
+class FontDropdownItem extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleSelect'
+ ]);
+ }
+
+ handleSelect () {
+ this.props.onSelect(this.props.family);
+ }
+
+ render () {
+ return (
+
+ {this.props.family}
+
+ );
+ }
+}
+
+FontDropdownItem.propTypes = {
+ family: PropTypes.string.isRequired,
+ onSelect: PropTypes.func.isRequired
+};
+
+export default FontDropdownItem;
diff --git a/src/components/tw-fonts-modal/font-name.jsx b/src/components/tw-fonts-modal/font-name.jsx
index 22108be0fce..4e02663d35d 100644
--- a/src/components/tw-fonts-modal/font-name.jsx
+++ b/src/components/tw-fonts-modal/font-name.jsx
@@ -1,53 +1,142 @@
import React from 'react';
+import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
-import styles from './fonts-modal.css';
import bindAll from 'lodash.bindall';
+import styles from './fonts-modal.css';
+import FontDropdownItem from './font-dropdown-item.jsx';
class FontName extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
+ 'setInputRef',
'handleChange',
- 'handleFlush',
- 'handleKeyPress'
+ 'handleFocus',
+ 'handleBlur',
+ 'handleResize',
+ 'handleSelectFont',
+ 'handleKeyDown'
]);
+ this.state = {
+ focused: false,
+ rect: null
+ };
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.handleResize);
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ setInputRef (input) {
+ this.input = input;
+
+ // can't use autoFocus because handleFocus relies on the ref existing already
+ if (input) {
+ input.focus();
+ }
}
handleChange (e) {
this.props.onChange(e.target.value);
}
- handleFlush () {
+ handleFocus () {
+ this.setState({
+ focused: true,
+ rect: this.input.getBoundingClientRect()
+ });
+ }
+
+ handleBlur () {
this.props.onChange(this.props.fontManager.getSafeName(this.props.name));
+ this.setState({
+ focused: false
+ });
}
- handleKeyPress (e) {
+ handleResize () {
+ if (this.state.focused) {
+ this.setState({
+ rect: this.input.getBoundingClientRect()
+ });
+ }
+ }
+
+ handleSelectFont (font) {
+ this.props.onChange(font);
+ }
+
+ handleKeyDown (e) {
if (e.key === 'Enter') {
- this.handleFlush();
+ this.handleBlur();
e.target.blur();
}
}
+ getFilteredOptions () {
+ if (!this.state.focused || !this.props.options) {
+ return [];
+ }
+ const name = this.props.name.toLowerCase();
+ const candidates = this.props.options
+ .filter(family => family.toLowerCase().includes(name));
+ if (candidates.length === 0 && candidates[0] === this.props.name) {
+ return [];
+ }
+ return candidates;
+ }
+
render () {
const {
/* eslint-disable no-unused-vars */
name,
onChange,
fontManager,
+ options,
/* eslint-enable no-unused-vars */
...props
} = this.props;
+
+ const filteredOptions = this.getFilteredOptions();
return (
-
+
+
+
+ {/* We need to use a portal to get out of the modal's overflow: hidden, unfortunately */}
+ {filteredOptions.length > 0 && ReactDOM.createPortal(
+
+ {this.getFilteredOptions().map(family => (
+
+ ))}
+
,
+ document.body
+ )}
+
);
}
}
@@ -57,7 +146,8 @@ FontName.propTypes = {
onChange: PropTypes.func.isRequired,
fontManager: PropTypes.shape({
getSafeName: PropTypes.func.isRequired
- }).isRequired
+ }).isRequired,
+ options: PropTypes.arrayOf(PropTypes.string.isRequired)
};
export default FontName;
diff --git a/src/components/tw-fonts-modal/fonts-modal.css b/src/components/tw-fonts-modal/fonts-modal.css
index f54ce978d63..57387e236aa 100644
--- a/src/components/tw-fonts-modal/fonts-modal.css
+++ b/src/components/tw-fonts-modal/fonts-modal.css
@@ -1,4 +1,5 @@
@import "../../css/colors.css";
+@import "../../css/z-index.css";
.modal-content {
max-width: 550px;
@@ -66,6 +67,9 @@
}
+.font-input-outer {
+
+}
.font-input {
width: 100%;
border: 1px solid $ui-black-transparent;
@@ -75,6 +79,34 @@
font: inherit;
}
+.font-dropdown-outer {
+ position: absolute;
+ z-index: $z-index-modal;
+ background-color: white;
+ color: $text-primary;
+ border-radius: 0.25rem;
+ overflow: auto;
+ max-height: 300px;
+ border: 1px solid $ui-black-transparent;
+ box-sizing: border-box;
+ box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, .3);
+}
+[theme="dark"] .font-dropdown-outer {
+ background-color: $ui-secondary;
+}
+.font-dropdown-item {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ height: 1.5rem;
+ cursor: pointer;
+ transition: .1s ease;
+}
+.font-dropdown-item:hover {
+ background-color: $motion-primary;
+ color: #ffffff;
+}
+
.font-playground {
background: none;
border: none;
diff --git a/src/components/tw-security-manager-modal/data-url.css b/src/components/tw-security-manager-modal/data-url.css
new file mode 100644
index 00000000000..d9d58b0eb0c
--- /dev/null
+++ b/src/components/tw-security-manager-modal/data-url.css
@@ -0,0 +1,20 @@
+@import "../../css/colors.css";
+
+.code {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ height: 5rem;
+ min-height: 3rem;
+ border: 1px solid $ui-black-transparent;
+ border-radius: 0.25rem;
+ padding: 0.25rem;
+ font-size: 0.875rem;
+ font-family: monospace;
+ margin: 0.5rem 0;
+}
+[theme="dark"] .code {
+ background: $ui-secondary;
+ color: white;
+}
diff --git a/src/components/tw-security-manager-modal/data-url.jsx b/src/components/tw-security-manager-modal/data-url.jsx
new file mode 100644
index 00000000000..94eb423c06c
--- /dev/null
+++ b/src/components/tw-security-manager-modal/data-url.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from './data-url.css';
+
+/**
+ * @param {string} dataURI data: URI
+ * @returns {string} A hopefully human-readable version
+ */
+const decodeDataURI = dataURI => {
+ const delimeter = dataURI.indexOf(',');
+ if (delimeter === -1) {
+ return dataURI;
+ }
+ const contentType = dataURI.substring(0, delimeter);
+ const data = dataURI.substring(delimeter + 1);
+ if (contentType.endsWith(';base64')) {
+ try {
+ return atob(data);
+ } catch (e) {
+ return dataURI;
+ }
+ }
+ try {
+ return decodeURIComponent(data);
+ } catch (e) {
+ return dataURI;
+ }
+};
+
+const DataURL = props => (
+
+);
+
+DataURL.propTypes = {
+ url: PropTypes.string.isRequired
+};
+
+export default DataURL;
diff --git a/src/components/tw-security-manager-modal/embed.jsx b/src/components/tw-security-manager-modal/embed.jsx
new file mode 100644
index 00000000000..3cdbe1af2ef
--- /dev/null
+++ b/src/components/tw-security-manager-modal/embed.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {FormattedMessage} from 'react-intl';
+import URL from './url.jsx';
+import DataURL from './data-url.jsx';
+
+const EmbedModal = props => (
+
+ {props.url.startsWith('data:') ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+
+ {!props.url.startsWith('data:') && (
+
+
+
+ )}
+
+);
+
+EmbedModal.propTypes = {
+ url: PropTypes.string.isRequired
+};
+
+export default EmbedModal;
diff --git a/src/components/tw-security-manager-modal/load-extension.css b/src/components/tw-security-manager-modal/load-extension.css
index 774bc4afe37..a1c2f52f183 100644
--- a/src/components/tw-security-manager-modal/load-extension.css
+++ b/src/components/tw-security-manager-modal/load-extension.css
@@ -7,25 +7,6 @@
margin: 8px 0;
}
-.code {
- display: block;
- width: 100%;
- max-width: 100%;
- min-width: 100%;
- height: 5rem;
- min-height: 3rem;
- border: 1px solid $ui-black-transparent;
- border-radius: 0.25rem;
- padding: 0.25rem;
- font-size: 0.875rem;
- font-family: monospace;
- margin: 0.5rem 0;
-}
-[theme="dark"] .code {
- background: $ui-secondary;
- color: white;
-}
-
.unsandboxed-container {
display: flex;
align-items: center;
diff --git a/src/components/tw-security-manager-modal/load-extension.jsx b/src/components/tw-security-manager-modal/load-extension.jsx
index b8f5901f165..3d1cd3d3612 100644
--- a/src/components/tw-security-manager-modal/load-extension.jsx
+++ b/src/components/tw-security-manager-modal/load-extension.jsx
@@ -3,34 +3,10 @@ import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import styles from './load-extension.css';
import URL from './url.jsx';
+import DataURL from './data-url.jsx';
import FancyCheckbox from '../tw-fancy-checkbox/checkbox.jsx';
import {APP_NAME} from '../../lib/brand';
-/**
- * @param {string} dataURI data: URI
- * @returns {string} A hopefully human-readable version
- */
-const decodeDataURI = dataURI => {
- const delimeter = dataURI.indexOf(',');
- if (delimeter === -1) {
- return dataURI;
- }
- const contentType = dataURI.substring(0, delimeter);
- const data = dataURI.substring(delimeter + 1);
- if (contentType.endsWith(';base64')) {
- try {
- return atob(data);
- } catch (e) {
- return dataURI;
- }
- }
- try {
- return decodeURIComponent(data);
- } catch (e) {
- return dataURI;
- }
-};
-
const LoadExtensionModal = props => (
{props.url.startsWith('data:') ? (
@@ -40,12 +16,7 @@ const LoadExtensionModal = props => (
description="Part of modal asking for permission to automatically load custom extension"
id="tw.loadExtension.embedded"
/>
-
+
) : (
diff --git a/src/components/tw-security-manager-modal/security-manager-modal.jsx b/src/components/tw-security-manager-modal/security-manager-modal.jsx
index 1c6bf48651f..dee81734f91 100644
--- a/src/components/tw-security-manager-modal/security-manager-modal.jsx
+++ b/src/components/tw-security-manager-modal/security-manager-modal.jsx
@@ -13,6 +13,7 @@ import RecordVideo from './record-video.jsx';
import ReadClipboard from './read-clipboard.jsx';
import Notify from './notify.jsx';
import Geolocate from './geolocate.jsx';
+import Embed from './embed.jsx';
import DelayedMountPropertyHOC from './delayed-mount-property-hoc.jsx';
import styles from './security-manager-modal.css';
@@ -53,6 +54,8 @@ const SecurityManagerModalComponent = props => (
) : props.type === SecurityModals.Geolocate ? (
+ ) : props.type === SecurityModals.Embed ? (
+
) : null}
diff --git a/src/containers/tw-security-manager.jsx b/src/containers/tw-security-manager.jsx
index 1653219559e..709f26183d7 100644
--- a/src/containers/tw-security-manager.jsx
+++ b/src/containers/tw-security-manager.jsx
@@ -38,6 +38,12 @@ const isTrustedExtension = url => (
*/
const fetchOriginsTrustedByUser = new Set();
+/**
+ * Set of origins manually trusted by the user for embedding.
+ * @type {Set}
+ */
+const embedOriginsTrustedByUser = new Set();
+
/**
* @param {URL} parsed Parsed URL object
* @returns {boolean} True if the URL is part of the builtin set of URLs to always trust fetching from.
@@ -64,10 +70,13 @@ const isAlwaysTrustedForFetching = parsed => (
// Itch
parsed.origin.endsWith('.itch.io') ||
+
// GameJolt
parsed.origin === 'https://api.gamejolt.com' ||
+
// httpbin
parsed.origin === 'https://httpbin.org' ||
+
// ScratchDB
parsed.origin === 'https://scratchdb.lefty.one'
);
@@ -106,7 +115,8 @@ const SECURITY_MANAGER_METHODS = [
'canRecordVideo',
'canReadClipboard',
'canNotify',
- 'canGeolocate'
+ 'canGeolocate',
+ 'canEmbed'
];
class TWSecurityManagerComponent extends React.Component {
@@ -226,6 +236,7 @@ class TWSecurityManagerComponent extends React.Component {
}
const {showModal} = await this.acquireModalLock();
// for backwards compatibility, allow urls to be unsandboxed
+ // TODO: this backwards compatibility needs to be deprecated at some point
// if (url.startsWith('data:')) {
const allowed = await showModal(SecurityModals.LoadExtension, {
url,
@@ -354,6 +365,28 @@ class TWSecurityManagerComponent extends React.Component {
return allowedGeolocation;
}
+ /**
+ * @param {string} url Frame URL
+ * @returns {Promise} True if embed is allowed.
+ */
+ async canEmbed (url) {
+ const parsed = parseURL(url);
+ if (!parsed) {
+ return false;
+ }
+ const origin = (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.origin : null;
+ const {showModal, releaseLock} = await this.acquireModalLock();
+ if (origin && embedOriginsTrustedByUser.has(origin)) {
+ releaseLock();
+ return true;
+ }
+ const allowed = await showModal(SecurityModals.Embed, {url});
+ if (origin && allowed) {
+ embedOriginsTrustedByUser.add(origin);
+ }
+ return allowed;
+ }
+
render () {
if (this.state.type) {
return (
diff --git a/src/lib/tw-security-manager-constants.js b/src/lib/tw-security-manager-constants.js
index 424908047e6..eba4839dae1 100644
--- a/src/lib/tw-security-manager-constants.js
+++ b/src/lib/tw-security-manager-constants.js
@@ -7,7 +7,8 @@ const SecurityModals = {
RecordVideo: 'RecordVideo',
ReadClipboard: 'ReadClipboard',
Notify: 'Notify',
- Geolocate: 'Geolocate'
+ Geolocate: 'Geolocate',
+ Embed: 'Embed'
};
export default SecurityModals;