Reorganized modules

This commit is contained in:
2024-09-05 16:32:22 +05:30
parent 4e11f29b0b
commit 33782dc484
38 changed files with 939 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{
"packages": [
"src/packages/*"
"packages/*"
],
"version": "independent"
}

View File

@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"workspaces": [
"src/packages/*"
"packages/*"
],
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,64 @@
import { ArPopoverSlots } from "@armco/types"
const textColors: Array<string> = [
"#172B4D",
"#0747A6",
"#008DA6",
"#006644",
"#FF991F",
"#BF2600",
"#403294",
"#97A0AF",
"#4C9AFF",
"#00B8D9",
"#36B37E",
"#FFC400",
"#FF5630",
"#6554C0",
"#FFFFFF",
"#B3D4FF",
"#B3F5FF",
"#ABF5D1",
"#FFF0B3",
"#FFBDAD",
"#EAE6FF",
]
const bgColors: Array<string> = [
"#FFFFFF",
"#DCDFE4",
"#C6EDFB",
"#D3F1A7",
"#FEDEC8",
"#FDD0EC",
"#DFD8FD",
]
const ColorSelectorRadio = ({
slot,
variant,
}: {
variant: "text" | "background"
slot: ArPopoverSlots
}) => {
const colors = variant === "text" ? textColors : bgColors
return (
<div
className="ar-ColorSelectorRadio d-grid p-1"
style={{ gridTemplateColumns: "repeat(7, 1.5rem)" }}
>
{colors.map((color) => (
<span
className="ar-ColorSelectorRadio__button m-1"
style={{ padding: "1px", height: "1rem", width: "1rem" }}
>
<span
className="d-inline-block border cursor-pointer w-100 h-100"
style={{ backgroundColor: color }}
/>
</span>
))}
</div>
)
}
export default ColorSelectorRadio

View File

