RichEditor
서식 툴바가 붙은 WYSIWYG Markdown 글·댓글 작성기
목차
글이나 댓글을 쓰는 WYSIWYG Markdown 작성기입니다. 서식 툴바(제목 1–3 | 굵게 · 기울임꼴 ·
취소선 · 코드 · 링크 | 글머리 기호 목록 · 번호 목록 · 체크리스트 | 인용 · 이미지)가 TextArea
모양의 상자 안쪽 위에 놓입니다 — 상자의 테두리와 포커스 링이 툴바까지 감싸고, 툴바 아래의 가는
선과 그룹 사이의 세로 선이 영역을 나눕니다. 값은 GitHub-flavoured Markdown 으로 들어가고
나옵니다. 사용자는
Markdown 기호가 아니라 서식이 적용된 결과를 보고 편집합니다. 이미지 버튼은 앱이 업로드와
삭제를 둘 다 구현했을 때(onImageUpload + onImageDelete)만 나타납니다.
Flutter/Web 양쪽이
같은 이름의 파라미터를 제공합니다.
기본 사용법 (Basic Usage)#
// 댓글 작성기 — 값은 Markdown 으로 들어가고 나온다
RichEditor(
placeholder: 'Write a comment…',
onChanged: (markdown) => draft = markdown,
onSubmit: (markdown) => post(markdown), // ⌘↩ / Ctrl+Enter
)
// 글자 수 제한 — 한도의 80% 부터 `113 / 140` 카운터가 나타난다
RichEditor(
placeholder: 'Write a comment…',
maxLength: 140,
onChanged: handleChange,
)
// 툴바 없이 — 단축키와 Markdown 자동 서식은 그대로 동작
RichEditor(
placeholder: 'Write a comment…',
showToolbar: false,
onChanged: handleChange,
)
// 컨트롤러로 값 읽기·바꾸기 (Flutter 전용)
final controller = RichEditorController(markdown: '**Hello**');
RichEditor(controller: controller);
controller.markdown = '- one\n- two';
// 이미지 — 업로드와 삭제가 한 쌍이어야 툴바에 이미지 버튼이 나타난다
RichEditor(
placeholder: 'Write a comment…',
onImageUpload: (file) => api.uploadImage(file.name, file.bytes), // → 이미지 주소
onImageDelete: (url) => api.deleteImage(url),
maxImageBytes: 5 * 1024 * 1024,
)
// 댓글 작성기 — 값은 Markdown 으로 들어가고 나온다
RichEditor(
placeholder: 'Write a comment…',
onChanged: (markdown) => draft = markdown,
onSubmit: (markdown) => post(markdown), // ⌘↩ / Ctrl+Enter
)
// 글자 수 제한 — 한도의 80% 부터 `113 / 140` 카운터가 나타난다
RichEditor(
placeholder: 'Write a comment…',
maxLength: 140,
onChanged: handleChange,
)
// 툴바 없이 — 단축키와 Markdown 자동 서식은 그대로 동작
RichEditor(
placeholder: 'Write a comment…',
showToolbar: false,
onChanged: handleChange,
)
// 이미지 — 업로드와 삭제가 한 쌍이어야 툴바에 이미지 버튼이 나타난다
RichEditor(
placeholder: 'Write a comment…',
onImageUpload: (file) => api.uploadImage(file.name, file.bytes), // → 이미지 주소
onImageDelete: (url) => api.deleteImage(url),
maxImageBytes: 5 * 1024 * 1024,
)
툴바 구성은 toolbar 로 바꿉니다. 바깥 목록이 그룹이고, 그룹 사이에 간격과 세로 선이 들어갑니다.
목록에서 뺀 서식은 버튼만 사라지고 단축키는 남습니다. 이미지 버튼(CoreRichEditorFormat.image)은
목록에 있어도 onImageUpload 와 onImageDelete 가 둘 다 없으면 나타나지 않고, 그 때문에 빈
그룹이 되면 간격도 남지 않습니다.
RichEditor(
placeholder: 'Write a comment…',
toolbar: const [
[CoreRichEditorFormat.bold, CoreRichEditorFormat.italic, CoreRichEditorFormat.link],
[CoreRichEditorFormat.bulletedList],
],
)
툴바 오른쪽 끝에 앱의 컨트롤 두기#
toolbarTrailing 에 넘긴 위젯(Web 은 컴포넌트)은 툴바의 오른쪽 끝에 놓입니다 — 예: "바뀐 부분
표시" 체크박스. 버튼 옆에 들어갈 자리가 있으면 같은 줄 맨 오른쪽에, 없으면 버튼 아래 줄로 내려가
왼쪽에 놓이고, 두 줄 사이는 toolbarRowSpacing(기본 4)입니다. 툴바 영역을 누르는 것은 편집이
아니므로 이 컨트롤을 눌러도 편집기가 포커스를 받지 않습니다. 체크박스 · 버튼처럼 누르는 컨트롤용입니다 —
Web 은 툴바 띠 전체가 mousedown 을 막아 편집 면의 포커스를 지키므로, 글자를 입력하는 칸은 마우스로
포커스를 받을 수 없습니다.
RichEditor(
placeholder: 'Write a comment…',
toolbarTrailing: Checkbox(label: '바뀐 부분 표시', checked: show, onChanged: onShow),
)
툴바를 편집기 밖에서 쓰기 (Flutter 전용)#
RichEditor 의 툴바는 showToolbar: false 로 끄고, 같은 버튼을 RichEditorToolbar
로 원하는
자리에 둘 수 있습니다. 편집기가 내부에서 그리는 바로 그 막대(32 정사각 아이콘 버튼 · 툴팁 · 켜진
서식 표시 · 11개 로케일 라벨)이고, 컨트롤러에 묶여 캐럿을 따라 켜지고 꺼집니다. 컨테이너는 그리지
않으므로 디자인이 요구하는 바(바탕 · 안쪽 여백 · 아래 줄)는 앱이 감쌉니다. 상자 안의 툴바(위)와
달리 그룹 사이의 세로 선도 그리지 않습니다 — 앱의 디자인이 정합니다.
final controller = RichEditorController(markdown: '# 약관');
final editorKey = GlobalKey<RichEditorState>();
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerLow.toValue(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
RichEditorToolbar(controller: controller, editorKey: editorKey),
Checkbox(label: '바뀐 부분 표시', checked: show, onChanged: onShow),
],
),
),
),
RichEditor(key: editorKey, controller: controller, showToolbar: false),
],
)
| 옵션 | 하는 일 |
|---|---|
controller (필수) |
서식을 걸 RichEditorController. 막대가 이 컨트롤러를 듣고 켜진 버튼을 바꿉니다 |
editorKey |
화면에 있는 편집기의
GlobalKey<RichEditorState>
. 누른 뒤 포커스를 편집기로 돌리고, 링크 버튼이 편집기의 링크 창을 열고, 이미지 버튼이 편집기가 파일을 받을 수 있을 때(
onImageUpload
+
onImageDelete
) 나타납니다. 편집기가 막대보다 늦게 그려져도 다음 프레임에 이어집니다
|
toolbar · enabled |
버튼 구성(RichEditor.toolbar 와 같은 모양)과 전체 끄기. 컨트롤러가 readOnly 여도 꺼집니다 |
onFormat |
누를 때마다 가장 먼저 불리고, true 를 돌려주면 막대의 기본 동작을 건너뜁니다 — 편집기가 아닌 보기 화면 위에서 누르면 편집으로 바꾸고 싶을 때 |
trailing |
막대의 오른쪽 끝에 놓을 위젯. 버튼 옆에 들어갈 자리가 있으면 같은 줄 맨 오른쪽, 없으면 버튼 아래 줄 왼쪽(두 줄 사이
toolbarRowSpacing
)
|
richEditorStyle |
막대의 toolbar* 슬롯(버튼 · 켜진 버튼 · 아이콘 · 간격 · 줄 간격 · 툴팁 · 키캡) |
- 링크 버튼은
editorKey도onFormat도 없으면 꺼져 있습니다 — 링크 창은 편집기의 것입니다. - 막대 하나가 편집기 둘을 맡을 수 있습니다. 마지막으로 포커스를 받은 편집기의 컨트롤러와 키를 넘기면 됩니다.
- 좁으면 그룹 단위로 줄을 바꿉니다. 한 그룹 안에서는 바뀌지 않습니다.
-
같은 줄 · 아래 줄 배치는
trailing하나로 정해집니다. 앱 Figma 의 SH-A11 처럼 1440 에서는 체크박스가 버튼과 같은 줄 맨 오른쪽(바 폭 700), 1024 에서는 아래 줄 왼쪽(바 폭 524)으로 갈리는 것도 이 규칙 하나입니다.
Live Preview#
Review notes
Looks good to me! Two small things:
- Rename the toolbar group
- Add the missing tooltip
Ship it once CI is green.
코드 보기
class RichEditorDefaultExample extends StatelessComponent {
const RichEditorDefaultExample({super.key});
@override
Component build(BuildContext context) {
return RichEditor(
placeholder: 'Write a comment…',
initialValue:
'## Review notes\n'
'\n'
'**Looks good to me!** Two small things:\n'
'\n'
'- Rename the toolbar group\n'
'- Add the missing tooltip\n'
'\n'
'> Ship it once CI is green.',
);
}
}
class RichEditorDefaultExample extends StatelessWidget {
const RichEditorDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return RichEditor(
placeholder: 'Write a comment…',
initialValue:
'## Review notes\n'
'\n'
'**Looks good to me!** Two small things:\n'
'\n'
'- Rename the toolbar group\n'
'- Add the missing tooltip\n'
'\n'
'> Ship it once CI is green.',
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 댓글, 게시글, 리뷰 답글처럼 굵게 · 목록 · 인용 같은 가벼운 서식이 필요한 글을 받을 때
- 저장 형식이 Markdown 이고, 쓰는 사람에게는 기호 대신 서식을 보여 주고 싶을 때
- ⌘↩ / Ctrl+Enter 로 바로 보내는 작성 흐름이 필요할 때 (
onSubmit) -
글에 사진을 붙이고 싶고, 파일을 앱의 서버에 올리고 지우는 코드를 앱이 가지고 있을 때 (
onImageUpload·onImageDelete)
대신 다른 컴포넌트를 사용하세요:
Props / Parameters#
양 플랫폼의 이름 · 순서 · 기본값이 같습니다. controller / focusNode 는 Flutter 전용
입력 인프라이고, Web 에서는 타입이 Null 이라 항상 비어 있습니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
initialValue |
String? |
null |
처음 보여 줄 Markdown (비제어 모드). controller 가 있으면 무시 |
onChanged |
CoreValueChanged<String>? |
null |
바뀔 때마다 편집기의 Markdown 으로 호출 |
onSubmit |
CoreValueChanged<String>? |
null |
⌘↩ / Ctrl+Enter 에서 Markdown 으로 호출. null 이면 단축키가 묶이지 않음 |
placeholder |
String? |
null |
빈 편집기에 보이는 안내 |
enabled |
bool |
true |
입력 가능 여부. 끄면 흐려지고 툴바도 꺼짐 |
readOnly |
bool |
false |
선택 · 복사는 되지만 바꿀 수 없음 |
autofocus |
bool |
false |
처음 나타날 때 포커스를 받음 |
maxLength |
int? |
null |
받을 수 있는 Markdown 최대 글자 수. 한도의 80% 부터 카운터 표시 |
showToolbar |
bool |
true |
서식 툴바 표시 여부. 단축키는 툴바와 무관하게 동작 |
toolbar |
List<List<CoreRichEditorFormat>> |
CoreRichEditorFormat.defaultToolbar |
툴바 버튼 그룹. 그룹 사이에 간격과 세로 선 |
toolbarTrailing |
Widget? (Web Component?) |
null |
툴바 오른쪽 끝에 놓을 앱의 컨트롤. 자리가 없으면 버튼 아래 줄로 내려감 |
onLinkRequest |
CoreRichEditorLinkRequest? |
null |
링크 버튼 / ⌘K 에서 링크 주소를 묻는 함수. null 이면 편집기의 작은 다이얼로그 |
onImageUpload |
CoreRichEditorImageUpload? |
null |
사용자가 고른 이미지를 서버에 올리고 그 주소를 돌려주는 함수.
onImageDelete 와 함께 있어야 이미지 버튼이 나타남
(업로드만 넘기면 디버그 빌드에서 assert)
|
onImageDelete |
CoreRichEditorImageDelete? |
null |
사용자가 문서에서 뺀 이미지를 서버에서 지우는 함수. 업로드의 짝이라 없으면 이미지 버튼이 없음 . 서버가 알아서 정리하는 앱은 아무것도 하지 않는 함수를 넘김 |
maxImageBytes |
int? |
10 * 1024 * 1024 |
읽고 올릴 이미지의 최대 바이트. null 이면 제한 없음 |
richEditorStyle |
CoreRichEditorStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 스타일 시스템) |
controller |
Flutter RichEditorController? / Web Null |
null |
문서를 들고 Markdown 을 읽고 쓰는 컨트롤러 (Flutter 전용) |
focusNode |
Flutter FocusNode? / Web Null |
null |
키보드 포커스 관리 (Flutter 전용) |
CoreRichEditorImageUpload 는 Future<String> Function(CoreRichEditorImageFile file),
CoreRichEditorImageDelete 는 Future<void> Function(String url)
입니다.
CoreRichEditorImageFile 은 고른 파일의 name · mimeType
· bytes(와 size)를 담습니다.
자세한 규칙은 동작 스펙의 '이미지' 절을 보세요.
CoreRichEditorLinkRequest 는 Future<String?> Function(String? current)
입니다.
current 는 편집 중인 링크(새 링크면 null)이고, 새 주소 · 링크 제거('') · 취소(null)
중 하나로 완료합니다. 주소는 CoreRichEditorContract.normalizeLink 를 거쳐
http / https / mailto 만 받습니다 — javascript:
같은 주소는 문서에 들어가지 않습니다.
스타일 시스템 (Style System)#
richEditorStyle 슬롯#
RichEditor 는 다른 컴포넌트를 합성하고 그 chrome 을 직접 그리지 않습니다. 상자는
TextArea 의 것(textAreaStyle)이고, 툴바는 그 상자 안쪽 위의 띠(toolbarBackgroundColor
·
toolbarPadding)와 그 아래 · 그룹 사이의 선(Divider — toolbarDividerStyle, 그룹 사이 길이는
toolbarGroupDividerHeight)입니다. 툴바 버튼은 plain Button(toolbarButtonStyle)과
Icon(toolbarIconStyle), 힌트는 Tooltip(tooltipStyle) 안의
Kbd(kbdStyle), 간격은
Gap 입니다. 이 슬롯들은 그대로 자식 컴포넌트에 전달됩니다. 이미지 업로드 중 · 실패를 알리는
줄은 Alert 이고 uploadAlertStyle 슬롯이 그대로 전달합니다. 문서 자체의 표시 — 제목 · 코드 ·
인용 · 링크 · 구분선 · 이미지 — 만 평평한 필드로 둡니다. 제목 1–3 의 글자는 heading1TextStyle
·
heading2TextStyle · heading3TextStyle(기본: 제목 큰 · 중간 · 작은 역할)이고, 제목 4–6 은 제목 3 의
스타일을 따릅니다.
RichEditor(
placeholder: 'Write a comment…',
richEditorStyle: const CoreRichEditorStyle(
textAreaStyle: CoreTextAreaStyle( // ← 상자는 TextArea 의 슬롯
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
),
maxHeight: CoreSize.size80 * 4, // ← 스크롤 전까지 자라는 높이
quoteBarColor: CoreColor.token(CoreColors.primary), // ← 문서 표시
),
)
Resolve chain#
design system default (CoreRichEditorStyle.defaultX static const)
→ CoreRichEditorTheme.style // 프로젝트 공통
→ widget.richEditorStyle // 인스턴스별
자식 슬롯(textAreaStyle · toolbarButtonStyle …)은 이 체인으로 합쳐진 뒤 풀리지 않은 채
TextArea · Button · Icon · Gap
· Tooltip · Kbd 의 resolver 에 넘어갑니다.
activeToolbarButtonStyle 은 서식이 켜진 버튼에서 toolbarButtonStyle
위에 합쳐집니다.
CoreRichEditorStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
textAreaStyle |
CoreTextAreaStyle? |
The box — border, radius, fill, padding, focus ring, text, placeholder, selection and caret colour — handed to the
TextArea
resolver as is.
|
minHeight |
double? |
Height of the editing area when it holds little text. |
maxHeight |
double? |
Height the editing area grows to before it scrolls. |
toolbarButtonStyle |
CoreButtonStyle? |
Each toolbar button, forwarded to its plain Button. |
activeToolbarButtonStyle |
CoreButtonStyle? |
Merged over [toolbarButtonStyle] while the button's format is on. |
toolbarIconStyle |
CoreIconStyle? |
Each toolbar button's icon, forwarded to its Icon. |
toolbarItemSpacing |
double? |
Space between the buttons of one group. |
toolbarGroupGapStyle |
CoreGapStyle? |
Gap between toolbar groups. |
toolbarGapStyle |
CoreGapStyle? |
Gap between the box and the rows above it (the upload rows). |
toolbarBackgroundColor |
CoreColor? |
Fill of the toolbar strip at the top of the box (
surface-container
); none while the editor is disabled.
|
toolbarPadding |
CoreEdgeInsets? |
Inset of the buttons inside the strip (8 at the sides, 4 above and below). |
toolbarDividerStyle |
CoreDividerStyle? |
The line under the strip and the lines between groups, forwarded to
Divider
(
outline-variant
). While disabled the line under the strip turns
disabled-outline
unless a colour is stated; the lines between groups keep their colour.
|
toolbarGroupDividerHeight |
double? |
Length of the line between two groups (20); 0 draws none. |
toolbarRowSpacing |
double? |
Space between the rows of a toolbar that wraps (4). |
tooltipStyle |
CoreTooltipStyle? |
The toolbar buttons' tooltip, forwarded to Tooltip. |
kbdStyle |
CoreKbdStyle? |
The shortcut keys inside the tooltip, forwarded to Kbd. |
blockSpacing |
double? |
Space below each block of the document. |
linkColor |
CoreColor? |
Colour of link text and its underline. |
codeTextStyle |
CoreTextStyle? |
Text of inline code and literal blocks. Its size, weight and family apply; the colour stays the text's own. |
codeBackgroundColor |
CoreColor? |
Fill behind inline code and literal blocks. |
codePadding |
CoreEdgeInsets? |
Inset of a literal block's text inside its box. |
quoteBarColor |
CoreColor? |
Colour of the bar beside a quote. |
quoteBarWidth |
double? |
Width of the bar beside a quote. |
quoteSpacing |
double? |
Space between the quote bar and the quoted text. |
quoteTextColor | CoreColor? | Colour of quoted text. |
ruleColor | CoreColor? | Colour of a horizontal rule. |
completedTaskColor |
CoreColor? |
Colour of a checked task's text. |
heading1TextStyle |
CoreTextStyle? |
Text of a level-1 heading. Its size, weight and family apply; the colour stays the text's own. |
heading2TextStyle |
CoreTextStyle? |
Text of a level-2 heading. Its size, weight and family apply; the colour stays the text's own. |
heading3TextStyle |
CoreTextStyle? |
Text of a level-3 heading. Its size, weight and family apply; the colour stays the text's own. |
imageBorderRadius |
CoreBorderRadius? |
Corner of an image. |
imageMaxHeight |
double? |
Height an image is scaled down to when it is taller; a smaller image keeps its size. |
imageBackgroundColor |
CoreColor? |
Fill behind an image: what shows while it loads, and through its transparent parts. |
uploadAlertStyle |
CoreAlertStyle? |
The row above the box that reports an image upload under way or failed, and the note that stands in for an image that cannot be shown (Flutter), forwarded to their
Alert
.
|
테마 커스터마이징 (Theme)#
CoreComponentTheme(
richEditor: CoreRichEditorTheme(
style: CoreRichEditorStyle(
linkColor: CoreColor.token(CoreColors.primary),
codeBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
),
),
)
Resolve 우선순위: widget.richEditorStyle > CoreRichEditorTheme.style
> 디자인 시스템 기본값.
동작 스펙 (Behavior)#
상자는 TextArea 와 같은 모양 · 테두리 · 포커스 경계를 씁니다. 편집 영역은 내용이 적을 때
minHeight(기본 80, 약 세 줄)에서 시작해 maxHeight(기본 240)까지 자라고, 그 뒤로는
스크롤됩니다.
인터랙션#
- 툴바 버튼: 누르면 캐럿 · 선택 영역에 서식을 적용하거나 해제합니다. 캐럿 위치에서 켜져 있는 서식의 버튼은 hover 채움이 유지되어 "눌린" 상태로 보입니다.
-
제목 버튼: 캐럿의 블록을 제목 1 · 2 · 3 으로 바꾸고, 이미 그 제목이면 다시 문단으로
돌립니다. 한 블록은 한 종류라서 목록 항목이나 인용 줄에서 누르면 그 블록이 제목이 됩니다.
제목 4–6(
####…)이 들어 있는 Markdown 은 그대로 보존되고 제목 3 의 모양으로 그려집니다. -
툴팁: 버튼에 마우스를 올리면 서식 이름과 단축키 키캡(
⌘ E, Windows/Linux 는Ctrl E)이Tooltip으로 나타납니다. -
제출: ⌘↩ / Ctrl+Enter 에서
onSubmit이 현재 Markdown 으로 호출됩니다.onSubmit이 없으면 이 조합은 편집기가 가로채지 않습니다. -
링크: 링크 버튼이나 ⌘K 에서
onLinkRequest를 부르고, 없으면 편집기의 다이얼로그(주소 · 제거 · 취소 · 확인)를 엽니다. 안전하지 않은 주소는 거절하고 오류를 보여 줍니다. -
글자 수:
maxLength가 있으면 Markdown 길이가 한도의 80%(counterThreshold)에 닿을 때 상자 아래 끝에길이 / 한도카운터가 나타나고, 한도에 닿으면 에러 색으로 바뀝니다. 한도를 넘는 입력과 붙여넣기는 잘립니다.
단축키#
Mod 는 Apple 플랫폼에서 ⌘, 그 밖에서는 Ctrl 입니다. 두 플랫폼이 CoreRichEditorFormat 의
같은 표를 읽으므로 서식마다 키가 같습니다. 툴바에서 뺀 서식도 단축키는 남습니다.
| 서식 | 단축키 (Apple) | 단축키 (그 밖) | Markdown |
|---|---|---|---|
굵게 (bold) | ⌘ B | Ctrl B | **bold** |
기울임꼴 (italic) | ⌘ I | Ctrl I | *italic* |
취소선 (strikethrough) |
⇧ ⌘ X | Ctrl Shift X | ~~strike~~ |
코드 (code) | ⌘ E | Ctrl E | `code` |
링크 (link) | ⌘ K | Ctrl K | [text](url) |
글머리 기호 목록 (bulletedList) |
⇧ ⌘ 8 | Ctrl Shift 8 | - item |
번호 목록 (numberedList) |
⇧ ⌘ 7 | Ctrl Shift 7 | 1. item |
체크리스트 (taskList) |
⇧ ⌘ 9 | Ctrl Shift 9 | - [ ] item |
인용 (quote) | ⇧ ⌘ . | Ctrl Shift . | > quote |
제목 1 (heading1) |
⌥ ⌘ 1 | Ctrl Alt 1 | # Heading |
제목 2 (heading2) |
⌥ ⌘ 2 | Ctrl Alt 2 | ## Heading |
제목 3 (heading3) |
⌥ ⌘ 3 | Ctrl Alt 3 | ### Heading |
이미지 (image) | 없음 (버튼만) | 없음 (버튼만) |  |
제출 (onSubmit) | ⌘ ↩ | Ctrl Enter | — |
키는 Shift 를 누르지 않은 글자로 판정합니다 — 대부분의 자판에서 Shift+8 은 * 를 치지만
단축키는 8 로 읽습니다. Alt(Option)와 함께 쓰는 제목 단축키는 글자가 아니라 눌린 물리 키(숫자
1 · 2 · 3)로 판정합니다 — Mac 에서 Option+1 은 ¡ 를 치기 때문입니다. Windows · Linux 에서
AltGr(오른쪽 Alt)로 문자를 치는 자판(여러 유럽 자판)은 AltGr+숫자가 문자 입력으로 가야 하므로
제목 단축키로 읽지 않습니다 — 그 자판에서는 툴바 버튼이나 # 자동 서식을 쓰세요.
Markdown 자동 서식#
입력하는 동안 Markdown 이 인식되는 순간 서식으로 바뀝니다.
| 입력 | 결과 |
|---|---|
**굵게** · *기울임* · ~~취소선~~ · `코드` |
닫는 기호를 치는 순간 해당 인라인 서식 |
[텍스트](주소) | 링크 |
줄 머리의 # · ## · ### | 제목 1–3 |
줄 머리의 - · * | 글머리 기호 목록 |
줄 머리의 1. | 번호 목록 |
줄 머리의 [] · [ ] · - [ ] | 체크리스트 |
줄 머리의 > | 인용 |
--- 다음 Enter | 구분선 |
빈 목록 항목이나 빈 인용 줄에서 Enter 를 누르면 그 블록에서 빠져나옵니다. 편집기가 보여 줄 수 없는 Markdown(표 · HTML · 펜스 코드 블록 · 주소가 안전하지 않은 이미지 — Flutter 에서는 문장 안에 섞인 이미지도)은 리터럴 블록으로 남고, 내보낼 때 바뀌지 않고 그대로 쓰입니다.
이미지#
이미지 버튼은 onImageUpload 와 onImageDelete 가 둘 다 있을 때만 툴바에 나타납니다. 파일을
어디에 두는지, 어떻게 지우는지 편집기는 모르기 때문에 서버 코드가 없으면 버튼도 없고 편집기는
네트워크에 손대지 않습니다. 둘을 한 쌍으로 묶은 이유는 업로드만 있으면 사용자가 문서에서 뺀 이미지가
서버에 영원히 남기 때문입니다. 업로드만 넘기면 디버그 빌드에서 assert 로 알려 주고, 서버가 알아서
정리하는 앱은 아무것도 하지 않는 onImageDelete 를 넘기면 됩니다. Markdown 에 이미
 한 줄이 있으면 버튼과 상관없이 이미지로 그려집니다.
-
고르기 — 버튼이 플랫폼의 파일 선택창을 엽니다(PNG · JPEG · GIF · WebP). 그 밖의 형식이나
maxImageBytes를 넘는 파일은 파일을 읽기도 전에 거절하고 상자 위에 실패 줄을 보여 줍니다. 이때 업로드 함수는 불리지 않습니다. -
올리기 —
onImageUpload(file)이 도는 동안 상자 위에 "이미지를 올리는 중…" 줄이 있습니다. 함수는 서버에 파일을 저장하고 이미지를 보여 줄 주소를 돌려줍니다. 함수가 던지거나 편집기가 거절하는 주소(javascript:·file:등)를 돌려주면 줄이 실패로 바뀌고 ✕ 로 닫습니다. -
넣기 — 주소가 돌아오면 버튼을 누른 때 캐럿이 있던 블록 아래에 이미지 블록이 들어갑니다
(그 블록이 빈 문단이면 그 자리를 대신합니다). 캐럿은 그 아래 문단으로 옮겨 가고, Markdown 에는
한 줄이 쓰입니다. 업로드가 도는 동안 계속 글을 써도 됩니다. - 지우기 — 이미지 블록을 선택하고 Backspace / Delete 로 지웁니다(잘라내기도 됩니다).
이미지는 imageMaxHeight(기본 192)보다 크면 비율을 지키며 줄어들고, 모서리는
imageBorderRadius 입니다. 불러오지 못한 이미지는 Flutter 에서는 그 자리에 "이미지를 불러올 수
없음" Alert(uploadAlertStyle)로, Web 에서는 브라우저의 깨진 이미지 상자(대체 텍스트 · 같은
문구의 툴팁)로 보입니다. 두 경우 모두 Markdown 의 줄은 그대로 남습니다.
서버에서 지우기
onImageDelete 는 사용자가 문서에서 뺀 이미지를 서버에서 지우는 자리입니다. 파일을 잃지 않도록
다음 규칙으로만 불립니다.
- 편집기가 올린 이미지만 — 편집기가 시작할 때 Markdown 에 이미 있던 이미지는 부르지 않습니다. 그 편집을 저장할지는 편집기가 알 수 없고, 취소한 편집이 원래 글의 파일을 지우면 안 되기 때문입니다.
- 빠진 지 10초 뒤, 한 번만 — 실행 취소로 돌아오거나 잘라내기 · 붙여넣기로 옮겨진 이미지는 지워지지 않습니다. 같은 주소가 문서에 하나라도 남아 있으면 지우지 않습니다.
- 편집기가 사라질 때 — 빠진 채 기다리던 이미지는 그때 바로 보고하고, 편집기가 사라진 뒤에 끝난 업로드의 파일도 바로 지웁니다.
-
보여 줄 수 없는 주소가 돌아왔을 때 —
onImageUpload가blob:처럼 문서에 남길 수 없는 주소를 돌려주면 줄은 실패로 바뀌고, 서버에는 파일이 이미 있으므로 그 주소로onImageDelete를 바로 부릅니다. 파일을 서버에 두고 쓸 수 없게 만들지 않기 위해서입니다. -
바깥에서 내용을 바꾸면 부르지 않습니다 — 글을 보낸 뒤
controller.markdown = ''로 비워도 보낸 글이 가리키는 파일은 지워지지 않습니다. - 기다리지 않고 오류도 잡지 않습니다 — 서버 호출의 오류는 앱의 오류 처리로 갑니다.
실행 취소로 이미 지워진 이미지가 돌아오면 서버에는 파일이 없어 불러올 수 없는 이미지로 보입니다. 되돌릴 수 있는 삭제가 필요하면
onImageDelete에서 표시만 해 두었다가 글을 저장할 때 지우세요.
예제의 image 변형은 서버 없이 동작합니다. 업로드 함수가 파일을 data: 주소로 바꿔 Markdown
안에 담고(그래서 1 MiB 로 제한), 삭제 함수는 메모리의 가짜 서버에서 항목을 지웁니다. 실제 앱은 두
함수에 서버 호출을 넣으면 됩니다.
이 문서 사이트에 끼워 넣은 Flutter 미리보기는
file_picker의 웹 구현이 등록되지 않은 빌드라 이미지 버튼을 눌러도 선택창이 열리지 않습니다(FileInput의 찾아보기 버튼도 같습니다). 선택창까지 눈으로 확인하려면 같은 문서의 Web 미리보기를 쓰세요.
상태 전환#
-
비제어 / 제어:
initialValue는 처음 한 번만 읽습니다. Flutter 에서controller를 주면 문서는 컨트롤러가 들고,controller.markdown으로 읽고 씁니다. Web 은 문서를 DOM (contenteditable)에 두므로 컨트롤러가 없습니다. -
비활성:
enabled: false면 입력 · 툴바 · 단축키가 모두 꺼지고 흐리게 그려집니다. 툴바 띠는 바탕을 그리지 않고(상자의 비활성 바탕이 비칩니다) 툴바 아래 선은disabled-outline이 됩니다 — 그룹 사이 세로 선은 그대로입니다(디자이너 비활성 프레임). - 읽기 전용:
readOnly: true면 선택 · 복사는 되지만 서식 적용과 입력은 막힙니다.
애니메이션#
자체 애니메이션은 없습니다. 툴팁 · 다이얼로그 · 버튼 hover 는 각 합성 컴포넌트의 모션을 따릅니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
저장은 Markdown 으로, 보내기는 onSubmit 으로
RichEditor(
placeholder: 'Write a comment…',
maxLength: 2000,
onChanged: (markdown) => draft = markdown,
onSubmit: (markdown) => post(markdown),
)
이유: 값이 이미 Markdown 이라 그대로 저장하고 렌더러로 보여 주면 됩니다. ⌘↩ 제출은 키보드만으로 작성 흐름을 끝낼 수 있게 합니다.
❌ Don't#
평문만 받는 칸에 RichEditor 쓰기
// 이름 · 주소처럼 서식이 의미 없는 칸
RichEditor(placeholder: 'Your name')
이유: 값에 ** 같은 Markdown 기호가 섞여 들어옵니다. 서식이 필요 없으면
TextArea 나 TextField 를 씁니다.
접근성 (Accessibility)#
역할#
-
Flutter: 편집 영역은
Semantics(textField: true, multiline: true)이고readOnly·enabled를 함께 노출합니다. 툴바는CoUISemantics(role: group)이며 이름은 번역된richEditorToolbar("서식") 입니다. 각 버튼은 서식 이름을 라벨로, 켜짐 여부를toggled로 냅니다. -
이미지: 이미지 블록은 대체 텍스트(파일 이름에서 온
alt)를 이름으로 냅니다. 올리는 중 · 실패 줄은Alert입니다 — 실패 줄은 즉시 알려지고(Webrole="alert"), 올리는 중 줄은 알리지 않습니다. -
Web: 편집 영역은
contenteditable요소에role="textbox"·aria-multiline="true", 필요하면aria-disabled·aria-readonly·aria-placeholder를 붙입니다. 툴바는role="toolbar"+aria-label+aria-controls, 그룹마다role="group"이고, 버튼은aria-label·aria-pressed·aria-keyshortcuts(Meta+Shift+X형식) ·aria-controls를 가집니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
| 서식 단축키 (위 표) | 적용 / 해제 | 적용 / 해제 |
Mod+Enter | onSubmit | onSubmit |
Tab / Shift+Tab |
목록 안에서는 들여쓰기 · 내어쓰기, 그 밖에서는 포커스 이동 | 포커스 이동 (브라우저 기본) |
Mod+Z / Mod+Shift+Z |
편집기 자체 실행 취소 · 다시 실행 | 브라우저의 contenteditable 실행 취소 |
Mod+C / Mod+X |
선택 영역을 Markdown 으로 복사 · 잘라내기 | 브라우저 기본 |
Mod+V / Mod+Shift+V |
Markdown 을 서식으로 붙여넣기 / 평문 붙여넣기 | 항상 평문으로 붙여넣기 |
Enter / Space (툴바 버튼) | 버튼 활성화 | 버튼 활성화 |
툴바는 ARIA toolbar 의 방향키 roving 포커스를 구현하지 않습니다 — 버튼마다 Tab 정지점이 있습니다.
스크린 리더#
편집 영역의 이름은 placeholder 에서 옵니다(Flutter 는 label, Web 은 aria-placeholder).
툴바 버튼은 번역된 서식 이름(예: "굵게", "체크리스트")을 읽고 켜짐 상태를 함께 알립니다.
글자 수 카운터와 서식 적용 결과는 live region 으로 통보되지 않습니다.
포커스 관리#
-
진입 —
autofocus: true면 처음 나타날 때 편집 영역이 포커스를 받습니다. 상자 안 글 아래 빈 곳을 눌러도 포커스됩니다. - 이탈 — 상자와 툴바 바깥을 누르면 포커스가 풀립니다. 툴바 버튼을 눌러도 선택 영역은 유지되어 서식이 그 선택에 적용됩니다.
- 트랩 — 없음. 링크 다이얼로그는
Dialog의 포커스 규칙을 따르고, 닫히면 편집기로 돌아옵니다. - 키보드로 들어왔을 때만 바깥 포커스 링을 그립니다(
TextArea와 같은:focus-visible모델).
알려진 제약#
-
툴바 버튼은 32×32 로, 전역 최소 24×24 는 넘지만 roving 포커스가 없어 버튼이 많으면
Tab이동이 길어집니다. 필요한 서식만toolbar에 둡니다. - 표 · HTML · 펜스 코드 블록은 편집할 수 없는 리터럴 블록으로 남습니다.
- 이미지는 파일 선택창으로만 넣습니다 — 붙여넣기와 끌어다 놓기는 지원하지 않습니다(Flutter 에는 클립보드 이미지를 읽는 의존성이 없어 두 플랫폼을 같게 두었습니다). 이미지 블록에는 삭제 버튼이 없어 선택한 뒤 Backspace / Delete 로 지우고, 대체 텍스트는 파일 이름이며 편집기에서 바꾸는 UI 는 없습니다.
- Web 은 붙여넣기가 항상 평문이고 실행 취소가 브라우저 기본이라, 서식 붙여넣기와 편집기 단위 실행 취소는 Flutter 에만 있습니다.
전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 편집 표면 | super_editor 문서 편집기 |
contenteditable 요소 — 서버가 initialValue 로 첫 화면을 그리고 hydration 뒤 브라우저가 넘겨받음 |
| 외부 값 · 포커스 제어 | RichEditorController / FocusNode |
없음 (controller · focusNode 는 Null) |
| 툴바 위치 | 상자 안쪽 위 — 테두리 · 포커스 링이 툴바까지 감쌈 | 상자 안쪽 위 — 같음 |
| 상자 테두리 색의 포커스 기준 | 편집 면의 포커스 | :focus-within — 툴바 버튼에 키보드 포커스가 가도 테두리가 물듦. 포커스 링은 편집 면만 따름 |
toolbarTrailing |
Widget? — 누르는 컨트롤 · 입력칸 모두 가능 |
Component? — 띠가 mousedown 을 막아 입력칸은 마우스로 포커스를 못 받음 |
| 툴바를 편집기 밖에서 쓰기 | RichEditorToolbar — 컨트롤러에 묶임 |
없음 (문서가 DOM 에 있어 묶을 컨트롤러가 없다 — 편집기 id 에 묶는 짝은 후속) |
| 실행 취소 | 편집기 자체 기록 (Mod+Z / Mod+Shift+Z, Windows/Linux 는 Ctrl+Y 도) |
브라우저 기본 |
| 복사 · 붙여넣기 | Markdown 으로 복사, Markdown 을 서식으로 붙여넣기 (Mod+Shift+V 평문) |
평문 붙여넣기 |
Tab | 목록 · 체크리스트 들여쓰기, 코드 안에서는 공백 두 칸 | 포커스 이동 |
| 이미지 파일 고르기 | file_picker (OS 선택창, 웹에서는 브라우저 선택창) |
숨은 <input type="file"> |
| 이미지 블록 | 선택할 수 있는 블록 노드 | 문단 안의 <img> — 브라우저의 contenteditable 선택 · 끌어 옮기기 |
| 문장 안에 섞인 이미지 | 리터럴 블록 — Markdown 글자 그대로 | 줄 안의 <img> 로 보임 |
상대 주소(/uploads/a.png)의 이미지 | 웹에서만 불러옴 | 불러옴 |
| 불러오지 못한 이미지 | 그 자리의 Alert (이미지 이름 포함) |
브라우저의 깨진 이미지 상자 + 툴팁 — 상자 크기만 imageMaxHeight 에서 옴 |
| 파일을 편집기에 끌어다 놓기 | 지원하지 않음 | 막음 (브라우저가 blob: / file: 주소의 이미지를 넣지 못하게) |
| Web 전용 HTML 표면 | — | attributes · classes · css · id |
툴바 구성 · 단축키 표 · 자동 서식 규칙 · 링크 정규화 · 카운터 임계값은 Core 한 곳에서 읽으므로
같습니다. 이미지의 파일 형식 · 크기 검사, 안전한 주소 판정, 업로드 · 삭제 시점(onImageDelete
의
10초 규칙)도 Core 가 소유하고 두 플랫폼이 같은 코드를 부릅니다.