diff --git a/packages/kami-design/components/Banner/index.tsx b/packages/kami-design/components/Banner/index.tsx new file mode 100644 index 000000000..889402d37 --- /dev/null +++ b/packages/kami-design/components/Banner/index.tsx @@ -0,0 +1,75 @@ +import { clsx } from 'clsx' +import type { FC } from 'react' + +import { + ClaritySuccessLine, + FluentShieldError20Regular, + FluentWarning28Regular, + IonInformation, +} from '../Icons/status' + +const IconMap = { + warning: FluentWarning28Regular, + info: IonInformation, + error: FluentShieldError20Regular, + success: ClaritySuccessLine, +} + +const bgColorMap = { + warning: 'bg-amber-50', + info: 'bg-default-blue-50', + success: 'bg-default-green-50', + error: 'bg-default-red-50', +} + +const borderColorMap = { + warning: 'border-amber-300', + info: 'border-default-blue-300', + + success: 'border-default-green-300', + error: 'border-default-red-300', +} + +const iconColorMap = { + warning: 'text-amber-500', + info: 'text-default-blue-500', + success: 'text-default-green-500', + error: 'text-default-red-500', +} + +export const Banner: FC<{ + type: 'warning' | 'error' | 'success' | 'info' + message?: string | React.ReactNode + className?: string + children?: React.ReactNode + placement?: 'center' | 'left' + showIcon?: boolean +}> = (props) => { + const Icon = IconMap[props.type] || IconMap.info + const { placement = 'center', showIcon = true } = props + return ( +
+ {showIcon && ( + + )} + {props.message ? ( + {props.message} + ) : ( + props.children + )} +
+ ) +} diff --git a/packages/kami-design/components/CodeBlock/index.tsx b/packages/kami-design/components/CodeBlock/index.tsx new file mode 100644 index 000000000..2cc0af3c0 --- /dev/null +++ b/packages/kami-design/components/CodeBlock/index.tsx @@ -0,0 +1,18 @@ +import { Mermaid } from '../Mermaid' + +export const CodeBlock = (props: { + lang: string | undefined + content: string + + HighLighter: React.ComponentType<{ + content: string + lang: string | undefined + }> +}) => { + const { HighLighter } = props + if (props.lang === 'mermaid') { + return + } else { + return + } +} diff --git a/packages/kami-design/components/Divider/index.tsx b/packages/kami-design/components/Divider/index.tsx new file mode 100644 index 000000000..eb5bbf451 --- /dev/null +++ b/packages/kami-design/components/Divider/index.tsx @@ -0,0 +1,34 @@ +import { clsx } from 'clsx' +import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react' + +export const Divider: FC< + DetailedHTMLProps, HTMLHRElement> +> = (props) => { + const { className, ...rest } = props + return ( +
+ ) +} + +export const DividerVertical: FC< + DetailedHTMLProps, HTMLSpanElement> +> = (props) => { + const { className, ...rest } = props + return ( + + w + + ) +} diff --git a/packages/kami-design/components/FloatPopover/index.module.css b/packages/kami-design/components/FloatPopover/index.module.css new file mode 100644 index 000000000..dea25d6c5 --- /dev/null +++ b/packages/kami-design/components/FloatPopover/index.module.css @@ -0,0 +1,21 @@ +.popover-root { + @apply p-4 relative bg-light-bg rounded-lg overflow-hidden z-2 shadow-out-sm; +} + +//// +.popover-root, +.headless { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; + + @apply delay-100; +} + +.show, +.headless.show { + opacity: 1; + transform: translateY(0); + + @apply delay-0; +} diff --git a/packages/kami-design/components/FloatPopover/index.tsx b/packages/kami-design/components/FloatPopover/index.tsx new file mode 100644 index 000000000..86d2f06dc --- /dev/null +++ b/packages/kami-design/components/FloatPopover/index.tsx @@ -0,0 +1,221 @@ +import { clsx } from 'clsx' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useClickAway } from 'react-use' + +import type { UseFloatingProps } from '@floating-ui/react-dom' +import { flip, offset, shift, useFloating } from '@floating-ui/react-dom' + +import { NoSSRWrapper } from '~/utils/no-ssr' + +import { RootPortal } from '../Portal' +import styles from './index.module.css' + +export const FloatPopover: FC< + { + triggerComponent: FC + headless?: boolean + wrapperClassNames?: string + trigger?: 'click' | 'hover' | 'both' + padding?: number + offset?: number + popoverWrapperClassNames?: string + + /** + * 不消失 + */ + debug?: boolean + } & UseFloatingProps +> = NoSSRWrapper( + memo((props) => { + const { + headless = false, + wrapperClassNames, + triggerComponent: TriggerComponent, + trigger = 'hover', + padding, + offset: offsetValue, + popoverWrapperClassNames, + debug, + ...rest + } = props + const [mounted, setMounted] = useState(false) + const { x, y, reference, floating, strategy, update } = useFloating({ + middleware: rest.middleware ?? [ + flip({ padding: padding ?? 20 }), + offset(offsetValue ?? 10), + shift(), + ], + strategy: rest.strategy, + placement: rest.placement ?? 'bottom-start', + whileElementsMounted: rest.whileElementsMounted, + }) + const [currentStatus, setCurrentStatus] = useState(false) + const [open, setOpen] = useState(false) + const updateOnce = useRef(false) + const doPopoverShow = useCallback(() => { + setCurrentStatus(true) + setMounted(true) + + if (!updateOnce.current) { + requestAnimationFrame(() => { + update() + updateOnce.current = true + }) + } + }, []) + + const [containerAnchorRef, setContainerAnchorRef] = + useState() + const containerRef = useRef(null) + + const handleTransition = useCallback( + (status: 'in' | 'out') => { + const nextElementSibling = + containerAnchorRef?.nextElementSibling as HTMLDivElement + + if (!nextElementSibling) { + return + } + + if (status === 'in') { + nextElementSibling.ontransitionend = null + nextElementSibling?.classList.add(styles.show) + } else { + nextElementSibling?.classList.remove(styles.show) + nextElementSibling!.ontransitionend = () => { + setOpen(false) + setMounted(false) + } + } + }, + [containerAnchorRef?.nextElementSibling], + ) + + useEffect(() => { + if (!containerAnchorRef) { + return + } + + if (currentStatus) { + setOpen(true) + requestAnimationFrame(() => { + handleTransition('in') + }) + } else { + requestAnimationFrame(() => { + handleTransition('out') + }) + } + }, [currentStatus, containerAnchorRef, handleTransition]) + + useClickAway(containerRef, () => { + if (trigger == 'click' || trigger == 'both') { + doPopoverDisappear() + clickTriggerFlag.current = false + } + }) + + const doPopoverDisappear = useCallback(() => { + if (debug) { + return + } + setCurrentStatus(false) + }, [debug]) + + const clickTriggerFlag = useRef(false) + const handleMouseOut = useCallback(() => { + if (clickTriggerFlag.current === true) { + return + } + doPopoverDisappear() + }, []) + const handleClickTrigger = useCallback(() => { + clickTriggerFlag.current = true + doPopoverShow() + }, []) + + const listener = useMemo(() => { + const baseListener = { + onFocus: doPopoverShow, + onBlur: doPopoverDisappear, + } + switch (trigger) { + case 'click': + return { + ...baseListener, + onClick: doPopoverShow, + } + case 'hover': + return { + ...baseListener, + onMouseOver: doPopoverShow, + onMouseOut: doPopoverDisappear, + } + case 'both': + return { + ...baseListener, + onClick: handleClickTrigger, + onMouseOver: doPopoverShow, + onMouseOut: handleMouseOut, + } + } + }, [ + doPopoverDisappear, + doPopoverShow, + handleClickTrigger, + handleMouseOut, + trigger, + ]) + + const TriggerWrapper = ( +
+ +
+ ) + + if (!props.children) { + return TriggerWrapper + } + + return ( + <> + {TriggerWrapper} + + {mounted && ( + +
+
+ {open && ( +
+ {props.children} +
+ )} +
+ + )} + + ) + }), +) diff --git a/packages/kami-design/components/Icons/arrow.tsx b/packages/kami-design/components/Icons/arrow.tsx new file mode 100644 index 000000000..a9251d6f9 --- /dev/null +++ b/packages/kami-design/components/Icons/arrow.tsx @@ -0,0 +1,45 @@ +import type { SVGProps } from 'react' + +export function IcRoundKeyboardDoubleArrowLeft(props: SVGProps) { + return ( + + + + + ) +} + +export function IcRoundKeyboardDoubleArrowRight( + props: SVGProps, +) { + return ( + + + + + ) +} diff --git a/packages/kami-design/components/Icons/emoji.tsx b/packages/kami-design/components/Icons/emoji.tsx new file mode 100644 index 000000000..0b182b45c --- /dev/null +++ b/packages/kami-design/components/Icons/emoji.tsx @@ -0,0 +1,111 @@ +import type { SVGProps } from 'react' + +export function FaSolidSmile(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidSadCry(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidSadTear(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidAngry(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidTired(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidMeh(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidGrinSquintTears(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidFrownOpen(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidGrimace(props: SVGProps) { + return ( + + + + ) +} + +export function FaSolidFlushed(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-comment.tsx b/packages/kami-design/components/Icons/for-comment.tsx new file mode 100644 index 000000000..59148475a --- /dev/null +++ b/packages/kami-design/components/Icons/for-comment.tsx @@ -0,0 +1,118 @@ +import type { FC, SVGProps } from 'react' +import { memo } from 'react' + +export function SiGlyphGlobal(props: SVGProps) { + return ( + + + + + + + ) +} + +export function PhUser(props: SVGProps) { + return ( + + + + ) +} + +export function MdiEmailFastOutline(props: SVGProps) { + return ( + + + + ) +} + +export const EmptyIcon: FC = memo(() => { + return ( + + + + + + + + ) +}) + +export function GridiconsNoticeOutline(props: SVGProps) { + return ( + + + + ) +} + +export function LaUserSecret(props: SVGProps) { + return ( + + + + ) +} + +export function RadixIconsAvatar(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-fav.tsx b/packages/kami-design/components/Icons/for-fav.tsx new file mode 100644 index 000000000..99d348dc7 --- /dev/null +++ b/packages/kami-design/components/Icons/for-fav.tsx @@ -0,0 +1,43 @@ +import type { SVGProps } from 'react' + +export function MusicIcon(props: SVGProps) { + return ( + + + + + + + ) +} + +export function PauseIcon(props: SVGProps) { + return ( + + + + ) +} + +export function RiNeteaseCloudMusicFill(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-footer.tsx b/packages/kami-design/components/Icons/for-footer.tsx new file mode 100644 index 000000000..876fe5153 --- /dev/null +++ b/packages/kami-design/components/Icons/for-footer.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' + +export function BxBxsArrowToTop(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidHeadphonesAlt(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-home.tsx b/packages/kami-design/components/Icons/for-home.tsx new file mode 100644 index 000000000..76a011805 --- /dev/null +++ b/packages/kami-design/components/Icons/for-home.tsx @@ -0,0 +1,50 @@ +import type { SVGProps } from 'react' + +export function MdiDrawPen(props: SVGProps) { + return ( + + + + ) +} + +export function PhUsersDuotone(props: SVGProps) { + return ( + + + + + + ) +} + +export function FaSolidKissWinkHeart(props: SVGProps) { + return ( + + + + ) +} + +export function IcBaselineArrowForwardIos(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-login.tsx b/packages/kami-design/components/Icons/for-login.tsx new file mode 100644 index 000000000..f7196db0b --- /dev/null +++ b/packages/kami-design/components/Icons/for-login.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' + +export function CarbonPassword(props: SVGProps) { + return ( + + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-note.tsx b/packages/kami-design/components/Icons/for-note.tsx new file mode 100644 index 000000000..f7ee951e9 --- /dev/null +++ b/packages/kami-design/components/Icons/for-note.tsx @@ -0,0 +1,102 @@ +import type { SVGProps } from 'react' + +export function PhBookOpen(props: SVGProps) { + return ( + + + + ) +} + +export function SolidBookmark(props: SVGProps) { + return ( + + + + ) +} + +export function MdiTagHeartOutline(props: SVGProps) { + return ( + + + + ) +} + +export function GgCoffee(props: SVGProps) { + return ( + + + + + + + + ) +} + +export function MaterialSymbolsArrowCircleRightOutlineRounded( + props: SVGProps, +) { + return ( + + + + ) +} + +export function MdiClockOutline(props: SVGProps) { + return ( + + + + ) +} + +export function MdiClockTimeThreeOutline(props: SVGProps) { + return ( + + + + ) +} + +export function MdiFountainPenTip(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-post.tsx b/packages/kami-design/components/Icons/for-post.tsx new file mode 100644 index 000000000..0ceef86f3 --- /dev/null +++ b/packages/kami-design/components/Icons/for-post.tsx @@ -0,0 +1,80 @@ +import type { SVGProps } from 'react' + +export function EntypoCreativeCommons(props: SVGProps) { + return ( + + + + ) +} + +export function IonThumbsup(props: SVGProps) { + return ( + + + + ) +} + +export function FeHash(props: SVGProps) { + return ( + + + + ) +} + +export function MdiCalendar(props: SVGProps) { + return ( + + + + ) +} + +export function PhPushPinFill(props: SVGProps) { + return ( + + + + ) +} + +export function PhPushPin(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/for-recently.tsx b/packages/kami-design/components/Icons/for-recently.tsx new file mode 100644 index 000000000..ff99a9628 --- /dev/null +++ b/packages/kami-design/components/Icons/for-recently.tsx @@ -0,0 +1,29 @@ +import type { SVGProps } from 'react' + +export function JamTrash(props: SVGProps) { + return ( + + + + ) +} + +export function PhLinkFill(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/layout.tsx b/packages/kami-design/components/Icons/layout.tsx new file mode 100644 index 000000000..240e11e57 --- /dev/null +++ b/packages/kami-design/components/Icons/layout.tsx @@ -0,0 +1,113 @@ +import type { SVGProps } from 'react' + +export function BiMoonStarsFill(props: SVGProps) { + return ( + + + + + + + ) +} + +export function PhSunBold(props: SVGProps) { + return ( + + + + ) +} + +export const CloseIcon = () => ( + + + +) + +export function JamTags(props: SVGProps) { + return ( + + + + ) +} + +export function IonSearch(props: SVGProps) { + return ( + + + + ) +} + +export function RegularBookmark(props: SVGProps) { + return ( + + + + ) +} + +export function IcBaselineMenuOpen(props: SVGProps) { + return ( + + + + ) +} + +export function FluentEyeHide20Regular(props: SVGProps) { + return ( + + + + + + + + ) +} + +export function MdiShare(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/menu-icon.tsx b/packages/kami-design/components/Icons/menu-icon.tsx new file mode 100644 index 000000000..414b9f67b --- /dev/null +++ b/packages/kami-design/components/Icons/menu-icon.tsx @@ -0,0 +1,250 @@ +import type { SVGProps } from 'react' + +export function FaSolidCircleNotch(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidComment(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidComments(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidFeatherAlt(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidHistory(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidSubway(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidUserFriends(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidCircle(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidDotCircle(props: SVGProps) { + return ( + + + + ) +} +export function FaSolidTv(props: SVGProps) { + return ( + + + + ) +} +export function IconParkOutlineTencentQq(props: SVGProps) { + return ( + + + + + + + + + + + ) +} +export function MdiFlask(props: SVGProps) { + return ( + + + + ) +} +export function MdiTwitter(props: SVGProps) { + return ( + + + + ) +} + +export function IcTwotoneSignpost(props: SVGProps) { + return ( + + + + + ) +} + +export function IonBook(props: SVGProps) { + return ( + + + + ) +} + +export function RiNeteaseCloudMusicLine(props: SVGProps) { + return ( + + + + ) +} + +export function IcBaselineLiveTv(props: SVGProps) { + return ( + + + + ) +} + +export function JamRssFeed(props: SVGProps) { + return ( + + + + ) +} + +export function AkarIconsMention(props: SVGProps) { + return ( + + + + + + + + ) +} + +export function CodiconGithubInverted(props: SVGProps) { + return ( + + + + ) +} + +export function IcBaselineTelegram(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/shared.tsx b/packages/kami-design/components/Icons/shared.tsx new file mode 100644 index 000000000..2332ce424 --- /dev/null +++ b/packages/kami-design/components/Icons/shared.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' + +export function FluentList16Filled(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Icons/status.tsx b/packages/kami-design/components/Icons/status.tsx new file mode 100644 index 000000000..1da9c768b --- /dev/null +++ b/packages/kami-design/components/Icons/status.tsx @@ -0,0 +1,69 @@ +import type { SVGProps } from 'react' + +export function FluentWarning28Regular(props: SVGProps) { + return ( + + + + ) +} + +export function FluentShieldError20Regular(props: SVGProps) { + return ( + + + + ) +} + +export function ClaritySuccessLine(props: SVGProps) { + return ( + + + + + ) +} + +export function IonInformation(props: SVGProps) { + return ( + + + + + + ) +} diff --git a/packages/kami-design/components/Icons/weather.tsx b/packages/kami-design/components/Icons/weather.tsx new file mode 100644 index 000000000..1d089f979 --- /dev/null +++ b/packages/kami-design/components/Icons/weather.tsx @@ -0,0 +1,56 @@ +import type { SVGProps } from 'react' + +export function RiSunCloudyFill(props: SVGProps) { + return ( + + + + ) +} + +export function MdiCloud(props: SVGProps) { + return ( + + + + ) +} + +export function MdiSnowflake(props: SVGProps) { + return ( + + + + ) +} + +export function BiCloudRainFill(props: SVGProps) { + return ( + + + + ) +} + +export function BiCloudLightningRainFill(props: SVGProps) { + return ( + + + + ) +} diff --git a/packages/kami-design/components/Markdown/components/gallery/index.module.css b/packages/kami-design/components/Markdown/components/gallery/index.module.css new file mode 100644 index 000000000..23aa6d1ba --- /dev/null +++ b/packages/kami-design/components/Markdown/components/gallery/index.module.css @@ -0,0 +1,34 @@ +.root { + &:hover .indicator { + opacity: 1; + } +} + +.container { + scroll-snap-type: x mandatory; + display: flex; + align-items: flex-start; + + &::-webkit-scrollbar { + display: none; + } +} + +.child { + scroll-snap-align: center; + flex-shrink: 0; + + text-align: center; +} + +.child:last-child { + margin-right: 0 !important; +} + +.indicator { + @apply absolute bottom-[24px] left-[50%] flex bg-bg-opacity rounded-[24px] z-1 px-6 py-4 opacity-0; + @apply transition-opacity duration-300; + + transform: translateX(-50%); + backdrop-filter: blur(20px) saturate(180%); +} diff --git a/packages/kami-design/components/Markdown/components/gallery/index.tsx b/packages/kami-design/components/Markdown/components/gallery/index.tsx new file mode 100644 index 000000000..170d0cddd --- /dev/null +++ b/packages/kami-design/components/Markdown/components/gallery/index.tsx @@ -0,0 +1,223 @@ +import clsx from 'clsx' +import { throttle } from 'lodash-es' +import type { FC, UIEventHandler } from 'react' +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useInView } from 'react-intersection-observer' + +import { ImageLazy } from '~/components/universal/Image' +import { ImageSizeMetaContext } from '~/context' +import { useStateRef } from '~/hooks/use-state-ref' +import { calculateDimensions } from '~/utils/images' + +import type { MImageType } from '../../utils/image' +import styles from './index.module.css' + +const IMAGE_CONTAINER_MARGIN_INSET = 60 +const CHILD_GAP = 15 +const AUTOPLAY_DURATION = 5000 + +interface GalleryProps { + images: MImageType[] +} + +export const Gallery: FC = (props) => { + const { images } = props + const imageMeta = useContext(ImageSizeMetaContext) + const [containerRef, setContainerRef] = useState(null) + const containerWidth = useMemo( + () => containerRef?.clientWidth || 0, + [containerRef?.clientWidth], + ) + + const [, setUpdated] = useState({}) + const memoedChildContainerWidthRef = useRef(0) + + useEffect(() => { + if (!containerRef) { + return + } + + const ob = new ResizeObserver(() => { + setUpdated({}) + calChild(containerRef) + }) + function calChild(containerRef: HTMLDivElement) { + const $child = containerRef.children.item(0) + if ($child) { + memoedChildContainerWidthRef.current = $child.clientWidth + } + } + + calChild(containerRef) + + ob.observe(containerRef) + return () => { + ob.disconnect() + } + }, [containerRef]) + + const childStyle = useRef({ + width: `calc(100% - ${IMAGE_CONTAINER_MARGIN_INSET}px)`, + marginRight: `${CHILD_GAP}px`, + }).current + + const [currentIndex, setCurrentIndex] = useState(0) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleOnScroll: UIEventHandler = useCallback( + throttle>((e) => { + const $ = e.target as HTMLDivElement + + const index = Math.floor( + ($.scrollLeft + IMAGE_CONTAINER_MARGIN_INSET + 15) / + memoedChildContainerWidthRef.current, + ) + setCurrentIndex(index) + }, 60), + [], + ) + const handleScrollTo = useCallback( + (i: number) => { + if (!containerRef) { + return + } + + containerRef.scrollTo({ + left: memoedChildContainerWidthRef.current * i, + behavior: 'smooth', + }) + }, + [containerRef], + ) + + const autoplayTimerRef = useRef(null as any) + + const currentIndexRef = useStateRef(currentIndex) + const totalImageLengthRef = useStateRef(images.length) + + // 向后翻页状态 + const isForward = useRef(true) + + const autoplayRef = useRef(true) + const handleCancelAutoplay = useCallback(() => { + if (!autoplayRef.current) { + return + } + + autoplayRef.current = false + clearInterval(autoplayTimerRef.current) + }, []) + + const { ref } = useInView({ + initialInView: false, + triggerOnce: images.length < 2, + onChange(inView) { + if (totalImageLengthRef.current < 2 || !autoplayRef.current) { + return + } + if (inView) { + autoplayTimerRef.current = setInterval(() => { + if ( + currentIndexRef.current + 1 > totalImageLengthRef.current - 1 && + isForward.current + ) { + isForward.current = false + } + if (currentIndexRef.current - 1 < 0 && !isForward.current) { + isForward.current = true + } + + const index = currentIndexRef.current + (isForward.current ? 1 : -1) + handleScrollTo(index) + }, AUTOPLAY_DURATION) + } else { + autoplayTimerRef.current = clearInterval(autoplayTimerRef.current) + } + }, + }) + + useEffect(() => { + return () => { + clearInterval(autoplayTimerRef.current) + } + }, []) + + return ( +
+
+ {images.map((image) => { + const info = imageMeta.get(image.url) + const maxWidth = containerWidth - IMAGE_CONTAINER_MARGIN_INSET + const { height, width } = calculateDimensions( + info?.width || 0, + info?.height || 0, + { + width: maxWidth, + + height: 600, + }, + ) + const alt = image.name + const title = image.footnote + const imageCaption = + title || + (['!', '¡'].some((ch) => ch == alt?.[0]) ? alt?.slice(1) : '') || + '' + return ( +
+ +
+ ) + })} +
+ +
+ {Array.from({ + length: images.length, + }).map((_, i) => { + return ( +
+ ) + })} +
+
+ ) +} diff --git a/packages/kami-design/components/Markdown/index.module.css b/packages/kami-design/components/Markdown/index.module.css new file mode 100644 index 000000000..57f65dc6d --- /dev/null +++ b/packages/kami-design/components/Markdown/index.module.css @@ -0,0 +1,47 @@ +.md { + &.code-fully pre > code { + max-height: unset !important; + } + + summary { + list-style: none; + + &:hover { + opacity: 0.8; + } + } + + summary::marker { + display: none; + } + + details summary::before { + content: '+ '; + } + + details[open] summary::before { + content: '- '; + } + + details[open] summary::before, + details summary::before { + font-weight: 800; + font-family: var(--mono-font); + } + + sub span, + sup span { + border: 0 !important; + } + + sub, + sup { + & > a { + @apply inline-block; + } + + & > a::first-letter { + @apply hidden; + } + } +} diff --git a/packages/kami-design/components/Markdown/index.tsx b/packages/kami-design/components/Markdown/index.tsx new file mode 100644 index 000000000..8b9b40b13 --- /dev/null +++ b/packages/kami-design/components/Markdown/index.tsx @@ -0,0 +1,264 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { clsx } from 'clsx' +import range from 'lodash-es/range' +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { compiler, sanitizeUrl } from 'markdown-to-jsx' +import type { FC } from 'react' +import React, { + Fragment, + memo, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { isDev } from '~/utils/env' +import { springScrollToElement } from '~/utils/spring' + +import { CodeBlock } from '../CodeBlock' +import styles from './index.module.css' +import { CommentAtRule } from './parsers/comment-at' +import { ContainerRule } from './parsers/container' +import { InsertRule } from './parsers/ins' +import { KateXRule } from './parsers/katex' +import { MarkRule } from './parsers/mark' +import { MentionRule } from './parsers/mention' +import { SpoilderRule } from './parsers/spoiler' +import { + MHeading, + MImage, + MParagraph, + MTableBody, + MTableHead, + MTableRow, +} from './renderers' +import { MDetails } from './renderers/collapse' +import { MFootNote } from './renderers/footnotes' + +interface MdProps { + value?: string + toc?: boolean + + style?: React.CSSProperties + readonly renderers?: { [key: string]: Partial } + wrapperProps?: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > + codeBlockFully?: boolean + className?: string + HighLighter: React.ComponentType<{ + content: string + lang: string | undefined + }> +} + +export const Markdown: FC = memo((props) => { + const { + value, + renderers, + style, + wrapperProps = {}, + codeBlockFully = false, + className, + overrides, + extendsRules, + additionalParserRules, + + ...rest + } = props + + const [headings, setHeadings] = useState([]) + + const ref = useRef(null) + useEffect(() => { + if (!ref.current) { + return + } + + const $headings = ref.current.querySelectorAll( + range(1, 6) + .map((i) => `h${i}`) + .join(','), + ) as NodeListOf + + setHeadings(Array.from($headings)) + + return () => { + setHeadings([]) + } + }, [value, props.children]) + + const node = useMemo(() => { + if (!value && typeof props.children != 'string') return null + + const Heading = MHeading() + + const mdElement = compiler(`${value || props.children}`, { + wrapper: null, + // @ts-ignore + overrides: { + p: MParagraph, + img: MImage, + thead: MTableHead, + tr: MTableRow, + tbody: MTableBody, + // FIXME: footer tag in raw html will renders not as expected, but footer tag in this markdown lib will wrapper as linkReferer footnotes + footer: MFootNote, + details: MDetails, + + // for custom react component + // LinkCard, + ...overrides, + }, + + extendsRules: { + link: { + // @ts-ignore + react(node, output, state) { + const { target, title } = node + return null + // + // {output(node.content, state!)} + // + }, + }, + heading: { + react(node, output, state) { + return ( + + {output(node.content, state!)} + + ) + }, + }, + + footnoteReference: { + react(node, output, state) { + const { footnoteMap, target, content } = node + const footnote = footnoteMap.get(content) + const linkCardId = (() => { + try { + const thisUrl = new URL(footnote?.footnote?.replace(': ', '')) + const isCurrentHost = + thisUrl.hostname === window.location.hostname + + if (!isCurrentHost && !isDev) { + return undefined + } + const pathname = thisUrl.pathname + return pathname.slice(1) + } catch { + return undefined + } + })() + + return ( + + { + e.preventDefault() + + springScrollToElement( + document.getElementById(content)!, + undefined, + -window.innerHeight / 2, + ) + }} + > + ^{content} + + + ) + }, + }, + codeBlock: { + react(node, output, state) { + return ( + + ) + }, + }, + gfmTask: { + react(node, _, state) { + return ( + + ) + }, + }, + + list: { + react(node, output, state) { + const Tag = node.ordered ? 'ol' : 'ul' + + return ( + + {node.items.map((item, i) => { + let className = '' + if (item[0]?.type == 'gfmTask') { + className = 'list-none flex items-center' + } + + return ( +
  • + {output(item, state!)} +
  • + ) + })} +
    + ) + }, + }, + + ...extendsRules, + ...renderers, + }, + additionalParserRules: { + spoilder: SpoilderRule, + mention: MentionRule, + commentAt: CommentAtRule, + mark: MarkRule, + ins: InsertRule, + kateX: KateXRule, + container: ContainerRule, + ...additionalParserRules, + }, + ...rest, + }) + + return mdElement + }, [ + value, + props.children, + overrides, + extendsRules, + renderers, + additionalParserRules, + rest, + ]) + + return ( +
    + {className ?
    {node}
    : node} +
    + ) +}) diff --git a/packages/kami-design/components/Markdown/parsers/comment-at.tsx b/packages/kami-design/components/Markdown/parsers/comment-at.tsx new file mode 100644 index 000000000..23b179b6f --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/comment-at.tsx @@ -0,0 +1,23 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import { Fragment } from 'react' + +// @ +export const CommentAtRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex(/^@(\w+)\s/), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, _, state) { + const { content } = node + + if (!content || !content[0]?.content) { + return + } + + return @{content[0]?.content} + }, +} diff --git a/packages/kami-design/components/Markdown/parsers/container.tsx b/packages/kami-design/components/Markdown/parsers/container.tsx new file mode 100644 index 000000000..db7f83915 --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/container.tsx @@ -0,0 +1,65 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { Priority, blockRegex } from 'markdown-to-jsx' + +import { Banner } from '../../Banner' +import { Gallery } from '../components/gallery' +import { pickImagesFromMarkdown } from '../utils/image' + +const shouldCatchContainerName = ['gallery', 'banner', 'carousel'].join('|') +export const ContainerRule: MarkdownToJSX.Rule = { + match: blockRegex( + new RegExp( + `^\\s*::: *(?(${shouldCatchContainerName})) *({(?(.*?))})? *\n(?[\\s\\S]+?)\\s*::: *(?:\n *)+\n?`, + ), + ), + order: Priority.MED, + parse(capture) { + const { groups } = capture + return { + ...groups, + } + }, + // @ts-ignore + react(node, _, state) { + const { name, content, params } = node + + switch (name) { + case 'carousel': + case 'gallery': { + return ( + + ) + } + case 'banner': { + if (!params) { + break + } + + return ( + + ) + } + } + + return ( +
    +

    {content}

    +
    + ) + }, +} + +/** + * gallery container + * + * ::: gallery + * ![name](url) + * ![name](url) + * ![name](url) + * ::: + */ diff --git a/packages/kami-design/components/Markdown/parsers/ins.tsx b/packages/kami-design/components/Markdown/parsers/ins.tsx new file mode 100644 index 000000000..22c0d1bd2 --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/ins.tsx @@ -0,0 +1,18 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' + +// ++Insert++ +export const InsertRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\+\+((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\+\+/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return {output(node.content, state!)} + }, +} diff --git a/packages/kami-design/components/Markdown/parsers/katex.tsx b/packages/kami-design/components/Markdown/parsers/katex.tsx new file mode 100644 index 000000000..327bc14e0 --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/katex.tsx @@ -0,0 +1,48 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import type { FC } from 'react' +import { useInsertionEffect, useState } from 'react' + +import { loadScript, loadStyleSheet } from '~/utils/load-script' + +// $ c = \pm\sqrt{a^2 + b^2} $ +export const KateXRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\$\s{1,}((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\s{1,}\$/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, _, state?) { + try { + const str = node.content.map((item) => item.content).join('') + + return {str} + } catch { + return null as any + } + }, +} + +const LateX: FC<{ children: string }> = (props) => { + const { children } = props + + const [html, setHtml] = useState('') + + useInsertionEffect(() => { + loadStyleSheet( + 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/KaTeX/0.15.2/katex.min.css', + ) + loadScript( + 'https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/KaTeX/0.15.2/katex.min.js', + ).then(() => { + const html = window.katex.renderToString(children) + setHtml(html) + }) + }, []) + + return +} diff --git a/packages/kami-design/components/Markdown/parsers/mark.tsx b/packages/kami-design/components/Markdown/parsers/mark.tsx new file mode 100644 index 000000000..8698e183f --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/mark.tsx @@ -0,0 +1,23 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' + +// ==Mark== +export const MarkRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex(/^==((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)==/), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return ( + + {output(node.content, state!)} + + ) + }, +} diff --git a/packages/kami-design/components/Markdown/parsers/mention.tsx b/packages/kami-design/components/Markdown/parsers/mention.tsx new file mode 100644 index 000000000..584e1b294 --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/mention.tsx @@ -0,0 +1,69 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { Priority, simpleInlineRegex } from 'markdown-to-jsx' + +import { + CodiconGithubInverted, + IcBaselineTelegram, + MdiTwitter, +} from '../../Icons/menu-icon' + +const prefixToIconMap = { + GH: , + TW: , + TG: , +} + +const prefixToUrlMap = { + GH: 'https://github.com/', + TW: 'https://twitter.com/', + TG: 'https://t.me/', +} + +// {GH@Innei} {TW@Innei} {TG@Innei} +export const MentionRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\{((?(GH)|(TW)|(TG))@(?\w+\b))\}\s?(?!\[.*?\])/, + ), + order: Priority.MIN, + parse(capture) { + const { groups } = capture + + if (!groups) { + return {} + } + return { + content: { prefix: groups.prefix, name: groups.name }, + type: 'mention', + } + }, + react(result, _, state) { + const { content } = result + if (!content) { + return null as any + } + + const { prefix, name } = content + if (!name) { + return null as any + } + + const Icon = prefixToIconMap[prefix] + const urlPrefix = prefixToUrlMap[prefix] + + return ( +
    + {Icon} + + {name} + +
    + ) + }, +} diff --git a/packages/kami-design/components/Markdown/parsers/spoiler.tsx b/packages/kami-design/components/Markdown/parsers/spoiler.tsx new file mode 100644 index 000000000..5f122a519 --- /dev/null +++ b/packages/kami-design/components/Markdown/parsers/spoiler.tsx @@ -0,0 +1,22 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' + +// ||Spoilder|| +export const SpoilderRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\|\|((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\|\|/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return ( + + {output(node.content, state!)} + + ) + }, +} diff --git a/packages/kami-design/components/Markdown/renderers/collapse.module.css b/packages/kami-design/components/Markdown/renderers/collapse.module.css new file mode 100644 index 000000000..62341f4a4 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/collapse.module.css @@ -0,0 +1,15 @@ +.collapse { + @apply my-2; + + :global(.ReactCollapse--collapse) { + transition: height 200ms; + } + + .title { + @apply mb-2 pl-2 flex items-center; + } + + p { + @apply !m-0; + } +} diff --git a/packages/kami-design/components/Markdown/renderers/collapse.tsx b/packages/kami-design/components/Markdown/renderers/collapse.tsx new file mode 100644 index 000000000..290655bd8 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/collapse.tsx @@ -0,0 +1,44 @@ +import clsx from 'clsx' +import type { FC, ReactNode } from 'react' +import React, { useState } from 'react' +import { Collapse } from 'react-collapse' + +import { IcRoundKeyboardDoubleArrowRight } from '../../Icons/arrow' +import styles from './collapse.module.css' + +export const MDetails: FC<{ children: ReactNode[] }> = (props) => { + const [open, setOpen] = useState(false) + + const $head = props.children[0] + + return ( +
    +
    { + setOpen((o) => !o) + }} + > + + + + {$head} +
    + +
    + {props.children.slice(1)} +
    +
    +
    + ) +} diff --git a/packages/kami-design/components/Markdown/renderers/footnotes.tsx b/packages/kami-design/components/Markdown/renderers/footnotes.tsx new file mode 100644 index 000000000..ffdd00c49 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/footnotes.tsx @@ -0,0 +1,12 @@ +import type { FC } from 'react' + +import { Divider } from '../../Divider' + +export const MFootNote: FC = (props) => { + return ( +
    + + {props.children} +
    + ) +} diff --git a/packages/kami-design/components/Markdown/renderers/heading.tsx b/packages/kami-design/components/Markdown/renderers/heading.tsx new file mode 100644 index 000000000..111aa7c3a --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/heading.tsx @@ -0,0 +1,70 @@ +import type { DOMAttributes, FC } from 'react' +import React, { + Fragment, + createElement, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useInView } from 'react-intersection-observer' + +import { CustomEventTypes } from '~/types/events' +import { eventBus } from '~/utils/event-emitter' + +interface HeadingProps { + id: string + className?: string + children: React.ReactNode + level: number +} +export const MHeading: () => FC = () => { + let index = 0 + + const RenderHeading = (props: HeadingProps) => { + const currentIndex = useMemo(() => index++, []) + // TODO nested children heading + + const [id, setId] = useState('') + + useEffect(() => { + if (!$titleRef.current) { + return + } + + setId($titleRef.current.textContent || '') + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const { ref } = useInView({ + rootMargin: '-33% 0% -33% 0%', + onChange(inView) { + if (inView) { + eventBus.emit(CustomEventTypes.TOC, currentIndex) + } + }, + }) + + const $titleRef = useRef(null) + + return ( + + {createElement, HTMLHeadingElement>( + `h${props.level}`, + { + id, + ref: $titleRef, + 'data-index': currentIndex, + } as any, + + {props.children} + + , + )} + + ) + } + + return RenderHeading +} diff --git a/packages/kami-design/components/Markdown/renderers/image.tsx b/packages/kami-design/components/Markdown/renderers/image.tsx new file mode 100644 index 000000000..6ec2bf4cb --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/image.tsx @@ -0,0 +1,139 @@ +import { sanitizeUrl } from 'markdown-to-jsx' +import { reaction } from 'mobx' +import { observer } from 'mobx-react-lite' +import type { FC } from 'react' +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react' + +import type { ImageLazyRef } from '~/components/universal/Image' +import { ImageLazy } from '~/components/universal/Image' +import { useIsClient } from '~/hooks/use-is-client' +import { useStore } from '~/store' +import { calculateDimensions } from '~/utils/images' + +export interface RenderImageProps { + height: number + width: number + type: string + accent?: string + src: string +} + +export const ImageSizeMetaContext = createContext( + new Map() as Map, +) + +const getContainerSize = () => { + const $wrap = document.getElementById('write') + if (!$wrap) { + return + } + + return $wrap.getBoundingClientRect().width +} + +/** + * This Component only can render in browser. + */ +const _Image: FC<{ src: string; alt?: string }> = observer(({ src, alt }) => { + const { appUIStore } = useStore() + const imageRef = useRef(null) + useEffect(() => { + const disposer = reaction( + () => appUIStore.viewport.w | appUIStore.viewport.h, + () => { + if (imageRef.current?.status === 'loaded') { + disposer() + + return + } + setMaxWidth(getContainerSize()) + }, + ) + + return () => { + disposer() + } + }, []) + const images = useContext(ImageSizeMetaContext) + + const isPrintMode = appUIStore.mediaType === 'print' + + const [maxWidth, setMaxWidth] = useState(getContainerSize()) + + // 因为有动画开始不能获取到大小 , 直到获取到 container 的大小 + useEffect(() => { + let raf = requestAnimationFrame(function a() { + const size = getContainerSize() + if (!size) { + requestAnimationFrame(a) + } else { + setMaxWidth(size) + } + }) as any + return () => { + raf = cancelAnimationFrame(raf) + } + }, []) + + if (isPrintMode) { + return {alt} + } + + const { accent, height, width } = images.get(src) || { + height: undefined, + width: undefined, + } + + const max = { + width: maxWidth ?? 644, + height: Infinity, + } + + let cal = {} as any + if (width && height) { + cal = calculateDimensions(width, height, max) + } + + return ( + + ) +}) +const style = { padding: '1rem 0' } +export const MImage: FC< + React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement + > +> = (props) => { + const { src, alt, title } = props + const sanitizedUrl = sanitizeUrl(src!) + const isClient = useIsClient() + const imageCaption = + title || + (['!', '¡'].some((ch) => ch == alt?.[0]) ? alt?.slice(1) : '') || + '' + + return !isClient ? ( + {imageCaption} + ) : ( + <_Image src={sanitizedUrl!} alt={imageCaption} /> + ) +} diff --git a/packages/kami-design/components/Markdown/renderers/index.module.css b/packages/kami-design/components/Markdown/renderers/index.module.css new file mode 100644 index 000000000..a423420a4 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/index.module.css @@ -0,0 +1,28 @@ +.link { + display: inline-block; + position: relative; + + a { + cursor: alias; + overflow: hidden; + position: relative; + color: var(--primary); + } + + a::after { + content: ''; + position: absolute; + bottom: -1.9px; + height: 1px; + background-color: currentColor; + width: 0; + transform: translateX(-50%); + left: 50%; + text-align: center; + transition: width 0.5s ease-in-out; + } + + a:hover::after { + width: 100%; + } +} diff --git a/packages/kami-design/components/Markdown/renderers/index.ts b/packages/kami-design/components/Markdown/renderers/index.ts new file mode 100644 index 000000000..ae747f8f3 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/index.ts @@ -0,0 +1,4 @@ +export * from './heading' +export * from './image' +export * from './table' +export * from './paragraph' diff --git a/packages/kami-design/components/Markdown/renderers/paragraph.tsx b/packages/kami-design/components/Markdown/renderers/paragraph.tsx new file mode 100644 index 000000000..8bc69bf04 --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/paragraph.tsx @@ -0,0 +1,14 @@ +import clsx from 'clsx' +import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react' + +export const MParagraph: FC< + DetailedHTMLProps, HTMLParagraphElement> +> = (props) => { + const { children, ...other } = props + const { className, ...rest } = other + return ( +

    + {children} +

    + ) +} diff --git a/packages/kami-design/components/Markdown/renderers/table.tsx b/packages/kami-design/components/Markdown/renderers/table.tsx new file mode 100644 index 000000000..9c3b2947d --- /dev/null +++ b/packages/kami-design/components/Markdown/renderers/table.tsx @@ -0,0 +1,16 @@ +import type { FC } from 'react' + +export const MTableHead: FC = (props) => { + const { children, ...rest } = props + return {children} +} + +export const MTableRow: FC = (props) => { + const { children, ...rest } = props + return {children} +} + +export const MTableBody: FC = (props) => { + const { children, ...rest } = props + return {children} +} diff --git a/packages/kami-design/components/Markdown/test-text.md b/packages/kami-design/components/Markdown/test-text.md new file mode 100644 index 000000000..92356c8c6 --- /dev/null +++ b/packages/kami-design/components/Markdown/test-text.md @@ -0,0 +1,354 @@ +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +↑ 引用 + +# Test 文本 + +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +# 一级 + +我与父亲不相见已二年余了,我最不能忘记的是他的背影。 + +那年冬天,祖母死了,父亲的差使也交卸了,正是祸不单行的日子。我从北京到徐州,打算跟着父亲奔丧回家。到徐州见着父亲,看见满院狼藉的东西,又想起祖母,不禁簌簌地流下眼泪。父亲说:“事已如此,不必难过,好在天无绝人之路!” + +## 二级 + +回家变卖典质,父亲还了亏空;又借钱办了丧事。这些日子,家中光景很是惨澹,一半为了丧事,一半为了父亲赋闲。丧事完毕,父亲要到南京谋事,我也要回北京念书,我们便同行。 + +到南京时,有朋友约去游逛,勾留了一日;第二日上午便须渡江到浦口,下午上车北去。父亲因为事忙,本已说定不送我,叫旅馆里一个熟识的茶房陪我同去。他再三嘱咐茶房,甚是仔细。但他终于不放心,怕茶房不妥帖;颇踌躇了一会。其实我那年已二十岁,北京已来往过两三次,是没有什么要紧的了。他踌躇了一会,终于决定还是自己送我去。我再三劝他不必去;他只说:“不要紧,他们去不好!” + +### 三级 + +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +#### 四级 + +\`code: \` + +```tsx + +``` + +|| 你知道的太多了 || spoiler || 你知道的太多了 || + +[链接](https://baidu.com) + +![!图片描述](https://gitee.com/xun7788/my-imagination/raw/master/uPic/1615516941397.jpg) + +↑ 图片描述 + +**加粗: 歌词** + +~~dele~~ 删除 + +```mermaid +flowchart TD + 1([手动打 tag 发布一个 release]) --> + 2([CI 监视 release 的发布 开始构建和发布]) --> + 3([云构建打包产物 zip 发布到 GitHub Release]) -- SSH 连接到服务器--> + 4([执行部署脚本]) --> + 5([下载构建产物解压]) --> + 6([直接运行或使用 PM2 托管]) +``` + +GH Mention: (GH@Innei) + +> _夕暮れ_ +> +> 作詞:甲本ヒロト +> 作曲:甲本ヒロト +> +> はっきりさせなくてもいい +> あやふやなまんまでいい +> 僕達はなんなとなく幸せになるんだ +> +> 何年たってもいい 遠く離れてもいい +> 独りぼっちじゃないぜウィンクするぜ +> +> 夕暮れが僕のドアをノックする頃に +> あなたを「ギュッ」と抱きたくなってる +> 幻なんかじゃない 人生は夢じゃない +> 僕達ははっきりと生きてるんだ +> 夕焼け空は赤い 炎のように赤い +> この星の半分を真っ赤に染めた +> それよりももっと赤い血が +> 体中を流れてるんだぜ +> 夕暮れが僕のドアをノックする頃に +> +> あなたを「ギュッ」と抱きたくなってる +> 幻なんかじゃない 人生は夢じゃない +> 僕達ははっきりと生きてるんだ +> +> 夕焼け空は赤い 炎のように赤い +> この星の半分を真っ赤に染めた +> +> それよりももっと赤い血が +> 体中を流れてるんだぜ +> 体中を流れてるんだぜ +> 体中を流れてるんだぜ + + + +--- +__Advertisement :)__ + +- __[pica](https://nodeca.github.io/pica/demo/)__ - high quality and fast image + resize in browser. +- __[babelfish](https://github.com/nodeca/babelfish/)__ - developer friendly + i18n with plurals support and easy syntax. + +You will like those projects! + +--- + +# h1 Heading 8-) +## h2 Heading +### h3 Heading +#### h4 Heading +##### h5 Heading +###### h6 Heading + + +## Horizontal Rules + +___ + +--- + +*** + + +## Typographic replacements + +Enable typographer option to see result. + +(c) (C) (r) (R) (tm) (TM) (p) (P) +- + +test.. test... test..... test?..... test!.... + +!!!!!! ???? ,, -- --- + +"Smartypants, double quotes" and 'single quotes' + + +## Emphasis + +**This is bold text** + +__This is bold text__ + +*This is italic text* + +_This is italic text_ + +~~Strikethrough~~ + + +## Blockquotes + + +> Blockquotes can also be nested... +>> ...by using additional greater-than signs right next to each other... +> > > ...or with spaces between arrows. + + +## Lists + +Unordered + ++ Create a list by starting a line with `+`, `-`, or `*` ++ Sub-lists are made by indenting 2 spaces: + - Marker character change forces new list start: + * Ac tristique libero volutpat at + + Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit ++ Very easy! + +Ordered + +1. Lorem ipsum dolor sit amet +2. Consectetur adipiscing elit +3. Integer molestie lorem at massa + + +1. You can use sequential numbers... +1. ...or keep all the numbers as `1.` + +Start numbering with offset: + +57. foo +1. bar + + +## Code + +Inline `code` + +Indented code + + // Some comments + line 1 of code + line 2 of code + line 3 of code + + +Block code "fences" + +``` +Sample text here... +``` + +Syntax highlighting + +``` js +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +``` + +## Tables + +| Option | Description | +| ------ | ----------- | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +Right aligned columns + +| Option | Description | +| ------:| -----------:| +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + + +## Links + +[link text](http://dev.nodeca.com) + +[link with title](http://nodeca.github.io/pica/demo/ "title text!") + +Autoconverted link https://github.com/nodeca/pica (enable linkify to see) + + +## Images + +![Minion](https://octodex.github.com/images/minion.png) +![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") + +Like links, Images also have a footnote style syntax + +![Alt text][id] + +With a reference later in the document defining the URL location: + +[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" + + +## Plugins + +The killer feature of `markdown-it` is very effective support of +[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin). + + +### [Emojies](https://github.com/markdown-it/markdown-it-emoji) + +> Classic markup: :wink: :crush: :cry: :tear: :laughing: :yum: +> +> Shortcuts (emoticons): :-) :-( 8-) ;) + +see [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji. + + +### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup) + +- 19^th^ +- H~2~O + + +### [\](https://github.com/markdown-it/markdown-it-ins) + +++Inserted text++ + + +### [\](https://github.com/markdown-it/markdown-it-mark) + +==Marked text== + + +### [Footnotes](https://github.com/markdown-it/markdown-it-footnote) + +Footnote 1 link[^first]. + +Footnote 2 link[^second]. + +Inline footnote^[Text of inline footnote] definition. + +Duplicated footnote reference[^second]. + +[^first]: Footnote **can have markup** + + and multiple paragraphs. + +[^second]: Footnote text. + + +### [Definition lists](https://github.com/markdown-it/markdown-it-deflist) + +Term 1 + +: Definition 1 +with lazy continuation. + +Term 2 with *inline markup* + +: Definition 2 + + { some code, part of Definition 2 } + + Third paragraph of definition 2. + +_Compact style:_ + +Term 1 + ~ Definition 1 + +Term 2 + ~ Definition 2a + ~ Definition 2b + + +### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr) + +This is HTML abbreviation example. + +It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on. + +*[HTML]: Hyper Text Markup Language + +### [Custom containers](https://github.com/markdown-it/markdown-it-container) + +::: warning +*here be dragons* +::: diff --git a/packages/kami-design/components/Markdown/utils/image.ts b/packages/kami-design/components/Markdown/utils/image.ts new file mode 100644 index 000000000..6c85c032a --- /dev/null +++ b/packages/kami-design/components/Markdown/utils/image.ts @@ -0,0 +1,25 @@ +export interface MImageType { + name?: string + url: string + footnote?: string +} +export const pickImagesFromMarkdown = (md: string) => { + const regexp = + /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*?(?:\s+['"]([\s\S]*?)['"])?\s*\)/ + + const lines = md.split('\n') + + const res: MImageType[] = [] + + for (const line of lines) { + const match = regexp.exec(line) + if (!match) { + continue + } + + const [, name, url, footnote] = match + res.push({ name, url, footnote }) + } + + return res +} diff --git a/packages/kami-design/components/Mermaid/index.tsx b/packages/kami-design/components/Mermaid/index.tsx new file mode 100644 index 000000000..6bbd9ed10 --- /dev/null +++ b/packages/kami-design/components/Mermaid/index.tsx @@ -0,0 +1,21 @@ +import type { FC } from 'react' +import { useInsertionEffect } from 'react' + +import { loadScript } from '~/utils/load-script' + +export const Mermaid: FC<{ content: string }> = (props) => { + useInsertionEffect(() => { + loadScript( + 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/mermaid/8.9.0/mermaid.min.js', + ).then(() => { + if (window.mermaid) { + window.mermaid.initialize({ + theme: 'default', + startOnLoad: false, + }) + window.mermaid.init(undefined, '.mermaid') + } + }) + }, []) + return
    {props.content}
    +} diff --git a/packages/kami-design/components/Portal/index.tsx b/packages/kami-design/components/Portal/index.tsx new file mode 100644 index 000000000..c8fb0d50d --- /dev/null +++ b/packages/kami-design/components/Portal/index.tsx @@ -0,0 +1,14 @@ +import type { FC } from 'react' +import { memo } from 'react' +import { createPortal } from 'react-dom' + +import { useIsClient } from '~/hooks/use-is-client' + +export const RootPortal: FC = memo((props) => { + const isClient = useIsClient() + if (!isClient) { + return null + } + + return createPortal(props.children, document.body) +}) diff --git a/packages/kami-design/env.d.ts b/packages/kami-design/env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/packages/kami-design/env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/kami-design/index.ts b/packages/kami-design/index.ts new file mode 100644 index 000000000..c9359e93d --- /dev/null +++ b/packages/kami-design/index.ts @@ -0,0 +1,3 @@ +import 'virtual:windi.css' + +export { Markdown } from './components/Markdown' diff --git a/packages/kami-design/package.json b/packages/kami-design/package.json new file mode 100644 index 000000000..250a8c726 --- /dev/null +++ b/packages/kami-design/package.json @@ -0,0 +1,79 @@ +{ + "name": "@mx-space/kami-design", + "version": "0.0.0", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "types/index.d.ts", + "type": "module", + "exports": { + ".": { + "type": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./src": { + "import": "./src" + }, + "./src/*": { + "import": "./src/*" + } + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "dependencies": { + "markdown-to-jsx": "npm:@innei/markdown-to-jsx@7.1.3-15", + "@floating-ui/react-dom": "1.0.1", + "@formkit/auto-animate": "1.0.0-beta.5", + "@mx-space/api-client": "1.0.0-beta.3", + "axios": "0.27.2", + "clsx": "1.2.1", + "css-spring": "4.1.0", + "dayjs": "1.11.6", + "devtools-detector": "2.0.14", + "js-cookie": "3.0.1", + "lodash-es": "4.17.21", + "markdown-escape": "2.0.0", + "medium-zoom": "1.0.8", + "mobx": "6.7.0", + "mobx-react-lite": "3.4.0", + "next": "13.0.6", + "next-seo": "5.14.1", + "next-suspense": "0.1.3", + "randomcolor": "0.6.2", + "react": "18.2.0", + "react-collapse": "5.1.1", + "react-countup": "6.4.0", + "react-dom": "18.2.0", + "react-intersection-observer": "9.4.1", + "react-masonry-css": "1.0.16", + "react-message-popup": "1.0.0", + "react-shortcut-guide": "1.0.0-alpha.0", + "react-smooth-number-counter": "https://github.com/Innei/react-smooth-number-counter.git", + "react-toastify": "9.1.1", + "react-transition-group": "4.4.5", + "react-use": "17.4.0", + "remove-markdown": "0.5.0", + "socket.io-client": "4.5.4", + "validator": "13.7.0" + }, + "devDependencies": { + "@babel/plugin-transform-react-jsx": "7.19.0", + "@babel/plugin-transform-typescript": "7.20.2", + "@babel/preset-react": "7.18.6", + "@rollup/plugin-babel": "6.0.3", + "@rollup/plugin-commonjs": "22.0.2", + "@rollup/plugin-node-resolve": "14.0.1", + "@rollup/plugin-typescript": "8.5.0", + "@types/react": "17.0.52", + "esbuild": "0.15.17", + "rollup": "2.79.0", + "rollup-plugin-esbuild": "5.0.0", + "rollup-plugin-peer-deps-external": "2.2.4", + "rollup-plugin-postcss": "4.0.2", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-windicss": "1.8.8", + "typescript": "4.9.3" + } +} diff --git a/packages/kami-design/react-next.d.ts b/packages/kami-design/react-next.d.ts new file mode 100644 index 000000000..634d3ad5c --- /dev/null +++ b/packages/kami-design/react-next.d.ts @@ -0,0 +1,85 @@ +// copy from @types/react@18 +// why not use @types/react@18 directly, buz it is fucking shit break all change. +import 'react' + +declare module 'react' { + // must be synchronous + export type TransitionFunction = () => VoidOrUndefinedOnly + // strange definition to allow vscode to show documentation on the invocation + export interface TransitionStartFunction { + /** + * State updates caused inside the callback are allowed to be deferred. + * + * **If some state update causes a component to suspend, that state update should be wrapped in a transition.** + * + * @param callback A _synchronous_ function which causes state updates that can be deferred. + */ + (callback: TransitionFunction): void + } + + /** + * Returns a deferred version of the value that may “lag behind” it for at most `timeoutMs`. + * + * This is commonly used to keep the interface responsive when you have something that renders immediately + * based on user input and something that needs to wait for a data fetch. + * + * A good example of this is a text input. + * + * @param value The value that is going to be deferred + * + * @see https://reactjs.org/docs/concurrent-mode-reference.html#usedeferredvalue + */ + export function useDeferredValue(value: T): T + + /** + * Allows components to avoid undesirable loading states by waiting for content to load + * before transitioning to the next screen. It also allows components to defer slower, + * data fetching updates until subsequent renders so that more crucial updates can be + * rendered immediately. + * + * The `useTransition` hook returns two values in an array. + * + * The first is a boolean, React’s way of informing us whether we’re waiting for the transition to finish. + * The second is a function that takes a callback. We can use it to tell React which state we want to defer. + * + * **If some state update causes a component to suspend, that state update should be wrapped in a transition.** + * + * @param config An optional object with `timeoutMs` + * + * @see https://reactjs.org/docs/concurrent-mode-reference.html#usetransition + */ + export function useTransition(): [boolean, TransitionStartFunction] + + /** + * Similar to `useTransition` but allows uses where hooks are not available. + * + * @param callback A _synchronous_ function which causes state updates that can be deferred. + */ + export function startTransition(scope: TransitionFunction): void + + export function useId(): string + + /** + * @param effect Imperative function that can return a cleanup function + * @param deps If present, effect will only activate if the values in the list change. + * + * @see https://github.com/facebook/react/pull/21913 + */ + export function useInsertionEffect( + effect: EffectCallback, + deps?: DependencyList, + ): void + + /** + * @param subscribe + * @param getSnapshot + * + * @see https://github.com/reactwg/react-18/discussions/86 + */ + // keep in sync with `useSyncExternalStore` from `use-sync-external-store` + export function useSyncExternalStore( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot?: () => Snapshot, + ): Snapshot +} diff --git a/packages/kami-design/rollup.config.js b/packages/kami-design/rollup.config.js new file mode 100644 index 000000000..727e55a4b --- /dev/null +++ b/packages/kami-design/rollup.config.js @@ -0,0 +1,112 @@ +// @ts-check + +import esbuild from 'rollup-plugin-esbuild' +import peerDepsExternal from 'rollup-plugin-peer-deps-external' +import css from 'rollup-plugin-postcss' +import { terser } from 'rollup-plugin-terser' +import WindiCSS from 'rollup-plugin-windicss' + +import commonjs from '@rollup/plugin-commonjs' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' + +const packageJson = require('./package.json') + +const globals = { + // @ts-ignore + ...(packageJson?.dependencies || {}), +} + +const dir = 'dist' + +/** + * @type {import('rollup').RollupOptions[]} + */ +const config = [ + { + input: './index.ts', + // ignore lib + external: [ + 'react', + 'react-dom', + 'lodash', + 'lodash-es', + ...Object.keys(globals), + ], + + output: [ + { + file: `${dir}/index.cjs`, + format: 'cjs', + sourcemap: true, + }, + { + file: `${dir}/index.min.cjs`, + format: 'cjs', + sourcemap: true, + plugins: [terser()], + }, + { + file: `${dir}/index.js`, + format: 'esm', + sourcemap: true, + }, + { + file: `${dir}/index.min.js`, + format: 'esm', + sourcemap: true, + plugins: [terser()], + }, + ], + plugins: [ + ...WindiCSS(), + nodeResolve(), + commonjs({ include: 'node_modules/**' }), + typescript({ + tsconfig: './tsconfig.json', + declaration: false, + }), + css({ + // extract: true, + }), + + // @ts-ignore + peerDepsExternal(), + // babel({ + // babelHelpers: 'bundled', + // extensions: ['.ts', '.tsx', '.jsx', '.js'], + // presets: ['@babel/preset-react'], + // plugins: [ + // '@babel/plugin-transform-typescript', + // '@babel/plugin-transform-react-jsx', + // ], + // }), + esbuild({ + // All options are optional + include: /\.[jt]sx?$/, // default, inferred from `loaders` option + exclude: /node_modules/, // default + sourceMap: true, // default + minify: process.env.NODE_ENV === 'production', + target: 'es2017', // default, or 'es20XX', 'esnext' + jsx: 'transform', // default, or 'preserve' + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + // Like @rollup/plugin-replace + + tsconfig: 'tsconfig.json', // default + // Add extra loaders + loaders: { + // Add .json files support + // require @rollup/plugin-commonjs + '.json': 'json', + // Enable JSX in .js files too + '.js': 'jsx', + }, + }), + ], + + treeshake: true, + }, +] + +export default config diff --git a/packages/kami-design/tsconfig.json b/packages/kami-design/tsconfig.json new file mode 100644 index 000000000..34d53c93f --- /dev/null +++ b/packages/kami-design/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "experimentalDecorators": true, + "strict": true, + "target": "ES2020", + "module": "ES2020", + "jsx": "preserve", + "allowJs": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true, + "skipLibCheck": true, + "noImplicitAny": false, + "useDefineForClassFields": true, + "lib": [ + "dom" + ], + "outDir": "dist", + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "baseUrl": ".", + "paths": { + "~": [ + "../../src" + ], + "~/*": [ + "../../src/*" + ] + } + }, + "exclude": [ + ".next", + "server/**/*.*" + ], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ] +} \ No newline at end of file diff --git a/packages/kami-design/types.d.ts b/packages/kami-design/types.d.ts new file mode 100644 index 000000000..03b78a953 --- /dev/null +++ b/packages/kami-design/types.d.ts @@ -0,0 +1,7 @@ +declare global { + export interface Window { + [key: string]: any + } +} + +export {}