@@ -0,0 +1,385 @@
/**
* Document component, that wraps and manages Text components
* - on enter add a new Text component
*
*/
import {
FormEvent,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { v4 as uuid } from "uuid"
import {
ArPopoverPositions,
ArPopoverSlots,
ChunkInfo,
CompInfo,
FlexContentProps,
FlexContentType,
LibRepoType,
SelectionInfo,
TextInfo,
TextProps,
} from "@armco/types"
import {
applyTool, // Formatting
DomHelper,
PopoverV2,
selectionHandler, // Selection
splitChunkAtCursor, // Formatting
TextFormatter,
} from "../.."
import * as atoms from "../../atoms"
import * as molecules from "../../molecules"
import "./index.scss"
const demoDummyState: {
entireSelectionFormatted?: boolean
contentRegistry: FlexContentType
} = {
contentRegistry: {},
}
Array.from({ length: 4 }, (i: number) => {
const id = uuid()
demoDummyState.contentRegistry[id] = {
id,
name: "Text",
text: `lorem ipsum${i}`,
order: i,
chunks: {},
length: -1,
}
return null
})
const MemoWrapper = memo(
(props: { componentName: string; props: TextProps; repo: LibRepoType }) => {
const { componentName, props: componentProps, repo } = props
const Component = repo[componentName]
return Component ? (
<Component key={componentProps.descriptor.id} {...componentProps} />
) : null
},
(prevProps, props) => {
return (
JSON.stringify(prevProps.props.descriptor.chunks) !==
JSON.stringify(props.props.descriptor.chunks)
)
},
)
const initId = uuid()
const initText = {
[initId]: {
id: initId,
name: "Text",
text: "",
order: 0,
chunks: {},
length: -1,
},
}
const DEFAULT_PREVENTERS = ["Enter", "Backspace", "Delete"]
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isAndroid = /Android/.test(navigator.userAgent)
const FlexContent = (props: FlexContentProps): JSX.Element => {
const {
allowFormatting,
isEditable = true,
hasFloatingFormatter,
content = initText,
demo,
onCommit,
theme,
} = props
const [contentState, updateContentState] = useState<{
entireSelectionFormatted?: boolean
contentRegistry: FlexContentType
}>()
const [formatterDisplayed, displayFormatter] = useState<boolean>(false)
const activeChunkRef = useRef<ChunkInfo>()
const flexContentRef = useRef<HTMLDivElement>(null)
const selectionInfoRef = useRef<SelectionInfo | null>()
const keyRef = useRef<string>()
const repo: LibRepoType = useMemo(() => ({ ...atoms, ...molecules }), [])
const winObj = useMemo(() => DomHelper.getWindowElement(demo), [demo])
const docObj = useMemo(() => DomHelper.getDocumentElement(demo), [demo])
const memoizedSelectionHandler = useCallback(
() =>
selectionHandler(
activeChunkRef,
docObj,
winObj,
selectionInfoRef,
contentState,
),
[contentState, docObj, winObj],
)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault()
selectionInfoRef.current &&
contentState &&
applyTool(
"bold",
true,
selectionInfoRef.current,
contentState,
(state) => {
updateContentState(state)
selectionInfoRef.current = null
},
)
// undoSlots()
}
}
docObj?.addEventListener("keydown", handleKeyDown)
return () => {
docObj?.removeEventListener("keydown", handleKeyDown)
}
}, [contentState, docObj])
useEffect(() => {
const useContent = content
? { contentRegistry: content }
: demo
? demoDummyState
: { contentRegistry: content || {} }
useContent?.contentRegistry &&
Object.values(useContent.contentRegistry).forEach(
(obj: TextInfo | CompInfo) => {
obj.length = (obj.text as string)?.length
if (Object.keys(obj.chunks).length === 0) {
const id = uuid()
const end = obj.text ? (obj.text as string).length - 1 : 0
obj.chunks[id] = {
id,
start: 0,
end,
text: obj.text.substring(0, end),
formats: {},
parent: obj.id,
}
}
},
)
updateContentState(useContent)
}, [content, demo])
useEffect(() => {
const flexContentDom = flexContentRef.current
const displayFormatterHandler = () =>
winObj?.getSelection()?.toString().length && displayFormatter(true)
// flexContentDom?.addEventListener("focus", memoizedSelectionHandler)
docObj?.addEventListener("selectionchange", memoizedSelectionHandler)
flexContentDom?.addEventListener("mouseup", displayFormatterHandler)
return () => {
// flexContentDom?.removeEventListener("focus", memoizedSelectionHandler)
docObj?.removeEventListener("selectionchange", memoizedSelectionHandler)
flexContentDom?.removeEventListener("mouseup", displayFormatterHandler)
}
}, [winObj, docObj, memoizedSelectionHandler, flexContentRef])
useEffect(() => {
if (flexContentRef.current) {
setTimeout(() => flexContentRef.current?.focus(), 10)
}
}, [flexContentRef])
const textConfigs =
contentState &&
Object.entries(contentState.contentRegistry as FlexContentType)
textConfigs?.sort((t1, t2) => t1[1].order - t2[1].order)
const handleInput = (e: FormEvent<HTMLDivElement>) => {
e.preventDefault()
const input = (e as unknown as InputEvent).data
const selection = winObj?.getSelection()
if (selection) {
const range = selection.getRangeAt(0)
const anchorText =
(selection.anchorNode as HTMLElement).textContent + (input || "")
const cursorPosition = selection.anchorOffset
const startContainer = range.startContainer
if (flexContentRef.current) {
const childNodes = Array.from(flexContentRef.current.childNodes)
childNodes.forEach((node) => {
node.nodeType === Node.TEXT_NODE && node.remove()
})
}
if (contentState && activeChunkRef.current) {
const content = contentState.contentRegistry
const textConfig = content[activeChunkRef.current.parent]
textConfig.text = input // TODO: Fix this value, might only capture chunk's text
textConfig.cursorPosition = cursorPosition
if (
startContainer?.parentNode &&
!(startContainer.parentNode as HTMLElement).classList.contains(
"ar-FlexContent",
)
) {
textConfig.startContainer = startContainer
}
const activeText =
contentState.contentRegistry[activeChunkRef.current.parent]
const activeChunk = activeText?.chunks[activeChunkRef.current.id]
activeChunk.text = anchorText
activeChunk.end = activeChunk.start + activeChunk.text.length
updateContentState({ ...contentState })
}
onCommit && onCommit()
keyRef.current = undefined
}
}
const contentRenders = (
<div
ref={flexContentRef}
slot={ArPopoverSlots.ANCHOR}
className="ar-FlexContent w-100"
tabIndex={-1}
onKeyDown={(e) => {
const selection = winObj?.getSelection()
if (selection && contentState) {
const range = selection.getRangeAt(0)
if (e.key === "Enter") {
e.preventDefault()
splitChunkAtCursor(
selection,
contentState,
activeChunkRef,
updateContentState,
)
} else if (e.key === "Backspace") {
if (range && range.startOffset > 0) {
range.setStart(range.startContainer, range.startOffset - 1)
range.deleteContents()
}
} else if (e.key === "Delete") {
if (isMac || isIOS || isAndroid) {
if (range.startOffset > 0) {
range.setStart(range.startContainer, range.startOffset - 1)
range.deleteContents()
}
} else {
// On Windows, "Delete" key deletes character after the cursor
if (
range.startContainer.textContent &&
range.startOffset < range.startContainer.textContent.length
) {
range.setEnd(range.startContainer, range.startOffset + 1)
range.deleteContents()
}
}
}
}
}}
onBeforeInput={handleInput}
contentEditable={isEditable}
suppressContentEditableWarning={true}
>
{textConfigs?.map((entry) => {
const props: TextProps = {
descriptor: entry[1] as TextInfo,
allowFormatting,
isEditable,
demo,
}
return (
<MemoWrapper
key={`flex-content-item-${entry[0]}`}
componentName={entry[1].name}
repo={repo}
props={props}
/>
)
})}
</div>
)
return (
<>
{isEditable && allowFormatting && hasFloatingFormatter ? (
<PopoverV2
classes="align-self-start w-100 border-bottom"
contentClasses="invert-bg"
isOpen={formatterDisplayed}
onClose={() => displayFormatter(false)}
position={ArPopoverPositions.TOP}
demo={demo}
topOffset={16}
popAtPointer
hideMarker
>
{contentRenders}
<TextFormatter
activeFormats={activeChunkRef.current?.formats}
demo={demo}
slot={ArPopoverSlots.POPOVER}
content={contentState}
callback={(state) => {
updateContentState(state)
selectionInfoRef.current = null
}}
selectionInfo={selectionInfoRef.current}
theme={theme}
isFloating
/>
</PopoverV2>
) : (
contentRenders
)}
<hr />
<div className="row">
<div className="col-4">
<h6>Content Registry</h6>
<pre className="my-3">
{JSON.stringify(
contentState?.contentRegistry,
(key, value) => {
if (key.startsWith("__reactFiber")) {
if (value.stateNode?.data) {
return value.stateNode.data
} else {
return value.toString()
}
}
if (key === "id" || key === "length") {
return undefined
}
return value
},
2,
)}
</pre>
</div>
<div className="col-4">
<h6>Active Chunk (ref - no live update)</h6>
<pre className="my-3">
{JSON.stringify(activeChunkRef.current, null, 2)}
</pre>
</div>
<div className="col-4">
<h6>Selection Info (ref - no live update)</h6>
<pre className="my-3">
{JSON.stringify(selectionInfoRef.current, null, 2)}
</pre>
</div>
</div>
</>
)
}
export default FlexContent

