mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2025-01-08 22:24:45 +01:00
e6fea4c061
(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.)
673 lines
15 KiB
Java
673 lines
15 KiB
Java
|
|
import javax.swing.JComponent;
|
|
import java.awt.Graphics;
|
|
import java.awt.FontMetrics;
|
|
import java.awt.Font;
|
|
import java.awt.Image;
|
|
import java.awt.Color;
|
|
import java.awt.Dimension;
|
|
import java.awt.event.ComponentListener;
|
|
import java.awt.event.ComponentEvent;
|
|
import java.awt.event.MouseListener;
|
|
import java.awt.event.MouseMotionListener;
|
|
import java.awt.event.MouseEvent;
|
|
import java.awt.event.KeyListener;
|
|
import java.awt.event.KeyEvent;
|
|
import java.util.Map;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.LinkedList;
|
|
import java.util.ArrayList;
|
|
import cafe.biskuteri.hinoki.Tree;
|
|
|
|
class
|
|
RichTextPane3 extends JComponent
|
|
implements
|
|
ComponentListener,
|
|
MouseListener, MouseMotionListener, KeyListener {
|
|
|
|
private Tree<String>
|
|
html;
|
|
|
|
private Map<String, Image>
|
|
emojis;
|
|
|
|
private Map<Tree<String>, Position>
|
|
layout;
|
|
|
|
private Tree<String>
|
|
layoutEnd, selStart, selEnd;
|
|
|
|
private int
|
|
startingLine, endingLine;
|
|
|
|
// ---%-@-%---
|
|
|
|
public void
|
|
setText(Tree<String> html)
|
|
{
|
|
assert html != null;
|
|
|
|
this.html = html;
|
|
|
|
if (!isValid()) return;
|
|
|
|
assert html.key != null;
|
|
assert html.key.equals("tag");
|
|
assert html.get("children") != null;
|
|
|
|
FontMetrics fm = getFontMetrics(getFont());
|
|
Position cursor = new Position(0, 1);
|
|
|
|
// Manually negate if first element is a break.
|
|
Tree<String> children = html.get("children");
|
|
if (children.size() > 0)
|
|
{
|
|
Tree<String> first = children.get(0);
|
|
if (first.key.equals("tag"))
|
|
{
|
|
String tagName = first.get(0).key;
|
|
if (tagName.equals("br")) cursor.line -= 1;
|
|
if (tagName.equals("p")) cursor.line -= 2;
|
|
}
|
|
}
|
|
|
|
selStart = selEnd = null;
|
|
layout.clear();
|
|
startingLine = 1;
|
|
layout(html, fm, cursor);
|
|
layout.put(layoutEnd, cursor.clone());
|
|
endingLine = cursor.line;
|
|
repaint();
|
|
|
|
int iy = fm.getAscent();
|
|
int lh = fm.getAscent() + fm.getDescent();
|
|
int h = snap2(cursor.line, iy, lh);
|
|
h += fm.getDescent();
|
|
setPreferredSize(new Dimension(1, h));
|
|
}
|
|
|
|
public void
|
|
setEmojis(Map<String, Image> emojis)
|
|
{
|
|
assert emojis != null;
|
|
this.emojis = emojis;
|
|
setText(html);
|
|
}
|
|
|
|
public void
|
|
previousPage()
|
|
{
|
|
int advance = getHeightInLines();
|
|
if (startingLine < advance) startingLine = 1;
|
|
else startingLine -= advance;
|
|
repaint();
|
|
}
|
|
|
|
public void
|
|
nextPage()
|
|
{
|
|
int advance = getHeightInLines();
|
|
if (endingLine - startingLine < advance) return;
|
|
else startingLine += advance;
|
|
repaint();
|
|
}
|
|
|
|
// - -%- -
|
|
|
|
private void
|
|
layout(Tree<String> node, FontMetrics fm, Position cursor)
|
|
{
|
|
assert cursor != null;
|
|
|
|
if (node.key.equals("space"))
|
|
{
|
|
int w = fm.stringWidth(node.value);
|
|
if (cursor.x + w < getWidth())
|
|
{
|
|
layout.put(node, cursor.clone());
|
|
cursor.x += w;
|
|
}
|
|
else
|
|
{
|
|
layout.put(node, cursor.clone());
|
|
++cursor.line;
|
|
cursor.x = 0;
|
|
}
|
|
}
|
|
else if (node.key.equals("text"))
|
|
{
|
|
int w = fm.stringWidth(node.value);
|
|
if (cursor.x + w < getWidth())
|
|
{
|
|
layout.put(node, cursor.clone());
|
|
cursor.x += w;
|
|
}
|
|
else if (w < getWidth())
|
|
{
|
|
++cursor.line;
|
|
cursor.x = 0;
|
|
layout.put(node, cursor.clone());
|
|
cursor.x += w;
|
|
}
|
|
else
|
|
{
|
|
StringBuilder rem = new StringBuilder();
|
|
rem.append(node.value);
|
|
int mw = getWidth();
|
|
int aw = mw - cursor.x;
|
|
|
|
w = fm.charWidth(node.value.charAt(0));
|
|
if (w >= aw)
|
|
{
|
|
++cursor.line;
|
|
cursor.x = 0;
|
|
}
|
|
|
|
while (rem.length() > 0)
|
|
{
|
|
int l = 2;
|
|
for (; l <= rem.length(); ++l)
|
|
{
|
|
String substr = rem.substring(0, l);
|
|
w = fm.stringWidth(substr);
|
|
if (w >= aw) break;
|
|
}
|
|
String substr = rem.substring(0, --l);
|
|
w = fm.stringWidth(substr);
|
|
|
|
Tree<String> temp = new Tree<>();
|
|
temp.key = node.key;
|
|
temp.value = substr;
|
|
layout.put(temp, cursor.clone());
|
|
|
|
rem.delete(0, l);
|
|
boolean more = rem.length() != 0;
|
|
if (more) ++cursor.line;
|
|
cursor.x = more ? 0 : w;
|
|
aw = mw;
|
|
}
|
|
}
|
|
}
|
|
else if (node.key.equals("emoji"))
|
|
{
|
|
Image image = emojis.get(node.value);
|
|
int w; if (image != null)
|
|
{
|
|
int ow = image.getWidth(this);
|
|
int oh = image.getHeight(this);
|
|
int h = fm.getAscent() + fm.getDescent();
|
|
w = ow * h/oh;
|
|
}
|
|
else
|
|
{
|
|
w = fm.stringWidth(node.value);
|
|
}
|
|
|
|
if (cursor.x + w < getWidth())
|
|
{
|
|
layout.put(node, cursor.clone());
|
|
cursor.x += w;
|
|
}
|
|
else
|
|
{
|
|
++cursor.line;
|
|
cursor.x = 0;
|
|
layout.put(node, cursor.clone());
|
|
cursor.x += w;
|
|
}
|
|
}
|
|
else if (node.key.equals("tag"))
|
|
{
|
|
String tagName = node.get(0).key;
|
|
Tree<String> children = node.get("children");
|
|
|
|
// We won't place tag nodes on the layout.
|
|
|
|
if (tagName.equals("br"))
|
|
{
|
|
++cursor.line;
|
|
cursor.x = 0;
|
|
}
|
|
else if (tagName.equals("p"))
|
|
{
|
|
//cursor.line += 3/2;
|
|
cursor.line += 2;
|
|
// We don't have vertical cursor movement
|
|
// other than the line. Maybe fix in the
|
|
// future..?
|
|
cursor.x = 0;
|
|
}
|
|
|
|
for (Tree<String> child: children)
|
|
{
|
|
// Shallow copy this child node,
|
|
Tree<String> aug = new Tree<>();
|
|
aug.key = child.key;
|
|
aug.value = child.value;
|
|
for (Tree<String> gc: child) aug.add(gc);
|
|
|
|
// Append all of our attributes. We'd like
|
|
// those like href to end up at the text
|
|
// nodes. This might collide with our
|
|
// child node's attributes, for now I'll
|
|
// assume that's not an issue.
|
|
for (int o = 1; o < node.size(); ++o)
|
|
{
|
|
Tree<String> attr = node.get(o);
|
|
if (attr == children) continue;
|
|
aug.add(attr);
|
|
}
|
|
|
|
layout(aug, fm, cursor);
|
|
}
|
|
}
|
|
else assert false;
|
|
}
|
|
|
|
protected void
|
|
paintComponent(Graphics g)
|
|
{
|
|
final Color LINK_COLOUR = Color.BLUE;
|
|
final Color PLAIN_COLOUR = getForeground();
|
|
final Color SEL_COLOUR = new Color(0, 0, 0, 25);
|
|
|
|
g.setFont(getFont());
|
|
FontMetrics fm = g.getFontMetrics();
|
|
int iy = fm.getAscent();
|
|
int lh = fm.getAscent() + fm.getDescent();
|
|
int asc = fm.getAscent();
|
|
int w = getWidth(), h = getHeight();
|
|
|
|
if (isOpaque()) g.clearRect(0, 0, w, h);
|
|
|
|
if (selEnd != null)
|
|
{
|
|
Position ssp = layout.get(selStart);
|
|
assert ssp != null;
|
|
Position sep = layout.get(selEnd);
|
|
assert sep != null;
|
|
/*
|
|
* (知) One way these can go null is if we clear
|
|
* the layout but don't clear the selection.
|
|
*/
|
|
|
|
if (ssp.compareTo(sep) > 0)
|
|
{
|
|
Position temp = ssp;
|
|
ssp = sep;
|
|
sep = ssp;
|
|
}
|
|
|
|
int ls = 1 + ssp.line - startingLine;
|
|
int le = 1 + sep.line - startingLine;
|
|
int ys = snap2(ls, iy, lh) - asc;
|
|
int ye = snap2(le, iy, lh) - asc;
|
|
|
|
g.setColor(SEL_COLOUR);
|
|
if (ssp.line == sep.line)
|
|
{
|
|
g.fillRect(ssp.x, ys, sep.x - ssp.x, lh);
|
|
}
|
|
else
|
|
{
|
|
g.fillRect(ssp.x, ys, w - ssp.x, lh);
|
|
for (int l = ls + 1; l < le; ++l)
|
|
{
|
|
int y = snap2(l, iy, lh) - asc;
|
|
g.fillRect(0, y, w, lh);
|
|
}
|
|
g.fillRect(0, ye, sep.x, lh);
|
|
}
|
|
}
|
|
|
|
((java.awt.Graphics2D)g).setRenderingHint(
|
|
java.awt.RenderingHints.KEY_ANTIALIASING,
|
|
java.awt.RenderingHints.VALUE_ANTIALIAS_ON
|
|
);
|
|
|
|
g.setColor(getForeground());
|
|
for (Tree<String> node: layout.keySet())
|
|
{
|
|
Position position = layout.get(node);
|
|
int x = position.x;
|
|
int line = 1 + position.line - startingLine;
|
|
int y = snap2(line, iy, lh);
|
|
if (y > h) continue;
|
|
|
|
if (node.key.equals("text"))
|
|
{
|
|
boolean isLink = node.get("href") != null;
|
|
if (isLink) g.setColor(LINK_COLOUR);
|
|
g.drawString(node.value, x, y);
|
|
if (isLink) g.setColor(PLAIN_COLOUR);
|
|
}
|
|
else if (node.key.equals("emoji"))
|
|
{
|
|
Image image = emojis.get(node.value);
|
|
if (image != null)
|
|
{
|
|
int ow = image.getWidth(this);
|
|
int oh = image.getHeight(this);
|
|
int nh = fm.getAscent() + fm.getDescent();
|
|
int nw = ow * nh/oh;
|
|
y -= asc;
|
|
g.drawImage(image, x, y, nw, nh, this);
|
|
}
|
|
else
|
|
{
|
|
g.drawString(node.value, x, y);
|
|
}
|
|
}
|
|
else continue;
|
|
}
|
|
}
|
|
|
|
public void
|
|
mousePressed(MouseEvent eM)
|
|
{
|
|
if (eM.getButton() != MouseEvent.BUTTON1) return;
|
|
selStart = identifyNodeAt(eM.getX(), eM.getY());
|
|
selEnd = null;
|
|
repaint();
|
|
requestFocusInWindow();
|
|
}
|
|
|
|
public void
|
|
mouseDragged(MouseEvent eM)
|
|
{
|
|
if (selStart == null) return;
|
|
selEnd = identifyNodeAfter(eM.getX(), eM.getY());
|
|
if (selEnd == null) selEnd = layoutEnd;
|
|
repaint();
|
|
}
|
|
|
|
public void
|
|
mouseMoved(MouseEvent eM)
|
|
{
|
|
Tree<String> h = identifyNodeAt(eM.getX(), eM.getY());
|
|
if (h == null || h.get("href") == null)
|
|
{
|
|
setToolTipText("");
|
|
}
|
|
else
|
|
{
|
|
setToolTipText(h.get("href").value);
|
|
}
|
|
}
|
|
|
|
private Tree<String>
|
|
identifyNodeAt(int x, int y)
|
|
{
|
|
FontMetrics fm = getFontMetrics(getFont());
|
|
int initial = fm.getAscent();
|
|
int advance = fm.getAscent() + fm.getDescent();
|
|
int line = isnap2(y, initial, advance);
|
|
|
|
Tree<String> returnee = null;
|
|
Position closest = new Position(0, 0);
|
|
for (Tree<String> node: layout.keySet())
|
|
{
|
|
Position position = layout.get(node);
|
|
assert position != null;
|
|
|
|
if (position.line != line) continue;
|
|
if (position.x > x) continue;
|
|
if (position.x >= closest.x)
|
|
{
|
|
returnee = node;
|
|
closest = position;
|
|
}
|
|
}
|
|
return returnee;
|
|
}
|
|
|
|
private Tree<String>
|
|
identifyNodeAfter(int x, int y)
|
|
{
|
|
FontMetrics fm = getFontMetrics(getFont());
|
|
int initial = fm.getAscent();
|
|
int advance = fm.getAscent() + fm.getDescent();
|
|
int line = isnap2(y, initial, advance);
|
|
|
|
Tree<String> returnee = null;
|
|
Position closest = new Position(Integer.MAX_VALUE, 0);
|
|
for (Tree<String> node: layout.keySet())
|
|
{
|
|
Position position = layout.get(node);
|
|
assert position != null;
|
|
|
|
if (position.line != line) continue;
|
|
if (position.x < x) continue;
|
|
if (position.x < closest.x)
|
|
{
|
|
returnee = node;
|
|
closest = position;
|
|
}
|
|
}
|
|
return returnee;
|
|
}
|
|
|
|
public void
|
|
keyPressed(KeyEvent eK)
|
|
{
|
|
if (!eK.isControlDown()) return;
|
|
|
|
switch (eK.getKeyCode())
|
|
{
|
|
case KeyEvent.VK_C:
|
|
if (selEnd == null) return;
|
|
ClipboardApi.serve(getSelectedText());
|
|
return;
|
|
case KeyEvent.VK_A:
|
|
selStart = identifyNodeAt(0, 0);
|
|
selEnd = layoutEnd;
|
|
repaint();
|
|
return;
|
|
}
|
|
}
|
|
|
|
private String
|
|
getSelectedText()
|
|
{
|
|
assert selStart != null && selEnd != null;
|
|
|
|
Position ssp = layout.get(selStart);
|
|
Position sep = layout.get(selEnd);
|
|
assert ssp != null && sep != null;
|
|
if (ssp.compareTo(sep) > 0)
|
|
{
|
|
Position temp = ssp;
|
|
ssp = sep;
|
|
sep = ssp;
|
|
}
|
|
|
|
List<Tree<String>> selected = new ArrayList<>();
|
|
List<Position> positions = new ArrayList<>();
|
|
for (Tree<String> node: layout.keySet())
|
|
{
|
|
Position position = layout.get(node);
|
|
assert position != null;
|
|
|
|
boolean after = position.compareTo(ssp) >= 0;
|
|
boolean before = position.compareTo(sep) < 0;
|
|
if (!(after && before)) continue;
|
|
|
|
// Just throw them in a pile for now..
|
|
selected.add(node);
|
|
positions.add(position);
|
|
}
|
|
|
|
// Now sort them into reading order.
|
|
Tree<String> n1, n2;
|
|
Position p1, p2;
|
|
for (int eo = 1; eo < positions.size(); ++eo)
|
|
for (int o = positions.size() - 1; o >= eo; --o)
|
|
{
|
|
n1 = selected.get(o - 1); n2 = selected.get(o);
|
|
p1 = positions.get(o - 1); p2 = positions.get(o);
|
|
|
|
if (p2.compareTo(p1) > 0) continue;
|
|
|
|
selected.set(o - 1, n2);
|
|
selected.set(o, n1);
|
|
positions.set(o - 1, p2);
|
|
positions.set(o, p1);
|
|
}
|
|
|
|
StringBuilder b = new StringBuilder();
|
|
for (Tree<String> node: selected)
|
|
{
|
|
boolean t = node.key.equals("text");
|
|
boolean e = node.key.equals("emoji");
|
|
boolean s = node.key.equals("space");
|
|
assert t || e || s;
|
|
b.append(node.value);
|
|
/*
|
|
* I actually want to copy the link if the node is
|
|
* associated with one. However, a link has
|
|
* multiple text nodes, so I'd end up copying
|
|
* multiple times. The correct action is to
|
|
* associate the nodes with the same link object,
|
|
* then mark that as copied. Or, associate the
|
|
* nodes with their superiors in the HTML, then
|
|
* walk up until we find an anchor with a href.
|
|
* Then again, have to mark that as copied too.
|
|
*
|
|
* I can also walk the HTML and copy any that are
|
|
* in the selected region, careful to copy an
|
|
* anchor's href in stead of the anchor contents.
|
|
* I'd need a guarantee that my walking order is
|
|
* the same as how they were rendered on the screen.
|
|
*/
|
|
}
|
|
return b.toString();
|
|
}
|
|
|
|
private int
|
|
getHeightInLines()
|
|
{
|
|
FontMetrics fm = getFontMetrics(getFont());
|
|
int initial = fm.getAscent();
|
|
int advance = fm.getAscent() + fm.getDescent();
|
|
return isnap2(getHeight(), initial, advance);
|
|
}
|
|
|
|
public void
|
|
keyReleased(KeyEvent eK) { }
|
|
|
|
public void
|
|
keyTyped(KeyEvent eK) { }
|
|
|
|
public void
|
|
mouseReleased(MouseEvent eM) { }
|
|
|
|
public void
|
|
mouseClicked(MouseEvent eM) { }
|
|
|
|
public void
|
|
mouseEntered(MouseEvent eM) { }
|
|
|
|
public void
|
|
mouseExited(MouseEvent eM) { }
|
|
|
|
public void
|
|
componentResized(ComponentEvent eC) { setText(html); }
|
|
|
|
public void
|
|
componentMoved(ComponentEvent eC) { }
|
|
|
|
public void
|
|
componentShown(ComponentEvent eC) { }
|
|
|
|
public void
|
|
componentHidden(ComponentEvent eC) { }
|
|
|
|
// - -%- -
|
|
|
|
private static int
|
|
snap2(int value, int initial, int advance)
|
|
{
|
|
return initial + (value - 1) * advance;
|
|
// If you'd like to go behind the first line 1,
|
|
// note that the first negative line is 0.
|
|
}
|
|
|
|
private static int
|
|
isnap2(int value, int initial, int advance)
|
|
{
|
|
int offset = value - initial;
|
|
return 1 + ((offset - 1) / advance);
|
|
// Mostly correct for negative numbers. I just
|
|
// need this function to accept negative numbers,
|
|
// not give usable results.
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
private static class
|
|
Position {
|
|
|
|
int
|
|
x, line;
|
|
|
|
// -=%=-
|
|
|
|
public int
|
|
compareTo(Position other)
|
|
{
|
|
if (line < other.line) return -1;
|
|
if (line > other.line) return 1;
|
|
if (x < other.x) return -1;
|
|
if (x > other.x) return 1;
|
|
return 0;
|
|
}
|
|
|
|
public String
|
|
toString()
|
|
{
|
|
return "(" + x + "," + line + ")";
|
|
}
|
|
|
|
// -=%=-
|
|
|
|
public
|
|
Position(int x, int line)
|
|
{
|
|
this.x = x;
|
|
this.line = line;
|
|
}
|
|
|
|
public Position
|
|
clone()
|
|
{
|
|
return new Position(x, line);
|
|
}
|
|
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
RichTextPane3()
|
|
{
|
|
layout = new HashMap<>();
|
|
layoutEnd = new Tree<>("text", "");
|
|
emojis = new HashMap<>();
|
|
|
|
Tree<String> blank = new Tree<>();
|
|
blank.key = "tag";
|
|
blank.add(new Tree<>("html", null));
|
|
blank.add(new Tree<>("children", null));
|
|
setText(blank);
|
|
|
|
this.addComponentListener(this);
|
|
this.addMouseListener(this);
|
|
this.addMouseMotionListener(this);
|
|
this.addKeyListener(this);
|
|
setFocusable(true);
|
|
// A keyboard user can still copy by tabbing in
|
|
// and selecting all.
|
|
}
|
|
|
|
}
|