Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"braillify": "workspace:*",
"clsx": "^2.1.1",
"katex": "^0.17.0",
"mathlive": "^0.110.0",
"next": "16.2.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
50 changes: 50 additions & 0 deletions apps/landing/src/app/DemoChrome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Box, Flex, Image, Text } from '@devup-ui/react'

/** 데모 섹션 상단의 손가락 아이콘 + 안내 문구. 한글·수학 데모가 공유한다. */
export function DemoHeading({ children }: { children: string }) {
return (
<Flex
alignItems="flex-start"
gap={['10px', null, null, '20px']}
justifyContent={['center', null, null, 'flex-start']}
>
<Box
aria-hidden="true"
bg="$text"
flexShrink={0}
h={['20px', null, null, '32px']}
maskImage="url(/images/home/finger-point.svg)"
maskPosition="center"
maskRepeat="no-repeat"
maskSize="contain"
w={['17px', null, null, '28px']}
/>
<Text color="$text" pos="relative" top="-2px" typography="mainTextSm">
{children}
</Text>
</Flex>
)
}

/** 입력 박스와 출력 박스 사이의 방향 화살표. 한글·수학 데모가 공유한다. */
export function DemoArrow() {
return (
<Flex aria-hidden="true">
<Image
alt=""
display={['none', null, null, 'block']}
mr="10px"
role="presentation"
src="/images/home/translate-arrow-circle.svg"
w="16px"
/>
<Image
alt=""
role="presentation"
src="/images/home/translate-arrow.svg"
transform={['rotate(0deg)', null, null, 'rotate(-90deg)']}
w={['16px', null, null, '24px']}
/>
</Flex>
)
}
80 changes: 80 additions & 0 deletions apps/landing/src/app/DemoTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client'
import { Box, Flex, Text, VStack } from '@devup-ui/react'
import { useRef, useState } from 'react'

import { MathTrans } from './MathTrans'
import { Trans } from './Trans'

const TABS = [
{ key: 'korean', label: '한글' },
{ key: 'math', label: '수학' },
] as const

type DemoMode = (typeof TABS)[number]['key']

const PANEL_ID = 'demo-panel'
const tabId = (key: DemoMode) => `demo-tab-${key}`

export function DemoTabs() {
const [mode, setMode] = useState<DemoMode>('korean')
const tabRefs = useRef<(HTMLElement | null)[]>([])

const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
e.preventDefault()
const dir = e.key === 'ArrowRight' ? 1 : -1
const next = (index + dir + TABS.length) % TABS.length
setMode(TABS[next].key)
tabRefs.current[next]?.focus()
}

return (
<VStack flex="1" gap={['16px', null, null, '30px']} w="100%">
<Flex
aria-label="점역 데모 종류"
gap="10px"
justifyContent={['center', null, null, 'flex-start']}
role="tablist"
>
{TABS.map(({ key, label }, index) => (
<Text
key={key}
ref={(el: HTMLElement | null) => {
tabRefs.current[index] = el
}}
aria-controls={PANEL_ID}
aria-selected={mode === key}
as="button"
bg={mode === key ? '$text' : 'transparent'}
border="1px solid $text"
borderRadius="9999px"
color={mode === key ? '$background' : '$text'}
cursor="pointer"
id={tabId(key)}
onClick={() => setMode(key)}
onKeyDown={(e) => handleKeyDown(e, index)}
px={['20px', null, null, '28px']}
py={['8px', null, null, '10px']}
role="tab"
tabIndex={mode === key ? 0 : -1}
type="button"
typography="button"
>
{label}
</Text>
))}
</Flex>
<Box
aria-labelledby={tabId(mode)}
display="flex"
flex="1"
flexDirection="column"
id={PANEL_ID}
role="tabpanel"
w="100%"
>
{mode === 'korean' ? <Trans /> : <MathTrans />}
</Box>
</VStack>
)
}
56 changes: 56 additions & 0 deletions apps/landing/src/app/MathTrans.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client'
import { Flex, VStack } from '@devup-ui/react'
import { useEffect, useState } from 'react'

import { DemoArrow, DemoHeading } from './DemoChrome'
import { MathTransInput } from './MathTransInput'
import { TransInput } from './TransInput'

export function MathTrans() {
const [latex, setLatex] = useState('')
const [translateToUnicode, setTranslateToUnicode] = useState<
(input: string) => string
>(() => () => '')
useEffect(() => {
import('braillify').then((mod) => {
setTranslateToUnicode(() => (input: string) => {
try {
return mod.translateToUnicode(input)
} catch (e) {
console.error(e)
return '점역할 수 없는 수식이 포함되어 있습니다.'
}
})
})
}, [])

const braille = latex.trim() ? translateToUnicode(`$${latex}$`) : ''

return (
<VStack flex="1" gap={['16px', null, null, '30px']}>
<DemoHeading>
수식도 점자가 됩니다. 수식 키보드로 입력해 수학 점역을 체험해보세요!
</DemoHeading>
<Flex
alignItems="center"
flexDirection={['column', null, null, 'row']}
gap={['12px', null, null, '30px']}
h={['auto', null, null, '500px']}
w="100%"
>
<MathTransInput
latex={latex}
onLatexChange={setLatex}
placeholder="수식을 입력하면 이곳에 수학 점자가 표시됩니다."
/>
<DemoArrow />
<TransInput
blurPlaceholder={'예: 사분의 삼 → ⠼⠙⠌⠼⠉'}
focusPlaceholder={'예: 사분의 삼 → ⠼⠙⠌⠼⠉'}
readOnly
value={braille}
/>
</Flex>
</VStack>
)
}
169 changes: 169 additions & 0 deletions apps/landing/src/app/MathTransInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use client'

