From cb1b56f5a0eddbf77446f415f2beda57c8305f85 Mon Sep 17 00:00:00 2001 From: polwex Date: Sun, 23 Nov 2025 01:12:53 +0700 Subject: wut --- packages/prosody-ui/src/zoom/FullText.tsx | 60 ++++++++ packages/prosody-ui/src/zoom/Paragraph.tsx | 60 ++++++++ packages/prosody-ui/src/zoom/Sentence.tsx | 46 ++++++ packages/prosody-ui/src/zoom/SpacyClause.tsx | 125 ++++++++++++++++ packages/prosody-ui/src/zoom/animations.ts | 199 +++++++++++++++++++++++++ packages/prosody-ui/src/zoom/hooks/useZoom.tsx | 135 +++++++++++++++++ packages/prosody-ui/src/zoom/index.ts | 8 + packages/prosody-ui/src/zoom/logic/types.ts | 53 +++++++ packages/prosody-ui/src/zoom/spacy.css | 39 +++++ 9 files changed, 725 insertions(+) create mode 100644 packages/prosody-ui/src/zoom/FullText.tsx create mode 100644 packages/prosody-ui/src/zoom/Paragraph.tsx create mode 100644 packages/prosody-ui/src/zoom/Sentence.tsx create mode 100644 packages/prosody-ui/src/zoom/SpacyClause.tsx create mode 100644 packages/prosody-ui/src/zoom/animations.ts create mode 100644 packages/prosody-ui/src/zoom/hooks/useZoom.tsx create mode 100644 packages/prosody-ui/src/zoom/index.ts create mode 100644 packages/prosody-ui/src/zoom/logic/types.ts create mode 100644 packages/prosody-ui/src/zoom/spacy.css (limited to 'packages/prosody-ui/src/zoom') diff --git a/packages/prosody-ui/src/zoom/FullText.tsx b/packages/prosody-ui/src/zoom/FullText.tsx new file mode 100644 index 0000000..ec85f09 --- /dev/null +++ b/packages/prosody-ui/src/zoom/FullText.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { motion, AnimatePresence } from "motion/react"; +import Paragraph from "./Paragraph"; +import { useZoom } from "./hooks/useZoom"; +import { containerVariants, buttonVariants } from "./animations"; +import { NLP } from "sortug-ai"; + +interface TextFocusMorphProps { + text: string; + doc: NLP.Spacy.SpacyRes; +} + +const FullText: React.FC = ({ text, doc }) => { + const { viewState, navigateBack, handleElementClick } = useZoom(); + const { level } = viewState; + + // Split text into paragraphs + const paragraphs = text + .split("\n\n") + .map((p) => p.trim()) + .filter(Boolean); + + return ( +
+ {level !== "text" && ( + + + ← Back + + + )} + + + {paragraphs.map((paragraph, idx) => ( + + ))} + +
+ ); +}; + +export default FullText; diff --git a/packages/prosody-ui/src/zoom/Paragraph.tsx b/packages/prosody-ui/src/zoom/Paragraph.tsx new file mode 100644 index 0000000..c26f806 --- /dev/null +++ b/packages/prosody-ui/src/zoom/Paragraph.tsx @@ -0,0 +1,60 @@ +import React, { memo, useCallback, useEffect, useState } from "react"; +import { motion } from "motion/react"; +import type { ViewProps, LoadingStatus } from "./logic/types"; +import { NLP } from "sortug-ai"; +import Sentence from "./Sentence"; +import { paragraphVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; + +function Paragraph({ rawText, context, idx, doc }: ViewProps) { + const { viewState, handleElementClick } = useZoom(); + const { level, pIndex } = viewState; + const selected = pIndex === idx; + const isFocused = level === "paragraph" && selected; + + // State for sentences + const [loading, setLoading] = useState("pending"); + + return ( + <> + handleElementClick(e, idx)} + whileHover={ + level === "text" + ? createHoverEffect(level, "text", "255, 255, 200") + : {} + } + > + {loading === "loading" &&
} + {level === "text" || !selected || doc.segs.length === 0 ? ( +

{rawText}

+ ) : ( +
+ {doc.segs.map((sentence, sentIdx) => ( + s.text), + }} + doc={doc} + /> + ))} +
+ )} + + + ); +} + +export default memo(Paragraph); diff --git a/packages/prosody-ui/src/zoom/Sentence.tsx b/packages/prosody-ui/src/zoom/Sentence.tsx new file mode 100644 index 0000000..1d90346 --- /dev/null +++ b/packages/prosody-ui/src/zoom/Sentence.tsx @@ -0,0 +1,46 @@ +import React, { memo } from "react"; +import { motion } from "motion/react"; +import type { ViewProps, LoadingStatus } from "./logic/types"; +import { NLP } from "sortug-ai"; +import SpacyClause from "./SpacyClause"; +import { sentenceVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; + +interface Props extends ViewProps { + spacy: NLP.Spacy.Sentence; + stanza?: NLP.Stanza.Sentence; +} + +function Sentence({ spacy, stanza, context, idx }: Props) { + const { viewState, handleElementClick } = useZoom(); + const { level, sIndex } = viewState; + const selected = sIndex === idx; + const isFocused = level === "sentence" && selected; + + return ( + <> + handleElementClick(e, idx)} + whileHover={ + level === "paragraph" + ? createHoverEffect(level, "paragraph", "200, 220, 255") + : {} + } + > + {level === "paragraph" || !selected ? ( + {spacy.text} + ) : ( + + )} + + + ); +} + +export default memo(Sentence); diff --git a/packages/prosody-ui/src/zoom/SpacyClause.tsx b/packages/prosody-ui/src/zoom/SpacyClause.tsx new file mode 100644 index 0000000..6b6f178 --- /dev/null +++ b/packages/prosody-ui/src/zoom/SpacyClause.tsx @@ -0,0 +1,125 @@ +import React, { memo, useState } from "react"; +import { motion } from "motion/react"; +import "./spacy.css"; +import { NLP } from "sortug-ai"; +// import { clauseVariants, createHoverEffect } from "./animations"; +// import { useZoom } from "./hooks/useZoom"; + +function Grammar({ sentence }: { sentence: NLP.Spacy.Sentence }) { + const [hoveredClause, setHoveredClause] = useState(null); + + // Ref to manage the timeout for debouncing mouse leave + return ( +
+ {sentence.words.map((w, idx) => { + const isRoot = + w.ancestors.length === 0 || w.dep.toLowerCase() === "root"; + const isSubj = NLP.Spacy.isChild(w, sentence.subj.id); + const isPred = !isSubj && !isRoot; + const predClass = isPred ? "pred" : ""; + const relClass = isRoot ? "root" : `rel-${w.dep}`; + const ownClass = isRoot + ? "" + : isSubj + ? "subj" + : w.children.length === 0 + ? "" + : `clause-${w.id}`; + const clase = w.ancestors.reduce((acc, item) => { + if (item === sentence.subj.id || item === sentence.root.id) + return acc; + else return `${acc} clause-${item}`; + }, ``); + const className = `suword ${relClass} ${ownClass} ${clase} ${predClass}`; + const isHovering = + !isRoot && + !!hoveredClause && + (w.id === hoveredClause || w.ancestors.includes(hoveredClause)); + function handleClick(w: NLP.Spacy.Word) { + console.log("show the whole clause and all that", w); + } + return ( + + ); + })} +
+ ); +} + +const spanVariants: any = { + initial: { + // Base style + backgroundColor: "rgba(0, 0, 0, 0)", // Transparent background initially + fontWeight: "normal", + scale: 1, + zIndex: 0, // Default stacking + position: "relative", // Needed for zIndex to work reliably + // Add other base styles if needed + }, + hovered: { + // Style when this span's group is hovered + backgroundColor: "rgba(255, 255, 0, 0.5)", // Yellow highlight + scale: 1.05, + zIndex: 1, // Bring hovered spans slightly forward + boxShadow: "0px 2px 5px rgba(0,0,0,0.2)", + // Add other hover effects + }, +}; + +// Define the transition +const spanTransition = { + type: "spring", + stiffness: 500, + damping: 30, + // duration: 0.1 // Or use duration for non-spring types +}; + +function ClauseSpan({ + word, + className, + hovering, + setHovering, + onClick, +}: { + word: NLP.Spacy.Word; + className: string; + hovering: boolean; + setHovering: (n: number | null) => void; + onClick: (w: NLP.Spacy.Word) => void; +}) { + function handleMouseOver() { + setHovering(word.id); + // if (word.children.length > 0) setHovering(word.id); + // else setHovering(word.head); + } + function handleMouseLeave() { + setHovering(null); + } + function handleClick(e: React.MouseEvent) { + e.stopPropagation(); + onClick(word); + } + return ( + + {word.text} + + ); +} + +export default memo(Grammar); diff --git a/packages/prosody-ui/src/zoom/animations.ts b/packages/prosody-ui/src/zoom/animations.ts new file mode 100644 index 0000000..6135e7f --- /dev/null +++ b/packages/prosody-ui/src/zoom/animations.ts @@ -0,0 +1,199 @@ +import type { Variants } from "motion/react"; + +// Base transition configurations for consistent animations +const baseTransition = { + duration: 0.5, + ease: [0.43, 0.13, 0.23, 0.96], // Improved easing for smoother feel +}; + +export const fadeTransition = { + ...baseTransition, + duration: 0.3, +}; + +// Shared variants for different view levels +export const containerVariants: Variants = { + text: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + paragraph: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + sentence: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + clause: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + word: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + syllable: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + phoneme: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, +}; + +// Function to create element variants based on selection state +export const createElementVariants = ( + currentLevel: string, + nextLevel: string, + prevLevel: string, + selectedOpacity = 1, + unselectedOpacity = 0.1, + selectedScale = 1.05, + unselectedScale = 0.95, + selectedBlur = "0px", + unselectedBlur = "2px", + bgColor = "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity instead of transparent +): Variants => { + return { + [prevLevel]: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + backgroundColor: "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }, + [currentLevel]: (isSelected: boolean) => ({ + opacity: isSelected ? selectedOpacity : unselectedOpacity, + scale: isSelected ? selectedScale : unselectedScale, + filter: isSelected ? `blur(${selectedBlur})` : `blur(${unselectedBlur})`, + backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }), + [nextLevel]: (isSelected: boolean) => ({ + opacity: isSelected ? selectedOpacity : unselectedOpacity / 2, + scale: isSelected ? selectedScale : unselectedScale * 0.95, + filter: isSelected + ? `blur(${selectedBlur})` + : `blur(${parseInt(unselectedBlur) + 1}px)`, + backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }), + }; +}; + +// Pre-configured variants for each level +export const paragraphVariants = createElementVariants( + "paragraph", + "sentence", + "text", + 1, + 0.1, + 1.05, + 0.95, + "0px", + "2px", + "rgba(200, 220, 255, 0.1)", +); + +export const sentenceVariants = createElementVariants( + "sentence", + "clause", + "paragraph", + 1, + 0.1, + 1.1, + 0.95, + "0px", + "2px", + "rgba(200, 220, 255, 0.2)", +); + +export const clauseVariants = createElementVariants( + "clause", + "word", + "sentence", + 1, + 0.1, + 1.1, + 0.95, + "0px", + "2px", + "rgba(220, 200, 255, 0.2)", +); + +export const wordVariants = createElementVariants( + "word", + "syllable", + "clause", + 1, + 0.1, + 1.15, + 0.9, + "0px", + "2px", + "rgba(255, 200, 200, 0.2)", +); + +export const syllableVariants = createElementVariants( + "syllable", + "phoneme", + "word", + 1, + 0.1, + 1.2, + 0.9, + "0px", + "2px", + "rgba(200, 255, 200, 0.2)", +); + +// Button animations +export const buttonVariants: Variants = { + initial: { opacity: 0, x: -20 }, + animate: { opacity: 1, x: 0, transition: fadeTransition }, + exit: { opacity: 0, x: -20, transition: fadeTransition }, +}; + +// Hover effects +export const createHoverEffect = ( + level: string, + currentLevel: string, + color: string, +) => { + if (level === currentLevel) { + return { + scale: 1.02, + backgroundColor: `rgba(${color}, 0.3)`, + transition: { duration: 0.2 }, + }; + } + return { + // Return empty animation with same properties to avoid errors + scale: 1, + backgroundColor: "rgba(255, 255, 255, 0)", + transition: { duration: 0.2 }, + }; +}; diff --git a/packages/prosody-ui/src/zoom/hooks/useZoom.tsx b/packages/prosody-ui/src/zoom/hooks/useZoom.tsx new file mode 100644 index 0000000..733ca06 --- /dev/null +++ b/packages/prosody-ui/src/zoom/hooks/useZoom.tsx @@ -0,0 +1,135 @@ +import React, { + createContext, + useState, + useContext, + type ReactNode, +} from "react"; +import type { ViewLevel, ViewState } from "../logic/types"; + +// Type definitions for the context +interface ZoomContextType { + viewState: ViewState; + setLevel: (level: ViewLevel) => void; + setParagraphIndex: (idx: number | null) => void; + setSentenceIndex: (idx: number | null) => void; + setClauseIndex: (idx: number | null) => void; + setWordIndex: (idx: number | null) => void; + setSyllableIndex: (idx: number | null) => void; + setPhonemeIndex: (idx: number | null) => void; + navigateBack: () => void; + handleElementClick: (e: React.MouseEvent, idx: number) => void; +} + +// Create the context with default empty values +const ZoomContext = createContext({ + viewState: { + level: "text", + pIndex: null, + sIndex: null, + cIndex: null, + wIndex: null, + yIndex: null, + fIndex: null, + }, + setLevel: () => {}, + setParagraphIndex: () => {}, + setSentenceIndex: () => {}, + setClauseIndex: () => {}, + setWordIndex: () => {}, + setSyllableIndex: () => {}, + setPhonemeIndex: () => {}, + navigateBack: () => {}, + handleElementClick: () => {}, +}); + +// Provider component +export const ZoomProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [viewState, setViewState] = useState({ + level: "text", + pIndex: null, + sIndex: null, + cIndex: null, + wIndex: null, + yIndex: null, + fIndex: null, + }); + + // Helper functions to update individual parts of the state + const setLevel = (level: ViewLevel) => + setViewState((prev) => ({ ...prev, level })); + const setParagraphIndex = (pIndex: number | null) => + setViewState((prev) => ({ ...prev, pIndex })); + const setSentenceIndex = (sIndex: number | null) => + setViewState((prev) => ({ ...prev, sIndex })); + const setClauseIndex = (cIndex: number | null) => + setViewState((prev) => ({ ...prev, cIndex })); + const setWordIndex = (wIndex: number | null) => + setViewState((prev) => ({ ...prev, wIndex })); + const setSyllableIndex = (yIndex: number | null) => + setViewState((prev) => ({ ...prev, yIndex })); + const setPhonemeIndex = (fIndex: number | null) => + setViewState((prev) => ({ ...prev, fIndex })); + + // Handle navigation levels + const navigateBack = () => { + const { level } = viewState; + + if (level === "paragraph") { + setViewState((prev) => ({ ...prev, level: "text", pIndex: null })); + } else if (level === "sentence") { + setViewState((prev) => ({ ...prev, level: "paragraph", sIndex: null })); + } else if (level === "clause") { + setViewState((prev) => ({ ...prev, level: "sentence", cIndex: null })); + } else if (level === "word") { + setViewState((prev) => ({ ...prev, level: "clause", wIndex: null })); + } else if (level === "syllable") { + setViewState((prev) => ({ ...prev, level: "word", yIndex: null })); + } else if (level === "phoneme") { + setViewState((prev) => ({ ...prev, level: "syllable", fIndex: null })); + } + }; + + // Handle clicks on elements to navigate forward + const handleElementClick = (e: React.MouseEvent, idx: number) => { + e.stopPropagation(); + const { level } = viewState; + + if (level === "text") { + setViewState((prev) => ({ ...prev, level: "paragraph", pIndex: idx })); + } else if (level === "paragraph") { + setViewState((prev) => ({ ...prev, level: "sentence", sIndex: idx })); + } else if (level === "sentence") { + setViewState((prev) => ({ ...prev, level: "clause", cIndex: idx })); + } else if (level === "clause") { + setViewState((prev) => ({ ...prev, level: "word", wIndex: idx })); + } else if (level === "word") { + setViewState((prev) => ({ ...prev, level: "syllable", yIndex: idx })); + } else if (level === "syllable") { + setViewState((prev) => ({ ...prev, level: "phoneme", fIndex: idx })); + } + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the zoom context +export const useZoom = () => useContext(ZoomContext); diff --git a/packages/prosody-ui/src/zoom/index.ts b/packages/prosody-ui/src/zoom/index.ts new file mode 100644 index 0000000..baf5db1 --- /dev/null +++ b/packages/prosody-ui/src/zoom/index.ts @@ -0,0 +1,8 @@ +export { ZoomProvider, useZoom } from "./hooks/useZoom"; +import Paragraph from "./Paragraph"; +import FullText from "./FullText"; +import Sentence from "./Paragraph"; +import SpacyClause from "./SpacyClause"; +import type * as Types from "./logic/types"; + +export { Paragraph, FullText, Sentence, SpacyClause, Types }; diff --git a/packages/prosody-ui/src/zoom/logic/types.ts b/packages/prosody-ui/src/zoom/logic/types.ts new file mode 100644 index 0000000..bea68ff --- /dev/null +++ b/packages/prosody-ui/src/zoom/logic/types.ts @@ -0,0 +1,53 @@ +import type { NLP } from "sortug-ai"; + +export type ViewLevel = + | "text" + | "paragraph" + | "sentence" + | "clause" + | "word" + | "syllable" + | "phoneme"; +export interface ViewState { + level: ViewLevel; + pIndex: number | null; + sIndex: number | null; + cIndex: number | null; + wIndex: number | null; + yIndex: number | null; + fIndex: number | null; +} + +export interface ViewProps { + idx: number; + rawText: string; + context: Context; + doc: NLP.Spacy.SpacyRes; +} +export type Context = { + parentText: string; + segmented: string[]; + idx: number; +}; + +export type WordData = { + confidence: number; + frequency: number | null; + id: number; + ipa: Array<{ ipa: string; tags: string[] }>; + spelling: string; + type: ExpressionType; + syllables: number; + lang: string; + prosody: any; + senses: Sense[]; +}; +export type ExpressionType = "word" | "expression" | "syllable"; +export type Sense = { + etymology: string; + pos: string; + forms: Array<{ form: string; tags: string[] }>; + related: any; + senses: Array<{ glosses: string[]; links: Array<[string, string]> }>; +}; +export type LoadingStatus = "pending" | "loading" | "success" | "error"; diff --git a/packages/prosody-ui/src/zoom/spacy.css b/packages/prosody-ui/src/zoom/spacy.css new file mode 100644 index 0000000..0077119 --- /dev/null +++ b/packages/prosody-ui/src/zoom/spacy.css @@ -0,0 +1,39 @@ +.suword { + margin-left: 0.5ch; + margin-right: 0.5ch; +} + +/* .suword.pred { */ +/* color: gold; */ +/* } */ + +/* Clause level */ +.clause-container { + max-width: 600px; + white-space: normal !important; + hyphens: auto; + + padding: 2px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + will-change: transform, opacity, filter, background-color; + + span { + white-space: normal !important; + } +} + +.clause-container.selected { + background-color: rgba(220, 200, 255, 0.2); + z-index: 3; +} + +.suword.subj { + color: blue; + /* border-bottom: 2px solid blue; */ +} + +.suword.root { + color: darkred; +} \ No newline at end of file -- cgit v1.2.3