mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Fix selected tagger search result being lost when creating objects (#4715)
* Wrap search result details * Move utility functions to separate file * Fix selected result being reset on object create
This commit is contained in:
@@ -253,6 +253,14 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
});
|
||||
}
|
||||
|
||||
function clearSearchResults(sceneID: string) {
|
||||
setSearchResults((current) => {
|
||||
const newSearchResults = { ...current };
|
||||
delete newSearchResults[sceneID];
|
||||
return newSearchResults;
|
||||
});
|
||||
}
|
||||
|
||||
async function doSceneQuery(sceneID: string, searchVal: string) {
|
||||
if (!currentSource) {
|
||||
return;
|
||||
@@ -260,6 +268,7 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
clearSearchResults(sceneID);
|
||||
|
||||
const results = await queryScrapeSceneQuery(
|
||||
currentSource.sourceInput,
|
||||
@@ -295,6 +304,8 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSearchResults(sceneID);
|
||||
|
||||
let newResult: ISceneQueryResult;
|
||||
|
||||
try {
|
||||
@@ -330,11 +341,7 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchResults((current) => {
|
||||
const newResults = { ...current };
|
||||
delete newResults[sceneID];
|
||||
return newResults;
|
||||
});
|
||||
clearSearchResults(sceneID);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -456,14 +463,6 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearchResults(sceneID: string) {
|
||||
setSearchResults((current) => {
|
||||
const newSearchResults = { ...current };
|
||||
delete newSearchResults[sceneID];
|
||||
return newSearchResults;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveScene(
|
||||
sceneCreateInput: GQL.SceneUpdateInput,
|
||||
queueFingerprint: boolean
|
||||
|
||||
@@ -7,16 +7,76 @@ import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import { OperationButton } from "src/components/Shared/OperationButton";
|
||||
import { IScrapedScene, TaggerStateContext } from "../context";
|
||||
import { ISceneQueryResult, TaggerStateContext } from "../context";
|
||||
import Config from "./Config";
|
||||
import { TaggerScene } from "./TaggerScene";
|
||||
import { SceneTaggerModals } from "./sceneTaggerModals";
|
||||
import { SceneSearchResults } from "./StashSearchResult";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { faCog } from "@fortawesome/free-solid-svg-icons";
|
||||
import { distance } from "src/utils/hamming";
|
||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
||||
|
||||
const Scene: React.FC<{
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
searchResult?: ISceneQueryResult;
|
||||
queue?: SceneQueue;
|
||||
index: number;
|
||||
showLightboxImage: (imagePath: string) => void;
|
||||
}> = ({ scene, searchResult, queue, index, showLightboxImage }) => {
|
||||
const intl = useIntl();
|
||||
const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } =
|
||||
useContext(TaggerStateContext);
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
const cont = configuration?.interface.continuePlaylistDefault ?? false;
|
||||
|
||||
const sceneLink = useMemo(
|
||||
() =>
|
||||
queue
|
||||
? queue.makeLink(scene.id, { sceneIndex: index, continue: cont })
|
||||
: `/scenes/${scene.id}`,
|
||||
[queue, scene.id, index, cont]
|
||||
);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (searchResult?.error) {
|
||||
return searchResult.error;
|
||||
} else if (searchResult && searchResult.results?.length === 0) {
|
||||
return intl.formatMessage({
|
||||
id: "component_tagger.results.match_failed_no_result",
|
||||
});
|
||||
}
|
||||
}, [intl, searchResult]);
|
||||
|
||||
return (
|
||||
<TaggerScene
|
||||
loading={loading}
|
||||
scene={scene}
|
||||
url={sceneLink}
|
||||
errorMessage={errorMessage}
|
||||
doSceneQuery={
|
||||
currentSource?.supportSceneQuery
|
||||
? async (v) => {
|
||||
await doSceneQuery(scene.id, v);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
scrapeSceneFragment={
|
||||
currentSource?.supportSceneFragment
|
||||
? async () => {
|
||||
await doSceneFragmentScrape(scene.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showLightboxImage={showLightboxImage}
|
||||
>
|
||||
{searchResult && searchResult.results?.length ? (
|
||||
<SceneSearchResults scenes={searchResult.results} target={scene} />
|
||||
) : undefined}
|
||||
</TaggerScene>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITaggerProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
queue?: SceneQueue;
|
||||
@@ -27,8 +87,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
sources,
|
||||
setCurrentSource,
|
||||
currentSource,
|
||||
doSceneQuery,
|
||||
doSceneFragmentScrape,
|
||||
doMultiSceneFragmentScrape,
|
||||
stopMultiScrape,
|
||||
searchResults,
|
||||
@@ -38,21 +96,11 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
submitFingerprints,
|
||||
pendingFingerprints,
|
||||
} = useContext(TaggerStateContext);
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [hideUnmatched, setHideUnmatched] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const cont = configuration?.interface.continuePlaylistDefault ?? false;
|
||||
|
||||
function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
|
||||
return queue
|
||||
? queue.makeLink(scene.id, { sceneIndex: index, continue: cont })
|
||||
: `/scenes/${scene.id}`;
|
||||
}
|
||||
|
||||
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
|
||||
}
|
||||
@@ -93,139 +141,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
);
|
||||
}
|
||||
|
||||
function minDistance(hash: string, stashScene: GQL.SlimSceneDataFragment) {
|
||||
let ret = 9999;
|
||||
stashScene.files.forEach((cv) => {
|
||||
if (ret === 0) return;
|
||||
|
||||
const stashHash = cv.fingerprints.find((fp) => fp.type === "phash");
|
||||
if (!stashHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const d = distance(hash, stashHash.value);
|
||||
if (d < ret) {
|
||||
ret = d;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function calculatePhashComparisonScore(
|
||||
stashScene: GQL.SlimSceneDataFragment,
|
||||
scrapedScene: IScrapedScene
|
||||
) {
|
||||
const phashFingerprints =
|
||||
scrapedScene.fingerprints?.filter((f) => f.algorithm === "PHASH") ?? [];
|
||||
const filteredFingerprints = phashFingerprints.filter(
|
||||
(f) => minDistance(f.hash, stashScene) <= 8
|
||||
);
|
||||
|
||||
if (phashFingerprints.length == 0) return [0, 0];
|
||||
|
||||
return [
|
||||
filteredFingerprints.length,
|
||||
filteredFingerprints.length / phashFingerprints.length,
|
||||
];
|
||||
}
|
||||
|
||||
function minDurationDiff(
|
||||
stashScene: GQL.SlimSceneDataFragment,
|
||||
duration: number
|
||||
) {
|
||||
let ret = 9999;
|
||||
stashScene.files.forEach((cv) => {
|
||||
if (ret === 0) return;
|
||||
|
||||
const d = Math.abs(duration - cv.duration);
|
||||
if (d < ret) {
|
||||
ret = d;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function calculateDurationComparisonScore(
|
||||
stashScene: GQL.SlimSceneDataFragment,
|
||||
scrapedScene: IScrapedScene
|
||||
) {
|
||||
if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) {
|
||||
const durations = scrapedScene.fingerprints.map((f) => f.duration);
|
||||
const diffs = durations.map((d) => minDurationDiff(stashScene, d));
|
||||
const filteredDurations = diffs.filter((duration) => duration <= 5);
|
||||
|
||||
const minDiff = Math.min(...diffs);
|
||||
|
||||
return [
|
||||
filteredDurations.length,
|
||||
filteredDurations.length / durations.length,
|
||||
minDiff,
|
||||
];
|
||||
}
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
function compareScenesForSort(
|
||||
stashScene: GQL.SlimSceneDataFragment,
|
||||
sceneA: IScrapedScene,
|
||||
sceneB: IScrapedScene
|
||||
) {
|
||||
// Compare sceneA and sceneB to each other for sorting based on similarity to stashScene
|
||||
// Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match
|
||||
|
||||
// scenes without any fingerprints should be sorted to the end
|
||||
if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const [nbPhashMatchSceneA, ratioPhashMatchSceneA] =
|
||||
calculatePhashComparisonScore(stashScene, sceneA);
|
||||
const [nbPhashMatchSceneB, ratioPhashMatchSceneB] =
|
||||
calculatePhashComparisonScore(stashScene, sceneB);
|
||||
|
||||
// If only one scene has matching phash, prefer that scene
|
||||
if (
|
||||
(nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) ||
|
||||
nbPhashMatchSceneB === 0
|
||||
) {
|
||||
return nbPhashMatchSceneB - nbPhashMatchSceneA;
|
||||
}
|
||||
|
||||
// Prefer scene with highest ratio of phash matches
|
||||
if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) {
|
||||
return ratioPhashMatchSceneB - ratioPhashMatchSceneA;
|
||||
}
|
||||
|
||||
// Same ratio of phash matches, check duration
|
||||
const [
|
||||
nbDurationMatchSceneA,
|
||||
ratioDurationMatchSceneA,
|
||||
minDurationDiffSceneA,
|
||||
] = calculateDurationComparisonScore(stashScene, sceneA);
|
||||
const [
|
||||
nbDurationMatchSceneB,
|
||||
ratioDurationMatchSceneB,
|
||||
minDurationDiffSceneB,
|
||||
] = calculateDurationComparisonScore(stashScene, sceneB);
|
||||
|
||||
if (nbDurationMatchSceneA != nbDurationMatchSceneB) {
|
||||
return nbDurationMatchSceneB - nbDurationMatchSceneA;
|
||||
}
|
||||
|
||||
// Same number of phash & duration, check duration ratio
|
||||
if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) {
|
||||
return ratioDurationMatchSceneB - ratioDurationMatchSceneA;
|
||||
}
|
||||
|
||||
// fall back to duration difference - less is better
|
||||
return minDurationDiffSceneA - minDurationDiffSceneB;
|
||||
}
|
||||
|
||||
const [spriteImage, setSpriteImage] = useState<string | null>(null);
|
||||
const lightboxImage = useMemo(
|
||||
() => [{ paths: { thumbnail: spriteImage, image: spriteImage } }],
|
||||
@@ -239,61 +154,13 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
showLightbox();
|
||||
}
|
||||
|
||||
function renderScenes() {
|
||||
const filteredScenes = !hideUnmatched
|
||||
? scenes
|
||||
: scenes.filter((s) => searchResults[s.id]?.results?.length);
|
||||
|
||||
return filteredScenes.map((scene, index) => {
|
||||
const sceneLink = generateSceneLink(scene, index);
|
||||
let errorMessage: string | undefined;
|
||||
const searchResult = searchResults[scene.id];
|
||||
if (searchResult?.error) {
|
||||
errorMessage = searchResult.error;
|
||||
} else if (searchResult && searchResult.results?.length === 0) {
|
||||
errorMessage = intl.formatMessage({
|
||||
id: "component_tagger.results.match_failed_no_result",
|
||||
});
|
||||
} else if (
|
||||
searchResult &&
|
||||
searchResult.results &&
|
||||
searchResult.results?.length >= 2
|
||||
) {
|
||||
searchResult.results?.sort((scrapedSceneA, scrapedSceneB) =>
|
||||
compareScenesForSort(scene, scrapedSceneA, scrapedSceneB)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TaggerScene
|
||||
key={scene.id}
|
||||
loading={loading}
|
||||
scene={scene}
|
||||
url={sceneLink}
|
||||
errorMessage={errorMessage}
|
||||
doSceneQuery={
|
||||
currentSource?.supportSceneQuery
|
||||
? async (v) => {
|
||||
await doSceneQuery(scene.id, v);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
scrapeSceneFragment={
|
||||
currentSource?.supportSceneFragment
|
||||
? async () => {
|
||||
await doSceneFragmentScrape(scene.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showLightboxImage={showLightboxImage}
|
||||
>
|
||||
{searchResult && searchResult.results?.length ? (
|
||||
<SceneSearchResults scenes={searchResult.results} target={scene} />
|
||||
) : undefined}
|
||||
</TaggerScene>
|
||||
);
|
||||
});
|
||||
}
|
||||
const filteredScenes = useMemo(
|
||||
() =>
|
||||
!hideUnmatched
|
||||
? scenes
|
||||
: scenes.filter((s) => searchResults[s.id]?.results?.length),
|
||||
[scenes, searchResults, hideUnmatched]
|
||||
);
|
||||
|
||||
const toggleHideUnmatchedScenes = () => {
|
||||
setHideUnmatched(!hideUnmatched);
|
||||
@@ -394,7 +261,18 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
</div>
|
||||
<Config show={showConfig} />
|
||||
</div>
|
||||
<div>{renderScenes()}</div>
|
||||
<div>
|
||||
{filteredScenes.map((s, i) => (
|
||||
<Scene
|
||||
key={i}
|
||||
scene={s}
|
||||
searchResult={searchResults[s.id]}
|
||||
index={i}
|
||||
showLightboxImage={showLightboxImage}
|
||||
queue={queue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SceneTaggerModals>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import StudioResult from "./StudioResult";
|
||||
import { useInitialState } from "src/hooks/state";
|
||||
import { getStashboxBase } from "src/utils/stashbox";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
import { compareScenesForSort } from "./utils";
|
||||
|
||||
const getDurationIcon = (matchPercentage: number) => {
|
||||
if (matchPercentage > 65)
|
||||
@@ -810,17 +811,30 @@ export interface ISceneSearchResults {
|
||||
|
||||
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
|
||||
target,
|
||||
scenes,
|
||||
scenes: unsortedScenes,
|
||||
}) => {
|
||||
const [selectedResult, setSelectedResult] = useState<number | undefined>();
|
||||
|
||||
const scenes = useMemo(
|
||||
() =>
|
||||
unsortedScenes
|
||||
.slice()
|
||||
.sort((scrapedSceneA, scrapedSceneB) =>
|
||||
compareScenesForSort(target, scrapedSceneA, scrapedSceneB)
|
||||
),
|
||||
[unsortedScenes, target]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scenes) {
|
||||
setSelectedResult(undefined);
|
||||
} else if (scenes.length > 0 && scenes[0].resolved) {
|
||||
setSelectedResult(0);
|
||||
// #3198 - if the selected result is no longer in the list, reset it
|
||||
if (selectedResult && scenes?.length <= selectedResult) {
|
||||
if (!scenes) {
|
||||
setSelectedResult(undefined);
|
||||
} else if (scenes.length > 0 && scenes[0].resolved) {
|
||||
setSelectedResult(0);
|
||||
}
|
||||
}
|
||||
}, [scenes]);
|
||||
}, [scenes, selectedResult]);
|
||||
|
||||
function getClassName(i: number) {
|
||||
return cx("row mx-0 mt-2 search-result", {
|
||||
|
||||
136
ui/v2.5/src/components/Tagger/scenes/utils.ts
Normal file
136
ui/v2.5/src/components/Tagger/scenes/utils.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { SlimSceneDataFragment } from "src/core/generated-graphql";
|
||||
import { IScrapedScene } from "../context";
|
||||
import { distance } from "src/utils/hamming";
|
||||
|
||||
export function minDistance(hash: string, stashScene: SlimSceneDataFragment) {
|
||||
let ret = 9999;
|
||||
stashScene.files.forEach((cv) => {
|
||||
if (ret === 0) return;
|
||||
|
||||
const stashHash = cv.fingerprints.find((fp) => fp.type === "phash");
|
||||
if (!stashHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const d = distance(hash, stashHash.value);
|
||||
if (d < ret) {
|
||||
ret = d;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function calculatePhashComparisonScore(
|
||||
stashScene: SlimSceneDataFragment,
|
||||
scrapedScene: IScrapedScene
|
||||
) {
|
||||
const phashFingerprints =
|
||||
scrapedScene.fingerprints?.filter((f) => f.algorithm === "PHASH") ?? [];
|
||||
const filteredFingerprints = phashFingerprints.filter(
|
||||
(f) => minDistance(f.hash, stashScene) <= 8
|
||||
);
|
||||
|
||||
if (phashFingerprints.length == 0) return [0, 0];
|
||||
|
||||
return [
|
||||
filteredFingerprints.length,
|
||||
filteredFingerprints.length / phashFingerprints.length,
|
||||
];
|
||||
}
|
||||
|
||||
export function minDurationDiff(
|
||||
stashScene: SlimSceneDataFragment,
|
||||
duration: number
|
||||
) {
|
||||
let ret = 9999;
|
||||
stashScene.files.forEach((cv) => {
|
||||
if (ret === 0) return;
|
||||
|
||||
const d = Math.abs(duration - cv.duration);
|
||||
if (d < ret) {
|
||||
ret = d;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function calculateDurationComparisonScore(
|
||||
stashScene: SlimSceneDataFragment,
|
||||
scrapedScene: IScrapedScene
|
||||
) {
|
||||
if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) {
|
||||
const durations = scrapedScene.fingerprints.map((f) => f.duration);
|
||||
const diffs = durations.map((d) => minDurationDiff(stashScene, d));
|
||||
const filteredDurations = diffs.filter((duration) => duration <= 5);
|
||||
|
||||
const minDiff = Math.min(...diffs);
|
||||
|
||||
return [
|
||||
filteredDurations.length,
|
||||
filteredDurations.length / durations.length,
|
||||
minDiff,
|
||||
];
|
||||
}
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
export function compareScenesForSort(
|
||||
stashScene: SlimSceneDataFragment,
|
||||
sceneA: IScrapedScene,
|
||||
sceneB: IScrapedScene
|
||||
) {
|
||||
// Compare sceneA and sceneB to each other for sorting based on similarity to stashScene
|
||||
// Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match
|
||||
|
||||
// scenes without any fingerprints should be sorted to the end
|
||||
if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const [nbPhashMatchSceneA, ratioPhashMatchSceneA] =
|
||||
calculatePhashComparisonScore(stashScene, sceneA);
|
||||
const [nbPhashMatchSceneB, ratioPhashMatchSceneB] =
|
||||
calculatePhashComparisonScore(stashScene, sceneB);
|
||||
|
||||
// If only one scene has matching phash, prefer that scene
|
||||
if (
|
||||
(nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) ||
|
||||
nbPhashMatchSceneB === 0
|
||||
) {
|
||||
return nbPhashMatchSceneB - nbPhashMatchSceneA;
|
||||
}
|
||||
|
||||
// Prefer scene with highest ratio of phash matches
|
||||
if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) {
|
||||
return ratioPhashMatchSceneB - ratioPhashMatchSceneA;
|
||||
}
|
||||
|
||||
// Same ratio of phash matches, check duration
|
||||
const [
|
||||
nbDurationMatchSceneA,
|
||||
ratioDurationMatchSceneA,
|
||||
minDurationDiffSceneA,
|
||||
] = calculateDurationComparisonScore(stashScene, sceneA);
|
||||
const [
|
||||
nbDurationMatchSceneB,
|
||||
ratioDurationMatchSceneB,
|
||||
minDurationDiffSceneB,
|
||||
] = calculateDurationComparisonScore(stashScene, sceneB);
|
||||
|
||||
if (nbDurationMatchSceneA != nbDurationMatchSceneB) {
|
||||
return nbDurationMatchSceneB - nbDurationMatchSceneA;
|
||||
}
|
||||
|
||||
// Same number of phash & duration, check duration ratio
|
||||
if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) {
|
||||
return ratioDurationMatchSceneB - ratioDurationMatchSceneA;
|
||||
}
|
||||
|
||||
// fall back to duration difference - less is better
|
||||
return minDurationDiffSceneA - minDurationDiffSceneB;
|
||||
}
|
||||
@@ -47,6 +47,7 @@
|
||||
.scene-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user