mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2024-11-20 04:54:50 +01:00
2b63e37276
Slightly fixed text selection in RichTextPane3.
685 lines
16 KiB
Java
685 lines
16 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);
|
|
Image scaled = emojis.get(node.value + "_scaled");
|
|
if (scaled != null)
|
|
{
|
|
y -= asc;
|
|
g.drawImage(scaled, x, y, this);
|
|
}
|
|
else if (image != null)
|
|
{
|
|
scaled = image.getScaledInstance(
|
|
-1, fm.getAscent() + fm.getDescent(),
|
|
Image.SCALE_SMOOTH
|
|
);
|
|
// I hope #getScaledInstance knows how to
|
|
// wait if the image is yet to be loaded.
|
|
emojis.put(node.value + "_scaled", scaled);
|
|
}
|
|
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 blocks, int initial, int advance)
|
|
{
|
|
return initial + (blocks - 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 units, int initial, int advance)
|
|
{
|
|
int offset = units - initial;
|
|
return 2 + bfloor(offset - 1, advance);
|
|
// Not yet sure how this behaves for negative numbers.
|
|
}
|
|
|
|
private static int
|
|
bfloor(int units, int block)
|
|
{
|
|
if (units < 0) return (units / block) - 1;
|
|
else return units / block;
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
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.
|
|
}
|
|
|
|
}
|