Remove hotkeys and fix tag selection (#505)

* Remove broken scene player hotkeys
* Disable closing tag select menu after a select
This commit is contained in:
InfiniteTF
2020-04-29 01:55:34 +02:00
committed by GitHub
parent 52a1059380
commit a0306bfbd2
24 changed files with 116 additions and 142 deletions

View File

@@ -46,7 +46,6 @@
"react-apollo": "^3.1.3", "react-apollo": "^3.1.3",
"react-bootstrap": "^1.0.0-beta.16", "react-bootstrap": "^1.0.0-beta.16",
"react-dom": "16.12.0", "react-dom": "16.12.0",
"react-hotkeys": "^2.0.0",
"react-images": "0.5.19", "react-images": "0.5.19",
"react-intl": "^3.12.0", "react-intl": "^3.12.0",
"react-jw-player": "1.19.0", "react-jw-player": "1.19.0",

View File

@@ -45,7 +45,10 @@ export const GalleryList: React.FC = () => {
</Link> </Link>
</td> </td>
<td className="d-none d-sm-block"> <td className="d-none d-sm-block">
<Link to={`/galleries/${gallery.id}`}>{gallery.path} ({gallery.files.length} {gallery.files.length === 1 ? 'image' : 'images'})</Link> <Link to={`/galleries/${gallery.id}`}>
{gallery.path} ({gallery.files.length}{" "}
{gallery.files.length === 1 ? "image" : "images"})
</Link>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -253,7 +253,7 @@ export const Movie: React.FC = () => {
})} })}
{TableUtils.renderHtmlSelect({ {TableUtils.renderHtmlSelect({
title: "Rating", title: "Rating",
value: rating ? rating : "", value: rating ?? "",
isEditing, isEditing,
onChange: (value: string) => onChange: (value: string) =>
setRating(Number.parseInt(value, 10)), setRating(Number.parseInt(value, 10)),

View File

@@ -189,7 +189,10 @@ export const Performer: React.FC = () => {
{performer.twitter && ( {performer.twitter && (
<Button className="minimal"> <Button className="minimal">
<a <a
href={TextUtils.sanitiseURL(performer.twitter, TextUtils.twitterURL)} href={TextUtils.sanitiseURL(
performer.twitter,
TextUtils.twitterURL
)}
className="twitter" className="twitter"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -201,7 +204,10 @@ export const Performer: React.FC = () => {
{performer.instagram && ( {performer.instagram && (
<Button className="minimal"> <Button className="minimal">
<a <a
href={TextUtils.sanitiseURL(performer.instagram, TextUtils.instagramURL)} href={TextUtils.sanitiseURL(
performer.instagram,
TextUtils.instagramURL
)}
className="instagram" className="instagram"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -215,18 +221,14 @@ export const Performer: React.FC = () => {
function renderPerformerImage() { function renderPerformerImage() {
if (imagePreview) { if (imagePreview) {
return ( return <img className="photo" src={imagePreview} alt="Performer" />;
<img className="photo" src={imagePreview} alt="Performer" />
);
} }
} }
function renderNewView() { function renderNewView() {
return ( return (
<div className="row new-view"> <div className="row new-view">
<div className="col-4"> <div className="col-4">{renderPerformerImage()}</div>
{renderPerformerImage()}
</div>
<div className="col-6"> <div className="col-6">
<h2>Create Performer</h2> <h2>Create Performer</h2>
{renderTabs()} {renderTabs()}

View File

@@ -11,7 +11,12 @@ import {
ScrapePerformerSuggest, ScrapePerformerSuggest,
LoadingIndicator, LoadingIndicator,
} from "src/components/Shared"; } from "src/components/Shared";
import { ImageUtils, TableUtils, TextUtils, EditableTextUtils } from "src/utils"; import {
ImageUtils,
TableUtils,
TextUtils,
EditableTextUtils,
} from "src/utils";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
interface IPerformerDetails { interface IPerformerDetails {
@@ -100,22 +105,22 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
); );
} }
function translateScrapedGender(gender?: string) { function translateScrapedGender(scrapedGender?: string) {
if (!gender) { if (!scrapedGender) {
return; return;
} }
let retEnum: GQL.GenderEnum | undefined; let retEnum: GQL.GenderEnum | undefined;
// try to translate from enum values first // try to translate from enum values first
const upperGender = gender?.toUpperCase(); const upperGender = scrapedGender?.toUpperCase();
const asEnum = StashService.genderToString(upperGender as GQL.GenderEnum); const asEnum = StashService.genderToString(upperGender as GQL.GenderEnum);
if (asEnum) { if (asEnum) {
retEnum = StashService.stringToGender(asEnum); retEnum = StashService.stringToGender(asEnum);
} else { } else {
// try to match against gender strings // try to match against gender strings
const caseInsensitive = true; const caseInsensitive = true;
retEnum = StashService.stringToGender(gender, caseInsensitive); retEnum = StashService.stringToGender(scrapedGender, caseInsensitive);
} }
return StashService.genderToString(retEnum); return StashService.genderToString(retEnum);
@@ -131,7 +136,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
// image is a base64 string // image is a base64 string
// #404: don't overwrite image if it has been modified by the user // #404: don't overwrite image if it has been modified by the user
if (image === undefined && (state as GQL.ScrapedPerformerDataFragment).image !== undefined) { if (
image === undefined &&
(state as GQL.ScrapedPerformerDataFragment).image !== undefined
) {
const imageStr = (state as GQL.ScrapedPerformerDataFragment).image; const imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
setImage(imageStr ?? undefined); setImage(imageStr ?? undefined);
if (onImageChange) { if (onImageChange) {

View File

@@ -145,7 +145,7 @@ export const ParserInput: React.FC<IParserInputProps> = (
key={item.field} key={item.field}
onSelect={() => addParserField(item)} onSelect={() => addParserField(item)}
> >
<span>{item.field || "{}"}</span> <span className="mr-2">{item.field || "{}"}</span>
<span className="ml-auto text-muted">{item.helperText}</span> <span className="ml-auto text-muted">{item.helperText}</span>
</Dropdown.Item> </Dropdown.Item>
))} ))}

View File

@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import ReactJWPlayer from "react-jw-player"; import ReactJWPlayer from "react-jw-player";
import { HotKeys } from "react-hotkeys";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { JWUtils } from "src/utils"; import { JWUtils } from "src/utils";
@@ -21,13 +20,6 @@ interface IScenePlayerState {
config: Record<string, any>; config: Record<string, any>;
} }
const KeyMap = {
NUM0: "0",
NUM1: "1",
NUM2: "2",
SPACE: " ",
};
export class ScenePlayerImpl extends React.Component< export class ScenePlayerImpl extends React.Component<
IScenePlayerProps, IScenePlayerProps,
IScenePlayerState IScenePlayerState
@@ -37,21 +29,6 @@ export class ScenePlayerImpl extends React.Component<
private player: any; private player: any;
private lastTime = 0; private lastTime = 0;
private KeyHandlers = {
NUM0: () => {
this.onReset();
},
NUM1: () => {
this.onDecrease();
},
NUM2: () => {
this.onIncrease();
},
SPACE: () => {
this.onPause();
},
};
constructor(props: IScenePlayerProps) { constructor(props: IScenePlayerProps) {
super(props); super(props);
this.onReady = this.onReady.bind(this); this.onReady = this.onReady.bind(this);
@@ -207,11 +184,6 @@ export class ScenePlayerImpl extends React.Component<
public render() { public render() {
return ( return (
<HotKeys
keyMap={KeyMap}
handlers={this.KeyHandlers}
className="row scene-player"
>
<div <div
id="jwplayer-container" id="jwplayer-container"
className="w-100 col-sm-9 m-sm-auto no-gutter" className="w-100 col-sm-9 m-sm-auto no-gutter"
@@ -231,7 +203,6 @@ export class ScenePlayerImpl extends React.Component<
onScrolled={this.onScrubberScrolled} onScrolled={this.onScrubberScrolled}
/> />
</div> </div>
</HotKeys>
); );
} }
} }

View File

@@ -182,7 +182,9 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return ( return (
<div> <div>
<Button className="minimal"> <Button className="minimal">
<span className="fa-icon"><SweatDrops /></span> <span className="fa-icon">
<SweatDrops />
</span>
<span>{props.scene.o_counter}</span> <span>{props.scene.o_counter}</span>
</Button> </Button>
</div> </div>

View File

@@ -151,9 +151,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
<div className="row"> <div className="row">
<span className="col-4">Downloaded From</span> <span className="col-4">Downloaded From</span>
<span className="col-8 text-truncate"> <span className="col-8 text-truncate">
<a href={TextUtils.sanitiseURL(props.scene.url)}> <a href={TextUtils.sanitiseURL(props.scene.url)}>{props.scene.url}</a>
{props.scene.url}
</a>
</span> </span>
</div> </div>
); );

View File

@@ -84,7 +84,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
) )
} }
numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)} numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)}
mandatory={true} mandatory
/> />
</div> </div>
); );

View File

@@ -307,7 +307,8 @@ export const SettingsConfigurationPanel: React.FC = () => {
onChange={() => setForceMkv(!forceMkv)} onChange={() => setForceMkv(!forceMkv)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Treat Matroska (MKV) as a supported container. Recommended for Chromium based browsers Treat Matroska (MKV) as a supported container. Recommended for
Chromium based browsers
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="force-options-hevc"> <Form.Group id="force-options-hevc">
@@ -318,7 +319,8 @@ export const SettingsConfigurationPanel: React.FC = () => {
onChange={() => setForceHevc(!forceHevc)} onChange={() => setForceHevc(!forceHevc)}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Treat HEVC as a supported codec. Recommended for Safari or some Android based browsers Treat HEVC as a supported codec. Recommended for Safari or some
Android based browsers
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>

View File

@@ -68,9 +68,10 @@ class LogEntry {
const MAX_LOG_ENTRIES = 200; const MAX_LOG_ENTRIES = 200;
const logLevels = ["Debug", "Info", "Warning", "Error"]; const logLevels = ["Debug", "Info", "Warning", "Error"];
const logReducer = (existingEntries:LogEntry[], newEntries:LogEntry[]) => ( const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
[...newEntries.reverse(), ...existingEntries] ...newEntries.reverse(),
); ...existingEntries,
];
export const SettingsLogsPanel: React.FC = () => { export const SettingsLogsPanel: React.FC = () => {
const { data, error } = StashService.useLoggingSubscribe(); const { data, error } = StashService.useLoggingSubscribe();
@@ -78,10 +79,9 @@ export const SettingsLogsPanel: React.FC = () => {
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []); const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
const [logLevel, setLogLevel] = useState<string>("Info"); const [logLevel, setLogLevel] = useState<string>("Info");
useEffect(() => { useEffect(() => {
const newData = (data?.loggingSubscribe ?? []).map((e) => new LogEntry(e)); const newData = (data?.loggingSubscribe ?? []).map((e) => new LogEntry(e));
dispatchLogUpdate(newData) dispatchLogUpdate(newData);
}, [data]); }, [data]);
const oldData = (existingData?.logs ?? []).map((e) => new LogEntry(e)); const oldData = (existingData?.logs ?? []).map((e) => new LogEntry(e));

View File

@@ -105,9 +105,9 @@ export const SettingsTasksPanel: React.FC = () => {
cancel={{ onClick: () => setIsCleanAlertOpen(false) }} cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
> >
<p> <p>
Are you sure you want to Clean? This will delete database information and Are you sure you want to Clean? This will delete database information
generated content for all scenes and galleries that are no longer found in the and generated content for all scenes and galleries that are no longer
filesystem. found in the filesystem.
</p> </p>
</Modal> </Modal>
); );

View File

@@ -7,14 +7,19 @@ interface IProps {
disabled?: boolean; disabled?: boolean;
numericValue: number | undefined; numericValue: number | undefined;
mandatory?: boolean; mandatory?: boolean;
onValueChange(valueAsNumber: number | undefined, valueAsString?: string): void; onValueChange(
valueAsNumber: number | undefined,
valueAsString?: string
): void;
onReset?(): void; onReset?(): void;
className?: string; className?: string;
} }
export const DurationInput: React.FC<IProps> = (props: IProps) => { export const DurationInput: React.FC<IProps> = (props: IProps) => {
const [value, setValue] = useState<string | undefined>( const [value, setValue] = useState<string | undefined>(
props.numericValue !== undefined ? DurationUtils.secondsToString(props.numericValue) : undefined props.numericValue !== undefined
? DurationUtils.secondsToString(props.numericValue)
: undefined
); );
useEffect(() => { useEffect(() => {

View File

@@ -22,7 +22,7 @@ export const ImageInput: React.FC<IImageInput> = ({
<Form.Control <Form.Control
type="file" type="file"
onChange={onImageChange} onChange={onImageChange}
accept={`.jpg,.jpeg,.png${acceptSVG ? ',.svg' : ''}`} accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
/> />
</Form.Label> </Form.Label>
); );

View File

@@ -42,6 +42,7 @@ interface ISelectProps {
placeholder?: string; placeholder?: string;
showDropdown?: boolean; showDropdown?: boolean;
groupHeader?: string; groupHeader?: string;
closeMenuOnSelect?: boolean;
} }
interface ISceneGallerySelect { interface ISceneGallerySelect {
@@ -355,6 +356,7 @@ export const TagSelect: React.FC<IFilterProps> = (props) => {
items={items} items={items}
onCreateOption={onCreate} onCreateOption={onCreate}
selectedOptions={selected} selectedOptions={selected}
closeMenuOnSelect={false}
/> />
); );
}; };
@@ -376,6 +378,7 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
placeholder, placeholder,
showDropdown = true, showDropdown = true,
groupHeader, groupHeader,
closeMenuOnSelect = true,
}) => { }) => {
const defaultValue = const defaultValue =
items.filter((item) => initialIds?.indexOf(item.value) !== -1) ?? null; items.filter((item) => initialIds?.indexOf(item.value) !== -1) ?? null;
@@ -421,6 +424,7 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
isDisabled, isDisabled,
isLoading, isLoading,
styles, styles,
closeMenuOnSelect,
components: { components: {
IndicatorSeparator: () => null, IndicatorSeparator: () => null,
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),

View File

@@ -10,7 +10,7 @@ export const Stats: React.FC = () => {
if (error) return <span>error.message</span>; if (error) return <span>error.message</span>;
var size = data.stats.scene_size_count.split(" ") const size = data.stats.scene_size_count.split(" ");
return ( return (
<div className="mt-5"> <div className="mt-5">
@@ -18,7 +18,7 @@ export const Stats: React.FC = () => {
<div className="stats-element"> <div className="stats-element">
<p className="title"> <p className="title">
<FormattedNumber value={parseFloat(size[0])} /> <FormattedNumber value={parseFloat(size[0])} />
{" " + size[1]} {` ${size[1]}`}
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="library-size" defaultMessage="Library size" /> <FormattedMessage id="library-size" defaultMessage="Library size" />

View File

@@ -196,14 +196,6 @@ div.dropdown-menu {
.dropdown-item { .dropdown-item {
display: flex; display: flex;
& > * {
margin-right: 7px;
}
& > :last-child {
margin-right: 0;
}
} }
} }
@@ -396,8 +388,8 @@ div.dropdown-menu {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
input[type=file], /* FF, IE7+, chrome (except button) */ input[type="file"], /* FF, IE7+, chrome (except button) */
input[type=file]::-webkit-file-upload-button { input[type="file"]::-webkit-file-upload-button {
/* chromes and blink button */ /* chromes and blink button */
cursor: pointer; cursor: pointer;
} }

View File

@@ -43,7 +43,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
"piercings", "piercings",
"aliases", "aliases",
"gender", "gender",
"scenes" "scenes",
]; ];
} }

View File

@@ -9,7 +9,10 @@ import {
} from "./criterion"; } from "./criterion";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import {PerformerIsMissingCriterion, SceneIsMissingCriterion} from "./is-missing"; import {
PerformerIsMissingCriterion,
SceneIsMissingCriterion,
} from "./is-missing";
import { NoneCriterion } from "./none"; import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers"; import { PerformersCriterion } from "./performers";
import { RatingCriterion } from "./rating"; import { RatingCriterion } from "./rating";

View File

@@ -29,7 +29,7 @@ import {
import { import {
IsMissingCriterion, IsMissingCriterion,
PerformerIsMissingCriterionOption, PerformerIsMissingCriterionOption,
SceneIsMissingCriterionOption SceneIsMissingCriterionOption,
} from "./criteria/is-missing"; } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none"; import { NoneCriterionOption } from "./criteria/none";
import { import {
@@ -148,7 +148,9 @@ export class ListFilterModel {
new FavoriteCriterionOption(), new FavoriteCriterionOption(),
new GenderCriterionOption(), new GenderCriterionOption(),
new PerformerIsMissingCriterionOption(), new PerformerIsMissingCriterionOption(),
...numberCriteria.concat(stringCriteria).map(c => ListFilterModel.createCriterionOption(c)) ...numberCriteria
.concat(stringCriteria)
.map((c) => ListFilterModel.createCriterionOption(c)),
]; ];
break; break;

View File

@@ -20,7 +20,7 @@ const renderTextArea = (options: {
value={options.value} value={options.value}
/> />
); );
} };
const renderEditableText = (options: { const renderEditableText = (options: {
title?: string; title?: string;
@@ -42,8 +42,8 @@ const renderEditableText = (options: {
} }
placeholder={options.title} placeholder={options.title}
/> />
) );
} };
const renderInputGroup = (options: { const renderInputGroup = (options: {
title?: string; title?: string;
@@ -55,11 +55,7 @@ const renderInputGroup = (options: {
}) => { }) => {
if (options.url && !options.isEditing) { if (options.url && !options.isEditing) {
return ( return (
<a <a href={options.url} target="_blank" rel="noopener noreferrer">
href={options.url}
target="_blank"
rel="noopener noreferrer"
>
{options.value} {options.value}
</a> </a>
); );
@@ -77,13 +73,13 @@ const renderInputGroup = (options: {
} }
/> />
); );
} };
const renderDurationInput = (options: { const renderDurationInput = (options: {
value: string | undefined; value: string | undefined;
isEditing: boolean; isEditing: boolean;
url?: string; url?: string;
asString?: boolean asString?: boolean;
onChange: (value: string | undefined) => void; onChange: (value: string | undefined) => void;
}) => { }) => {
let numericValue: number | undefined; let numericValue: number | undefined;
@@ -121,14 +117,15 @@ const renderDurationInput = (options: {
onValueChange={(valueAsNumber: number, valueAsString?: string) => { onValueChange={(valueAsNumber: number, valueAsString?: string) => {
let value = valueAsString; let value = valueAsString;
if (!options.asString) { if (!options.asString) {
value = valueAsNumber !== undefined ? valueAsNumber.toString() : undefined; value =
valueAsNumber !== undefined ? valueAsNumber.toString() : undefined;
} }
options.onChange(value); options.onChange(value);
}} }}
/> />
); );
} };
const renderHtmlSelect = (options: { const renderHtmlSelect = (options: {
value?: string | number; value?: string | number;
@@ -164,7 +161,7 @@ const renderHtmlSelect = (options: {
))} ))}
</Form.Control> </Form.Control>
); );
} };
// TODO: isediting // TODO: isediting
const renderFilterSelect = (options: { const renderFilterSelect = (options: {

View File

@@ -9,9 +9,7 @@ const renderEditableTextTableRow = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderEditableText(options)}</td>
{EditableTextUtils.renderEditableText(options)}
</td>
</tr> </tr>
); );
@@ -23,9 +21,7 @@ const renderTextArea = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderTextArea(options)}</td>
{EditableTextUtils.renderTextArea(options)}
</td>
</tr> </tr>
); );
@@ -39,9 +35,7 @@ const renderInputGroup = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderInputGroup(options)}</td>
{EditableTextUtils.renderInputGroup(options)}
</td>
</tr> </tr>
); );
@@ -56,9 +50,7 @@ const renderDurationInput = (options: {
return ( return (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderDurationInput(options)}</td>
{EditableTextUtils.renderDurationInput(options)}
</td>
</tr> </tr>
); );
}; };
@@ -72,9 +64,7 @@ const renderHtmlSelect = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderHtmlSelect(options)}</td>
{EditableTextUtils.renderHtmlSelect(options)}
</td>
</tr> </tr>
); );
@@ -87,9 +77,7 @@ const renderFilterSelect = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderFilterSelect(options)}</td>
{EditableTextUtils.renderFilterSelect(options)}
</td>
</tr> </tr>
); );
@@ -102,9 +90,7 @@ const renderMultiSelect = (options: {
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>{EditableTextUtils.renderMultiSelect(options)}</td>
{EditableTextUtils.renderMultiSelect(options)}
</td>
</tr> </tr>
); );

View File

@@ -103,12 +103,12 @@ const sanitiseURL = (url?: string, siteURL?: URL) => {
} }
// otherwise, construct the url from the protocol, host and passed url // otherwise, construct the url from the protocol, host and passed url
return siteURL.protocol + siteURL.host + "/" + url; return `${siteURL.protocol}${siteURL.host}/${url}`;
} }
// just prepend the protocol - assume https // just prepend the protocol - assume https
return "https://" + url; return `https://${url}`;
} };
const TextUtils = { const TextUtils = {
truncate, truncate,