-
-
Notifications
You must be signed in to change notification settings - Fork 43
feat: 랜딩 페이지에 MathLive 기반 수학 점역 데모 추가 및 한글·수학 탭 통합 #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
82b5d63
990c939
3af34fd
c4c32c5
f1ce9b9
fa0c527
1d8bb3c
8ffa6cf
3bd1df8
9e9f713
8140e02
0f0d99e
143aaa8
0d2ff62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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="" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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> | ||
| ) | ||
| } | ||
| 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%"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. flex 1은 맨 처음 return에서 바로 쓰는 것 보다 이 컴포넌트를 호출하는 곳에서 flex=1을 안쓰더라도 넓게 펼쳐지도록 alignItems center 등의 옵션을 뺴는 것이 이롭습니다 |
||
| <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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 50%가 같은 의미일겁니다. |
||
| 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> | ||
| ) | ||
| } | ||
| 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']}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. flex 1 위 피드백과 같이 개선되어야 합니다 |
||
| <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} | ||
| /> | ||
|
Comment on lines
+40
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이것만 따로 클라이언트 컴포넌트로 뺴는게 맞습니다 |
||
| </VStack> | ||
| </VStack> | ||
| ) | ||
| } | ||
| 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] | ||
| } | ||
|
Comment on lines
+23
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 순수 함수는 ts파일로 빼는게 낫습니다 |
||
|
|
||
| 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%"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 위 피드백과 같은 이슈 |
||
| <VStack | ||
| bg="$containerBackground" | ||
| borderRadius={['16px', null, null, '30px']} | ||
| cursor="text" | ||
| flex="1" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이건 flex 1이 필요 없지 않나요 |
||
| gap="12px" | ||
| h="100%" | ||
| minH="25dvh" | ||
| onClick={() => fieldRef.current?.focus()} | ||
| p={['16px', null, null, '40px']} | ||
| > | ||
| <Flex flex="1" flexDirection="column" gap="8px"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. VStack 사용 |
||
| {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> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
17px같은 홀수는 피해야 합니다