라이브 인용 콜아웃 입력 보정
This commit is contained in:
@@ -1979,7 +1979,11 @@ const focusPreviewAfterSlashCommand = (line, replacementLines) => {
|
|||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,25 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block'])
|
const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block', 'focus-line'])
|
||||||
|
|
||||||
|
const bodyLines = computed(() => {
|
||||||
|
const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
|
||||||
|
return lines.length ? lines : ['']
|
||||||
|
})
|
||||||
|
|
||||||
|
const bodyLineEntries = computed(() => bodyLines.value.map((text, index) => ({
|
||||||
|
text,
|
||||||
|
index,
|
||||||
|
sourceLine: props.bodySourceLine + index
|
||||||
|
})))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콜아웃 마크다운 줄을 반영한다.
|
* 콜아웃 마크다운 줄을 반영한다.
|
||||||
* @param {string} body - 본문
|
* @param {string[]} contentLines - 본문 줄
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const commitCallout = (body) => {
|
const commitCalloutLines = (contentLines) => {
|
||||||
const contentLines = String(body ?? '').replace(/\r/g, '').split('\n')
|
|
||||||
|
|
||||||
emit('commit', [
|
emit('commit', [
|
||||||
buildCalloutOpenerLine({
|
buildCalloutOpenerLine({
|
||||||
calloutEmojiEnabled: props.calloutEmojiEnabled,
|
calloutEmojiEnabled: props.calloutEmojiEnabled,
|
||||||
@@ -55,21 +64,96 @@ const commitCallout = (body) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 본문 편집 반영
|
* 인라인 편집 값을 문자열로 정규화한다.
|
||||||
* @param {string} body - 본문
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||||
* @returns {void}
|
* @returns {string} 편집 값
|
||||||
*/
|
*/
|
||||||
const onBodyCommit = (body) => {
|
const normalizeInlineValue = (payload) => {
|
||||||
commitCallout(body)
|
if (typeof payload === 'string') {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(payload?.value ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콜아웃 아래로 이탈한다.
|
* 아래 줄 삽입 페이로드를 정규화한다.
|
||||||
* @param {Object} payload - 이탈 페이로드
|
* @param {string|Object} payload - 아래 줄 삽입 페이로드
|
||||||
|
* @returns {{ value: string, before: string, after: string, caretAtStart: boolean }}
|
||||||
|
*/
|
||||||
|
const normalizeInsertPayload = (payload) => {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
return {
|
||||||
|
value: payload,
|
||||||
|
before: '',
|
||||||
|
after: '',
|
||||||
|
caretAtStart: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: String(payload?.value ?? ''),
|
||||||
|
before: String(payload?.before ?? ''),
|
||||||
|
after: String(payload?.after ?? ''),
|
||||||
|
caretAtStart: payload?.caretAtStart === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정한 콜아웃 본문 줄에 포커스를 요청한다.
|
||||||
|
* @param {number} sourceLine - 원본 줄 번호
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const onExitBelow = (payload) => {
|
const focusCalloutLine = (sourceLine) => {
|
||||||
emit('insert-below', payload)
|
emit('focus-line', {
|
||||||
|
line: sourceLine,
|
||||||
|
position: 'start',
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 한 줄 편집 반영
|
||||||
|
* @param {number} lineIndex - 본문 줄 인덱스
|
||||||
|
* @param {string|{ value?: string }} payload - 편집 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyLineCommit = (lineIndex, payload) => {
|
||||||
|
const nextLines = [...bodyLines.value]
|
||||||
|
nextLines[lineIndex] = normalizeInlineValue(payload)
|
||||||
|
commitCalloutLines(nextLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 줄 아래에 콜아웃 본문 줄을 추가한다.
|
||||||
|
* @param {number} lineIndex - 본문 줄 인덱스
|
||||||
|
* @param {Object|string} payload - 아래 줄 삽입 페이로드
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onBodyLineInsertBelow = (lineIndex, payload) => {
|
||||||
|
const { value, before, after, caretAtStart } = normalizeInsertPayload(payload)
|
||||||
|
const nextLines = [...bodyLines.value]
|
||||||
|
|
||||||
|
if (caretAtStart && after.length) {
|
||||||
|
nextLines[lineIndex] = after
|
||||||
|
nextLines.splice(lineIndex, 0, '')
|
||||||
|
focusCalloutLine(props.bodySourceLine + lineIndex)
|
||||||
|
commitCalloutLines(nextLines)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before.length && after.length) {
|
||||||
|
nextLines[lineIndex] = before
|
||||||
|
nextLines.splice(lineIndex + 1, 0, after)
|
||||||
|
focusCalloutLine(props.bodySourceLine + lineIndex + 1)
|
||||||
|
commitCalloutLines(nextLines)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLines[lineIndex] = value
|
||||||
|
nextLines.splice(lineIndex + 1, 0, '')
|
||||||
|
focusCalloutLine(props.bodySourceLine + lineIndex + 1)
|
||||||
|
commitCalloutLines(nextLines)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -90,17 +174,21 @@ const onExitBelow = (payload) => {
|
|||||||
<span v-if="calloutEmojiEnabled">{{ calloutEmoji || '💡' }}</span>
|
<span v-if="calloutEmojiEnabled">{{ calloutEmoji || '💡' }}</span>
|
||||||
<span v-else class="text-base text-[#8e9cac]">+</span>
|
<span v-else class="text-base text-[#8e9cac]">+</span>
|
||||||
</span>
|
</span>
|
||||||
<ContentMarkdownEditableInline
|
<div class="content-markdown-callout-editor__body-lines min-w-0 flex-1">
|
||||||
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
|
<ContentMarkdownEditableInline
|
||||||
enter-mode="multiline"
|
v-for="line in bodyLineEntries"
|
||||||
:source-line="bodySourceLine"
|
:key="`${blockSourceLine}-callout-line-${line.sourceLine}`"
|
||||||
:model-value="modelValue"
|
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
@commit="onBodyCommit"
|
enter-mode="insert-below"
|
||||||
@delete-line="emit('delete-line', $event)"
|
:source-line="line.sourceLine"
|
||||||
@insert-below="onExitBelow"
|
:model-value="line.text"
|
||||||
@merge-with-previous="emit('merge-with-previous', $event)"
|
@commit="onBodyLineCommit(line.index, $event)"
|
||||||
@leave-block="emit('leave-block', $event)"
|
@delete-line="emit('delete-line', $event)"
|
||||||
/>
|
@insert-below="onBodyLineInsertBelow(line.index, $event)"
|
||||||
|
@merge-with-previous="emit('merge-with-previous', line.sourceLine, $event)"
|
||||||
|
@leave-block="emit('leave-block', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ProseCallout>
|
</ProseCallout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const suppressBlurCommit = ref(false)
|
|||||||
const splitLock = ref(false)
|
const splitLock = ref(false)
|
||||||
/** 조합 중 Enter 후 compositionend에서 분리할지 */
|
/** 조합 중 Enter 후 compositionend에서 분리할지 */
|
||||||
const pendingSplitAfterComposition = ref(false)
|
const pendingSplitAfterComposition = ref(false)
|
||||||
|
/** 조합 종료 직후 중복 Enter를 무시할 기준 시각 */
|
||||||
|
const suppressComposedEnterUntil = ref(0)
|
||||||
const showingRaw = ref(false)
|
const showingRaw = ref(false)
|
||||||
|
|
||||||
/** @returns {string} Enter 동작 모드 */
|
/** @returns {string} Enter 동작 모드 */
|
||||||
@@ -526,7 +528,7 @@ const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => {
|
|||||||
const target = elements[currentIndex + direction]
|
const target = elements[currentIndex + direction]
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
if (resolvedEnterMode.value === 'multiline' && direction === 1) {
|
if (['insert-below', 'multiline'].includes(resolvedEnterMode.value) && direction === 1) {
|
||||||
emit('insert-below', buildInsertBelowPayload())
|
emit('insert-below', buildInsertBelowPayload())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,9 +773,14 @@ const onKeydown = (event) => {
|
|||||||
&& props.sourceLine !== null
|
&& props.sourceLine !== null
|
||||||
&& !readEditorValue().trim()
|
&& !readEditorValue().trim()
|
||||||
) {
|
) {
|
||||||
|
const lineContext = getCaretLineContext()
|
||||||
|
const sourceLine = resolvedEnterMode.value === 'multiline'
|
||||||
|
? props.sourceLine + lineContext.lineIndex
|
||||||
|
: props.sourceLine
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
emit('delete-line', props.sourceLine)
|
emit('delete-line', sourceLine)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,6 +870,17 @@ const onKeydown = (event) => {
|
|||||||
|
|
||||||
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === 'Enter'
|
||||||
|
&& !event.shiftKey
|
||||||
|
&& suppressComposedEnterUntil.value
|
||||||
|
&& Date.now() < suppressComposedEnterUntil.value
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
|
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -880,6 +898,7 @@ const onKeydown = (event) => {
|
|||||||
|
|
||||||
if (event.isComposing || event.keyCode === 229) {
|
if (event.isComposing || event.keyCode === 229) {
|
||||||
pendingSplitAfterComposition.value = true
|
pendingSplitAfterComposition.value = true
|
||||||
|
suppressComposedEnterUntil.value = Date.now() + 240
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +936,7 @@ const onCompositionEnd = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingSplitAfterComposition.value = false
|
pendingSplitAfterComposition.value = false
|
||||||
|
suppressComposedEnterUntil.value = Date.now() + 240
|
||||||
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
|
||||||
|
|
||||||
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
|
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
hasListMarker,
|
hasListMarker,
|
||||||
hasQuoteMarker,
|
hasQuoteMarker,
|
||||||
isEmptyListMarkerLine,
|
isEmptyListMarkerLine,
|
||||||
isEmptyQuoteMarkerLine,
|
|
||||||
parseOrderedListMarker,
|
parseOrderedListMarker,
|
||||||
stripListMarker,
|
stripListMarker,
|
||||||
stripQuoteMarker
|
stripQuoteMarker
|
||||||
@@ -1291,6 +1290,21 @@ const onCalloutBlockInsertBelow = (block, payload) => {
|
|||||||
onInsertBelowBlock(block, { lines: [''] })
|
onInsertBelowBlock(block, { lines: [''] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자식 편집기가 요청한 원본 줄에 포커스를 둔다.
|
||||||
|
* @param {{ line?: number, position?: 'start'|'end'|'auto', offset?: number|null }} payload - 포커스 요청
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const onEditorFocusLine = (payload) => {
|
||||||
|
if (typeof payload?.line !== 'number') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFocusLine.value = payload.line
|
||||||
|
pendingFocusPosition.value = payload.position || 'auto'
|
||||||
|
pendingFocusOffset.value = typeof payload.offset === 'number' ? payload.offset : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토글 편집 반영
|
* 토글 편집 반영
|
||||||
* @param {Object} block - 토글 블록
|
* @param {Object} block - 토글 블록
|
||||||
@@ -1751,16 +1765,6 @@ const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
|
|||||||
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
nextLines[lineIndex] = formatQuoteLine(value, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
const committedLine = nextLines[lineIndex] ?? ''
|
|
||||||
|
|
||||||
if (isEmptyQuoteMarkerLine(committedLine)) {
|
|
||||||
nextLines[lineIndex] = ''
|
|
||||||
pendingFocusLine.value = block.meta.startLine + lineIndex
|
|
||||||
pendingFocusPosition.value = 'start'
|
|
||||||
commitInlineBlockLines(block, nextLines)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nextLines.splice(lineIndex + 1, 0, '> ')
|
nextLines.splice(lineIndex + 1, 0, '> ')
|
||||||
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
|
||||||
pendingFocusPosition.value = 'start'
|
pendingFocusPosition.value = 'start'
|
||||||
@@ -2610,7 +2614,8 @@ onBeforeUnmount(() => {
|
|||||||
@commit="onCalloutBlockCommit(block, $event)"
|
@commit="onCalloutBlockCommit(block, $event)"
|
||||||
@delete-line="onDeleteLine"
|
@delete-line="onDeleteLine"
|
||||||
@insert-below="onCalloutBlockInsertBelow(block, $event)"
|
@insert-below="onCalloutBlockInsertBelow(block, $event)"
|
||||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + 1, $event)"
|
@merge-with-previous="onMergeWithPreviousLine"
|
||||||
|
@focus-line="onEditorFocusLine"
|
||||||
/>
|
/>
|
||||||
<ProseCallout
|
<ProseCallout
|
||||||
v-else-if="block.type === 'callout'"
|
v-else-if="block.type === 'callout'"
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ const backgroundClass = computed(() => {
|
|||||||
return 'prose-callout--yellow'
|
return 'prose-callout--yellow'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.background === 'red') {
|
if (props.background === 'blue') {
|
||||||
return 'prose-callout--red'
|
return 'prose-callout--blue'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.background === 'purple') {
|
if (props.background === 'purple') {
|
||||||
return 'prose-callout--purple'
|
return 'prose-callout--purple'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'prose-callout--blue'
|
return 'prose-callout--red'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.50
|
||||||
|
|
||||||
|
- 라이브 작성 모드에서 한글 인용문 Enter가 외부 문단으로 빠지지 않고 다음 인용 줄을 만들도록 보강했다.
|
||||||
|
- 마지막 인용 줄에서 아래 방향키로 외부 문단을 만들며 빠져나갈 수 있게 했다.
|
||||||
|
- 콜아웃 본문을 줄 단위로 편집해 현재 줄 삭제와 한글 Enter 줄 추가가 더 안정적으로 동작하도록 했다.
|
||||||
|
|
||||||
## v1.5.49
|
## v1.5.49
|
||||||
|
|
||||||
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.
|
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
|||||||
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### v1.5.50 참고
|
||||||
|
|
||||||
|
- 추가 DB 마이그레이션은 없다.
|
||||||
|
- 라이브 모드에서 한글 `> 텍스트` 입력 후 Enter 시 다음 인용 줄이 생성되고 커서가 내부에 있는지 확인한다.
|
||||||
|
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||||
|
- 라이브 콜아웃 본문 여러 줄에서 2번째·3번째 줄 `Cmd+Shift+K`가 해당 줄만 삭제하는지 확인한다.
|
||||||
|
- 라이브 콜아웃에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
|
||||||
|
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 한글 조합 글자가 남지 않는지 확인한다.
|
||||||
|
|
||||||
### v1.5.49 참고
|
### v1.5.49 참고
|
||||||
|
|
||||||
- 추가 DB 마이그레이션은 없다.
|
- 추가 DB 마이그레이션은 없다.
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
||||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 코드·콜아웃·토글 내부 줄 삭제, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 코드·콜아웃·토글 내부 줄 삭제, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원, 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃·코드·토글), 갤러리 선택 이미지 강조 |
|
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃·코드·토글), 갤러리 선택 이미지 강조 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
|
||||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||||
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
|
||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
|
|||||||
36
docs/spec.md
36
docs/spec.md
@@ -587,24 +587,24 @@ components/content/
|
|||||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||||
> 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
||||||
> 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
|
- 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
|
||||||
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
|
- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
|
||||||
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
|
- 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
|
||||||
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
- 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
||||||
> 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
|
- 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
|
||||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
- 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
- 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||||
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
- 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
- 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||||
> 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
- 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||||
> 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
- 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||||
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
- 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||||
|
|
||||||
### 관리자 글 편집
|
### 관리자 글 편집
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.50
|
||||||
|
|
||||||
|
- 게시물 글쓰기: 라이브 모드 인용 Enter가 한글 조합 입력 뒤에도 내부 다음 인용 줄을 만들도록 수정.
|
||||||
|
- 게시물 글쓰기: 라이브 모드 인용 마지막 줄에서 아래 방향키로 외부 문단을 만들며 빠져나오도록 보강.
|
||||||
|
- 게시물 글쓰기: 라이브 모드 콜아웃 본문을 줄 단위 편집으로 전환하고 한글 조합 Enter 중복 줄 생성 문제를 완화.
|
||||||
|
|
||||||
## v1.5.49
|
## v1.5.49
|
||||||
|
|
||||||
- 게시물 글쓰기: 라이브 모드 `Cmd+Shift+K`가 코드·콜아웃·토글 블록 내부 현재 줄을 삭제하도록 수정.
|
- 게시물 글쓰기: 라이브 모드 `Cmd+Shift+K`가 코드·콜아웃·토글 블록 내부 현재 줄을 삭제하도록 수정.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.49",
|
"version": "1.5.50",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.49",
|
"version": "1.5.50",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.49",
|
"version": "1.5.50",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user