View File

@@ -0,0 +1,39 @@
.ar-TextFormatter {
max-height: 2.5rem;
--color-tool-indicator-color: linear-gradient(to right, rgb(101, 84, 192) 25%, rgb(0, 184, 217) 25%, rgb(0, 184, 217) 50%, rgb(255, 153, 31) 50%, rgb(255, 153, 31) 75%, rgb(222, 53, 11) 75%);
--color-tool-indicator-color-inactive: linear-gradient(to right, rgb(151, 160, 175) 25%, rgb(179, 186, 197) 25%, rgb(179, 186, 197) 50%, rgb(223, 225, 230) 50%, rgb(223, 225, 230) 75%, rgb(179, 186, 197) 75%);
.ar-TextTool {
.ar-TextTool__anchor {
height: 2rem;
min-width: 2rem;
transition: background-color 0.3s;
.ar-LoadableIcon {
margin-bottom: 1px;
}
&.active:not(.enabled):hover {
background-color: var(--ar-color-disabled-4-invert) !important;
}
&.enabled {
background-color: #e9f2ff !important;
}
&.for-color .ar-TextTool__color-tool-indicator {
background: var(--color-tool-indicator-color-inactive);
width: 1rem;
height: 3px;
border-radius: 3px;
}
&.for-color.active .ar-TextTool__color-tool-indicator {
background: var(--color-tool-indicator-color);
}
}
}
}
.ar-ColorSelectorRadio__button {
outline: 1px solid transparent;
&:hover {
outline: 1px solid #aeaeae;
}
}

282
packages/ui/src/TextFormatter.tsx Executable file
View File

