Skip to content

Commit 3ec6bba

Browse files
author
Chiselo Maintainer
committed
Apply selected HTML source snippets
1 parent c69c98a commit 3ec6bba

5 files changed

Lines changed: 163 additions & 8 deletions

File tree

Chiselo/ContentView.swift

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4148,6 +4148,8 @@ private struct GeometryMetrics {
41484148
private struct InspectorPanel: View {
41494149
@EnvironmentObject private var model: EditorModel
41504150
@State private var selectedTab: InspectorTab = .layout
4151+
@State private var sourceDraft = ""
4152+
@State private var sourceDraftElementID: String?
41514153

41524154
var body: some View {
41534155
VStack(alignment: .leading, spacing: 0) {
@@ -4172,6 +4174,15 @@ private struct InspectorPanel: View {
41724174
.onChange(of: model.documentMode) { _ in
41734175
normalizeSelectedTab()
41744176
}
4177+
.onAppear {
4178+
syncSourceDraft(for: element)
4179+
}
4180+
.onChange(of: element.id) { _ in
4181+
syncSourceDraft(for: element)
4182+
}
4183+
.onChange(of: element.sourceSnippet) { _ in
4184+
syncSourceDraft(for: element)
4185+
}
41754186

41764187
ScrollView {
41774188
LazyVStack(alignment: .leading, spacing: 16) {
@@ -4201,6 +4212,17 @@ private struct InspectorPanel: View {
42014212
}
42024213
}
42034214

4215+
private func syncSourceDraft(for element: EditorElement) {
4216+
guard sourceDraftElementID != element.id else {
4217+
if sourceDraft.isEmpty, let snippet = element.sourceSnippet {
4218+
sourceDraft = snippet
4219+
}
4220+
return
4221+
}
4222+
sourceDraftElementID = element.id
4223+
sourceDraft = element.sourceSnippet ?? ""
4224+
}
4225+
42044226
@ViewBuilder
42054227
private func inspectorContent(for element: EditorElement) -> some View {
42064228
switch activeTab {
@@ -4679,13 +4701,13 @@ private struct InspectorPanel: View {
46794701
Spacer(minLength: 0)
46804702
}
46814703

4682-
if let snippet = element.sourceSnippet?.trimmingCharacters(in: .whitespacesAndNewlines), !snippet.isEmpty {
4704+
if !(element.sourceSnippet ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
46834705
ScrollView(.horizontal, showsIndicators: true) {
4684-
Text(snippet)
4706+
TextEditor(text: $sourceDraft)
46854707
.font(.system(size: 10, weight: .semibold, design: .monospaced))
46864708
.foregroundStyle(MaterialTheme.ink)
4687-
.textSelection(.enabled)
46884709
.padding(10)
4710+
.scrollContentBackground(.hidden)
46894711
.frame(maxWidth: .infinity, alignment: .leading)
46904712
}
46914713
.frame(minHeight: 72, maxHeight: 220, alignment: .topLeading)
@@ -4697,17 +4719,26 @@ private struct InspectorPanel: View {
46974719

46984720
HStack(spacing: 8) {
46994721
Button {
4700-
copySourceSnippet(snippet)
4722+
copySourceSnippet(sourceDraft)
47014723
} label: {
47024724
Label("复制片段", systemImage: "doc.on.doc")
47034725
.frame(maxWidth: .infinity)
47044726
}
47054727
.buttonStyle(MaterialButtonStyle(compact: true))
47064728

4729+
Button {
4730+
model.applySelectedHTMLSource(sourceDraft)
4731+
} label: {
4732+
Label("应用源码", systemImage: "checkmark.square")
4733+
.frame(maxWidth: .infinity)
4734+
}
4735+
.buttonStyle(MaterialButtonStyle(compact: true))
4736+
.disabled(!canApplySourceDraft(for: element))
4737+
47074738
Button {
47084739
model.selectHTMLNode(id: element.id)
47094740
} label: {
4710-
Label("定位对象", systemImage: "scope")
4741+
Label("定位", systemImage: "scope")
47114742
.frame(maxWidth: .infinity)
47124743
}
47134744
.buttonStyle(MaterialButtonStyle(compact: true))
@@ -4796,6 +4827,12 @@ private struct InspectorPanel: View {
47964827
model.status = "已复制选中对象源码片段"
47974828
}
47984829

4830+
private func canApplySourceDraft(for element: EditorElement) -> Bool {
4831+
let original = element.sourceSnippet?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
4832+
let draft = sourceDraft.trimmingCharacters(in: .whitespacesAndNewlines)
4833+
return !draft.isEmpty && draft != original
4834+
}
4835+
47994836
private func sourceSyncTitle(for element: EditorElement) -> String {
48004837
let tag = element.tagName?.uppercased() ?? "HTML"
48014838
return "\(tag) 源码片段"

Chiselo/EditorModel.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,49 @@ final class EditorModel: ObservableObject {
14951495
runJavaScript("window.ChiseloEditor?.selectHTMLById(\(literal));")
14961496
}
14971497

1498+
func applySelectedHTMLSource(_ html: String) {
1499+
guard hasOpenDocument, documentMode == "html" else {
1500+
status = "请先打开 HTML 文件"
1501+
return
1502+
}
1503+
1504+
guard let literal = jsStringLiteral(html) else {
1505+
status = "源码片段包含无法提交的字符"
1506+
return
1507+
}
1508+
1509+
let source = "JSON.stringify(window.ChiseloEditor?.applySelectedHTMLSource?.(\(literal)) ?? null);"
1510+
webView?.evaluateJavaScript(source) { [weak self] result, error in
1511+
Task { @MainActor in
1512+
guard let self else { return }
1513+
1514+
if let error {
1515+
self.status = "源码片段应用失败:\(error.localizedDescription)"
1516+
return
1517+
}
1518+
1519+
guard let json = result as? String,
1520+
json != "null",
1521+
let data = json.data(using: .utf8),
1522+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1523+
self.status = "源码片段应用失败:编辑器未返回结果"
1524+
return
1525+
}
1526+
1527+
if (object["ok"] as? Bool) == true {
1528+
if let element = self.bridgeElement(object["element"]) {
1529+
self.updatePublished(\.selectedElement, to: element)
1530+
self.updatePublished(\.selectionPath, to: element.htmlPath)
1531+
}
1532+
self.refreshHTMLDiagnostics()
1533+
self.status = "已应用源码片段,可用撤销恢复"
1534+
} else {
1535+
self.status = object["reason"] as? String ?? "源码片段应用失败"
1536+
}
1537+
}
1538+
}
1539+
}
1540+
14981541
func revertHTMLVisualChange(changeKey: String) {
14991542
guard hasOpenDocument, documentMode == "html" else {
15001543
status = "请先打开 HTML 文件"

Chiselo/Resources/Editor/editor.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2412,6 +2412,56 @@
24122412
return new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]).has(match[1].toLowerCase());
24132413
}
24142414

2415+
function parseSingleHTMLSourceElement(html, doc) {
2416+
const source = String(html || "").trim();
2417+
if (!source) return { ok: false, reason: "源码片段为空。" };
2418+
const template = doc.createElement("template");
2419+
try {
2420+
template.innerHTML = source;
2421+
} catch (error) {
2422+
return { ok: false, reason: `源码片段无法解析:${error?.message || error}` };
2423+
}
2424+
const elements = [...template.content.children];
2425+
if (elements.length !== 1) return { ok: false, reason: "源码片段必须只有一个顶层 HTML 对象。" };
2426+
const extraText = [...template.content.childNodes]
2427+
.filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0);
2428+
if (extraText.length) return { ok: false, reason: "顶层对象外不能包含额外文本。" };
2429+
const element = elements[0];
2430+
if (element.matches?.("html,head,body,script,style,link,meta,title")) {
2431+
return { ok: false, reason: "当前安全编辑只支持替换页面正文对象。" };
2432+
}
2433+
return { ok: true, element };
2434+
}
2435+
2436+
function applySelectedHTMLSource(html) {
2437+
if (editorMode !== "html") return { ok: false, reason: "当前不是 HTML 文档模式。" };
2438+
if (!directSelectedNode || !directSelectedNode.isConnected) return { ok: false, reason: "请先选中一个 HTML 对象。" };
2439+
if (directSelectionNodes().length > 1) return { ok: false, reason: "源码片段编辑暂不支持多选对象。" };
2440+
if (directSelectedNode.matches?.("html,body")) return { ok: false, reason: "不能直接替换 html/body 根对象。" };
2441+
2442+
const doc = directSelectedNode.ownerDocument;
2443+
const parsed = parseSingleHTMLSourceElement(html, doc);
2444+
if (!parsed.ok) return parsed;
2445+
2446+
const replacement = parsed.element;
2447+
const previousId = ensureDirectId(directSelectedNode);
2448+
if (!replacement.dataset.chiseloId) replacement.dataset.chiseloId = previousId;
2449+
prepareDirectSubtree(replacement);
2450+
2451+
const parent = directSelectedNode.parentElement;
2452+
if (!parent) return { ok: false, reason: "当前对象没有可替换的父级。" };
2453+
2454+
pushHistory({ label: "编辑源码片段" });
2455+
parent.replaceChild(replacement, directSelectedNode);
2456+
selectDirectNode(replacement);
2457+
updateSelectionBox();
2458+
scheduleDirectLayoutRefresh();
2459+
scheduleHTMLTreeChanged();
2460+
scheduleHTMLDiagnosticsChanged();
2461+
postSelectionChanged({ immediate: true });
2462+
return { ok: true, element: selectedElement(), sourceSnippet: replacement.outerHTML || "" };
2463+
}
2464+
24152465
function normalizeDirectHTMLSource(input) {
24162466
let html = String(input || "");
24172467
const hadDoctype = /^\s*<!doctype/i.test(html);
@@ -7953,6 +8003,7 @@ ${htmlSlides}
79538003

79548004
window.ChiseloEditor = {
79558005
addHTMLToSelection,
8006+
applySelectedHTMLSource,
79568007
command,
79578008
exportHTML,
79588009
getDeck: () => clone(deck),

docs/dev/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ All notable changes to Chiselo will be documented here.
3535
- Added responsive-layout and source-writeback review signals so save/export preflight can flag multi-width checks and inline-style changes on stylesheet-backed HTML.
3636
- Added breakpoint-aware responsive review hints so changed HTML objects show nearby widths to check before save or export.
3737
- Added selected-object source writeback hints so users can see whether style edits will update a CSS rule or inline style before changing HTML.
38-
- Added a selected-object source sync panel that shows a clean HTML snippet for the visual selection and can re-locate the object from the Inspector.
38+
- Added a selected-object source sync panel that shows, edits, applies, copies, and re-locates a clean HTML snippet for the visual selection.
3939
- Added compact quick-action regression coverage to release preflight.
4040
- Added visual-change rollback regression coverage to release preflight.
4141
- Bumped the packaging version to `0.1.11` for the next preview build.

scripts/direct-html-source-sync-test.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,35 @@ final class DirectHTMLSourceSyncTest: NSObject, WKNavigationDelegate, WKScriptMe
4646
const sourceFormatted = snippet.includes('\\n <h2>');
4747
const reselected = editor.selectHTMLById(selected.id);
4848
const reselectedSame = reselected && reselected.id === selected.id && String(reselected.sourceSnippet || '').includes('Synced title');
49-
50-
if (!sourceHasTag || !sourceHasChildren || !sourceClean || !sourceFormatted || lineCount < 4 || !reselectedSame) {
49+
const nextSource = snippet.replace('Synced title', 'Edited from source').replace('real source', 'source editor');
50+
const applyResult = editor.applySelectedHTMLSource(nextSource);
51+
const applied = applyResult && applyResult.ok === true && applyResult.element && applyResult.element.id === selected.id;
52+
const appliedSnippet = String(applyResult?.element?.sourceSnippet || '');
53+
const exported = editor.exportHTML();
54+
const diagnostics = editor.getImportDiagnostics();
55+
const sourceApplied = appliedSnippet.includes('Edited from source') && exported.includes('Edited from source') && exported.includes('source editor');
56+
const exportClean = diagnostics.cleanExport === true && diagnostics.exportArtifactCount === 0 && !exported.includes('data-chiselo');
57+
editor.command('undo');
58+
await sleep(180);
59+
const undoExport = editor.exportHTML();
60+
const undoRestored = undoExport.includes('Synced title') && !undoExport.includes('Edited from source');
61+
62+
if (!sourceHasTag || !sourceHasChildren || !sourceClean || !sourceFormatted || lineCount < 4 || !reselectedSame || !applied || !sourceApplied || !exportClean || !undoRestored) {
5163
throw new Error(JSON.stringify({
5264
sourceHasTag,
5365
sourceHasChildren,
5466
sourceClean,
5567
sourceFormatted,
5668
lineCount,
5769
reselectedSame,
70+
applyResult,
71+
applied,
72+
sourceApplied,
73+
exportClean,
74+
undoRestored,
75+
diagnostics,
76+
exported,
77+
undoExport,
5878
selected,
5979
snippet
6080
}));
@@ -64,6 +84,10 @@ final class DirectHTMLSourceSyncTest: NSObject, WKNavigationDelegate, WKScriptMe
6484
type: 'result',
6585
id: selected.id,
6686
lineCount,
87+
applied,
88+
sourceApplied,
89+
cleanExport: diagnostics.cleanExport,
90+
undoRestored,
6791
snippet
6892
});
6993
})().catch(error => {

0 commit comments

Comments
 (0)