Preserve FXML source ranges and extend shared Component projection
This commit is contained in:
@@ -28,10 +28,83 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private final TextArea source = new TextArea(), output = new TextArea();
|
||||
private final ComboBox<DesignerWorkflow.Example> examples = new ComboBox<>();
|
||||
private final SplitPane design;
|
||||
private final ContentPanelController content;
|
||||
|
||||
public ContentPanelController content() {
|
||||
return content;
|
||||
}
|
||||
|
||||
private final VBox componentInspector = new VBox(4);
|
||||
|
||||
private void componentSelection() {
|
||||
componentInspector.getChildren().clear();
|
||||
if (projection == null) return;
|
||||
var hit = kit.editor().getSelection().getHitItem();
|
||||
if (hit == null) return;
|
||||
String id = hit.getFxId();
|
||||
if (id == null || !projection.components().containsKey(id)) return;
|
||||
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
|
||||
componentInspector
|
||||
.getChildren()
|
||||
.add(new Label("Declared FXML properties (validated by target runtime)"));
|
||||
projection
|
||||
.componentAttributes(id)
|
||||
.forEach(
|
||||
(name, value) -> {
|
||||
var field = new TextField(value);
|
||||
field.setId("component-property-" + name);
|
||||
field.setOnAction(
|
||||
e -> {
|
||||
try {
|
||||
submit(
|
||||
projection.componentAttribute(id, name, field.getText()),
|
||||
"Edit Component " + name);
|
||||
} catch (Exception error) {
|
||||
report(error);
|
||||
}
|
||||
});
|
||||
componentInspector.getChildren().add(new HBox(4, new Label(name), field));
|
||||
});
|
||||
}
|
||||
|
||||
private final Timeline poll;
|
||||
private final PauseTransition typing = new PauseTransition(Duration.millis(250)),
|
||||
resize = new PauseTransition(Duration.millis(350));
|
||||
private String submittedSource;
|
||||
private List<EditBatch.Replacement> pendingVisual;
|
||||
private String pendingBase;
|
||||
|
||||
private static String apply(String text, List<EditBatch.Replacement> edits) {
|
||||
return new EditBatch(0, "projection", edits)
|
||||
.applyTo(
|
||||
new DocumentSnapshot(java.net.URI.create("file:/projection"), 0, text, true, false));
|
||||
}
|
||||
|
||||
private static List<EditBatch.Replacement> difference(String before, String after) {
|
||||
int start = 0, end = before.length(), last = after.length();
|
||||
while (start < end && start < last && before.charAt(start) == after.charAt(start)) start++;
|
||||
while (end > start && last > start && before.charAt(end - 1) == after.charAt(last - 1)) {
|
||||
end--;
|
||||
last--;
|
||||
}
|
||||
return start == end && start == last
|
||||
? List.of()
|
||||
: List.of(new EditBatch.Replacement(start, end, after.substring(start, last)));
|
||||
}
|
||||
|
||||
private static int mapped(int offset, List<EditBatch.Replacement> edits) {
|
||||
int delta = 0;
|
||||
var sorted = new ArrayList<>(edits);
|
||||
sorted.sort(Comparator.comparingInt(EditBatch.Replacement::start));
|
||||
for (var edit : sorted) {
|
||||
if (offset < edit.start()) break;
|
||||
if (offset <= edit.end())
|
||||
return edit.start() + delta + Math.min(offset - edit.start(), edit.text().length());
|
||||
delta += edit.text().length() - (edit.end() - edit.start());
|
||||
}
|
||||
return offset + delta;
|
||||
}
|
||||
|
||||
private DocumentSession document;
|
||||
private DocumentSnapshot draft;
|
||||
private FxmlProjection projection;
|
||||
@@ -49,12 +122,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
this.file = file;
|
||||
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
|
||||
kit = new KitView(host);
|
||||
content = new ContentPanelController(kit.editor());
|
||||
design =
|
||||
new SplitPane(
|
||||
new HierarchyPanelController(kit.editor()).getPanelRoot(),
|
||||
new ContentPanelController(kit.editor()).getPanelRoot(),
|
||||
content.getPanelRoot(),
|
||||
new InspectorPanelController(kit.editor()).getPanelRoot());
|
||||
design.setDividerPositions(.18, .72);
|
||||
kit.editor().getSelection().revisionProperty().addListener((o, a, b) -> componentSelection());
|
||||
workflow =
|
||||
new DesignerWorkflow(
|
||||
host,
|
||||
@@ -102,21 +177,35 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
var tabs = new TabPane();
|
||||
var split = new SplitPane(source, design);
|
||||
split.setDividerPositions(.3);
|
||||
tabs.getTabs().add(new Tab("Source / Design", split));
|
||||
tabs.getTabs().getFirst().setClosable(false);
|
||||
var modes =
|
||||
new ToolBar(
|
||||
button("Source", () -> split.getItems().setAll(source)),
|
||||
button("Design", () -> split.getItems().setAll(design)),
|
||||
button(
|
||||
"Split",
|
||||
() -> {
|
||||
split.getItems().setAll(source, design);
|
||||
split.setDividerPositions(.3);
|
||||
}));
|
||||
var center = new BorderPane(split);
|
||||
center.setTop(modes);
|
||||
var properties = new ScrollPane(componentInspector);
|
||||
properties.setFitToWidth(true);
|
||||
properties.setPrefViewportHeight(100);
|
||||
center.setBottom(properties);
|
||||
output.setEditable(false);
|
||||
output.setPrefRowCount(5);
|
||||
setTop(bar);
|
||||
setCenter(tabs);
|
||||
setCenter(center);
|
||||
setBottom(new VBox(status, output));
|
||||
source
|
||||
.textProperty()
|
||||
.addListener(
|
||||
(o, a, b) -> {
|
||||
if (!loading) {
|
||||
if (pending && submittedSource == null) submittedSource = pendingBase;
|
||||
commitSource();
|
||||
workflow.cancel();
|
||||
renderSequence.incrementAndGet();
|
||||
@@ -211,6 +300,31 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
if (submittedSource != null && !source.getText().equals(submittedSource)) {
|
||||
// Source typing can arrive before an asynchronous visual command completes.
|
||||
// Rebase disjoint visual ranges; an overlapping later source edit wins explicitly
|
||||
// in the next host command, whose Undo still restores the visual command.
|
||||
if (submittedSource.equals(pendingBase) && pendingVisual != null) {
|
||||
var typed = difference(pendingBase, source.getText());
|
||||
var rebased = new ArrayList<EditBatch.Replacement>();
|
||||
for (var visual : pendingVisual) {
|
||||
if (typed.stream()
|
||||
.noneMatch(t -> t.start() <= visual.end() && visual.start() <= t.end()))
|
||||
rebased.add(
|
||||
new EditBatch.Replacement(
|
||||
mapped(visual.start(), typed),
|
||||
mapped(visual.end(), typed),
|
||||
visual.text()));
|
||||
}
|
||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||
loading = true;
|
||||
try {
|
||||
rebased.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
|
||||
for (var edit : rebased) source.replaceText(edit.start(), edit.end(), edit.text());
|
||||
source.selectRange(mapped(anchor, rebased), mapped(caret, rebased));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
draft = value;
|
||||
submittedSource = null;
|
||||
commitSource();
|
||||
@@ -227,8 +341,23 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
renderSequence.incrementAndGet();
|
||||
workflow.cancel();
|
||||
try {
|
||||
var hit = kit.editor().getSelection().getHitItem();
|
||||
String selectedId = hit == null ? null : hit.getFxId();
|
||||
draft = value;
|
||||
if (!source.getText().equals(value.text())) source.setText(value.text());
|
||||
if (!source.getText().equals(value.text())) {
|
||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||
var edits =
|
||||
pendingVisual != null
|
||||
&& source.getText().equals(pendingBase)
|
||||
&& apply(pendingBase, pendingVisual).equals(value.text())
|
||||
? pendingVisual
|
||||
: difference(source.getText(), value.text());
|
||||
var descending = new ArrayList<>(edits);
|
||||
descending.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
|
||||
for (var edit : descending) source.replaceText(edit.start(), edit.end(), edit.text());
|
||||
source.selectRange(mapped(anchor, edits), mapped(caret, edits));
|
||||
}
|
||||
pendingVisual = null;
|
||||
source.setEditable(value.writable());
|
||||
// End an inspector editing session before replacing FXOM (also after native undo).
|
||||
if (getScene() != null
|
||||
@@ -238,6 +367,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
String projected = projection.project();
|
||||
kit.editor().setFxmlTextAndLocation(projected, file.toUri().toURL());
|
||||
design.setDisable(!value.writable());
|
||||
componentInspector.setDisable(!value.writable());
|
||||
kit.editor()
|
||||
.getFxomDocument()
|
||||
.sceneGraphRevisionProperty()
|
||||
@@ -261,6 +391,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedId != null) {
|
||||
var selected = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(selectedId);
|
||||
if (selected != null) kit.editor().getSelection().select(selected);
|
||||
}
|
||||
componentSelection();
|
||||
status.setText(
|
||||
"Context "
|
||||
+ launch.context()
|
||||
@@ -269,6 +404,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
+ (value.dirty() ? " · unsaved" : ""));
|
||||
} catch (Throwable error) {
|
||||
design.setDisable(true);
|
||||
componentInspector.setDisable(true);
|
||||
projection = null;
|
||||
report(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -285,14 +422,15 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
|
||||
private void replace(String text, String name) {
|
||||
if (draft == null || pending) return;
|
||||
submit(difference(draft.text(), text), name);
|
||||
}
|
||||
|
||||
private void submit(List<EditBatch.Replacement> edits, String name) {
|
||||
if (draft == null || pending || edits.isEmpty()) return;
|
||||
pending = true;
|
||||
document
|
||||
.apply(
|
||||
new EditBatch(
|
||||
draft.revision(),
|
||||
name,
|
||||
List.of(new EditBatch.Replacement(0, draft.text().length(), text))))
|
||||
.whenComplete(this::updated);
|
||||
pendingVisual = edits;
|
||||
pendingBase = draft.text();
|
||||
document.apply(new EditBatch(draft.revision(), name, edits)).whenComplete(this::updated);
|
||||
}
|
||||
|
||||
private void commitSource() {
|
||||
@@ -305,8 +443,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private void canvasChanged() {
|
||||
if (loading || pending || closed) return;
|
||||
try {
|
||||
replace(
|
||||
projection.restore(kit.editor().getFxomDocument().getFxmlText(false)),
|
||||
submit(
|
||||
projection.edits(kit.editor().getFxomDocument().getFxmlText(false)),
|
||||
"Component layout / property edit");
|
||||
} catch (Throwable error) {
|
||||
report(error);
|
||||
@@ -439,6 +577,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
&& Path.of(d.document())
|
||||
.startsWith(launch.configuration().getParent()))
|
||||
.forEach(d -> drafts.put(d.document(), d));
|
||||
drafts.put(
|
||||
draft.document(),
|
||||
new DocumentSnapshot(
|
||||
draft.document(),
|
||||
draft.revision(),
|
||||
projection.previewText(),
|
||||
draft.writable(),
|
||||
draft.dirty()));
|
||||
renderComponents(List.copyOf(drafts.values()), selected.id(), ticket);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -36,13 +36,36 @@ public final class FxmlProjection {
|
||||
"GridPane.columnIndex",
|
||||
"GridPane.rowIndex",
|
||||
"GridPane.hgrow",
|
||||
"GridPane.vgrow");
|
||||
"GridPane.vgrow",
|
||||
"GridPane.columnSpan",
|
||||
"GridPane.rowSpan",
|
||||
"AnchorPane.topAnchor",
|
||||
"AnchorPane.bottomAnchor",
|
||||
"AnchorPane.leftAnchor",
|
||||
"AnchorPane.rightAnchor",
|
||||
"HBox.hgrow",
|
||||
"VBox.vgrow",
|
||||
"StackPane.alignment",
|
||||
"HBox.alignment",
|
||||
"VBox.alignment",
|
||||
"translateX",
|
||||
"translateY",
|
||||
"rotate",
|
||||
"scaleX",
|
||||
"scaleY",
|
||||
"style",
|
||||
"wrapText");
|
||||
private final Document original;
|
||||
private final XmlSource source;
|
||||
private final Map<String, Integer> sourceNodes = new LinkedHashMap<>();
|
||||
private final Map<String, Element> sourceElements = new LinkedHashMap<>();
|
||||
private final Set<String> synthetic = new HashSet<>();
|
||||
private Document baseline;
|
||||
private final Map<String, String> components = new LinkedHashMap<>();
|
||||
|
||||
public FxmlProjection(String text) throws Exception {
|
||||
original = parse(text);
|
||||
source = new XmlSource(text);
|
||||
}
|
||||
|
||||
static Document parse(String text) throws Exception {
|
||||
@@ -66,7 +89,46 @@ public final class FxmlProjection {
|
||||
}
|
||||
|
||||
public String project() throws Exception {
|
||||
components.clear();
|
||||
sourceNodes.clear();
|
||||
sourceElements.clear();
|
||||
synthetic.clear();
|
||||
var d = (Document) original.cloneNode(true);
|
||||
var all = d.getElementsByTagName("*");
|
||||
var originals = original.getElementsByTagName("*");
|
||||
var reserved = new HashSet<String>();
|
||||
for (int i = 0; i < all.getLength(); i++) {
|
||||
String id = ((Element) all.item(i)).getAttributeNS(FX, "id");
|
||||
if (!id.isBlank() && !reserved.add(id)) throw new IOException("Duplicate fx:id: " + id);
|
||||
}
|
||||
for (int i = 0; i < all.getLength(); i++) {
|
||||
var element = (Element) all.item(i);
|
||||
if (!isObject(element)
|
||||
&& !(i == 0
|
||||
&& FX.equals(element.getNamespaceURI())
|
||||
&& "root".equals(element.getLocalName()))) continue;
|
||||
String id = element.getAttributeNS(FX, "id");
|
||||
if (id.isBlank()) {
|
||||
id = "__designer_" + i;
|
||||
while (reserved.contains(id)) id += "_";
|
||||
reserved.add(id);
|
||||
synthetic.add(id);
|
||||
element.setAttributeNS(FX, "fx:id", id);
|
||||
}
|
||||
element.removeAttributeNS(FX, "id");
|
||||
element.setAttributeNS(FX, "fx:id", id);
|
||||
sourceNodes.put(id, i);
|
||||
sourceElements.put(id, (Element) originals.item(i));
|
||||
}
|
||||
if (!d.getDocumentElement().hasAttribute("xmlns:fx"))
|
||||
d.getDocumentElement().setAttribute("xmlns:fx", FX);
|
||||
var root = d.getDocumentElement();
|
||||
if (FX.equals(root.getNamespaceURI()) && "root".equals(root.getLocalName())) {
|
||||
String type = root.getAttribute("type");
|
||||
if (type.isBlank()) throw new IOException("fx:root requires a type");
|
||||
d.renameNode(root, null, type);
|
||||
root.removeAttribute("type");
|
||||
}
|
||||
var imports = new HashMap<String, String>();
|
||||
var remove = new ArrayList<Node>();
|
||||
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
|
||||
@@ -94,18 +156,27 @@ public final class FxmlProjection {
|
||||
String tag = e.getTagName();
|
||||
if (FX.equals(e.getNamespaceURI()))
|
||||
throw new IOException("This minimal projection does not support " + tag);
|
||||
if (imports.containsKey(tag)) {
|
||||
String componentType = imports.get(tag);
|
||||
if (componentType == null
|
||||
&& tag.contains(".")
|
||||
&& Character.isUpperCase(tag.charAt(tag.lastIndexOf('.') + 1))
|
||||
&& !tag.startsWith("javafx.")) componentType = tag;
|
||||
if (componentType != null) {
|
||||
String id = e.getAttributeNS(FX, "id");
|
||||
if (id.isBlank()) throw new IOException("Component projection requires fx:id");
|
||||
components.put(id, imports.get(tag));
|
||||
if (e.hasChildNodes())
|
||||
throw new IOException("Inline Component content is outside this projection");
|
||||
components.put(id, componentType);
|
||||
// Component internals belong to the target runtime and their own source document.
|
||||
while (e.hasChildNodes()) e.removeChild(e.getFirstChild());
|
||||
d.renameNode(e, null, "SnapshotRegion");
|
||||
var attrs = e.getAttributes();
|
||||
for (int a = attrs.getLength() - 1; a >= 0; a--) {
|
||||
var attr = (Attr) attrs.item(a);
|
||||
if (!EDITABLE.contains(attr.getName()) && !attr.getName().equals("fx:id"))
|
||||
e.removeAttributeNode(attr);
|
||||
if ((!EDITABLE.contains(attr.getName())
|
||||
|| attr.getName().equals("text")
|
||||
|| attr.getName().equals("wrapText")
|
||||
|| attr.getName().equals("spacing")
|
||||
|| attr.getName().equals("hgap")
|
||||
|| attr.getName().equals("vgap"))
|
||||
&& !attr.getName().equals("fx:id")) e.removeAttributeNode(attr);
|
||||
}
|
||||
} else if (Character.isUpperCase(tag.charAt(0))
|
||||
&& tag.contains(".")
|
||||
@@ -126,7 +197,7 @@ public final class FxmlProjection {
|
||||
|
||||
private static Map<String, Element> indexed(Document d) throws IOException {
|
||||
var result = new LinkedHashMap<String, Element>();
|
||||
result.put("@root", d.getDocumentElement());
|
||||
|
||||
var nodes = d.getElementsByTagName("*");
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
var e = (Element) nodes.item(i);
|
||||
@@ -144,9 +215,14 @@ public final class FxmlProjection {
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
var e = (Element) nodes.item(i);
|
||||
String tag = e.getTagName();
|
||||
if (Character.isUpperCase(tag.charAt(0))) {
|
||||
if (isObject(e)) {
|
||||
Node parent = e.getParentNode();
|
||||
while (parent != null && !objects.containsKey(parent)) parent = parent.getParentNode();
|
||||
var propertyPath = new ArrayList<String>();
|
||||
while (parent != null && !objects.containsKey(parent)) {
|
||||
if (parent instanceof Element wrapper && !wrapper.getTagName().equals("children"))
|
||||
propertyPath.add(wrapper.getTagName());
|
||||
parent = parent.getParentNode();
|
||||
}
|
||||
var fixed = new TreeMap<String, String>();
|
||||
if (e != document.getDocumentElement() && e.getAttributeNS(FX, "id").isBlank())
|
||||
for (int a = 0; a < e.getAttributes().getLength(); a++) {
|
||||
@@ -155,25 +231,39 @@ public final class FxmlProjection {
|
||||
fixed.put(attr.getNodeName(), attr.getNodeValue());
|
||||
}
|
||||
result.add(
|
||||
tag
|
||||
tag.substring(tag.lastIndexOf('.') + 1)
|
||||
+ ":"
|
||||
+ e.getAttributeNS(FX, "id")
|
||||
+ ":"
|
||||
+ objects.getOrDefault(parent, -1)
|
||||
+ ":"
|
||||
+ fixed);
|
||||
+ fixed
|
||||
+ ":"
|
||||
+ propertyPath);
|
||||
objects.put(e, objects.size());
|
||||
} else if (!e.getTextContent().isBlank() && e.getElementsByTagName("*").getLength() == 0) {
|
||||
result.add("property:" + tag + ":" + e.getTextContent());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isObject(Element e) {
|
||||
String tag = e.getTagName();
|
||||
return !FX.equals(e.getNamespaceURI())
|
||||
&& Character.isUpperCase(tag.charAt(tag.lastIndexOf('.') + 1));
|
||||
}
|
||||
|
||||
public String restore(String projected) throws Exception {
|
||||
return source.apply(edits(projected));
|
||||
}
|
||||
|
||||
public java.util.List<de.neitzel.fxdesigner.contracts.EditBatch.Replacement> edits(
|
||||
String projected) throws Exception {
|
||||
var changed = parse(projected);
|
||||
var before = indexed(baseline);
|
||||
var after = indexed(changed);
|
||||
var output = (Document) original.cloneNode(true);
|
||||
var targets = indexed(output);
|
||||
var result = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
|
||||
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
|
||||
throw new IOException("Structural editing is outside this minimal projection");
|
||||
for (String id : before.keySet()) {
|
||||
@@ -187,18 +277,72 @@ public final class FxmlProjection {
|
||||
&& !before.get(id).getAttribute(attr).equals(after.get(id).getAttribute(attr)))
|
||||
throw new IOException("Edit " + attr + " in source; this projection does not support it");
|
||||
}
|
||||
for (String id : before.keySet())
|
||||
var requiredImports = new TreeSet<String>();
|
||||
for (String id : before.keySet()) {
|
||||
var values = new HashMap<String, String>();
|
||||
for (String attr : EDITABLE) {
|
||||
String old = before.get(id).getAttribute(attr), value = after.get(id).getAttribute(attr);
|
||||
if (!old.equals(value)) {
|
||||
var target = targets.get(id);
|
||||
if (!old.equals(value)
|
||||
|| before.get(id).hasAttribute(attr) != after.get(id).hasAttribute(attr)) {
|
||||
var target = sourceElements.get(id);
|
||||
String originalValue = target.getAttribute(attr);
|
||||
if (originalValue.startsWith("$") || originalValue.startsWith("#"))
|
||||
throw new IOException("Bound attributes must be edited in source");
|
||||
if (value.isEmpty()) target.removeAttribute(attr);
|
||||
else target.setAttribute(attr, value);
|
||||
values.put(attr, after.get(id).hasAttribute(attr) ? value : null);
|
||||
if (after.get(id).hasAttribute(attr) && attr.contains(".")) {
|
||||
String owner = "javafx.scene.layout." + attr.substring(0, attr.indexOf('.'));
|
||||
boolean imported = false;
|
||||
for (Node n = original.getFirstChild(); n != null; n = n.getNextSibling())
|
||||
if (n instanceof ProcessingInstruction pi
|
||||
&& pi.getTarget().equals("import")
|
||||
&& (pi.getData().trim().equals(owner)
|
||||
|| pi.getData().trim().equals("javafx.scene.layout.*"))) imported = true;
|
||||
if (!imported) requiredImports.add(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
return xml(output);
|
||||
result.addAll(source.attributes(sourceNodes.get(id), values));
|
||||
}
|
||||
if (!requiredImports.isEmpty()) {
|
||||
int at = source.text.matches("(?s)^<\\?xml\\s.*") ? source.text.indexOf("?>") + 2 : 0;
|
||||
String newline = source.text.contains("\r\n") ? "\r\n" : "\n";
|
||||
String declarations =
|
||||
(at == 0 ? "" : newline)
|
||||
+ requiredImports.stream()
|
||||
.map(type -> "<?import " + type + "?>" + newline)
|
||||
.collect(java.util.stream.Collectors.joining());
|
||||
result.add(new de.neitzel.fxdesigner.contracts.EditBatch.Replacement(at, at, declarations));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
public Map<String, String> componentAttributes(String id) {
|
||||
var values = new TreeMap<String, String>();
|
||||
if (!components.containsKey(id)) return values;
|
||||
var attrs = sourceElements.get(id).getAttributes();
|
||||
for (int i = 0; i < attrs.getLength(); i++) {
|
||||
var attr = attrs.item(i);
|
||||
if (!attr.getNodeName().contains(":") && !attr.getNodeName().startsWith("on"))
|
||||
values.put(attr.getNodeName(), attr.getNodeValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
public List<de.neitzel.fxdesigner.contracts.EditBatch.Replacement> componentAttribute(
|
||||
String id, String name, String value) throws IOException {
|
||||
if (!componentAttributes(id).containsKey(name))
|
||||
throw new IOException("Unknown declared Component property");
|
||||
if (sourceElements.get(id).getAttribute(name).equals(value)) return List.of();
|
||||
return source.attributes(sourceNodes.get(id), Collections.singletonMap(name, value));
|
||||
}
|
||||
|
||||
public String previewText() {
|
||||
var edits = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
|
||||
for (String id : synthetic)
|
||||
if (components.containsKey(id))
|
||||
edits.addAll(source.attributes(sourceNodes.get(id), Map.of("fx:id", id)));
|
||||
if (!edits.isEmpty() && !original.getDocumentElement().hasAttribute("xmlns:fx"))
|
||||
edits.addAll(source.attributes(0, Map.of("xmlns:fx", FX)));
|
||||
return source.apply(edits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.neitzel.fxdesigner.kit;
|
||||
|
||||
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
|
||||
/** Lexical ranges for an already securely parsed XML document; never serializes untouched XML. */
|
||||
final class XmlSource {
|
||||
record Attribute(int start, int end, int valueStart, int valueEnd, char quote) {}
|
||||
|
||||
record Tag(int insert, Map<String, Attribute> attributes) {}
|
||||
|
||||
private static final Pattern ATTRIBUTE =
|
||||
Pattern.compile("([^\\s=<>/]+)\\s*=\\s*(['\"])(.*?)\\2", Pattern.DOTALL);
|
||||
final String text;
|
||||
final List<Tag> tags = new ArrayList<>();
|
||||
|
||||
XmlSource(String text) throws IOException {
|
||||
this.text = text;
|
||||
int i = 0;
|
||||
while ((i = text.indexOf('<', i)) >= 0) {
|
||||
String end =
|
||||
text.startsWith("<!--", i)
|
||||
? "-->"
|
||||
: text.startsWith("<![CDATA[", i) ? "]]>" : text.startsWith("<?", i) ? "?>" : null;
|
||||
if (end != null) {
|
||||
int close = text.indexOf(end, i);
|
||||
if (close < 0) throw new IOException("Incomplete XML");
|
||||
i = close + end.length();
|
||||
continue;
|
||||
}
|
||||
int start = i++, quote = 0;
|
||||
while (i < text.length()) {
|
||||
char c = text.charAt(i);
|
||||
if (quote != 0) {
|
||||
if (c == quote) quote = 0;
|
||||
} else if (c == '\'' || c == '"') quote = c;
|
||||
else if (c == '>') break;
|
||||
i++;
|
||||
}
|
||||
if (i == text.length()) throw new IOException("Incomplete XML tag");
|
||||
if (text.charAt(start + 1) != '/') {
|
||||
var attrs = new LinkedHashMap<String, Attribute>();
|
||||
var matcher = ATTRIBUTE.matcher(text);
|
||||
matcher.region(start + 1, i);
|
||||
while (matcher.find())
|
||||
attrs.put(
|
||||
matcher.group(1),
|
||||
new Attribute(
|
||||
matcher.start(),
|
||||
matcher.end(),
|
||||
matcher.start(3),
|
||||
matcher.end(3),
|
||||
matcher.group(2).charAt(0)));
|
||||
tags.add(new Tag(text.charAt(i - 1) == '/' ? i - 1 : i, attrs));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
List<Replacement> attributes(int index, Map<String, String> values) {
|
||||
var tag = tags.get(index);
|
||||
var result = new ArrayList<Replacement>();
|
||||
var added = new StringBuilder();
|
||||
new TreeMap<>(values)
|
||||
.forEach(
|
||||
(name, value) -> {
|
||||
var attr = tag.attributes().get(name);
|
||||
if (attr == null) {
|
||||
if (value != null)
|
||||
added
|
||||
.append(' ')
|
||||
.append(name)
|
||||
.append("=\"")
|
||||
.append(escape(value, '"'))
|
||||
.append('"');
|
||||
} else if (value == null) result.add(new Replacement(attr.start(), attr.end(), ""));
|
||||
else
|
||||
result.add(
|
||||
new Replacement(
|
||||
attr.valueStart(), attr.valueEnd(), escape(value, attr.quote())));
|
||||
});
|
||||
if (!added.isEmpty()) result.add(new Replacement(tag.insert(), tag.insert(), added.toString()));
|
||||
return result;
|
||||
}
|
||||
|
||||
static String escape(String value, char quote) {
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(quote == '"' ? "\"" : "'", quote == '"' ? """ : "'")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", "	");
|
||||
}
|
||||
|
||||
String apply(List<Replacement> edits) {
|
||||
var sorted = new ArrayList<>(edits);
|
||||
sorted.sort(Comparator.comparingInt(Replacement::start).reversed());
|
||||
var out = new StringBuilder(text);
|
||||
for (var edit : sorted) out.replace(edit.start(), edit.end(), edit.text());
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user