- _meta.json: customLabels -> terms.{label,showInInstaller,showInInstallerRp}
- Drop builtin protection; any term kind can be deleted/added/toggled
- New public route /manifest/terms/<pack>/index.json for installer term lists
- Installers fetch terms:list dynamically; skip agreement step if list empty
- Term editor: 2 visibility checkboxes (설치기 / 리소스팩 설치기), multi-select
- Migration from old schema preserves custom labels (default: visible in both)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
395 lines
15 KiB
JavaScript
395 lines
15 KiB
JavaScript
/* 약관(Markdown) 편집기.
|
|
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
|
|
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
|
|
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
|
|
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
|
|
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
|
|
*/
|
|
(function () {
|
|
'use strict'
|
|
|
|
var editor = document.getElementById('editor')
|
|
var preview = document.getElementById('preview')
|
|
var slashMenu = document.getElementById('slashMenu')
|
|
var status = document.getElementById('status')
|
|
var dirtyMark = document.getElementById('dirty-mark')
|
|
var saveBtn = document.getElementById('saveBtn')
|
|
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
|
var visInstaller = document.getElementById('visInstaller')
|
|
var visInstallerRp = document.getElementById('visInstallerRp')
|
|
|
|
editor.value = INITIAL || ''
|
|
var dirty = false
|
|
function setDirty(v) {
|
|
dirty = v
|
|
dirtyMark.hidden = !v
|
|
}
|
|
|
|
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
|
|
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
|
|
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
|
|
|
|
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
|
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
|
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
|
function escHtml(s) {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
function inline(s) {
|
|
s = escHtml(s)
|
|
// code `x`
|
|
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
// bold **x**
|
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
// italic *x*
|
|
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
|
// links [text](url) — also auto-link bare http(s)
|
|
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
|
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
|
})
|
|
return s
|
|
}
|
|
function renderMd(src) {
|
|
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
|
var out = []
|
|
var i = 0
|
|
var stackList = null // 'ul' | 'ol' | null
|
|
function closeList() { if (stackList) { out.push('</' + stackList + '>'); stackList = null } }
|
|
while (i < lines.length) {
|
|
var line = lines[i]
|
|
// 코드 블록 ```lang
|
|
var fence = /^```(\w*)\s*$/.exec(line)
|
|
if (fence) {
|
|
closeList()
|
|
var code = []
|
|
i += 1
|
|
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
code.push(lines[i]); i += 1
|
|
}
|
|
if (i < lines.length) i += 1
|
|
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
|
continue
|
|
}
|
|
// 토글 (자체 구문) :::toggle 제목 ... :::
|
|
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
|
if (togStart) {
|
|
closeList()
|
|
var summary = togStart[1]
|
|
var body = []
|
|
i += 1
|
|
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
|
|
body.push(lines[i]); i += 1
|
|
}
|
|
if (i < lines.length) i += 1
|
|
out.push('<details><summary>' + inline(summary) + '</summary>' + renderMd(body.join('\n')) + '</details>')
|
|
continue
|
|
}
|
|
// 헤딩
|
|
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
if (h) {
|
|
closeList()
|
|
var level = h[1].length
|
|
out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>')
|
|
i += 1; continue
|
|
}
|
|
// hr
|
|
if (/^---+\s*$/.test(line)) {
|
|
closeList()
|
|
out.push('<hr />'); i += 1; continue
|
|
}
|
|
// 인용 >
|
|
if (/^>\s?/.test(line)) {
|
|
closeList()
|
|
var q = []
|
|
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
q.push(lines[i].replace(/^>\s?/, '')); i += 1
|
|
}
|
|
out.push('<blockquote>' + renderMd(q.join('\n')) + '</blockquote>')
|
|
continue
|
|
}
|
|
// 번호 목록
|
|
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
|
if (ol) {
|
|
if (stackList !== 'ol') { closeList(); out.push('<ol>'); stackList = 'ol' }
|
|
out.push('<li>' + inline(ol[1]) + '</li>')
|
|
i += 1; continue
|
|
}
|
|
// 불릿
|
|
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
|
if (ul) {
|
|
if (stackList !== 'ul') { closeList(); out.push('<ul>'); stackList = 'ul' }
|
|
out.push('<li>' + inline(ul[1]) + '</li>')
|
|
i += 1; continue
|
|
}
|
|
// 빈 줄
|
|
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
|
// 일반 문단
|
|
closeList()
|
|
var para = [line]; i += 1
|
|
while (i < lines.length && !/^\s*$/.test(lines[i])
|
|
&& !/^(#{1,6})\s+/.test(lines[i])
|
|
&& !/^\s*[-*]\s+/.test(lines[i])
|
|
&& !/^\s*\d+\.\s+/.test(lines[i])
|
|
&& !/^>/.test(lines[i])
|
|
&& !/^---+\s*$/.test(lines[i])
|
|
&& !/^```/.test(lines[i])
|
|
&& !/^:::/.test(lines[i])) {
|
|
para.push(lines[i]); i += 1
|
|
}
|
|
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
|
}
|
|
closeList()
|
|
return out.join('\n')
|
|
}
|
|
|
|
function refreshPreview() {
|
|
preview.innerHTML = renderMd(editor.value)
|
|
}
|
|
|
|
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
|
|
tabBtns.forEach(function (b) {
|
|
b.addEventListener('click', function () {
|
|
tabBtns.forEach(function (x) { x.classList.remove('active') })
|
|
b.classList.add('active')
|
|
var mode = b.getAttribute('data-mode')
|
|
if (mode === 'preview') {
|
|
refreshPreview()
|
|
editor.hidden = true
|
|
preview.hidden = false
|
|
} else {
|
|
editor.hidden = false
|
|
preview.hidden = true
|
|
}
|
|
})
|
|
})
|
|
|
|
// ─── 저장 ───────────────────────────────────────────────────────────
|
|
function save() {
|
|
status.classList.remove('error')
|
|
status.textContent = I18N.saving
|
|
var payload = { content: editor.value }
|
|
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
|
|
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
|
|
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).then(function (r) {
|
|
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
|
}).then(function (res) {
|
|
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
|
|
setDirty(false)
|
|
status.textContent = I18N.saved
|
|
}).catch(function (err) {
|
|
status.classList.add('error')
|
|
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
|
|
})
|
|
}
|
|
saveBtn.addEventListener('click', save)
|
|
|
|
// Ctrl+S 저장
|
|
document.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
|
e.preventDefault(); save()
|
|
}
|
|
})
|
|
|
|
// 페이지 떠나기 가드
|
|
window.addEventListener('beforeunload', function (e) {
|
|
if (!dirty) return
|
|
e.preventDefault()
|
|
e.returnValue = I18N.leaveConfirm
|
|
return I18N.leaveConfirm
|
|
})
|
|
|
|
editor.addEventListener('input', function () {
|
|
setDirty(true)
|
|
})
|
|
|
|
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
|
|
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
|
|
var SLASH_ITEMS = [
|
|
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
|
|
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
|
|
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
|
|
{ label: I18N.slashText, hint: '', insert: '|' },
|
|
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
|
|
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
|
|
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
|
|
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
|
|
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
|
|
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
|
|
]
|
|
|
|
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
|
|
|
|
function renderSlashItems(filtered) {
|
|
slashMenu.innerHTML = ''
|
|
filtered.forEach(function (item, idx) {
|
|
var el = document.createElement('div')
|
|
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
|
|
var strong = document.createElement('strong')
|
|
strong.textContent = item.label
|
|
var span = document.createElement('span')
|
|
span.textContent = item.hint || ''
|
|
el.appendChild(strong); el.appendChild(span)
|
|
el.addEventListener('mousedown', function (e) {
|
|
e.preventDefault()
|
|
applySlash(item)
|
|
})
|
|
slashMenu.appendChild(el)
|
|
})
|
|
}
|
|
|
|
function positionSlash() {
|
|
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
|
|
var rect = editor.getBoundingClientRect()
|
|
var wrapRect = editor.parentElement.getBoundingClientRect()
|
|
var caret = getCaretCoords(editor)
|
|
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
|
|
var left = caret.left + (rect.left - wrapRect.left)
|
|
slashMenu.style.top = top + 'px'
|
|
slashMenu.style.left = left + 'px'
|
|
}
|
|
|
|
function openSlash() {
|
|
slashState = {
|
|
startPos: editor.selectionStart - 1, // '/' 위치
|
|
query: '',
|
|
activeIndex: 0,
|
|
filtered: SLASH_ITEMS.slice()
|
|
}
|
|
renderSlashItems(slashState.filtered)
|
|
slashMenu.hidden = false
|
|
positionSlash()
|
|
}
|
|
function closeSlash() {
|
|
slashState = null
|
|
slashMenu.hidden = true
|
|
}
|
|
|
|
function applySlash(item) {
|
|
if (!slashState) return
|
|
var value = editor.value
|
|
var start = slashState.startPos
|
|
var end = editor.selectionStart
|
|
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
|
|
var lineStart = value.lastIndexOf('\n', start - 1) + 1
|
|
var lineEnd = value.indexOf('\n', end)
|
|
if (lineEnd === -1) lineEnd = value.length
|
|
var beforeLine = value.slice(0, lineStart)
|
|
var afterLine = value.slice(lineEnd)
|
|
var currentLine = value.slice(lineStart, lineEnd)
|
|
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
|
|
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
|
|
var insert = item.insert
|
|
var caretMarker = insert.indexOf('|')
|
|
var inserted = insert.replace('|', rest)
|
|
editor.value = beforeLine + inserted + afterLine
|
|
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
|
|
editor.selectionStart = editor.selectionEnd = caretPos
|
|
closeSlash()
|
|
setDirty(true)
|
|
editor.focus()
|
|
}
|
|
|
|
editor.addEventListener('keydown', function (e) {
|
|
if (slashState) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
|
|
renderSlashItems(slashState.filtered)
|
|
return
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
|
|
renderSlashItems(slashState.filtered)
|
|
return
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
if (slashState.filtered.length > 0) {
|
|
e.preventDefault()
|
|
applySlash(slashState.filtered[slashState.activeIndex])
|
|
return
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
closeSlash()
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
editor.addEventListener('input', function (e) {
|
|
var pos = editor.selectionStart
|
|
var ch = editor.value.slice(pos - 1, pos)
|
|
if (!slashState && ch === '/') {
|
|
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
|
|
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
|
|
if (prev === '\n' || prev === ' ' || pos === 1) {
|
|
openSlash()
|
|
return
|
|
}
|
|
}
|
|
if (slashState) {
|
|
var startPos = slashState.startPos
|
|
if (pos < startPos || editor.value[startPos] !== '/') {
|
|
closeSlash()
|
|
return
|
|
}
|
|
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
|
|
slashState.query = q
|
|
slashState.filtered = SLASH_ITEMS.filter(function (it) {
|
|
if (!q) return true
|
|
return it.label.toLowerCase().indexOf(q) !== -1
|
|
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
|
|
})
|
|
slashState.activeIndex = 0
|
|
renderSlashItems(slashState.filtered)
|
|
positionSlash()
|
|
}
|
|
})
|
|
|
|
editor.addEventListener('blur', function () {
|
|
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
|
|
setTimeout(closeSlash, 100)
|
|
})
|
|
|
|
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
|
|
function getCaretCoords(el) {
|
|
var div = document.createElement('div')
|
|
var s = getComputedStyle(el)
|
|
var props = [
|
|
'boxSizing','width','height','overflowX','overflowY',
|
|
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
|
|
'paddingTop','paddingRight','paddingBottom','paddingLeft',
|
|
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
|
|
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
|
|
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
|
|
]
|
|
div.style.position = 'absolute'
|
|
div.style.visibility = 'hidden'
|
|
div.style.whiteSpace = 'pre-wrap'
|
|
div.style.wordWrap = 'break-word'
|
|
props.forEach(function (p) { div.style[p] = s[p] })
|
|
div.style.position = 'absolute'
|
|
div.style.top = '0'
|
|
div.style.left = '0'
|
|
var rect = el.getBoundingClientRect()
|
|
document.body.appendChild(div)
|
|
var pos = el.selectionStart
|
|
var before = el.value.substring(0, pos)
|
|
div.textContent = before
|
|
var span = document.createElement('span')
|
|
span.textContent = el.value.substring(pos) || '.'
|
|
div.appendChild(span)
|
|
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
|
|
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
|
|
document.body.removeChild(div)
|
|
return { top: top, left: left }
|
|
}
|
|
})()
|