@@ -0,0 +1,282 @@
import {
ArThemes,
TextFormatterTool,
TextFormatterProps,
TextFormat,
} from "@armco/types"
import { TextTool } from "../.."
import "./TextFormatter.component.scss"
const editToolGroups: {
[key: string]: {
scope: 0 | 1 | 2
popoverContentClasses?: string
tools: Array<TextFormatterTool>
}
} = {
fontStyle: {
scope: 0,
popoverContentClasses: "py-2 px-0",
tools: [
{
name: "bold",
label: "Bold",
darkModeHoverColor: "#dadada",
value: true,
icon: "fa.FaBold",
tooltip: "Bold",
size: "0.9rem",
},
{
name: "italic",
label: "Italic",
darkModeHoverColor: "#dadada",
value: true,
icon: "fa.FaItalic",
tooltip: "Oblique",
size: "0.9rem",
},
{
name: "underline",
label: "Underline",
darkModeHoverColor: "#dadada",
value: true,
icon: "fa.FaUnderline",
tooltip: "Underline",
size: "0.9rem",
},
{
name: "strikethrough",
label: "Strikethrough",
value: true,
icon: "fa.FaStrikethrough",
tooltip: "Strikethrough",
size: "0.9rem",
},
{
name: "code",
label: "Code",
value: true,
icon: "fa.FaCode",
tooltip: "Code",
},
{
name: "scriptPlacement",
label: "Subscript",
value: "sub",
icon: "fa.FaSubscript",
tooltip: "Subscript",
},
{
name: "scriptPlacement",
label: "SuperScript",
value: "sup",
icon: "fa.FaSuperscript",
tooltip: "Superscript",
},
],
},
size: {
scope: 0,
tools: [
{
name: "increase-size",
icon: "ri.RiCharacterRecognitionFill",
darkModeHoverColor: "#dadada",
subIcon: "fa.FaLongArrowAltUp",
size: "1.4rem",
tooltip: "Bigger",
},
{
name: "decrease-size",
icon: "ri.RiCharacterRecognitionFill",
darkModeHoverColor: "#dadada",
subIcon: "fa.FaLongArrowAltDown",
size: "0.9rem",
tooltip: "Smaller",
},
],
},
color: {
scope: 0,
tools: [
{
name: "foreground",
icon: "ri.RiCharacterRecognitionFill",
darkModeHoverColor: "#dadada",
subIcon: "md.MdKeyboardArrowDown",
subIconSize: "1rem",
tooltip: "Change Text Color",
popover: "textColor",
},
{
name: "background",
icon: "md.MdEdit",
darkModeHoverColor: "#dadada",
subIcon: "md.MdKeyboardArrowDown",
subIconSize: "1rem",
tooltip: "Change Background Color",
popover: "backgroundColor",
},
],
},
indentation: {
scope: 1,
popoverContentClasses: "py-2 px-0",
tools: [
{
name: "left-align",
label: "Align Left",
icon: "fa.FaAlignLeft",
darkModeHoverColor: "#dadada",
tooltip: "Align Left",
},
{
name: "center-align",
label: "Align Center",
icon: "fa.FaAlignCenter",
darkModeHoverColor: "#dadada",
tooltip: "Centre Text",
},
{
name: "right-align",
label: "Align Right",
icon: "fa.FaAlignRight",
darkModeHoverColor: "#dadada",
tooltip: "Align Right",
},
{
name: "increase-indent",
label: "Increase Indent",
icon: "im.ImIndentIncrease",
tooltip: "Indent Right",
},
{
name: "decrease-indent",
label: "Decrease Indent",
icon: "im.ImIndentDecrease",
tooltip: "Indent Right",
},
],
},
clear: {
scope: 0,
tools: [
{
name: "clear",
icon: "md.MdFormatClear",
darkModeHoverColor: "#dadada",
tooltip: "Clear Formatting",
},
],
},
}
const TextFormatter = (props: TextFormatterProps): JSX.Element => {
const {
activeFormats,
content,
callback,
demo,
isFloating,
selectionInfo,
theme,
variant = 0,
} = props
const otherTheme = theme === ArThemes.DARK1 ? ArThemes.LIGHT1 : ArThemes.DARK1
const toolRenders = (
editTools: {
scope: number
popoverContentClasses?: string
tools: Array<TextFormatterTool>
},
category: string,
) => {
const inScopeTools = editTools.tools
.filter((tool) => !tool.scope || tool.scope <= variant)
.map((t) => {
t.onClick = () =>
content &&
selectionInfo &&
applyTool(
t.name as keyof TextFormat,
t.value,
selectionInfo,
content,
callback,
)
return t
})
let initialTools
if (inScopeTools.length > 3) {
initialTools = inScopeTools.slice(0, 2)
initialTools.push({
name: "more",
icon: "md.MdMoreHoriz",
darkModeHoverColor: "#dadada",
tooltip: "View more",
popover: "moreTools",
popoverContentClasses: editTools.popoverContentClasses,
})
} else {
initialTools = inScopeTools
}
const initialRenders = initialTools.map((tool) => (
<TextTool
key={`text-tool-${tool.name}`}
active={isFloating}
bgClass={isFloating ? "invert-bg" : ""}
category={category}
demo={demo}
formatter={() =>
selectionInfo &&
content &&
tool.name !== "more" &&
applyTool(
tool.name as keyof TextFormat,
tool.value,
selectionInfo,
content,
callback,
)
}
restTools={inScopeTools.length > 3 ? inScopeTools.slice(2) : []}
tool={tool}
theme={(isFloating ? theme : otherTheme) || ArThemes.LIGHT1}
enabled={
!!(activeFormats && activeFormats[tool.name as keyof TextFormat])
}
/>
))
return initialRenders
}
return (
<div
className={`ar-TextFormatter py-1 invert-bg${
isFloating ? " is-floating" : ""
} ${isFloating ? "flex-center border-radius-l2" : "flex-v-center"}`}
>
{Object.entries(editToolGroups)
.filter((entry) => entry[1].scope <= variant)
.map(([category, editTools], index, arr) => {
return (
<div
key={`tools-group-${index}`}
className={`ar-TextFormatter__group flex-v-center${
index < arr.length - 1 ? " border-right" : ""
} px-1`}
>
{toolRenders(editTools, category)}
</div>
)
})
.filter((g) => g)}
</div>
)
}
export default TextFormatter

