biskuteri-cafe-JKomasto2/RichTextPane2.java
Snowyfox e6fea4c061 Fixed bug when redraft makes no changes
(Before this, JKomasto and sometimes the Mastodon web client would get '411 Record Not Found' when submitting the same text after deleting and redrafting. Presumably the Mastodon server caches both whether an idempotency key was fulfilled and which post it leads to, and for some reason it looks up the second and fails.)
2022-05-31 03:39:56 -04:00

480 lines
10 KiB
Java

import javax.swing.JComponent;
import java.text.AttributedString;
import java.text.AttributedCharacterIterator;
import java.awt.Graphics;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Image;
import java.awt.geom.Rectangle2D;
import java.awt.font.TextAttribute;
import java.awt.event.ComponentListener;
import java.awt.event.ComponentEvent;
import java.util.List;
import java.util.ArrayList;
import cafe.biskuteri.hinoki.Tree;
class
RichTextPane2 extends JComponent
implements ComponentListener {
private AttributedString
text;
// ---%-@-%---
public void
setText(Tree<String> html, Tree<String> emojiMap)
{
Tree<String> commands = turnIntoCommands(html);
class AStrSegment {
String text;
int offset;
Object[] values = new Object[Attribute.COUNT];
/*
{
values[3] = (Boolean)true;
values[4] = (Integer)0;
values[5] = (Boolean)true;
values[6] = (Boolean)false;
}
*/
}
List<AStrSegment> segments = new ArrayList<>();
int offset = 0;
for (Tree<String> command: commands)
{
if (command.key.equals("text"))
{
StringBuilder b = new StringBuilder();
Boolean cibl = null;
Boolean cwhi = null;
for (char c: command.value.toCharArray())
{
Boolean ibl = isBasicLatin(c);
Boolean whi = Character.isWhitespace(c);
if (!ibl.equals(cibl) || !whi.equals(cwhi))
{
if (b.length() > 0)
{
assert cibl != null && cwhi != null;
AStrSegment s = new AStrSegment();
s.offset = offset;
s.text = b.toString();
s.values[3] = cibl;
s.values[6] = cwhi;
segments.add(s);
offset += s.text.length();
b.delete(0, b.length());
}
cibl = ibl;
cwhi = whi;
}
b.append(c);
}
if (b.length() > 0)
{
AStrSegment s = new AStrSegment();
s.offset = offset;
s.text = b.toString();
s.values[3] = cibl;
s.values[6] = cwhi;
segments.add(s);
offset += s.text.length();
}
}
else if (command.key.equals("emoji"))
{
AStrSegment s = new AStrSegment();
s.offset = offset;
s.values[3] = true;
s.values[6] = false;
String shortcode = command.value;
String url = null;
Tree<String> m = emojiMap.get(shortcode);
if (m != null) url = m.value;
Image img = ImageApi.remote(url);
if (img != null)
{
s.text = " ";
s.values[0] = img;
s.values[1] = shortcode;
segments.add(s);
offset += 1;
}
else
{
s.text = shortcode;
s.values[0] = null;
s.values[1] = null;
segments.add(s);
offset += shortcode.length();
}
}
else if (command.key.equals("link"))
{
AStrSegment s = new AStrSegment();
s.offset = offset;
s.text = command.value;
s.values[2] = command.get("url").value;
s.values[3] = true;
s.values[6] = false;
/*
* Technically we're supposed to treat
* the anchor text like a text node.
* As in, it could be non-Basic-Latin..
* I'll be Mastodon-specific again, and
* assume it's a URL or some @ string.
*/
}
}
AttributedString astr;
StringBuilder b = new StringBuilder();
for (AStrSegment segment: segments)
{
b.append(segment.text);
}
astr = new AttributedString(b.toString());
for (AStrSegment segment: segments)
{
Object[] v = segment.values;
astr.addAttribute(
Attribute.IMAGE, segment.values[0],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.ALT, segment.values[1],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.LINK, segment.values[2],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.BASICLATIN, segment.values[3],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.Y, segment.values[4],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.OFFSCREEN, segment.values[5],
segment.offset,
segment.offset + segment.text.length()
);
astr.addAttribute(
Attribute.WHITESPACE, segment.values[6],
segment.offset,
segment.offset + segment.text.length()
);
}
this.text = astr;
componentResized(null);
}
// - -%- -
public void
componentResized(ComponentEvent eC)
{
int w = getWidth(), h = getHeight();
// We're going to evaluate the
// line and off-screen attributes.
FontMetrics fm = getFontMetrics(getFont());
Graphics g = getGraphics();
int x = 0, y = fm.getAscent();
AttributedCharacterIterator it;
it = text.getIterator();
while (it.getIndex() < it.getEndIndex())
{
int start = it.getIndex();
int end = it.getRunLimit();
Image img = (Image)
it.getAttribute(Attribute.IMAGE);
Boolean ibl = (Boolean)
it.getAttribute(Attribute.BASICLATIN);
Boolean whi = (Boolean)
it.getAttribute(Attribute.WHITESPACE);
assert ibl != null;
assert whi != null;
if (img != null)
{
int ow = img.getWidth(this);
int oh = img.getHeight(this);
int nh = fm.getAscent() + fm.getDescent();
int nw = ow * nh/oh;
if (x + nw > w)
{
y += fm.getAscent() + fm.getDescent();
x = nw;
}
text.addAttribute(
Attribute.Y, (Integer)y,
start, end
);
text.addAttribute(
Attribute.OFFSCREEN, (Boolean)(y > h),
start, end
);
it.setIndex(end);
}
else
{
int p, xOff = 0;
for (p = end; p > start; --p)
{
Rectangle2D r;
r = fm.getStringBounds(it, start, p, g);
xOff = (int)r.getWidth();
if (x + xOff < w) break;
}
if (p == end || whi)
{
x += xOff;
text.addAttribute(
Attribute.Y, (Integer)y,
start, end
);
text.addAttribute(
Attribute.OFFSCREEN, (Boolean)(y > h),
start, end
);
it.setIndex(end);
}
else if (p <= start)
{
y += fm.getAscent() + fm.getDescent();
x = xOff;
text.addAttribute(
Attribute.Y, (Integer)y,
start, end
);
text.addAttribute(
Attribute.OFFSCREEN, (Boolean)(y > h),
start, end
);
it.setIndex(end);
}
else
{
text.addAttribute(
Attribute.Y, (Integer)y,
start, p
);
text.addAttribute(
Attribute.OFFSCREEN, (Boolean)(y > h),
start, p
);
y += fm.getAscent() + fm.getDescent();
x = 0;
it.setIndex(p);
}
}
}
text.addAttribute(TextAttribute.FONT, getFont());
repaint();
}
protected void
paintComponent(Graphics g)
{
int w = getWidth(), h = getHeight();
g.clearRect(0, 0, w, h);
FontMetrics fm = g.getFontMetrics();
AttributedCharacterIterator it;
it = text.getIterator();
((java.awt.Graphics2D)g).setRenderingHint(
java.awt.RenderingHints.KEY_ANTIALIASING,
java.awt.RenderingHints.VALUE_ANTIALIAS_ON
);
int x = 0, y = fm.getAscent();
while (it.getIndex() < it.getEndIndex())
{
int start = it.getIndex();
int end = it.getRunLimit();
Image img = (Image)
it.getAttribute(Attribute.IMAGE);
Boolean ibl = (Boolean)
it.getAttribute(Attribute.BASICLATIN);
Integer ny = (Integer)
it.getAttribute(Attribute.Y);
if (ny > y)
{
y = ny;
x = 0;
}
if (img != null)
{
int ow = img.getWidth(this);
int oh = img.getHeight(this);
int nh = fm.getAscent() + fm.getDescent();
int nw = ow * nh/oh;
int iy = y + fm.getDescent() - nh;
g.drawImage(img, x, iy, nw, nh, this);
x += nw;
}
else
{
Rectangle2D r;
r = fm.getStringBounds(it, start, end, g);
AttributedCharacterIterator sit;
sit = text.getIterator(null, start, end);
g.drawString(sit, x, y);
x += (int)r.getWidth();
}
it.setIndex(end);
}
}
public void
componentMoved(ComponentEvent eC) { }
public void
componentShown(ComponentEvent eC) { }
public void
componentHidden(ComponentEvent eC) { }
// - -%- -
private static Boolean
isBasicLatin(char c)
{
return true;
}
private static String
toText(Tree<String> node)
{
Tree<String> children = node.get("children");
if (children == null)
{
boolean text = node.key.equals("text");
boolean emoji = node.key.equals("emoji");
assert text || emoji;
return node.value;
}
StringBuilder b = new StringBuilder();
for (Tree<String> child: children)
{
b.append(toText(child));
}
return b.toString();
}
private static Tree<String>
turnIntoCommands(Tree<String> tag)
{
assert tag.key.equals("tag");
Tree<String> returnee = new Tree<String>();
String tagName = tag.get(0).key;
Tree<String> children = tag.get("children");
if (tagName.equals("a"))
{
String url = tag.get("href").value;
Tree<String> addee = new Tree<>();
addee.key = "link";
addee.value = toText(tag);
addee.add(new Tree<>("url", url));
returnee.add(addee);
}
else if (tagName.equals("span"))
{
Tree<String> addee = new Tree<>();
addee.key = "text";
addee.value = toText(tag);
returnee.add(addee);
}
else if (tagName.equals("br"))
{
returnee.add(new Tree<>("text", "\n"));
}
else
{
for (Tree<String> child: children)
{
if (!child.key.equals("tag"))
{
returnee.add(child);
continue;
}
child = turnIntoCommands(child);
for (Tree<String> command: child)
{
returnee.add(command);
}
}
if (tagName.equals("p"))
{
returnee.add(new Tree<>("text", "\n"));
returnee.add(new Tree<>("text", "\n"));
}
}
return returnee;
}
// ---%-@-%---
public static class
Attribute extends AttributedCharacterIterator.Attribute {
public static final Attribute
IMAGE = new Attribute("IMAGE"),
ALT = new Attribute("ALT"),
LINK = new Attribute("LINK"),
BASICLATIN = new Attribute("BASICLATIN"),
Y = new Attribute("Y"),
OFFSCREEN = new Attribute("OFFSCREEN"),
WHITESPACE = new Attribute("WHITESPACE");
public static final int
COUNT = 7;
// -=%=-
private
Attribute(String name) { super(name); }
}
// ---%-@-%---
RichTextPane2()
{
this.addComponentListener(this);
text = new AttributedString("");
}
}