Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Vendored files must retain their registry bytes for manifest verification.
vendor/mathlive/** -text
14 changes: 10 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,28 @@ jobs:
node-version: '22'

- name: JS 语法检查
run: node --check main.js
shell: pwsh
run: |
foreach ($file in @('main.js', 'workspace.js', 'project-model.js', 'project-archive.js', 'preview.js', 'visual-math.js', 'visual-references.js', 'visual-references-ui.js', 'vendor/mathlive/mathlive.min.js', 'compiler.mjs', 'pdf-preview.mjs', 'server.mjs')) {
node --check $file
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}

- name: 冒烟检查
run: node tests/smoke.mjs

- name: 导入、导出与编辑回归测试
- name: 编辑、编译服务与预览回归测试
run: node --test

# 纯静态项目无运行时依赖,无需安装
# 工具链由用户自行安装,不将第三方引擎二进制打入源码包。
- name: 打包项目为 zip
shell: pwsh
run: |
$tag = $env:GITHUB_REF_NAME
$name = "VisualLaTeX-$tag"
New-Item -ItemType Directory -Path $name -Force | Out-Null
Copy-Item index.html, style.css, main.js, LICENSE, README.md, README.en.md -Destination $name
Copy-Item index.html, style.css, workspace.css, preview.css, visual-math.css, visual-references-ui.css, studio.css, studio-dialogs.css, main.js, workspace.js, project-model.js, project-archive.js, preview.js, visual-math.js, visual-references.js, visual-references-ui.js, compiler.mjs, pdf-preview.mjs, server.mjs, start.ps1, setup-windows.ps1, LICENSE, README.md, README.en.md -Destination $name
Copy-Item vendor -Destination $name -Recurse
Copy-Item screenshots -Destination $name -Recurse
Copy-Item tests -Destination $name -Recurse
Compress-Archive -Path $name -DestinationPath "$name.zip"
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ jobs:
with:
node-version: '22'

# JS 语法检查(等效于 compileall)
- name: JS 语法检查
run: node --check main.js
run: |
for file in main.js workspace.js project-model.js project-archive.js preview.js visual-math.js visual-references.js visual-references-ui.js vendor/mathlive/mathlive.min.js compiler.mjs pdf-preview.mjs server.mjs; do
node --check "$file"
done

# 无头冒烟检查:资源完整、标签配对、DOM id 一致性
- name: 冒烟检查
run: node tests/smoke.mjs

- name: 导入、导出与编辑回归测试
- name: 编辑、编译服务与预览回归测试
run: node --test
12 changes: 10 additions & 2 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,25 @@ jobs:

- name: 验证待部署的同一提交
run: |
node --check main.js
for file in main.js workspace.js project-model.js project-archive.js preview.js visual-math.js visual-references.js visual-references-ui.js vendor/mathlive/mathlive.min.js compiler.mjs pdf-preview.mjs server.mjs; do
node --check "$file"
done
node tests/smoke.mjs
node --test

- name: 配置 Pages
uses: actions/configure-pages@v5

- name: 准备静态编辑器
run: |
mkdir site
cp index.html style.css workspace.css preview.css visual-math.css visual-references-ui.css studio.css studio-dialogs.css main.js workspace.js project-model.js project-archive.js preview.js visual-math.js visual-references.js visual-references-ui.js LICENSE site/
cp -R vendor site/

- name: 上传站点产物
uses: actions/upload-pages-artifact@v3
with:
path: . # 纯静态项目,直接上传仓库根目录
path: site # Pages 仅提供编辑;编译需要本机 Node 服务和工具链。

deploy:
needs: build
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
test-results/
playwright-report/
.tools/
171 changes: 84 additions & 87 deletions README.en.md

Large diffs are not rendered by default.

202 changes: 114 additions & 88 deletions README.md

Large diffs are not rendered by default.

289 changes: 289 additions & 0 deletions compiler.mjs

Large diffs are not rendered by default.

332 changes: 237 additions & 95 deletions index.html

Large diffs are not rendered by default.

122 changes: 108 additions & 14 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ document.addEventListener('DOMContentLoaded', function() {
previewArea = document.getElementById('previewArea');
statusText = document.getElementById('statusText');

// 完整工作区使用独立的多文件状态;单文件核心仍可单独加载和测试。
if (typeof initWorkspace === 'function') {
initWorkspace();
return;
}

// 绑定工具栏按钮事件
initToolbar();

Expand Down Expand Up @@ -224,13 +230,14 @@ function insertBlockFormula() {
}

// 插入图片
function insertImage() {
saveEditorSelection();
const imagePath = prompt('请输入图片路径(LaTeX 中使用的路径):', 'example-image.png');
function insertImage(options) {
const supplied = options && typeof options.imagePath === 'string';
if (!supplied) saveEditorSelection();
const imagePath = supplied ? options.imagePath : prompt('请输入图片路径(LaTeX 中使用的路径):', 'example-image.png');
if (!imagePath) return;
const caption = prompt('请输入图片标题:', '图片标题');
const caption = supplied ? options.caption : prompt('请输入图片标题:', '图片标题');
if (caption === null) return;
const label = prompt('请输入图片标签(用于引用):', 'fig:example');
const label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example');
if (label === null) return;

const img = document.createElement('img');
Expand Down Expand Up @@ -740,9 +747,18 @@ function parseTexToHtml(tex) {
const env = readTexEnvironment(tex, i);
if (env) {
let visual = null;
const source = tex.slice(i, env.end);
if (env.complete && /^(?:equation|align|gather)\*?$/.test(env.name)) {
// Keep labels, tags, row breaks, comments and nested math in the
// semantic body. The visual editor must preserve this metadata.
visual = texSourceElement('div', source, escapeForHtml(source),
' class="formula-block" data-delimiter="environment" data-environment="' + escapeAttr(env.name) +
'" data-formula="' + texSourceAttribute(tex.slice(env.innerStart, env.innerEnd)) +
'" data-tex-env-open="' + texSourceAttribute(tex.slice(i, env.innerStart)) +
'" data-tex-env-close="' + texSourceAttribute(tex.slice(env.innerEnd, env.end)) + '" contenteditable="false"');
}
if (env.complete && (env.name === 'itemize' || env.name === 'enumerate')) visual = parseSimpleTexList(tex, env);
if (env.complete && env.name === 'figure') visual = parseSimpleTexFigure(tex, env);
const source = tex.slice(i, env.end);
html += visual || texSourceElement('div', source, escapeForHtml(source), ' class="raw-tex"');
i = env.end;
continue;
Expand Down Expand Up @@ -832,19 +848,42 @@ function hasTexPackage(preamble, packageName) {
}

// 同时检查内容和根节点属性:居中、标题级别或公式属性变化也必须使原文缓存失效。
function getTexNodeSnapshot(node) {
function getTexNodeSnapshot(node, legacyPresentation = false) {
function describe(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return [current.nodeType, current.textContent];
// Atomic TeX nodes have editable semantics separate from their rendered
// presentation. Decorations must not invalidate an ancestor's source,
// while changing a command or formula must invalidate that ancestor.
if (!legacyPresentation && current.classList.contains('tex-cmd')) {
const source = current.getAttribute('data-tex-source');
return ['tex-cmd', source === null ? current.textContent : source];
}
if (!legacyPresentation && (current.classList.contains('formula-inline') || current.classList.contains('formula-block'))) {
return ['formula', current.classList.contains('formula-block') ? 'block' : 'inline',
current.getAttribute('data-formula'), current.getAttribute('data-delimiter'),
current.getAttribute('data-environment'), current.getAttribute('data-tex-env-open'),
current.getAttribute('data-tex-env-close')];
}
const attributes = Array.from(current.attributes || [])
.filter(function(attr) { return !attr.name.startsWith('data-tex-'); })
.map(function(attr) { return [attr.name, attr.value]; })
.sort(function(a, b) { return a[0].localeCompare(b[0]); });
return [current.tagName.toLowerCase(), attributes, Array.from(current.childNodes).map(describe)];
}
// 不把子节点的原文和快照再次嵌进父快照,防止嵌套格式造成元数据膨胀。
// Ordinary containers exclude cached source/snapshots to avoid metadata growth.
return JSON.stringify(describe(node));
}

function upgradeTexNodeSnapshots(root) {
for (const node of Array.from(root.querySelectorAll('[data-tex-snapshot]'))) {
// Only migrate a still-valid legacy cache. Replacing stale snapshots
// would discard edits by making the original source authoritative again.
if (node.getAttribute('data-tex-snapshot') === getTexNodeSnapshot(node, true)) {
node.setAttribute('data-tex-snapshot', getTexNodeSnapshot(node));
}
}
}

// 浏览器按 Enter 拆分段落时,不一定为新兄弟节点插入源文件的空白占位符。
function convertEditorNodesToLatex(nodes) {
function ordinaryParagraph(node) {
Expand Down Expand Up @@ -900,11 +939,22 @@ function convertNodeToLatex(node) {
const separator = source !== null ? '' : '\n\n';
if (node.classList.contains('tex-whitespace')) return source || '';
if (node.classList.contains('raw-tex')) return getRawTexContent(node);
if (node.classList.contains('tex-cmd')) return node.textContent;
if (node.classList.contains('tex-cmd')) return source === null ? node.textContent : source;

if (node.classList.contains('formula-inline') || node.classList.contains('formula-block')) {
const formula = node.getAttribute('data-formula') || '';
const delimiter = node.getAttribute('data-delimiter');
const environment = node.getAttribute('data-environment');
if (delimiter === 'environment' && /^(?:equation|align|gather)\*?$/.test(environment || '')) {
const escapedName = environment.replace('*', '\\*');
const originalOpen = node.getAttribute('data-tex-env-open') || '';
const originalClose = node.getAttribute('data-tex-env-close') || '';
const open = new RegExp('^\\\\begin[ \\t\\r\\n]*\\{' + escapedName + '\\}$').test(originalOpen)
? originalOpen : '\\begin{' + environment + '}';
const close = new RegExp('^\\\\end[ \\t\\r\\n]*\\{' + escapedName + '\\}$').test(originalClose)
? originalClose : '\\end{' + environment + '}';
return open + formula + close;
}
if (delimiter === '$$') return '$$' + formula + '$$';
if (delimiter === '\\[') return '\\[' + formula + '\\]';
if (delimiter === '\\(') return '\\(' + formula + '\\)';
Expand Down Expand Up @@ -1082,9 +1132,9 @@ function sanitizeDraftHtml(html) {
template.innerHTML = html;
const allowedTags = new Set(['P', 'DIV', 'SPAN', 'H1', 'H2', 'H3', 'UL', 'OL', 'LI', 'B', 'STRONG', 'I', 'EM', 'BR', 'IMG']);
const allowedAttributes = new Set(['class', 'contenteditable', 'hidden', 'src', 'alt', 'align', 'title',
'data-caption', 'data-label', 'data-width', 'data-formula', 'data-delimiter',
'data-caption', 'data-label', 'data-width', 'data-formula', 'data-delimiter', 'data-environment',
'data-placement', 'data-centering', 'data-has-caption', 'data-caption-tex', 'data-label-tex', 'data-item-label',
'data-tex-command', 'data-tex-source', 'data-tex-snapshot']);
'data-tex-command', 'data-tex-source', 'data-tex-snapshot', 'data-tex-env-open', 'data-tex-env-close']);
for (const node of Array.from(template.content.querySelectorAll('*'))) {
if (!allowedTags.has(node.tagName)) {
if (/^(SCRIPT|STYLE|IFRAME|OBJECT|EMBED|SVG|MATH|TEMPLATE)$/.test(node.tagName)) node.remove();
Expand Down Expand Up @@ -1117,6 +1167,7 @@ function loadDraft() {
const draft = JSON.parse(serialized);
if (draft.version !== 1 || typeof draft.html !== 'string') throw new Error('草稿格式无效');
editorArea.innerHTML = sanitizeDraftHtml(draft.html);
upgradeTexNodeSnapshots(editorArea);
texPreamble = typeof draft.preamble === 'string' ? draft.preamble : null;
texTail = typeof draft.tail === 'string' ? draft.tail : '';
texDocumentOpen = typeof draft.open === 'string' ? draft.open : '\\begin{document}';
Expand All @@ -1130,6 +1181,7 @@ function loadDraft() {

if (content !== null) {
editorArea.innerHTML = sanitizeDraftHtml(content);
upgradeTexNodeSnapshots(editorArea);
texPreamble = localStorage.getItem('visuellatex_preamble') || null;
texTail = localStorage.getItem('visuellatex_tail') || '';
importedFileName = localStorage.getItem('visuellatex_filename') || null;
Expand Down Expand Up @@ -1214,13 +1266,18 @@ function openFormulaModal(mode, formulaNode = null) {

const radio = document.querySelector('input[name="formulaMode"][value="' + (mode || 'inline') + '"]');
if (radio) radio.checked = true;
document.querySelectorAll('input[name="formulaMode"]').forEach(function(option) {
option.disabled = Boolean(editingFormulaNode && editingFormulaNode.getAttribute('data-delimiter') === 'environment');
});

modal.classList.add('show');
if (typeof VisualMath !== 'undefined' && VisualMath.open(document.getElementById('formulaInput').value, editingFormulaNode)) return;
document.getElementById('formulaInput').focus();
}

// 关闭公式弹窗
function closeFormulaModal() {
if (typeof VisualMath !== 'undefined') VisualMath.close();
document.getElementById('formulaModal').classList.remove('show');
editingFormulaNode = null;
document.getElementById('btnInsertFormula').textContent = '插入公式';
Expand All @@ -1245,6 +1302,7 @@ function renderSymbolPalette() {

// 向公式输入框光标处插入符号
function insertSymbolIntoTextarea(symbol) {
if (typeof VisualMath !== 'undefined' && VisualMath.insert(symbol)) return;
const ta = document.getElementById('formulaInput');
const start = ta.selectionStart;
const end = ta.selectionEnd;
Expand All @@ -1255,7 +1313,13 @@ function insertSymbolIntoTextarea(symbol) {

// 从手动输入插入公式
function insertFormulaFromInput() {
const latex = document.getElementById('formulaInput').value;
let latex;
try { latex = typeof VisualMath !== 'undefined' ? VisualMath.value() : document.getElementById('formulaInput').value; }
catch (problem) {
const error = document.getElementById('formulaError');
if (error) error.textContent = problem.message;
updateStatus(problem.message); return;
}
if (!latex.trim()) {
updateStatus('请输入公式内容');
return;
Expand All @@ -1274,6 +1338,10 @@ function insertFormulaFromInput() {
const delimiter = old.getAttribute('data-delimiter');
if (mode === oldMode && delimiter) {
replacement.setAttribute('data-delimiter', delimiter);
['data-environment','data-tex-env-open','data-tex-env-close'].forEach(function(name) {
const value = old.getAttribute(name);
if (value !== null) replacement.setAttribute(name, value);
});
const close = delimiter === '\\(' ? '\\)' : delimiter === '\\[' ? '\\]' : delimiter;
replacement.textContent = delimiter + latex + close;
} else if (mode === oldMode && mode === 'block') {
Expand All @@ -1282,6 +1350,7 @@ function insertFormulaFromInput() {
}
// 新节点不携带旧源码快照,祖先的快照比较也会检测到此次修改。
old.parentNode.replaceChild(replacement, old);
if (typeof renderVisualMath === 'function') renderVisualMath(editorArea);
updatePreview();
if (saveDraft()) updateStatus('公式已更新');
} else {
Expand All @@ -1308,6 +1377,7 @@ function insertFormulaNode(latex, type) {
const node = document.createElement('span');
configureFormulaNode(node, latex, type);
if (!insertNodeAtCursor(node)) return;
if (typeof renderVisualMath === 'function') renderVisualMath(editorArea);
updatePreview();
if (saveDraft()) updateStatus('公式已插入');
}
Expand Down Expand Up @@ -1337,14 +1407,19 @@ function restoreEditorSelection() {
// 初始化公式弹窗
function initFormulaModal() {
renderSymbolPalette();
if (typeof VisualMath !== 'undefined') VisualMath.initialize();

editorArea.addEventListener('dblclick', function(e) {
function editFormula(e) {
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target.nodeType === Node.ELEMENT_NODE ? e.target : e.target.parentElement;
const formula = target && target.closest ? target.closest('.formula-inline, .formula-block') : null;
if (!formula || !editorArea.contains(formula)) return;
e.preventDefault();
openFormulaModal(formula.classList.contains('formula-block') ? 'block' : 'inline', formula);
});
}
editorArea.addEventListener('click', editFormula);
editorArea.addEventListener('dblclick', editFormula);
editorArea.addEventListener('keydown', editFormula);

// 关闭按钮
document.getElementById('formulaModalClose').addEventListener('click', closeFormulaModal);
Expand All @@ -1353,6 +1428,22 @@ function initFormulaModal() {
document.getElementById('formulaModal').addEventListener('click', function(e) {
if (e.target === this) closeFormulaModal();
});
document.getElementById('formulaModal').addEventListener('keydown', function(e) {
// Keep document shortcuts out of the formula draft. Ctrl+S commits the
// formula itself; native MathLive undo remains inside the mathfield.
e.stopPropagation();
if (e.key === 'Escape') { e.preventDefault(); closeFormulaModal(); }
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault(); insertFormulaFromInput();
} else if (e.key === 'Tab' && this.querySelectorAll) {
const fields = Array.from(this.querySelectorAll('button,input,textarea,summary,math-field'))
.filter(function(field) { return !field.disabled && field.getClientRects && field.getClientRects().length; });
if (!fields.length) return;
const first = fields[0], last = fields[fields.length-1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});

// 插入按钮
document.getElementById('btnInsertFormula').addEventListener('click', insertFormulaFromInput);
Expand All @@ -1361,6 +1452,9 @@ function initFormulaModal() {
document.getElementById('formulaInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// This shortcut confirms the formula locally; it must not also
// reach the workspace's compile shortcut and hide the editor.
e.stopPropagation();
insertFormulaFromInput();
}
});
Expand Down
Loading
Loading