import { Flex, Text } from '@devup-ui/react'
import type { MathfieldElement } from 'mathlive'
import { useEffect, useRef, useState } from 'react'

declare global {
namespace React.JSX {
interface IntrinsicElements {
'math-field': React.DetailedHTMLProps<
React.HTMLAttributes<MathfieldElement>,
MathfieldElement
> & { 'math-virtual-keyboard-policy'?: 'auto' | 'manual' }
}
}
}

/**
* MathLive는 한 글자 인자를 중괄호 없이 직렬화한다 (예: \frac34, \frac{3}4).
* braillify의 LaTeX 파서는 \frac{분자}{분모} 형태만 분수로 인식하므로
* \frac의 두 인자를 항상 중괄호로 감싼 정규형으로 변환한다.
*/
export function normalizeFracBraces(latex: string): string {
let out = ''
let i = 0
while (i < latex.length) {
if (latex.startsWith('\\frac', i) && !/[a-zA-Z]/.test(latex[i + 5] ?? '')) {
const [num, j] = readArg(latex, i + 5)
const [den, k] = readArg(latex, j)
out += `\\frac${num}${den}`
i = k
continue
}
out += latex[i]
i += 1
}
return out
}

/** i 위치부터 LaTeX 인자 하나를 읽어 중괄호로 감싼 형태와 다음 인덱스를 돌려준다. */
function readArg(latex: string, i: number): [string, number] {
while (latex[i] === ' ') i += 1
if (latex[i] === '{') {
let depth = 0
let j = i
do {
if (latex[j] === '{') depth += 1
else if (latex[j] === '}') depth -= 1
j += 1
} while (j < latex.length && depth > 0)
// depth === 0 이면 짝이 맞는 '}' 를 j-1 에서 소비한 것이고,
// depth > 0 이면 닫는 중괄호 없이 끝난 것이라 내용을 j 까지 살린다.
const end = depth === 0 ? j - 1 : j
return [`{${normalizeFracBraces(latex.slice(i + 1, end))}}`, j]
}
if (latex[i] === '\\') {
let j = i + 1
while (j < latex.length && /[a-zA-Z]/.test(latex[j] ?? '')) j += 1
// 제어기호(\, \! 등)는 백슬래시 뒤에 글자가 없으므로 기호 한 글자를 포함시킨다.
if (j === i + 1 && j < latex.length) j += 1
return [`{${latex.slice(i, j)}}`, j]
}
return [`{${latex[i] ?? ''}}`, i + 1]
}

export function MathTransInput({
latex,
onLatexChange,
placeholder,
}: {
latex: string
onLatexChange: (latex: string) => void
placeholder: string
}) {
const [ready, setReady] = useState(false)
const fieldRef = useRef<MathfieldElement>(null)

useEffect(() => {
let cancelled = false
import('mathlive').then(({ MathfieldElement }) => {
if (cancelled) return
MathfieldElement.fontsDirectory = '/mathlive/fonts'
MathfieldElement.soundsDirectory = null
setReady(true)
})
return () => {
cancelled = true
}
}, [])

useEffect(() => {
const field = fieldRef.current
if (!ready || !field) return
const show = () => window.mathVirtualKeyboard.show()
const hide = () => window.mathVirtualKeyboard.hide()
field.addEventListener('focusin', show)
field.addEventListener('focusout', hide)
return () => {
field.removeEventListener('focusin', show)
field.removeEventListener('focusout', hide)
window.mathVirtualKeyboard.hide()
}
}, [ready])

return (
// 바깥 Flex 는 padding 없는 flex 아이템으로, 출력측 TransInput 의 외곽
// Flex(flex=1 h=100% w=100%)와 flex-basis 를 동일하게 맞춰 좌우 박스 너비를
// 같게 한다. 실제 배경/여백은 안쪽 박스가 담당한다.
<Flex flex="1" h="100%" w="100%">
<Flex
bg="$containerBackground"
borderRadius={['16px', null, null, '30px']}
cursor="text"
flexDirection="column"
gap="12px"
h="100%"
minH="25dvh"
onClick={() => fieldRef.current?.focus()}
p={['16px', null, null, '40px']}
w="100%"
>
<Flex flex="1" flexDirection="column" gap="8px">
{ready && (
<math-field
ref={fieldRef}
math-virtual-keyboard-policy="manual"
onInput={(e) =>
onLatexChange(
normalizeFracBraces(
(e.target as MathfieldElement).getValue(
'latex-without-placeholders',
),
),
)
}
style={{
background: 'transparent',
border: 'none',
display: 'block',
fontSize: '28px',
width: '100%',
}}
/>
)}
{!latex && (
<Text
color="$text"
opacity={0.5}
pointerEvents="none"
typography="braille"
whiteSpace="pre-line"
>
{placeholder}
</Text>
)}
</Flex>
<Text
color="$text"
fontFamily="monospace"
minH="1.5em"
opacity={0.7}
wordBreak="break-all"
>
{latex ? `LaTeX: $${latex}$` : 'LaTeX가 자동으로 생성됩니다'}
</Text>
</Flex>
</Flex>
)
}
Loading
Loading