View File

@@ -0,0 +1,131 @@
import { useState } from "react"
import {
ArPopoverSlots,
ArThemes,
ListItemContent,
TextToolProps,
} from "@armco/types"
import {
ColorSelectorRadio,
List,
LoadableIcon,
PopoverV2,
Tooltip,
} from "../.."
const popovers = (restTools?: Array<ListItemContent>, theme?: ArThemes) => ({
textColor: (
<ColorSelectorRadio variant="text" slot={ArPopoverSlots.POPOVER} />
),
backgroundColor: (
<ColorSelectorRadio variant="background" slot={ArPopoverSlots.POPOVER} />
),
moreTools: (
<List
data={restTools || []}
itemVariant="soft"
itemClasses="px-3"
slot={ArPopoverSlots.POPOVER}
theme={theme}
invertBg
/>
),
})
const TextTool = ({
active,
bgClass,
category,
demo,
enabled,
formatter,
restTools,
tool,
theme,
}: TextToolProps) => {
const {
color,
darkModeColor,
name,
icon,
subIcon,
tooltip,
size,
subIconSize,
paddingClass,
popover,
popoverContentClasses,
} = tool
const [hovered, setHovered] = useState<boolean>()
const iconColor = active
? enabled
? "#0c66e4"
: theme === ArThemes.DARK1
? darkModeColor
: color || "white"
: "#929292"
const toolRenderWithTooltip = (
<Tooltip demo={demo} slot={ArPopoverSlots.ANCHOR}>
<span
className={`ar-TextTool ${theme || ArThemes.LIGHT1}`}
slot={ArPopoverSlots.ANCHOR}
onClick={(e) => {
// e.preventDefault()
// e.stopPropagation()
formatter && formatter()
}}
>
<span
className={`ar-TextTool__anchor d-inline-flex border-radius-l2 flex-center ${
paddingClass || "p-1"
}${hovered ? " hovered" : ""}${
category === "color" ? " for-color" : ""
}${active ? " active cursor-pointer" : ""}${
bgClass ? " " + bgClass : ""
}${enabled ? " enabled" : ""}`}
key={"text-formatter-tool-" + name}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<span>
<LoadableIcon
size={(size || "1rem") as string}
icon={icon}
color={iconColor}
/>
{category === "color" && (
<div className="ar-TextTool__color-tool-indicator" />
)}
</span>
{subIcon && (
<LoadableIcon
size={subIconSize || "0.8rem"}
icon={subIcon}
color={iconColor}
/>
)}
</span>
</span>
<span slot={ArPopoverSlots.POPOVER}>{tooltip}</span>
</Tooltip>
)
return popover && active ? (
<PopoverV2
demo={demo}
contentClasses={popoverContentClasses}
invertBg
hideMarker
preserveMarkerSpace
closeOnSelfClick
>
{toolRenderWithTooltip}
{popovers(restTools, theme)[popover as "textColor" | "backgroundColor"]}
</PopoverV2>
) : (
toolRenderWithTooltip
)
}
export default TextTool

View File

@@ -0,0 +1,8 @@
.ar-FlexContent {
min-height: 2.5rem;
line-height: 1.2rem;
font-family: "Noto Sans Chakma", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
&:focus-visible {
outline: none;
}
}

28
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"outDir": "build",
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"noEmit": false,
"jsx": "react-jsx",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"target": "es5",
},
"include": ["src"],
"exclude": ["build", "plop-templates", "node_modules", "src/**/*.test.*", "src/**/*.spec.*", "src/stories", "scripts"]
}