<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Word Editor</title>
<style>
:root {
--blue: #2563eb;
--border: #d1d5db;
--bg: #f3f4f6;
--text: #1f2937;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Microsoft JhengHei", "Noto Sans TC", Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
.topbar {
background: #fff;
border-bottom: 1px solid var(--border);
padding: 12px 18px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.brand {
font-size: 20px;
font-weight: 700;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button, select, input {
font: inherit;
}
button {
border: 1px solid var(--border);
background: #fff;
border-radius: 5px;
padding: 7px 10px;
cursor: pointer;
}
button:hover { background: #e5e7eb; }
button.primary {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
button.primary:hover { background: #1d4ed8; }
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
background: #fafafa;
border-bottom: 1px solid var(--border);
}
.toolbar select {
max-width: 180px;
min-height: 34px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 5px;
background: white;
}
.toolbar input[type="color"] {
width: 38px;
height: 34px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 5px;
background: white;
cursor: pointer;
}
.separator {
width: 1px;
height: 26px;
background: var(--border);
margin: 0 3px;
}
.workspace {
padding: 24px 12px 50px;
overflow: auto;
}
.page {
width: 100%;
max-width: 850px;
min-height: 900px;
margin: 0 auto;
padding: 64px 72px;
background: white;
box-shadow: 0 2px 12px #00000012;
border: 1px solid #e5e7eb;
}
#editor {
min-height: 750px;
outline: none;
overflow-wrap: anywhere;
line-height: 1.8;
font-size: 16px;
}
#editor:focus { outline: none; }
#editor h1 { font-size: 2em; }
#editor h2 { font-size: 1.5em; }
#editor h3 { font-size: 1.2em; }
#editor table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
#editor td, #editor th {
border: 1px solid #9ca3af;
padding: 8px;
min-width: 50px;
}
#editor img {
max-width: 100%;
height: auto;
}
.statusbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid var(--border);
padding: 7px 14px;
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 10px;
}
@media (max-width: 650px) {
.page {
padding: 24px 18px;
min-height: 650px;
}
.workspace { padding: 10px 5px 45px; }
.topbar { padding: 10px; }
.brand { font-size: 18px; }
}
@media print {
body { background: white; }
.topbar, .toolbar, .statusbar { display: none !important; }
.workspace { padding: 0; overflow: visible; }
.page {
max-width: none;
border: none;
box-shadow: none;
padding: 0;
}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">📝 Web Word Editor</div>
<div class="actions">
<button onclick="newDocument()">新增文件</button>
<button onclick="openHtml()">開啟 HTML</button>
<button onclick="saveHtml()">另存 HTML</button>
<button onclick="window.print()">列印 / 存成 PDF</button>
<button class="primary" onclick="exportDocx()">匯出 DOCX</button>
</div>
</header>
<nav class="toolbar" aria-label="編輯工具列">
<button title="復原" onclick="cmd('undo')">↶ 復原</button>
<button title="重做" onclick="cmd('redo')">↷ 重做</button>
<span class="separator"></span>
<select id="blockFormat" title="段落格式"
onchange="formatBlock(this.value)">
<option value="P">一般段落</option>
<option value="H1">標題 1</option>
<option value="H2">標題 2</option>
<option value="H3">標題 3</option>
<option value="BLOCKQUOTE">引用</option>
<option value="PRE">程式碼區塊</option>
</select>
<select title="字型" onchange="setFont(this.value)">
<option value="Microsoft JhengHei">微軟正黑體</option>
<option value="PMingLiU">新細明體</option>
<option value="DFKai-SB">標楷體</option>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
</select>
<select title="字級" onchange="setFontSize(this.value)">
<option value="2">小</option>
<option value="3" selected>正常</option>
<option value="4">大</option>
<option value="5">特大</option>
<option value="6">非常大</option>
<option value="7">最大</option>
</select>
<span class="separator"></span>
<button onclick="cmd('bold')" title="粗體"><b>B</b></button>
<button onclick="cmd('italic')" title="斜體"><i>I</i></button>
<button onclick="cmd('underline')" title="底線"><u>U</u></button>
<button onclick="cmd('strikeThrough')" title="刪除線"><s>S</s></button>
<label title="文字顏色">
<input type="color" value="#000000"
onchange="cmd('foreColor', this.value)">
</label>
<label title="螢光標示">
<input type="color" value="#ffff00"
onchange="cmd('hiliteColor', this.value)">
</label>
<span class="separator"></span>
<button onclick="cmd('justifyLeft')" title="靠左">≡←</button>
<button onclick="cmd('justifyCenter')" title="置中">≡</button>
<button onclick="cmd('justifyRight')" title="靠右">→≡</button>
<button onclick="cmd('justifyFull')" title="左右對齊">☰</button>
<span class="separator"></span>
<button onclick="cmd('insertUnorderedList')">• 項目符號</button>
<button onclick="cmd('insertOrderedList')">1. 編號</button>
<button onclick="insertLink()">插入連結</button>
<button onclick="insertImage()">插入圖片</button>
<button onclick="insertTable()">插入表格</button>
<button onclick="cmd('removeFormat')">清除格式</button>
</nav>
<main class="workspace">
<section class="page">
<article id="editor" contenteditable="true"
spellcheck="true" aria-label="文件編輯區">
<h1>我的文件</h1>
<p>請在此輸入文字,開始編輯文件。</p>
<p>您可以選取文字後,使用上方工具列套用格式。</p>
<h2>功能說明</h2>
<ul>
<li>支援基本文字格式及段落設定。</li>
<li>可以插入表格、超連結及圖片。</li>
<li>可以匯出 HTML、DOCX,或列印成 PDF。</li>
</ul>
</article>
</section>
</main>
<div class="statusbar">
<span id="status">就緒</span>
<span id="wordCount">字數:0</span>
</div>
<input id="htmlFile" type="file" accept=".html,.htm,text/html"
hidden onchange="loadHtml(event)">
<input id="imageFile" type="file" accept="image/*"
hidden onchange="loadImage(event)">
<script>
"use strict";
const editor = document.getElementById("editor");
const statusText = document.getElementById("status");
const wordCount = document.getElementById("wordCount");
function setStatus(message) {
statusText.textContent = message;
}
function updateCount() {
const text = editor.innerText || "";
const trimmed = text.trim();
const count = trimmed ? trimmed.replace(/\s/g, "").length : 0;
wordCount.textContent = "字數:" + count;
}
editor.addEventListener("input", () => {
updateCount();
setStatus("編輯中");
});
function cmd(command, value = null) {
editor.focus();
try {
document.execCommand(command, false, value);
updateCount();
} catch (error) {
setStatus("此瀏覽器不支援該格式功能");
}
}
function formatBlock(tag) {
editor.focus();
document.execCommand("formatBlock", false, "<" + tag + ">");
}
function setFont(font) {
editor.focus();
document.execCommand("fontName", false, font);
}
function setFontSize(size) {
editor.focus();
document.execCommand("fontSize", false, size);
}
function insertLink() {
const url = prompt("請輸入完整網址,例如 https://example.com");
if (!url) return;
if (!/^https?:\/\//i.test(url)) {
alert("網址請以 http:// 或 https:// 開頭。");
return;
}
editor.focus();
document.execCommand("createLink", false, url);
}
function insertImage() {
document.getElementById("imageFile").click();
}
function loadImage(event) {
const file = event.target.files[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert("請選擇圖片檔案。");
return;
}
const reader = new FileReader();
reader.onload = () => {
editor.focus();
const img = document.createElement("img");
img.src = reader.result;
img.alt = file.name;
img.style.maxWidth = "100%";
const selection = window.getSelection();
if (selection && selection.rangeCount &&
editor.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(img);
} else {
editor.appendChild(img);
}
updateCount();
setStatus("圖片已插入");
};
reader.readAsDataURL(file);
event.target.value = "";
}
function insertTable() {
const rows = parseInt(prompt("請輸入列數(1–20)", "3"), 10);
if (!Number.isInteger(rows) || rows < 1 || rows > 20) {
alert("列數必須介於 1 到 20。");
return;
}
const cols = parseInt(prompt("請輸入欄數(1–10)", "3"), 10);
if (!Number.isInteger(cols) || cols < 1 || cols > 10) {
alert("欄數必須介於 1 到 10。");
return;
}
const table = document.createElement("table");
const tbody = document.createElement("tbody");
for (let r = 0; r < rows; r++) {
const tr = document.createElement("tr");
for (let c = 0; c < cols; c++) {
const td = document.createElement("td");
td.innerHTML = " ";
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
editor.focus();
const selection = window.getSelection();
if (selection && selection.rangeCount &&
editor.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(table);
const paragraph = document.createElement("p");
paragraph.innerHTML = "<br>";
table.after(paragraph);
} else {
editor.appendChild(table);
}
updateCount();
setStatus("表格已插入");
}
function newDocument() {
if (!confirm("新增文件會清除目前內容,確定繼續?")) return;
editor.innerHTML = "<h1>我的文件</h1><p><br></p>";
updateCount();
setStatus("已新增文件");
}
function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
function saveHtml() {
const html = `<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我的文件</title>
<style>
body {
font-family: "Microsoft JhengHei", sans-serif;
max-width: 850px;
margin: 40px auto;
padding: 20px;
line-height: 1.8;
}
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #999; padding: 8px; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${editor.innerHTML}</body>
</html>`;
saveBlob(new Blob([html], {type: "text/html;charset=utf-8"}),
"我的文件.html");
setStatus("HTML 已下載");
}
function openHtml() {
document.getElementById("htmlFile").click();
}
function loadHtml(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const parser = new DOMParser();
const doc = parser.parseFromString(String(reader.result), "text/html");
// 僅載入 body 內容;不執行匯入檔案中的 script。
editor.innerHTML = doc.body ? doc.body.innerHTML : "";
updateCount();
setStatus("HTML 已載入");
};
reader.readAsText(file, "UTF-8");
event.target.value = "";
}
async function exportDocx() {
setStatus("正在準備 DOCX 匯出……");
if (!window.docx) {
try {
await loadDocxLibrary();
} catch (error) {
setStatus("DOCX 程式庫載入失敗");
alert(
"無法載入 DOCX 匯出程式庫。\n\n" +
"請確認電腦已連上網路,並允許載入 jsDelivr CDN," +
"然後重新整理頁面再試一次。"
);
return;
}
}
try {
const { Document, Packer, Paragraph, TextRun, HeadingLevel,
AlignmentType, Table, TableRow, TableCell, WidthType,
ImageRun } = window.docx;
const children = [];
const blocks = Array.from(editor.childNodes);
function getAlignment(element) {
const align = element.style?.textAlign ||
element.getAttribute?.("align") || "";
if (align === "center") return AlignmentType.CENTER;
if (align === "right") return AlignmentType.RIGHT;
if (align === "justify") return AlignmentType.JUSTIFIED;
return AlignmentType.LEFT;
}
function walkInline(node, inherited = {}) {
const output = [];
if (node.nodeType === Node.TEXT_NODE) {
if (node.nodeValue) {
output.push(new TextRun({
text: node.nodeValue,
bold: inherited.bold || false,
italics: inherited.italics || false,
underline: inherited.underline ? {} : undefined,
strike: inherited.strike || false,
color: inherited.color,
font: inherited.font,
size: inherited.size
}));
}
return output;
}
if (node.nodeType !== Node.ELEMENT_NODE) return output;
const tag = node.tagName.toLowerCase();
const next = {...inherited};
if (tag === "b" || tag === "strong") next.bold = true;
if (tag === "i" || tag === "em") next.italics = true;
if (tag === "u") next.underline = true;
if (tag === "s" || tag === "strike" || tag === "del") next.strike = true;
if (node.style?.color) {
const match = node.style.color.match(/\d+/g);
if (match && match.length >= 3) {
next.color = match.slice(0,3).map(n =>
Number(n).toString(16).padStart(2,"0")).join("").toUpperCase();
}
}
if (node.style?.fontFamily) {
next.font = node.style.fontFamily.split(",")[0].replace(/["']/g, "");
}
if (node.style?.fontSize) {
const px = parseFloat(node.style.fontSize);
if (Number.isFinite(px)) next.size = Math.round(px * 1.5);
}
if (tag === "br") {
output.push(new TextRun({break: 1}));
return output;
}
for (const child of node.childNodes) {
output.push(...walkInline(child, next));
}
return output;
}
function makeParagraph(element, extra = {}) {
const runs = walkInline(element);
if (!runs.length) runs.push(new TextRun(""));
const tag = element.tagName?.toLowerCase();
let heading;
if (tag === "h1") heading = HeadingLevel.HEADING_1;
if (tag === "h2") heading = HeadingLevel.HEADING_2;
if (tag === "h3") heading = HeadingLevel.HEADING_3;
return new Paragraph({
children: runs,
heading,
alignment: getAlignment(element),
bullet: extra.bullet ? {level: 0} : undefined,
numbering: extra.numbering ? {reference: "numbered", level: 0} : undefined,
spacing: {after: 160},
...extra.options
});
}
function processBlock(node) {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.nodeValue.trim();
if (t) children.push(new Paragraph({children:[new TextRun(t)]}));
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tag = node.tagName.toLowerCase();
if (tag === "ul" || tag === "ol") {
for (const li of node.children) {
if (li.tagName.toLowerCase() === "li") {
children.push(makeParagraph(li, {
bullet: tag === "ul",
numbering: tag === "ol"
}));
}
}
return;
}
if (tag === "table") {
const rows = Array.from(node.querySelectorAll("tr")).map(tr =>
new TableRow({
children: Array.from(tr.children).map(cell =>
new TableCell({
children: [new Paragraph({
children: walkInline(cell).length
? walkInline(cell)
: [new TextRun("")]
})],
width: {size: 100, type: WidthType.AUTO}
})
)
})
);
if (rows.length) {
children.push(new Table({
rows,
width: {size: 100, type: WidthType.PERCENTAGE}
}));
children.push(new Paragraph({children:[new TextRun("")]}));
}
return;
}
if (tag === "img") {
children.push(new Paragraph({
children: [new TextRun("[圖片未匯出:請在 Word 中重新插入圖片]")],
spacing: {after: 160}
}));
return;
}
if (["p","div","h1","h2","h3","h4","h5","h6","blockquote","pre"].includes(tag)) {
children.push(makeParagraph(node));
return;
}
for (const child of node.childNodes) processBlock(child);
}
blocks.forEach(processBlock);
if (!children.length) {
children.push(new Paragraph({children:[new TextRun("")]}));
}
const doc = new Document({
sections: [{
properties: {},
children
}]
});
const blob = await Packer.toBlob(doc);
saveBlob(blob, "我的文件.docx");
setStatus("DOCX 已下載");
} catch (error) {
console.error(error);
setStatus("DOCX 匯出失敗");
alert("DOCX 匯出失敗:" + (error?.message || "未知錯誤"));
}
}
function loadDocxLibrary() {
return new Promise((resolve, reject) => {
if (window.docx) {
resolve();
return;
}
const script = document.createElement("script");
script.src =
"https://cdn.jsdelivr.net/npm/docx@8.5.0/build/index.umd.js";
script.onload = () => window.docx ? resolve() :
reject(new Error("DOCX library did not initialize"));
script.onerror = () => reject(new Error("CDN load failed"));
document.head.appendChild(script);
});
}
updateCount();
</script>
</body>
</html>
|
沒有留言:
張貼留言