Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
82b5d63
feat: 랜딩에 mathlive 의존성 추가 및 폰트 self-hosting
jessieyeon Jul 22, 2026
990c939
feat: MathLive 수식 입력 래퍼 컴포넌트 MathTransInput 추가
jessieyeon Jul 22, 2026
3af34fd
feat: 홈페이지에 MathLive 기반 수학 점역 데모 섹션 추가
jessieyeon Jul 22, 2026
c4c32c5
fix: MathLive 중괄호 생략 \frac 정규화 및 수학 데모 접근성 속성 보정
jessieyeon Jul 22, 2026
f1ce9b9
docs: 수학 점역 랜딩 데모 구현 플랜 추가
jessieyeon Jul 22, 2026
fa0c527
fix: 수학 데모 마무리 보정 — 언마운트 시 가상 키보드 숨김, placeholder 정리
jessieyeon Jul 22, 2026
1d8bb3c
feat: 수학 데모 안내문을 입력창으로 이동하고 입출력 박스 크기 통일
jessieyeon Jul 22, 2026
8ffa6cf
fix: 수학 입력 박스 높이를 출력 박스와 동일한 동적 규격으로 보정
jessieyeon Jul 22, 2026
3bd1df8
feat: 한글·수학 점역 데모를 탭 토글로 통합
jessieyeon Jul 22, 2026
9e9f713
docs: 랜딩 데모 탭 통합 구현 플랜 추가
jessieyeon Jul 22, 2026
8140e02
fix: 코드리뷰 지적 반영 — 데모 입력 박스 클릭 포커스·파서 보강·레이아웃 중복 제거·탭 접근성·박스 크기 통일
jessieyeon Jul 22, 2026
0f0d99e
refactor: 코드리뷰 반영 — 세로 컨테이너를 VStack으로 정리하고 불필요한 속성 제거
jessieyeon Jul 28, 2026
143aaa8
fix: 코드리뷰 추가 반영 — style을 css로 전환, VStack flexDirection 정리, 플랜 문서 삭제
jessieyeon Jul 30, 2026
0d2ff62
fix: 수학 입력 박스의 w=100%를 flex=1로 대체
jessieyeon Jul 30, 2026
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={[null, 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>
)
}
55 changes: 55 additions & 0 deletions apps/landing/src/app/MathTrans.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client'
import { 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>
<VStack
alignItems="center"
flexDirection={[null, null, null, 'row']}
gap={['12px', null, null, '30px']}
h={['auto', null, null, '500px']}
>
<MathTransInput
latex={latex}
onLatexChange={setLatex}
placeholder="수식을 입력하면 이곳에 수학 점자가 표시됩니다."
/>
<DemoArrow />
<TransInput
blurPlaceholder={'예: 사분의 삼 → ⠼⠙⠌⠼⠉'}
focusPlaceholder={'예: 사분의 삼 → ⠼⠙⠌⠼⠉'}
readOnly
value={braille}
/>
</VStack>
</VStack>
)
}
168 changes: 168 additions & 0 deletions apps/landing/src/app/MathTransInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use client'

import { css, Flex, Text, VStack } 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%">
<VStack
bg="$containerBackground"
borderRadius={['16px', null, null, '30px']}
cursor="text"
flex="1"
gap="12px"
h="100%"
minH="25dvh"
onClick={() => fieldRef.current?.focus()}
p={['16px', null, null, '40px']}
>
<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',
),
),
)
}
className={css({
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>
</VStack>
</Flex>
)
}
Loading
Loading