feat(project): 업무 설명란을 간단한 워드프로세서 에디터로 — 서식 도구 추가

굵게/기울임/밑줄/글머리목록/번호목록/인용구/링크/서식지우기 버튼을
설명란 위에 추가. execCommand 기반(같은 파일의 이미지 삽입에서 이미
insertHTML 로 쓰던 것과 같은 패턴)이고, 생성되는 태그(b/i/u/ul/ol/li/
blockquote/a)는 전부 기존 sanitize_description_html 허용 목록 안이라
서버 쪽 변경 없음. 버튼 mousedown 에서 preventDefault 해 클릭 시 에디터
선택범위가 풀리는 것 방지, 커서 위치에 따라 버튼 활성 상태 표시.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 00:23:12 +09:00
parent a7a294ce93
commit 79be87e7e9
5 changed files with 79 additions and 8 deletions
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917a" /> <link rel="stylesheet" href="/static/project.css?v=20260917b" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260916o" defer></script> <script src="/static/project.js?v=20260917a" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917a" /> <link rel="stylesheet" href="/static/project.css?v=20260917b" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -290,12 +290,24 @@
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label> <label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</div> </div>
<!-- 설명 --> <!-- 설명 — 간단한 워드프로세서식 서식 도구 + 이미지 삽입 -->
<div class="pj-desc-toolbar"> <div class="pj-desc-toolbar">
<span class="pj-extra-label">설명</span> <span class="pj-extra-label">설명</span>
<label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label> <label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label>
<span class="pj-muted-sm" id="pj-desc-hint"></span> <span class="pj-muted-sm" id="pj-desc-hint"></span>
</div> </div>
<div class="pj-desc-fmtbar" id="pj-desc-fmtbar">
<button type="button" class="pj-desc-fmtbtn" data-cmd="bold" title="굵게 (Ctrl+B)"><b>B</b></button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="italic" title="기울임 (Ctrl+I)"><i>I</i></button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="underline" title="밑줄 (Ctrl+U)"><u>U</u></button>
<span class="pj-desc-fmtsep"></span>
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertUnorderedList" title="글머리 기호 목록">☰•</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertOrderedList" title="번호 매기기 목록">☰1</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="formatBlock" data-arg="blockquote" title="인용구"></button>
<span class="pj-desc-fmtsep"></span>
<button type="button" class="pj-desc-fmtbtn" data-cmd="createLink" title="링크 삽입">🔗</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="removeFormat" title="서식 지우기">✕서식</button>
</div>
<div class="pj-desc-wrap"> <div class="pj-desc-wrap">
<div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div> <div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div>
</div> </div>
@@ -380,5 +392,5 @@
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260916o" defer></script> <script src="/static/project.js?v=20260917a" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260917a" /> <link rel="stylesheet" href="/static/project.css?v=20260917b" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
@@ -277,12 +277,24 @@
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label> <label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
</div> </div>
<!-- 설명 --> <!-- 설명 — 간단한 워드프로세서식 서식 도구 + 이미지 삽입 -->
<div class="pj-desc-toolbar"> <div class="pj-desc-toolbar">
<span class="pj-extra-label">설명</span> <span class="pj-extra-label">설명</span>
<label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label> <label class="pj-upload-btn">이미지 추가<input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label>
<span class="pj-muted-sm" id="pj-desc-hint"></span> <span class="pj-muted-sm" id="pj-desc-hint"></span>
</div> </div>
<div class="pj-desc-fmtbar" id="pj-desc-fmtbar">
<button type="button" class="pj-desc-fmtbtn" data-cmd="bold" title="굵게 (Ctrl+B)"><b>B</b></button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="italic" title="기울임 (Ctrl+I)"><i>I</i></button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="underline" title="밑줄 (Ctrl+U)"><u>U</u></button>
<span class="pj-desc-fmtsep"></span>
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertUnorderedList" title="글머리 기호 목록">☰•</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertOrderedList" title="번호 매기기 목록">☰1</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="formatBlock" data-arg="blockquote" title="인용구"></button>
<span class="pj-desc-fmtsep"></span>
<button type="button" class="pj-desc-fmtbtn" data-cmd="createLink" title="링크 삽입">🔗</button>
<button type="button" class="pj-desc-fmtbtn" data-cmd="removeFormat" title="서식 지우기">✕서식</button>
</div>
<div class="pj-desc-wrap"> <div class="pj-desc-wrap">
<div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div> <div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div>
</div> </div>
@@ -393,5 +405,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260916o" defer></script> <script src="/static/project.js?v=20260917a" defer></script>
{% endblock %} {% endblock %}
+13
View File
@@ -597,6 +597,16 @@
/* ── 업무 설명 — contenteditable(이미지 붙여넣기/업로드 삽입) ── */ /* ── 업무 설명 — contenteditable(이미지 붙여넣기/업로드 삽입) ── */
.pj-desc-toolbar { display: flex; align-items: center; gap: 8px; margin: -6px 0 6px; } .pj-desc-toolbar { display: flex; align-items: center; gap: 8px; margin: -6px 0 6px; }
.pj-desc-toolbar .pj-muted-sm { font-size: 11px; color: #9aa1a9; } .pj-desc-toolbar .pj-muted-sm { font-size: 11px; color: #9aa1a9; }
/* 간단 워드프로세서 서식 도구 모음 */
.pj-desc-fmtbar { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; margin-bottom: 6px; }
.pj-desc-fmtbtn {
min-width: 26px; height: 26px; padding: 0 6px; border: 1px solid transparent; border-radius: 6px;
background: transparent; color: #444; font-size: 13px; font-family: inherit; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.pj-desc-fmtbtn:hover { background: #f1f2f4; }
.pj-desc-fmtbtn.is-active { background: var(--pj-accent-tint); border-color: var(--pj-accent); color: var(--pj-accent-dark); }
.pj-desc-fmtsep { width: 1px; height: 16px; background: #e2e4ea; margin: 0 4px; }
.pj-desc-editor { .pj-desc-editor {
min-height: 64px; max-height: 260px; overflow-y: auto; padding: 8px 10px; min-height: 64px; max-height: 260px; overflow-y: auto; padding: 8px 10px;
border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit;
@@ -605,6 +615,9 @@
.pj-desc-editor:empty::before { content: attr(data-placeholder); color: #b7bdc5; } .pj-desc-editor:empty::before { content: attr(data-placeholder); color: #b7bdc5; }
.pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; cursor: pointer; } .pj-desc-editor img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; cursor: pointer; }
.pj-desc-editor img.pj-img-selected { outline: 2px solid var(--pj-accent); outline-offset: 1px; } .pj-desc-editor img.pj-img-selected { outline: 2px solid var(--pj-accent); outline-offset: 1px; }
.pj-desc-editor blockquote { margin: 6px 0; padding: 2px 12px; border-left: 3px solid var(--pj-border); color: #565c66; }
.pj-desc-editor ul, .pj-desc-editor ol { margin: 4px 0; padding-left: 22px; }
.pj-desc-editor a { color: var(--pj-accent-dark); }
.pj-desc-editor[contenteditable="false"] { background: #f6f7f8; color: #9aa1a9; } .pj-desc-editor[contenteditable="false"] { background: #f6f7f8; color: #9aa1a9; }
/* 설명란 이미지 선택 시 오버레이(삭제 × / 우하단 크기조절 핸들) — wrap 은 /* 설명란 이미지 선택 시 오버레이(삭제 × / 우하단 크기조절 핸들) — wrap 은
+34
View File
@@ -1999,6 +1999,40 @@
const hint = el("pj-desc-hint"); const hint = el("pj-desc-hint");
let taskId = null; let taskId = null;
// ── 서식 도구 모음(간단 워드프로세서) — execCommand 는 구식 API지만
// 크롬/엣지는 여전히 지원하고, 이미 같은 파일의 이미지 삽입에서도 쓰고
// 있어(insertHTML) 일관된 패턴이다. 버튼 mousedown 에서 preventDefault
// 안 하면 클릭 순간 에디터가 포커스를 잃어 선택 범위가 풀린다.
const fmtbar = el("pj-desc-fmtbar");
if (fmtbar) {
fmtbar.addEventListener("mousedown", function (e) { e.preventDefault(); });
fmtbar.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-desc-fmtbtn");
if (!btn) return;
box.focus();
const cmd = btn.dataset.cmd;
let arg = btn.dataset.arg || null;
if (cmd === "createLink") {
arg = prompt("연결할 주소(URL)를 입력하세요", "https://");
if (!arg) return;
}
try { document.execCommand(cmd, false, arg); } catch (_) {}
updateFmtState();
});
function updateFmtState() {
fmtbar.querySelectorAll(".pj-desc-fmtbtn[data-cmd]").forEach(function (btn) {
const cmd = btn.dataset.cmd;
if (cmd === "createLink" || cmd === "removeFormat") return;
let active = false;
try { active = document.queryCommandState(cmd); } catch (_) { active = false; }
btn.classList.toggle("is-active", active);
});
}
box.addEventListener("keyup", updateFmtState);
box.addEventListener("mouseup", updateFmtState);
box.addEventListener("focus", updateFmtState);
}
// ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ── // ── 설명란 이미지 선택 → 크기 조절 핸들 + 삭제 버튼 오버레이 ──
// 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은 // 크기는 style이 아니라 width 속성으로 저장한다(서버 sanitize가 style은
// 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다). // 버리지만 img width/height 속성은 허용 목록에 있어 저장 후에도 유지된다).