mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2025-01-08 13:42:00 -05:00
Bit text tool (#515)
This commit is contained in:
parent
a70f8e1f36
commit
d7298c0c43
8 changed files with 142 additions and 86 deletions
|
@ -1,27 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
|
||||
import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx';
|
||||
|
||||
import textIcon from './text.svg';
|
||||
|
||||
const BitTextComponent = () => (
|
||||
<ComingSoonTooltip
|
||||
place="right"
|
||||
tooltipId="bit-text-mode"
|
||||
>
|
||||
<ToolSelectComponent
|
||||
disabled
|
||||
imgDescriptor={{
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the text tool',
|
||||
id: 'paint.textMode.text'
|
||||
}}
|
||||
imgSrc={textIcon}
|
||||
isSelected={false}
|
||||
onMouseDown={function () {}} // eslint-disable-line react/jsx-no-bind
|
||||
/>
|
||||
</ComingSoonTooltip>
|
||||
const BitTextComponent = props => (
|
||||
<ToolSelectComponent
|
||||
imgDescriptor={{
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the text tool',
|
||||
id: 'paint.textMode.text'
|
||||
}}
|
||||
imgSrc={textIcon}
|
||||
isSelected={props.isSelected}
|
||||
onMouseDown={props.onMouseDown}
|
||||
/>
|
||||
);
|
||||
|
||||
BitTextComponent.propTypes = {
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
onMouseDown: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BitTextComponent;
|
||||
|
|
|
@ -196,6 +196,8 @@ const ModeToolsComponent = props => {
|
|||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
case Modes.BIT_TEXT:
|
||||
/* falls through */
|
||||
case Modes.TEXT:
|
||||
return (
|
||||
<div className={classNames(props.className, styles.modeTools)}>
|
||||
|
|
|
@ -10,7 +10,6 @@ import BitBrushMode from '../../containers/bit-brush-mode.jsx';
|
|||
import BitLineMode from '../../containers/bit-line-mode.jsx';
|
||||
import BitOvalMode from '../../containers/bit-oval-mode.jsx';
|
||||
import BitRectMode from '../../containers/bit-rect-mode.jsx';
|
||||
import BitTextMode from '../../components/bit-text-mode/bit-text-mode.jsx';
|
||||
import BitFillMode from '../../containers/bit-fill-mode.jsx';
|
||||
import BitEraserMode from '../../containers/bit-eraser-mode.jsx';
|
||||
import BitSelectMode from '../../components/bit-select-mode/bit-select-mode.jsx';
|
||||
|
@ -167,7 +166,7 @@ const PaintEditorComponent = props => (
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{props.canvas !== null ? ( // eslint-disable-line no-negated-condition
|
||||
<div className={isBitmap(props.format) ? styles.modeSelector : styles.hidden}>
|
||||
<BitBrushMode
|
||||
|
@ -182,7 +181,11 @@ const PaintEditorComponent = props => (
|
|||
<BitRectMode
|
||||
onUpdateImage={props.onUpdateImage}
|
||||
/>
|
||||
<BitTextMode />
|
||||
<TextMode
|
||||
isBitmap
|
||||
textArea={props.textArea}
|
||||
onUpdateImage={props.onUpdateImage}
|
||||
/>
|
||||
<BitFillMode
|
||||
onUpdateImage={props.onUpdateImage}
|
||||
/>
|
||||
|
@ -192,7 +195,7 @@ const PaintEditorComponent = props => (
|
|||
<BitSelectMode />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
<div>
|
||||
{/* Canvas */}
|
||||
<div
|
||||
|
|
|
@ -127,6 +127,9 @@ class PaintEditor extends React.Component {
|
|||
case Modes.BIT_RECT:
|
||||
this.props.changeMode(Modes.RECT);
|
||||
break;
|
||||
case Modes.BIT_TEXT:
|
||||
this.props.changeMode(Modes.TEXT);
|
||||
break;
|
||||
case Modes.BIT_FILL:
|
||||
this.props.changeMode(Modes.FILL);
|
||||
break;
|
||||
|
@ -150,6 +153,9 @@ class PaintEditor extends React.Component {
|
|||
case Modes.RECT:
|
||||
this.props.changeMode(Modes.BIT_RECT);
|
||||
break;
|
||||
case Modes.TEXT:
|
||||
this.props.changeMode(Modes.BIT_TEXT);
|
||||
break;
|
||||
case Modes.FILL:
|
||||
this.props.changeMode(Modes.BIT_FILL);
|
||||
break;
|
||||
|
|
|
@ -17,6 +17,7 @@ import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
|
|||
import {clearSelection, getSelectedLeafItems} from '../helper/selection';
|
||||
import TextTool from '../helper/tools/text-tool';
|
||||
import TextModeComponent from '../components/text-mode/text-mode.jsx';
|
||||
import BitTextModeComponent from '../components/bit-text-mode/bit-text-mode.jsx';
|
||||
|
||||
class TextMode extends React.Component {
|
||||
constructor (props) {
|
||||
|
@ -49,7 +50,7 @@ class TextMode extends React.Component {
|
|||
}
|
||||
|
||||
if (nextProps.isTextModeActive && !this.props.isTextModeActive) {
|
||||
this.activateTool();
|
||||
this.activateTool(nextProps);
|
||||
} else if (!nextProps.isTextModeActive && this.props.isTextModeActive) {
|
||||
this.deactivateTool();
|
||||
}
|
||||
|
@ -57,13 +58,13 @@ class TextMode extends React.Component {
|
|||
shouldComponentUpdate (nextProps) {
|
||||
return nextProps.isTextModeActive !== this.props.isTextModeActive;
|
||||
}
|
||||
activateTool () {
|
||||
activateTool (nextProps) {
|
||||
clearSelection(this.props.clearSelectedItems);
|
||||
|
||||
|
||||
// If fill and stroke color are both mixed/transparent/absent, set fill to default and stroke to transparent.
|
||||
// If exactly one of fill or stroke color is set, set the other one to transparent.
|
||||
// This way the tool won't draw an invisible state, or be unclear about what will be drawn.
|
||||
const {fillColor, strokeColor, strokeWidth} = this.props.colorState;
|
||||
const {fillColor, strokeColor, strokeWidth} = nextProps.colorState;
|
||||
const fillColorPresent = fillColor !== MIXED && fillColor !== null;
|
||||
const strokeColorPresent =
|
||||
strokeColor !== MIXED && strokeColor !== null && strokeWidth !== null && strokeWidth !== 0;
|
||||
|
@ -75,8 +76,8 @@ class TextMode extends React.Component {
|
|||
} else if (fillColorPresent && !strokeColorPresent) {
|
||||
this.props.onChangeStrokeColor(null);
|
||||
}
|
||||
if (!this.props.font || Object.keys(Fonts).map(key => Fonts[key])
|
||||
.indexOf(this.props.font) < 0) {
|
||||
if (!nextProps.font || Object.keys(Fonts).map(key => Fonts[key])
|
||||
.indexOf(nextProps.font) < 0) {
|
||||
this.props.changeFont(Fonts.SANS_SERIF);
|
||||
}
|
||||
|
||||
|
@ -86,10 +87,11 @@ class TextMode extends React.Component {
|
|||
this.props.clearSelectedItems,
|
||||
this.props.onUpdateImage,
|
||||
this.props.setTextEditTarget,
|
||||
this.props.changeFont
|
||||
this.props.changeFont,
|
||||
nextProps.isBitmap
|
||||
);
|
||||
this.tool.setColorState(this.props.colorState);
|
||||
this.tool.setFont(this.props.font);
|
||||
this.tool.setColorState(nextProps.colorState);
|
||||
this.tool.setFont(nextProps.font);
|
||||
this.tool.activate();
|
||||
}
|
||||
deactivateTool () {
|
||||
|
@ -99,10 +101,15 @@ class TextMode extends React.Component {
|
|||
}
|
||||
render () {
|
||||
return (
|
||||
<TextModeComponent
|
||||
isSelected={this.props.isTextModeActive}
|
||||
onMouseDown={this.props.handleMouseDown}
|
||||
/>
|
||||
this.props.isBitmap ?
|
||||
<BitTextModeComponent
|
||||
isSelected={this.props.isTextModeActive}
|
||||
onMouseDown={this.props.handleChangeModeBitText}
|
||||
/> :
|
||||
<TextModeComponent
|
||||
isSelected={this.props.isTextModeActive}
|
||||
onMouseDown={this.props.handleChangeModeText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -116,7 +123,9 @@ TextMode.propTypes = {
|
|||
strokeWidth: PropTypes.number
|
||||
}).isRequired,
|
||||
font: PropTypes.string,
|
||||
handleMouseDown: PropTypes.func.isRequired,
|
||||
handleChangeModeBitText: PropTypes.func.isRequired,
|
||||
handleChangeModeText: PropTypes.func.isRequired,
|
||||
isBitmap: PropTypes.bool,
|
||||
isTextModeActive: PropTypes.bool.isRequired,
|
||||
onChangeFillColor: PropTypes.func.isRequired,
|
||||
onChangeStrokeColor: PropTypes.func.isRequired,
|
||||
|
@ -129,10 +138,12 @@ TextMode.propTypes = {
|
|||
viewBounds: PropTypes.instanceOf(paper.Matrix).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
colorState: state.scratchPaint.color,
|
||||
font: state.scratchPaint.font,
|
||||
isTextModeActive: state.scratchPaint.mode === Modes.TEXT,
|
||||
isTextModeActive: ownProps.isBitmap ?
|
||||
state.scratchPaint.mode === Modes.BIT_TEXT :
|
||||
state.scratchPaint.mode === Modes.TEXT,
|
||||
selectedItems: state.scratchPaint.selectedItems,
|
||||
textEditTarget: state.scratchPaint.textEditTarget,
|
||||
viewBounds: state.scratchPaint.viewBounds
|
||||
|
@ -144,15 +155,18 @@ const mapDispatchToProps = dispatch => ({
|
|||
clearSelectedItems: () => {
|
||||
dispatch(clearSelectedItems());
|
||||
},
|
||||
handleChangeModeBitText: () => {
|
||||
dispatch(changeMode(Modes.BIT_TEXT));
|
||||
},
|
||||
handleChangeModeText: () => {
|
||||
dispatch(changeMode(Modes.TEXT));
|
||||
},
|
||||
setSelectedItems: () => {
|
||||
dispatch(setSelectedItems(getSelectedLeafItems()));
|
||||
},
|
||||
setTextEditTarget: targetId => {
|
||||
dispatch(setTextEditTarget(targetId));
|
||||
},
|
||||
handleMouseDown: () => {
|
||||
dispatch(changeMode(Modes.TEXT));
|
||||
},
|
||||
onChangeFillColor: fillColor => {
|
||||
dispatch(changeFillColor(fillColor));
|
||||
},
|
||||
|
|
|
@ -9,13 +9,13 @@ const forEachLinePoint = function (point1, point2, callback) {
|
|||
const x2 = ~~point2.x;
|
||||
let y1 = ~~point1.y;
|
||||
const y2 = ~~point2.y;
|
||||
|
||||
|
||||
const dx = Math.abs(x2 - x1);
|
||||
const dy = Math.abs(y2 - y1);
|
||||
const sx = (x1 < x2) ? 1 : -1;
|
||||
const sy = (y1 < y2) ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
|
||||
callback(x1, y1);
|
||||
while (x1 !== x2 || y1 !== y2) {
|
||||
const e2 = err * 2;
|
||||
|
@ -336,7 +336,7 @@ const convertToBitmap = function (clearSelectedItems, onUpdateImage) {
|
|||
showGuideLayers(guideLayers);
|
||||
|
||||
// Get rid of anti-aliasing
|
||||
// @todo get crisp text?
|
||||
// @todo get crisp text https://github.com/LLK/scratch-paint/issues/508
|
||||
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||
inlineSvgFonts(svg);
|
||||
const svgString = (new XMLSerializer()).serializeToString(svg);
|
||||
|
|
|
@ -4,6 +4,7 @@ import {clearSelection, getSelectedLeafItems} from '../selection';
|
|||
import BoundingBoxTool from '../selection-tools/bounding-box-tool';
|
||||
import NudgeTool from '../selection-tools/nudge-tool';
|
||||
import {hoverBounds} from '../guides';
|
||||
import {getRaster} from '../layer';
|
||||
|
||||
/**
|
||||
* Tool for adding text. Text elements have limited editability; they can't be reshaped,
|
||||
|
@ -37,8 +38,10 @@ class TextTool extends paper.Tool {
|
|||
* @param {!function} onUpdateImage A callback to call when the image visibly changes
|
||||
* @param {!function} setTextEditTarget Call to set text editing target whenever text editing is active
|
||||
* @param {!function} changeFont Call to change the font in the dropdown
|
||||
* @param {?boolean} isBitmap True if text should be rasterized once it's deselected
|
||||
*/
|
||||
constructor (textAreaElement, setSelectedItems, clearSelectedItems, onUpdateImage, setTextEditTarget, changeFont) {
|
||||
constructor (textAreaElement, setSelectedItems, clearSelectedItems, onUpdateImage, setTextEditTarget, changeFont,
|
||||
isBitmap) {
|
||||
super();
|
||||
this.element = textAreaElement;
|
||||
this.setSelectedItems = setSelectedItems;
|
||||
|
@ -48,8 +51,8 @@ class TextTool extends paper.Tool {
|
|||
this.changeFont = changeFont;
|
||||
this.boundingBoxTool = new BoundingBoxTool(Modes.TEXT, setSelectedItems, clearSelectedItems, onUpdateImage);
|
||||
this.nudgeTool = new NudgeTool(this.boundingBoxTool, onUpdateImage);
|
||||
this.lastEvent = null;
|
||||
|
||||
this.isBitmap = isBitmap;
|
||||
|
||||
// We have to set these functions instead of just declaring them because
|
||||
// paper.js tools hook up the listeners in the setter functions.
|
||||
this.onMouseDown = this.handleMouseDown;
|
||||
|
@ -65,6 +68,7 @@ class TextTool extends paper.Tool {
|
|||
this.mode = null;
|
||||
this.active = false;
|
||||
this.lastTypeEvent = null;
|
||||
this.lastEvent = null;
|
||||
|
||||
// If text selected and then activate this tool, switch to text edit mode for that text
|
||||
// If double click on text while in select mode, does mode change to text mode? Text fully selected by default
|
||||
|
@ -100,6 +104,12 @@ class TextTool extends paper.Tool {
|
|||
*/
|
||||
onSelectionChanged (selectedItems) {
|
||||
this.boundingBoxTool.onSelectionChanged(selectedItems);
|
||||
if ((!this.textBox || !this.textBox.parent) &&
|
||||
selectedItems && selectedItems.length === 1 && selectedItems[0] instanceof paper.PointText) {
|
||||
// Infer that an undo occurred and get back the active text
|
||||
this.textBox = selectedItems[0];
|
||||
this.mode = TextTool.SELECT_MODE;
|
||||
}
|
||||
}
|
||||
setFont (font) {
|
||||
this.font = font;
|
||||
|
@ -117,12 +127,11 @@ class TextTool extends paper.Tool {
|
|||
}
|
||||
// Allow other tools to cancel text edit mode
|
||||
onTextEditCancelled () {
|
||||
this.endTextEdit();
|
||||
if (this.textBox) {
|
||||
this.mode = TextTool.SELECT_MODE;
|
||||
this.textBox.selected = true;
|
||||
this.setSelectedItems();
|
||||
if (this.mode !== TextTool.TEXT_EDIT_MODE) {
|
||||
return;
|
||||
}
|
||||
this.endTextEdit();
|
||||
this.beginSelect();
|
||||
}
|
||||
/**
|
||||
* Called when the view matrix changes
|
||||
|
@ -155,55 +164,44 @@ class TextTool extends paper.Tool {
|
|||
if (event.event.button > 0) return; // only first mouse button
|
||||
this.active = true;
|
||||
|
||||
const lastMode = this.mode;
|
||||
|
||||
// Check if double clicked
|
||||
let doubleClicked = false;
|
||||
if (this.lastEvent) {
|
||||
if ((event.event.timeStamp - this.lastEvent.event.timeStamp) < TextTool.DOUBLE_CLICK_MILLIS) {
|
||||
doubleClicked = true;
|
||||
} else {
|
||||
doubleClicked = false;
|
||||
}
|
||||
}
|
||||
const doubleClicked = this.lastEvent &&
|
||||
(event.event.timeStamp - this.lastEvent.event.timeStamp) < TextTool.DOUBLE_CLICK_MILLIS;
|
||||
this.lastEvent = event;
|
||||
|
||||
const doubleClickHitTest = paper.project.hitTest(event.point, this.getBoundingBoxHitOptions());
|
||||
if (doubleClicked &&
|
||||
this.mode === TextTool.SELECT_MODE &&
|
||||
doubleClickHitTest) {
|
||||
this.textBox.hitTest(event.point)) {
|
||||
// Double click in select mode moves you to text edit mode
|
||||
clearSelection(this.clearSelectedItems);
|
||||
this.textBox = doubleClickHitTest.item;
|
||||
this.endSelect();
|
||||
this.beginTextEdit(this.textBox.content, this.textBox.matrix);
|
||||
} else if (
|
||||
this.boundingBoxTool.onMouseDown(
|
||||
event, false /* clone */, false /* multiselect */, this.getBoundingBoxHitOptions())) {
|
||||
// In select mode staying in select mode
|
||||
return;
|
||||
}
|
||||
|
||||
// In select mode staying in select mode
|
||||
if (this.boundingBoxTool.onMouseDown(
|
||||
event, false /* clone */, false /* multiselect */, this.getBoundingBoxHitOptions())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We clicked away from the item, so end the current mode
|
||||
if (lastMode === TextTool.SELECT_MODE) {
|
||||
clearSelection(this.clearSelectedItems);
|
||||
this.mode = null;
|
||||
} else if (lastMode === TextTool.TEXT_EDIT_MODE) {
|
||||
const lastMode = this.mode;
|
||||
if (this.mode === TextTool.SELECT_MODE) {
|
||||
this.endSelect();
|
||||
if (this.isBitmap) {
|
||||
this.commitText();
|
||||
}
|
||||
} else if (this.mode === TextTool.TEXT_EDIT_MODE) {
|
||||
this.endTextEdit();
|
||||
}
|
||||
|
||||
const hitResults = paper.project.hitTestAll(event.point, this.getTextEditHitOptions());
|
||||
if (hitResults.length) {
|
||||
// Clicking a different text item to begin text edit mode on that item
|
||||
clearSelection(this.clearSelectedItems);
|
||||
this.textBox = hitResults[0].item;
|
||||
this.beginTextEdit(this.textBox.content, this.textBox.matrix);
|
||||
} else if (lastMode === TextTool.TEXT_EDIT_MODE) {
|
||||
// In text mode clicking away to begin select mode
|
||||
if (this.textBox) {
|
||||
this.mode = TextTool.SELECT_MODE;
|
||||
this.textBox.selected = true;
|
||||
this.setSelectedItems();
|
||||
}
|
||||
this.beginSelect();
|
||||
} else {
|
||||
// In no mode or select mode clicking away to begin text edit mode
|
||||
this.textBox = new paper.PointText({
|
||||
|
@ -230,7 +228,7 @@ class TextTool extends paper.Tool {
|
|||
}
|
||||
handleMouseUp (event) {
|
||||
if (event.event.button > 0 || !this.active) return; // only first mouse button
|
||||
|
||||
|
||||
if (this.mode === TextTool.SELECT_MODE) {
|
||||
this.boundingBoxTool.onMouseUp(event);
|
||||
this.isBoundingBoxMode = null;
|
||||
|
@ -264,7 +262,10 @@ class TextTool extends paper.Tool {
|
|||
handleTextInput (event) {
|
||||
// Save undo state if you paused typing for long enough.
|
||||
if (this.lastTypeEvent && event.timeStamp - this.lastTypeEvent.timeStamp > TextTool.TYPING_TIMEOUT_MILLIS) {
|
||||
// Select the textbox so that it will be selected if the user performs undo.
|
||||
this.textBox.selected = true;
|
||||
this.onUpdateImage();
|
||||
this.textBox.selected = false;
|
||||
}
|
||||
this.lastTypeEvent = event;
|
||||
if (this.mode === TextTool.TEXT_EDIT_MODE) {
|
||||
|
@ -280,6 +281,17 @@ class TextTool extends paper.Tool {
|
|||
this.element.style.width = `${this.textBox.internalBounds.width + 1}px`;
|
||||
this.element.style.height = `${this.textBox.internalBounds.height}px`;
|
||||
}
|
||||
beginSelect () {
|
||||
if (this.textBox) {
|
||||
this.mode = TextTool.SELECT_MODE;
|
||||
this.textBox.selected = true;
|
||||
this.setSelectedItems();
|
||||
}
|
||||
}
|
||||
endSelect () {
|
||||
clearSelection(this.clearSelectedItems);
|
||||
this.mode = null;
|
||||
}
|
||||
/**
|
||||
* @param {string} initialText Text to initialize the text area with
|
||||
* @param {paper.Matrix} matrix Transform matrix for the element. Defaults
|
||||
|
@ -334,19 +346,37 @@ class TextTool extends paper.Tool {
|
|||
this.element.removeEventListener('input', this.eventListener);
|
||||
this.eventListener = null;
|
||||
}
|
||||
this.lastTypeEvent = null;
|
||||
|
||||
// If you finished editing a textbox, save undo state
|
||||
if (this.textBox && this.textBox.content.trim().length) {
|
||||
if (this.textBox && this.lastTypeEvent) {
|
||||
// Finished editing a textbox, save undo state
|
||||
// Select the textbox so that it will be selected if the user performs undo.
|
||||
this.textBox.selected = true;
|
||||
this.onUpdateImage();
|
||||
this.textBox.selected = false;
|
||||
this.lastTypeEvent = null;
|
||||
}
|
||||
}
|
||||
commitText () {
|
||||
if (!this.textBox || !this.textBox.parent) return;
|
||||
|
||||
// @todo get crisp text https://github.com/LLK/scratch-paint/issues/508
|
||||
const textRaster = this.textBox.rasterize(72, false /* insert */);
|
||||
this.textBox.remove();
|
||||
this.textBox = null;
|
||||
getRaster().drawImage(
|
||||
textRaster.canvas,
|
||||
new paper.Point(Math.floor(textRaster.bounds.x), Math.floor(textRaster.bounds.y))
|
||||
);
|
||||
this.onUpdateImage();
|
||||
}
|
||||
deactivateTool () {
|
||||
if (this.textBox && this.textBox.content.trim() === '') {
|
||||
this.textBox.remove();
|
||||
this.textBox = null;
|
||||
}
|
||||
this.endTextEdit();
|
||||
if (this.isBitmap) {
|
||||
this.commitText();
|
||||
}
|
||||
this.boundingBoxTool.removeBoundsPath();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ const Modes = keyMirror({
|
|||
BIT_LINE: null,
|
||||
BIT_OVAL: null,
|
||||
BIT_RECT: null,
|
||||
BIT_TEXT: null,
|
||||
BIT_FILL: null,
|
||||
BIT_ERASER: null,
|
||||
BRUSH: null,
|
||||
|
@ -24,6 +25,7 @@ const BitmapModes = keyMirror({
|
|||
BIT_LINE: null,
|
||||
BIT_OVAL: null,
|
||||
BIT_RECT: null,
|
||||
BIT_TEXT: null,
|
||||
BIT_FILL: null,
|
||||
BIT_ERASER: null
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue