feat: add html-tools
This commit is contained in:
parent
e92bf8b21f
commit
b63fc41586
|
|
@ -0,0 +1,74 @@
|
|||
# HTML Tools
|
||||
|
||||
**HTML 工具集:集成多种独立 HTML 工具,涵盖单一或多功能设计,致力于一键高效完成任务。**
|
||||
|
||||
## 项目简介
|
||||
|
||||
HTML Tools 是一个集合各种 HTML 工具的项目,旨在帮助开发者快速、高效地完成 HTML 页面相关的任务。无论是单一功能的小工具,还是集成多种功能的工具,这个项目都能够满足您的需求。
|
||||
|
||||
---
|
||||
|
||||
## 功能特色
|
||||
|
||||
- **多种工具集成**:提供从简单到复杂 HTML 操作的工具。
|
||||
- **高效便捷**:一键式操作,快速完成目标任务。
|
||||
- **模块化设计**:工具独立,便于扩展和维护。
|
||||
- **开源免费**:完全开源,欢迎贡献。
|
||||
|
||||
---
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 1. 克隆仓库
|
||||
```bash
|
||||
git clone https://github.com/fud114514/html-tools.git
|
||||
cd html-tools
|
||||
```
|
||||
|
||||
### 2. 打开工具
|
||||
- 将工具直接部署在浏览器中运行,或者在本地 HTML 文件中打开。
|
||||
- 无需安装额外依赖。
|
||||
|
||||
### 3. 贡献代码
|
||||
如果您有兴趣为项目贡献代码,请按照以下步骤进行:
|
||||
1. Fork 本仓库。
|
||||
2. 创建自己的分支:`git checkout -b feature/your-feature`
|
||||
3. 提交更改:`git commit -m 'Add some feature'`
|
||||
4. 推送分支到 GitHub:`git push origin feature/your-feature`
|
||||
5. 提交 Pull Request。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```plaintext
|
||||
html-tools/
|
||||
├── index.html # 主页面
|
||||
├── tools/ # 各种独立工具文件
|
||||
├── assets/ # 静态资源 (如图片、样式表)
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎大家为 HTML Tools 项目贡献自己的力量!无论是修复问题、添加新功能或改进文档,您的每一个贡献都对我们非常重要。
|
||||
|
||||
### 提交 Issue
|
||||
如果您在使用过程中遇到问题,可以通过 [Issue 页面](https://github.com/fud114514/html-tools/issues) 提交问题。
|
||||
|
||||
### 提交 Pull Request
|
||||
详细步骤请参考上文的“贡献代码”部分。
|
||||
|
||||
---
|
||||
|
||||
## 开发者
|
||||
|
||||
该项目由 [fud114514](https://github.com/fud114514) 开发和维护。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [MIT 许可证](https://opensource.org/licenses/MIT) 开源,详情请参阅 [LICENSE 文件](https://github.com/fud114514/html-tools/blob/main/LICENSE)。
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EPUB CSS 清理工具 (Web版)</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #f4f4f4;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
#drop-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#drop-area.highlight {
|
||||
border-color: dodgerblue;
|
||||
}
|
||||
#drop-area p {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
}
|
||||
#fileInput {
|
||||
display: none; /* Hide default input, use drop area or label */
|
||||
}
|
||||
label.file-label {
|
||||
display: inline-block;
|
||||
padding: 10px 15px;
|
||||
background-color: dodgerblue;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
label.file-label:hover {
|
||||
background-color: #007ae5;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background-color: #eee;
|
||||
border-radius: 4px;
|
||||
min-height: 40px; /* Ensure space for messages */
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-details {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.warning {
|
||||
color: #a00;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background-color: #fdd;
|
||||
border: 1px solid #fbb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
display: none; /* Hidden by default */
|
||||
}
|
||||
</style>
|
||||
<!-- Include JSZip library -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<!-- Include FileSaver.js library -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EPUB CSS 清理工具 (Web版)</h1>
|
||||
|
||||
<p>将单个 EPUB 文件或包含多个 EPUB 的 ZIP 文件拖拽到下方区域,或点击按钮选择文件,即可移除指定的 CSS 样式。</p>
|
||||
|
||||
<div id="drop-area">
|
||||
<!-- Accept both .epub and .zip -->
|
||||
<input type="file" id="fileInput" accept=".epub,.zip">
|
||||
<p>将 EPUB 或 ZIP 文件拖拽到这里</p>
|
||||
<label for="fileInput" class="file-label">或者选择文件</label>
|
||||
</div>
|
||||
|
||||
<progress id="progressBar" value="0" max="100"></progress>
|
||||
<div id="status">请选择或拖拽一个 EPUB 或 ZIP 文件。</div>
|
||||
<div id="statusDetails" class="status-details"></div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>重要提示:</strong> 此工具在你的浏览器中处理文件,不会上传到服务器。处理后的文件需要你手动下载。建议在处理前备份原始文件!处理 ZIP 文件时,会生成一个包含所有处理后(或原始)EPUB 的新 ZIP 文件。
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Check if libraries loaded ---
|
||||
if (typeof JSZip === 'undefined' || typeof saveAs === 'undefined') {
|
||||
const errorMsg = '错误:无法加载必需的库 (JSZip 或 FileSaver)。请检查您的网络连接,并确保没有浏览器插件(如广告拦截器)阻止从 cdnjs.cloudflare.com 加载脚本。然后请刷新页面重试。';
|
||||
document.getElementById('status').textContent = errorMsg;
|
||||
document.getElementById('status').style.color = '#a00';
|
||||
document.getElementById('drop-area').style.display = 'none';
|
||||
document.querySelector('.warning').textContent = errorMsg;
|
||||
} else {
|
||||
// --- Global Variables & Constants ---
|
||||
const dropArea = document.getElementById('drop-area');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const statusDetailsDiv = document.getElementById('statusDetails');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const CSS_REMOVE_PATTERN = /((text-indent|line-height|font-size|height|font-family|color)\s*:\s*[^;]*;|display\s*:\s*block\s*;)/ig;
|
||||
|
||||
// --- Event Handlers ---
|
||||
setupDragDropHandlers();
|
||||
fileInput.addEventListener('change', handleFileSelect, false);
|
||||
|
||||
function setupDragDropHandlers() {
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||
document.body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => dropArea.classList.add('highlight'), false);
|
||||
});
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, () => dropArea.classList.remove('highlight'), false);
|
||||
});
|
||||
dropArea.addEventListener('drop', handleDrop, false);
|
||||
}
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
handleFiles(e.target.files);
|
||||
}
|
||||
|
||||
// --- Main File Handling Logic ---
|
||||
function handleFiles(files) {
|
||||
if (files.length === 0) return;
|
||||
const file = files[0]; // Process only the first file
|
||||
|
||||
// Check file type
|
||||
const isEpub = file.name.toLowerCase().endsWith('.epub');
|
||||
const isZip = file.name.toLowerCase().endsWith('.zip');
|
||||
|
||||
if (!isEpub && !isZip) {
|
||||
updateStatus('错误:请选择一个 .epub 或 .zip 文件。', true);
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(`正在读取文件: ${file.name}...`);
|
||||
clearStatusDetails();
|
||||
progressBar.style.display = 'block';
|
||||
progressBar.value = 0;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const fileContent = e.target.result; // ArrayBuffer
|
||||
try {
|
||||
if (isEpub) {
|
||||
updateStatus(`正在处理 EPUB 文件: ${file.name}...`);
|
||||
const result = await processEpub(fileContent, file.name);
|
||||
if (result.blob) {
|
||||
saveAs(result.blob, result.filename);
|
||||
updateStatus(`处理完成!已生成 ${result.filename}。`);
|
||||
} else if (result.modified === false) {
|
||||
updateStatus('信息:文件无需修改。');
|
||||
}
|
||||
resetInput();
|
||||
} else if (isZip) {
|
||||
updateStatus(`正在处理 ZIP 压缩包: ${file.name}...`);
|
||||
await processZipArchive(fileContent, file.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("处理失败:", error);
|
||||
updateStatus(`处理失败:${error.message}`, true);
|
||||
resetInput();
|
||||
}
|
||||
};
|
||||
reader.onprogress = updateReadProgress;
|
||||
reader.onerror = handleReadError;
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
function updateReadProgress(e) {
|
||||
if (e.lengthComputable) {
|
||||
progressBar.value = Math.round((e.loaded / e.total) * 10); // Reading is ~10%
|
||||
}
|
||||
}
|
||||
|
||||
function handleReadError() {
|
||||
updateStatus('错误:读取文件时出错。', true);
|
||||
resetInput();
|
||||
}
|
||||
|
||||
// --- ZIP Archive Processing ---
|
||||
async function processZipArchive(zipFileContent, zipFilename) {
|
||||
let outputZip = new JSZip();
|
||||
let processedCount = 0;
|
||||
let failedCount = 0;
|
||||
let foundCount = 0;
|
||||
let modifiedCount = 0;
|
||||
let filePromises = [];
|
||||
|
||||
updateStatus(`正在打开 ZIP 压缩包...`);
|
||||
progressBar.value = 15;
|
||||
const inputZip = await JSZip.loadAsync(zipFileContent);
|
||||
const totalFilesInZip = Object.keys(inputZip.files).length;
|
||||
|
||||
updateStatus(`在 ZIP 中查找 EPUB 文件...`);
|
||||
|
||||
inputZip.forEach((relativePath, zipEntry) => {
|
||||
if (!zipEntry.dir && relativePath.toLowerCase().endsWith('.epub')) {
|
||||
foundCount++;
|
||||
updateStatusDetails(`找到 EPUB: ${relativePath}`);
|
||||
|
||||
// Add a promise to process this EPUB entry
|
||||
const promise = zipEntry.async('arraybuffer')
|
||||
.then(async (innerEpubContent) => {
|
||||
try {
|
||||
// Process the inner EPUB
|
||||
const result = await processEpub(innerEpubContent, relativePath);
|
||||
if (result.blob) {
|
||||
// Add modified EPUB to output ZIP
|
||||
outputZip.file(result.filename, result.blob);
|
||||
modifiedCount++;
|
||||
console.log(`Added cleaned ${result.filename} to output zip.`);
|
||||
} else {
|
||||
// Add original EPUB back if not modified
|
||||
outputZip.file(relativePath, innerEpubContent);
|
||||
console.log(`Added original ${relativePath} (no changes needed) to output zip.`);
|
||||
}
|
||||
processedCount++;
|
||||
} catch (epubError) {
|
||||
// Add original EPUB back if processing failed
|
||||
console.error(`Error processing inner EPUB ${relativePath}:`, epubError);
|
||||
updateStatusDetails(`处理 ${relativePath} 时出错,将保留原文件。`);
|
||||
outputZip.file(relativePath, innerEpubContent); // Add original back
|
||||
failedCount++;
|
||||
}
|
||||
})
|
||||
.catch(extractError => {
|
||||
// Handle error extracting the inner EPUB content
|
||||
console.error(`Error extracting inner EPUB ${relativePath} from zip:`, extractError);
|
||||
updateStatusDetails(`无法从 ZIP 中提取 ${relativePath},已跳过。`);
|
||||
failedCount++;
|
||||
})
|
||||
.finally(() => {
|
||||
// Update progress based on entries processed (success or fail)
|
||||
const progress = 15 + Math.round(((processedCount + failedCount) / foundCount) * 60); // Processing EPUBs is ~60%
|
||||
progressBar.value = Math.min(progress, 75); // Cap at 75 before final packing
|
||||
});
|
||||
filePromises.push(promise);
|
||||
}
|
||||
// We are currently NOT adding non-EPUB files from the source ZIP to the output ZIP.
|
||||
// To do so, handle the 'else' case here and add zipEntry data to outputZip.
|
||||
});
|
||||
|
||||
// Wait for all inner EPUB processing promises to complete
|
||||
await Promise.all(filePromises);
|
||||
|
||||
progressBar.value = 80; // Done processing files inside zip
|
||||
|
||||
if (foundCount === 0) {
|
||||
updateStatus('错误:在 ZIP 文件中未找到任何 EPUB 文件。', true);
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(`处理了 ${foundCount} 个 EPUB 文件。正在生成输出 ZIP...`);
|
||||
statusDetailsDiv.innerHTML += `<br>成功: ${processedCount}, 修改: ${modifiedCount}, 失败/跳过: ${failedCount}`;
|
||||
progressBar.value = 90;
|
||||
|
||||
const outputZipBlob = await outputZip.generateAsync({
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
platform: "browser"
|
||||
}, (metadata) => {
|
||||
progressBar.value = 90 + Math.round(metadata.percent * 0.1);
|
||||
});
|
||||
|
||||
progressBar.value = 100;
|
||||
const baseName = zipFilename.substring(0, zipFilename.lastIndexOf('.')) || zipFilename;
|
||||
const outputZipFilename = `${baseName}_cleaned.zip`;
|
||||
saveAs(outputZipBlob, outputZipFilename);
|
||||
|
||||
updateStatus(`处理完成!已生成包含处理后 EPUB 的 ${outputZipFilename}。`);
|
||||
resetInput();
|
||||
}
|
||||
|
||||
|
||||
// --- Single EPUB Processing (Modified to return result) ---
|
||||
async function processEpub(fileContent, originalFilename) {
|
||||
// This function now returns an object:
|
||||
// { blob: Blob, filename: string } if modified
|
||||
// { modified: false } if no modification needed
|
||||
// Throws error if processing fails critically
|
||||
|
||||
console.log(`Processing EPUB: ${originalFilename}`);
|
||||
// Note: Progress bar updates inside this function are less meaningful now,
|
||||
// as the overall progress is handled by the caller (handleFiles or processZipArchive).
|
||||
// We could pass a progress callback if detailed inner progress is needed.
|
||||
|
||||
const zip = await JSZip.loadAsync(fileContent);
|
||||
const newZip = new JSZip();
|
||||
let cssFilesFound = false;
|
||||
let modified = false;
|
||||
let filePromises = [];
|
||||
|
||||
zip.forEach((relativePath, zipEntry) => {
|
||||
if (zipEntry.dir) { newZip.folder(relativePath); return; }
|
||||
|
||||
const options = {
|
||||
date: zipEntry.date,
|
||||
unixPermissions: zipEntry.unixPermissions,
|
||||
dosPermissions: zipEntry.dosPermissions,
|
||||
comment: zipEntry.comment,
|
||||
dir: zipEntry.dir,
|
||||
compression: zipEntry.options?.compression === "STORE" ? "STORE" : "DEFLATE"
|
||||
};
|
||||
|
||||
if (relativePath.toLowerCase() === 'mimetype') {
|
||||
const promise = zipEntry.async('uint8array').then(data => {
|
||||
newZip.file(relativePath, data, { ...options, compression: "STORE" });
|
||||
});
|
||||
filePromises.push(promise);
|
||||
} else if (relativePath.toLowerCase().endsWith('.css')) {
|
||||
cssFilesFound = true;
|
||||
const promise = zipEntry.async('string').then(originalCss => {
|
||||
const cleanedCss = originalCss.replace(CSS_REMOVE_PATTERN, '');
|
||||
let contentToAdd = originalCss;
|
||||
if (cleanedCss !== originalCss) {
|
||||
modified = true;
|
||||
contentToAdd = cleanedCss;
|
||||
console.log(` Cleaned CSS: ${relativePath}`);
|
||||
}
|
||||
newZip.file(relativePath, contentToAdd, options);
|
||||
}).catch(err => {
|
||||
console.warn(` Could not process CSS ${relativePath} as text, keeping original. Error: ${err}`);
|
||||
return zipEntry.async('uint8array').then(originalData => {
|
||||
newZip.file(relativePath, originalData, options);
|
||||
});
|
||||
});
|
||||
filePromises.push(promise);
|
||||
} else {
|
||||
const promise = zipEntry.async('uint8array').then(data => {
|
||||
newZip.file(relativePath, data, options);
|
||||
});
|
||||
filePromises.push(promise);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
|
||||
if (!modified) {
|
||||
console.log(` EPUB ${originalFilename} - No modifications needed.`);
|
||||
return { modified: false }; // Indicate no changes were made
|
||||
}
|
||||
|
||||
console.log(` Repacking modified EPUB: ${originalFilename}`);
|
||||
const newEpubBlob = await newZip.generateAsync({
|
||||
type: "blob",
|
||||
mimeType: "application/epub+zip",
|
||||
compression: "DEFLATE",
|
||||
platform: "browser"
|
||||
});
|
||||
|
||||
const baseName = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename;
|
||||
const newFilename = `${baseName}_cleaned.epub`;
|
||||
|
||||
return { blob: newEpubBlob, filename: newFilename }; // Return data for the caller
|
||||
} // end of processEpub function
|
||||
|
||||
// --- UI Update Functions ---
|
||||
function updateStatus(message, isError = false) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.style.color = isError ? '#a00' : '#000';
|
||||
if (isError) {
|
||||
progressBar.style.display = 'none';
|
||||
clearStatusDetails();
|
||||
}
|
||||
}
|
||||
function updateStatusDetails(message) {
|
||||
statusDetailsDiv.innerHTML += message + "<br>";
|
||||
}
|
||||
function clearStatusDetails() {
|
||||
statusDetailsDiv.innerHTML = "";
|
||||
}
|
||||
|
||||
function resetInput() {
|
||||
fileInput.value = '';
|
||||
// Hide progress bar after a delay
|
||||
setTimeout(() => { progressBar.style.display = 'none'; progressBar.value = 0; }, 5000);
|
||||
}
|
||||
} // End of the 'else' block for library check
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Markdown Editor</title>
|
||||
<style>
|
||||
:root {
|
||||
--border-color: #eee;
|
||||
--bg-color: #fafafa;
|
||||
--text-color: #333;
|
||||
--btn-border: #ddd;
|
||||
--code-bg: #f5f5f5;
|
||||
--scrollbar-color: #ccc;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 8px 20px;
|
||||
background: var(--bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--btn-border);
|
||||
background: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#preview, #editor {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#preview {
|
||||
color: var(--text-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#editor {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-color) transparent;
|
||||
}
|
||||
|
||||
#editor::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#editor::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#editor::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
#preview h1, #preview h2, #preview h3 {
|
||||
margin: 16px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#preview h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#preview p {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
#preview ul, #preview ol {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
#preview code {
|
||||
background-color: var(--code-bg);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#preview pre code {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="toolbar">
|
||||
<button class="btn" id="copyBtn">一键复制</button>
|
||||
<button class="btn" id="exportBtn">导出文本</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div id="preview">
|
||||
<div class="preview-content"></div>
|
||||
</div>
|
||||
<textarea id="editor" placeholder="在这里输入 Markdown 文本..."></textarea>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editor = document.getElementById('editor');
|
||||
const preview = document.getElementById('preview');
|
||||
const previewContent = preview.querySelector('.preview-content');
|
||||
const toast = document.getElementById('toast');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
|
||||
// Initialize marked with safe options
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
headerIds: true,
|
||||
sanitize: false,
|
||||
highlight: function(code, lang) {
|
||||
return code;
|
||||
}
|
||||
});
|
||||
|
||||
function showToast(message, duration = 2000) {
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
try {
|
||||
const markdownText = editor.value;
|
||||
const htmlContent = marked.parse(markdownText);
|
||||
previewContent.innerHTML = htmlContent;
|
||||
} catch (error) {
|
||||
console.error('Markdown parsing error:', error);
|
||||
previewContent.innerHTML = '<p style="color: red;">Error parsing markdown</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedUpdate = debounce(updatePreview, 150);
|
||||
|
||||
async function copyText() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(editor.value);
|
||||
showToast('已复制到剪贴板');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
function exportTxt() {
|
||||
try {
|
||||
const text = editor.value;
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const timestamp = new Date().toISOString().slice(0,19).replace(/[-:]/g, '');
|
||||
a.href = url;
|
||||
a.download = `markdown_${timestamp}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('已导出为文本文件');
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
showToast('导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
editor.addEventListener('input', debouncedUpdate);
|
||||
copyBtn.addEventListener('click', copyText);
|
||||
exportBtn.addEventListener('click', exportTxt);
|
||||
|
||||
// Sync scroll
|
||||
editor.addEventListener('scroll', () => {
|
||||
const percentage = editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
|
||||
preview.scrollTop = percentage * (preview.scrollHeight - preview.clientHeight);
|
||||
});
|
||||
|
||||
// Initial content
|
||||
const initialText = `# Markdown 编辑器
|
||||
|
||||
## 欢迎使用!
|
||||
|
||||
这是一个简单的 Markdown 编辑器。
|
||||
|
||||
### 主要功能:
|
||||
|
||||
1. 实时预览
|
||||
2. 一键复制内容
|
||||
3. 导出文本文件
|
||||
4. 支持常用 Markdown 语法
|
||||
|
||||
### 快速开始:
|
||||
|
||||
- 在右侧输入 Markdown 文本
|
||||
- 在左侧查看实时预览
|
||||
- 使用顶部按钮进行操作
|
||||
|
||||
\`\`\`markdown
|
||||
# 示例代码
|
||||
**粗体** 和 *斜体*
|
||||
- 列表项
|
||||
- 另一个列表项
|
||||
\`\`\``;
|
||||
|
||||
editor.value = initialText;
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>任务卡片生成器</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start; /* 从顶部开始排列 */
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #121212; /* 深色背景,让卡片在预览时效果更一致 */
|
||||
margin: 0;
|
||||
font-family: 'Arial', 'Helvetica Neue', Helvetica, sans-serif; /* 更现代的字体栈 */
|
||||
padding: 30px 20px; /* 给body一些padding */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.export-container {
|
||||
background: #282828; /* 图片中外部容器的深灰色 */
|
||||
padding: 20px; /* 容器内边距 */
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.3); /* 给容器一些阴影 */
|
||||
}
|
||||
.card {
|
||||
width: 370px; /* 调整宽度以适应两列布局和间距 */
|
||||
background: #000000; /* 卡片纯黑背景 */
|
||||
padding: 25px;
|
||||
box-sizing: border-box;
|
||||
color: #ffffff;
|
||||
border-radius: 6px; /* 给卡片本身也加一点圆角 */
|
||||
}
|
||||
.card-header {
|
||||
border-bottom: 1px solid #444444; /* 更细、颜色更深的分割线 */
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.task-id {
|
||||
font-size: 14px;
|
||||
color: #888888; /* 灰色ID文字 */
|
||||
margin-bottom: 8px; /* ID和标题间距 */
|
||||
min-height: 1.2em; /* 确保空的时候也有高度,placeholder可以显示 */
|
||||
}
|
||||
.task-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
word-wrap: break-word;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.task-description {
|
||||
font-size: 15px;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 25px;
|
||||
min-height: 45px; /* 描述区域最小高度 */
|
||||
padding: 5px 2px; /* 轻微内边距 */
|
||||
}
|
||||
.task-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr; /* 固定两列 */
|
||||
gap: 15px;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.detail-item {
|
||||
background-color: #1c1c1c; /* 详情项的背景色,比卡片黑底亮一点 */
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* 如果最后一个元素是奇数个中的最后一个(例如第3个),则让它占据整行 */
|
||||
.detail-item:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1; /* 跨越所有列 */
|
||||
}
|
||||
.detail-item label {
|
||||
font-weight: normal;
|
||||
color: #aaaaaa; /* 标签文字颜色 */
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-item .value.editable,
|
||||
.detail-item input[type="date"].value {
|
||||
color: #ffffff;
|
||||
background-color: #000000; /* 值区域纯黑背景 */
|
||||
border: 1px solid #333333; /* 值区域的细边框 */
|
||||
width: 100%;
|
||||
padding: 8px 10px; /* 值区域内边距 */
|
||||
min-height: 1.5em; /* 确保有高度 */
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
box-sizing: border-box; /* padding和border不增加额外宽度 */
|
||||
}
|
||||
|
||||
.detail-item input[type="date"].value {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
}
|
||||
.detail-item input[type="date"].value::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.8) brightness(0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.detail-item .value.editable {
|
||||
position: relative;
|
||||
}
|
||||
.detail-item .value.editable:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #555555;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
width: calc(100% - 20px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editable {
|
||||
position: relative;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.editable:empty:not(:focus)::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #666666;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-id.editable:empty:not(:focus)::before,
|
||||
.task-title.editable:empty:not(:focus)::before,
|
||||
.task-description.editable:empty:not(:focus)::before {
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
.editable:focus {
|
||||
outline: 1px dashed #777777;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
[contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
[contenteditable]:focus {
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
::selection {
|
||||
background-color: #444444;
|
||||
color: #ffffff;
|
||||
}
|
||||
.button-container {
|
||||
margin-top: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
.export-btn {
|
||||
padding: 12px 25px;
|
||||
background: #1c1c1c;
|
||||
color: #ffffff;
|
||||
border: 1px solid #444444;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.export-btn:hover {
|
||||
background: #282828;
|
||||
border-color: #555555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="export-container" id="export-container">
|
||||
<div id="taskCard" class="card">
|
||||
<div class="card-header">
|
||||
<div class="task-id editable" contenteditable="true" data-placeholder="任务ID (可选)"></div>
|
||||
<div class="task-title editable" contenteditable="true" data-placeholder="点击输入任务标题"></div>
|
||||
</div>
|
||||
|
||||
<div class="task-description editable" contenteditable="true" data-placeholder="点击输入任务详细描述..."></div>
|
||||
|
||||
<div class="task-details">
|
||||
<div class="detail-item">
|
||||
<label for="assignee-value">负责人:</label>
|
||||
<div id="assignee-value" class="value editable" contenteditable="true" data-placeholder="未分配"></div>
|
||||
</div>
|
||||
<!-- 标签现在是第二个 -->
|
||||
<div class="detail-item">
|
||||
<label for="tags-value">标签:</label>
|
||||
<div id="tags-value" class="value editable" contenteditable="true" data-placeholder="例如: 项目A, Bug"></div>
|
||||
</div>
|
||||
<!-- 截止日期现在是第三个,会自动占据整行 -->
|
||||
<div class="detail-item">
|
||||
<label for="dueDate-value">截止日期:</label>
|
||||
<input type="date" id="dueDate-value" class="value">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
|
||||
</div>
|
||||
|
||||
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
||||
<script>
|
||||
function sanitizeFilename(name) {
|
||||
return name.replace(/[^a-z0-9_\-.\u4e00-\u9fa5\s]/gi, '').replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function exportToPng() {
|
||||
if (window.getSelection) {
|
||||
if (window.getSelection().empty) { window.getSelection().empty(); }
|
||||
else if (window.getSelection().removeAllRanges) { window.getSelection().removeAllRanges(); }
|
||||
} else if (document.selection) { document.selection.empty(); }
|
||||
|
||||
const container = document.getElementById('export-container');
|
||||
// const card = document.getElementById('taskCard'); // card variable not strictly needed here
|
||||
const originalDueDate = document.getElementById('dueDate-value').value;
|
||||
|
||||
html2canvas(container, {
|
||||
backgroundColor: null,
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false, // Changed to false to reduce console noise unless debugging
|
||||
onclone: (documentClone) => {
|
||||
// const clonedCard = documentClone.getElementById('taskCard'); // clonedCard not strictly needed
|
||||
|
||||
const dueDateInputClone = documentClone.getElementById('dueDate-value');
|
||||
if (dueDateInputClone) {
|
||||
dueDateInputClone.value = originalDueDate;
|
||||
if (!originalDueDate) {
|
||||
dueDateInputClone.style.color = '#555555';
|
||||
} else {
|
||||
dueDateInputClone.style.color = '#ffffff';
|
||||
}
|
||||
}
|
||||
|
||||
const editables = documentClone.querySelectorAll('.editable');
|
||||
editables.forEach(el => {
|
||||
// Placeholder handling is mostly done by CSS (:empty::before)
|
||||
// If specific adjustments needed for clone, add here.
|
||||
// For example, ensuring text color if content exists:
|
||||
// if (el.textContent.trim() !== '' && el.classList.contains('value')) {
|
||||
// el.style.color = '#ffffff';
|
||||
// }
|
||||
});
|
||||
}
|
||||
}).then(canvas => {
|
||||
const link = document.createElement('a');
|
||||
const taskTitleElement = document.getElementById('task-title');
|
||||
let filename = '任务卡片.png';
|
||||
if (taskTitleElement && taskTitleElement.textContent.trim() !== '') {
|
||||
const sanitizedTitle = sanitizeFilename(taskTitleElement.textContent.trim());
|
||||
if (sanitizedTitle) { // Ensure title isn't just invalid chars
|
||||
filename = sanitizedTitle + '.png';
|
||||
}
|
||||
}
|
||||
link.download = filename;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}).catch(err => {
|
||||
console.error("导出PNG失败:", err);
|
||||
alert("导出PNG失败,请查看控制台获取更多信息。");
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.editable').forEach(element => {
|
||||
element.addEventListener('focus', function() {
|
||||
// CSS handles placeholder via :not(:focus)::before
|
||||
});
|
||||
element.addEventListener('blur', function() {
|
||||
// CSS handles placeholder
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Markdown 编辑与预览 (带同步滚动和滑块)</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"></script>
|
||||
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f0f0f0;
|
||||
margin: 0;
|
||||
font-family: 'Arial', sans-serif;
|
||||
padding-top: 20px;
|
||||
overflow: hidden; /* Prevent body scrollbars if editor is full height */
|
||||
}
|
||||
.editor-container {
|
||||
display: flex;
|
||||
width: 90%; /* Use percentage for better responsiveness */
|
||||
max-width: 1200px; /* Max width for large screens */
|
||||
height: calc(100vh - 100px); /* Adjust height to fill more of the viewport */
|
||||
background: #333333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
position: relative; /* For resizer positioning if needed */
|
||||
}
|
||||
.markdown-editor, .preview-pane {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
height: 100%; /* Fill the container height */
|
||||
}
|
||||
.markdown-editor {
|
||||
/* flex: 1; Will be set by JS */
|
||||
background: #222222;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
}
|
||||
.resizer {
|
||||
flex: 0 0 10px; /* Don't grow, don't shrink, base width 10px */
|
||||
background: #555555;
|
||||
cursor: col-resize;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none; /* Prevent text selection during drag */
|
||||
}
|
||||
.resizer::before { /* Optional: visual indicator for dragging */
|
||||
content: '⋮';
|
||||
color: #aaa;
|
||||
font-size: 18px;
|
||||
line-height: 0;
|
||||
}
|
||||
.preview-pane {
|
||||
/* flex: 1; Will be set by JS */
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
/* border-left: 2px solid #666666; /* Replaced by resizer */
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Styling for rendered Markdown elements in preview-pane */
|
||||
.preview-pane h1, .preview-pane h2, .preview-pane h3, .preview-pane h4, .preview-pane h5, .preview-pane h6 {
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid #444444;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.preview-pane h1 { font-size: 24px; font-weight: bold; border-bottom: 2px solid #666666; padding-bottom: 10px; margin-bottom: 15px;}
|
||||
.preview-pane p {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.preview-pane ul, .preview-pane ol {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.preview-pane li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.preview-pane blockquote {
|
||||
border-left: 4px solid #555555;
|
||||
padding-left: 10px;
|
||||
color: #aaaaaa;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.preview-pane pre {
|
||||
background-color: #1e1e1e;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
.preview-pane code {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
background-color: #282c34;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.preview-pane pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.preview-pane table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.preview-pane th, .preview-pane td {
|
||||
border: 1px solid #666666;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.preview-pane th {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
.preview-pane img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.export-btn {
|
||||
padding: 10px 20px;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: 2px solid #666666;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.export-btn:hover {
|
||||
background: #333333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="editor-container">
|
||||
<textarea id="markdown-input" class="markdown-editor" placeholder="在此输入 Markdown... 例如:\n# 标题\n\n- 列表项1\n- 列表项2\n\n**加粗文本**"></textarea>
|
||||
<div id="resizer" class="resizer"></div>
|
||||
<div id="preview-output" class="preview-pane"></div>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const markdownInput = document.getElementById('markdown-input');
|
||||
const previewOutput = document.getElementById('preview-output');
|
||||
const resizer = document.getElementById('resizer');
|
||||
const editorContainer = document.querySelector('.editor-container');
|
||||
|
||||
// Initial flex distribution
|
||||
markdownInput.style.flex = '1';
|
||||
previewOutput.style.flex = '1';
|
||||
|
||||
const converter = new showdown.Converter({
|
||||
tables: true,
|
||||
strikethrough: true,
|
||||
tasklists: true,
|
||||
ghCompatibleHeaderId: true,
|
||||
simpleLineBreaks: true
|
||||
});
|
||||
converter.setFlavor('github');
|
||||
|
||||
function renderMarkdown() {
|
||||
const markdownText = markdownInput.value;
|
||||
const html = converter.makeHtml(markdownText);
|
||||
previewOutput.innerHTML = html;
|
||||
}
|
||||
|
||||
markdownInput.addEventListener('input', renderMarkdown);
|
||||
renderMarkdown(); // Initial render
|
||||
|
||||
// --- Resizer Logic ---
|
||||
let isResizing = false;
|
||||
let startX, startEditorWidth, startPreviewWidth;
|
||||
|
||||
resizer.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // Prevent text selection, etc.
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startEditorWidth = markdownInput.offsetWidth;
|
||||
startPreviewWidth = previewOutput.offsetWidth;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
});
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
const dx = e.clientX - startX;
|
||||
let newEditorWidth = startEditorWidth + dx;
|
||||
let newPreviewWidth = startPreviewWidth - dx;
|
||||
|
||||
const minWidth = 50; // Minimum width for panes
|
||||
|
||||
if (newEditorWidth < minWidth) {
|
||||
newEditorWidth = minWidth;
|
||||
newPreviewWidth = startEditorWidth + startPreviewWidth - minWidth;
|
||||
} else if (newPreviewWidth < minWidth) {
|
||||
newPreviewWidth = minWidth;
|
||||
newEditorWidth = startEditorWidth + startPreviewWidth - minWidth;
|
||||
}
|
||||
|
||||
const totalWidth = editorContainer.offsetWidth - resizer.offsetWidth;
|
||||
markdownInput.style.flex = `0 0 ${newEditorWidth}px`;
|
||||
previewOutput.style.flex = `0 0 ${newPreviewWidth}px`;
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
}
|
||||
|
||||
// --- Synchronized Scrolling ---
|
||||
let scrollTimeout;
|
||||
let isSyncingEditor = false;
|
||||
let isSyncingPreview = false;
|
||||
|
||||
markdownInput.addEventListener('scroll', () => {
|
||||
if (isSyncingEditor) {
|
||||
isSyncingEditor = false; // Reset for next manual scroll
|
||||
return;
|
||||
}
|
||||
if (isResizing) return; // Don't sync scroll while resizing
|
||||
|
||||
isSyncingPreview = true; // Prevent preview scroll from triggering editor scroll
|
||||
|
||||
const scrollPercentage = markdownInput.scrollTop / (markdownInput.scrollHeight - markdownInput.clientHeight);
|
||||
if (isNaN(scrollPercentage)) return; // Avoid NaN if scrollHeight equals clientHeight
|
||||
|
||||
previewOutput.scrollTop = scrollPercentage * (previewOutput.scrollHeight - previewOutput.clientHeight);
|
||||
|
||||
// Clear any existing timeout to avoid race conditions
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isSyncingPreview = false;
|
||||
}, 100); // Adjust delay as needed
|
||||
});
|
||||
|
||||
previewOutput.addEventListener('scroll', () => {
|
||||
if (isSyncingPreview) {
|
||||
isSyncingPreview = false; // Reset for next manual scroll
|
||||
return;
|
||||
}
|
||||
if (isResizing) return; // Don't sync scroll while resizing
|
||||
|
||||
isSyncingEditor = true; // Prevent editor scroll from triggering preview scroll
|
||||
|
||||
const scrollPercentage = previewOutput.scrollTop / (previewOutput.scrollHeight - previewOutput.clientHeight);
|
||||
if (isNaN(scrollPercentage)) return;
|
||||
|
||||
markdownInput.scrollTop = scrollPercentage * (markdownInput.scrollHeight - markdownInput.clientHeight);
|
||||
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isSyncingEditor = false;
|
||||
}, 100); // Adjust delay as needed
|
||||
});
|
||||
|
||||
|
||||
function exportToPng() {
|
||||
const previewPane = document.getElementById('preview-output');
|
||||
const originalHeight = previewPane.style.height;
|
||||
const originalOverflowY = previewPane.style.overflowY;
|
||||
|
||||
previewPane.style.height = previewPane.scrollHeight + 'px';
|
||||
previewPane.style.overflowY = 'visible';
|
||||
previewPane.style.backgroundColor = '#000000';
|
||||
|
||||
html2canvas(previewPane, {
|
||||
backgroundColor: '#000000',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
onclone: (documentClone) => {
|
||||
const clonedPreviewPane = documentClone.getElementById('preview-output');
|
||||
if (clonedPreviewPane) {
|
||||
clonedPreviewPane.style.backgroundColor = '#000000';
|
||||
clonedPreviewPane.style.color = '#ffffff';
|
||||
clonedPreviewPane.style.height = clonedPreviewPane.scrollHeight + 'px';
|
||||
clonedPreviewPane.style.overflowY = 'visible';
|
||||
}
|
||||
}
|
||||
}).then(canvas => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'markdown_card.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}).finally(() => {
|
||||
previewPane.style.height = originalHeight;
|
||||
previewPane.style.overflowY = originalOverflowY;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小红书内容卡片</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f0f0f0;
|
||||
margin: 0;
|
||||
font-family: 'Arial', sans-serif;
|
||||
}
|
||||
.export-container {
|
||||
background: #333333;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card {
|
||||
width: 320px;
|
||||
background: #000000;
|
||||
border: 2px solid #666666; /* 改为灰色边框 */
|
||||
box-shadow: 4px 4px 0 #666666; /* 改为灰色阴影 */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
border-bottom: 2px solid #666666; /* 改为灰色边框 */
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
word-wrap: break-word;
|
||||
background-color: #000000;
|
||||
}
|
||||
.content {
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
background-color: #000000;
|
||||
}
|
||||
.editable {
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
}
|
||||
.editable:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: #666666;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.editable:focus {
|
||||
outline: none;
|
||||
background-color: #000000;
|
||||
}
|
||||
[contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
[contenteditable]:focus {
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
::selection {
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
}
|
||||
.button-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.export-btn {
|
||||
padding: 10px 20px;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: 2px solid #666666; /* 添加灰色边框保持一致性 */
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.export-btn:hover {
|
||||
background: #333333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="export-container" id="export-container">
|
||||
<div id="card" class="card">
|
||||
<div class="title editable" contenteditable="true" data-placeholder="点击输入标题"></div>
|
||||
<div class="content editable" contenteditable="true" data-placeholder="点击输入内容"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
|
||||
</div>
|
||||
|
||||
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
||||
<script>
|
||||
function exportToPng() {
|
||||
// 清除选中状态
|
||||
window.getSelection().removeAllRanges();
|
||||
|
||||
const container = document.getElementById('export-container');
|
||||
html2canvas(container, {
|
||||
backgroundColor: '#333333',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
removeContainer: true,
|
||||
foreignObjectRendering: false,
|
||||
onclone: (document) => {
|
||||
const editables = document.querySelectorAll('.editable');
|
||||
editables.forEach(el => {
|
||||
// 确保克隆的元素保持相同的样式
|
||||
el.style.backgroundColor = '#000000';
|
||||
if (el.classList.contains('title')) {
|
||||
el.style.borderBottom = '2px solid #666666';
|
||||
}
|
||||
});
|
||||
// 确保卡片样式在克隆中保持一致
|
||||
const card = document.querySelector('.card');
|
||||
card.style.border = '2px solid #666666';
|
||||
card.style.boxShadow = '4px 4px 0 #666666';
|
||||
}
|
||||
}).then(canvas => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'xiaohongshu_card.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
// 监听编辑事件,保持样式一致性
|
||||
document.querySelectorAll('.editable').forEach(element => {
|
||||
element.addEventListener('input', function() {
|
||||
this.style.backgroundColor = '#000000';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue