667 lines
22 KiB
C++
667 lines
22 KiB
C++
//=============================================================================
|
|
// MuseScore
|
|
// Music Composition & Notation
|
|
//
|
|
// Copyright (C) 2002-2011 Werner Schweer
|
|
//
|
|
// This program is free software; you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License version 2
|
|
// as published by the Free Software Foundation and appearing in
|
|
// the file LICENCE.GPL
|
|
//=============================================================================
|
|
|
|
#include "lyrics.h"
|
|
|
|
#include "chord.h"
|
|
#include "score.h"
|
|
#include "sym.h"
|
|
#include "system.h"
|
|
#include "xml.h"
|
|
#include "staff.h"
|
|
#include "segment.h"
|
|
#include "undo.h"
|
|
#include "textedit.h"
|
|
#include "measure.h"
|
|
|
|
namespace Ms {
|
|
|
|
//---------------------------------------------------------
|
|
// lyricsElementStyle
|
|
//---------------------------------------------------------
|
|
|
|
static const ElementStyle lyricsElementStyle {
|
|
{ Sid::lyricsPlacement, Pid::PLACEMENT },
|
|
};
|
|
|
|
//---------------------------------------------------------
|
|
// Lyrics
|
|
//---------------------------------------------------------
|
|
|
|
Lyrics::Lyrics(Score* s)
|
|
: TextBase(s, Tid::LYRICS_ODD)
|
|
{
|
|
_even = false;
|
|
initElementStyle(&lyricsElementStyle);
|
|
_no = 0;
|
|
_ticks = Fraction(0,1);
|
|
_syllabic = Syllabic::SINGLE;
|
|
_separator = 0;
|
|
}
|
|
|
|
Lyrics::Lyrics(const Lyrics& l)
|
|
: TextBase(l)
|
|
{
|
|
_even = l._even;
|
|
_no = l._no;
|
|
_ticks = l._ticks;
|
|
_syllabic = l._syllabic;
|
|
_separator = 0;
|
|
}
|
|
|
|
Lyrics::~Lyrics()
|
|
{
|
|
if (_separator)
|
|
remove(_separator);
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// scanElements
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::scanElements(void* data, void (*func)(void*, Element*), bool /*all*/)
|
|
{
|
|
func(data, this);
|
|
/* DO NOT ADD EITHER THE LYRICSLINE OR THE SEGMENTS: segments are added through the system each belongs to;
|
|
LyricsLine is not needed, as it is internally manged.
|
|
if (_separator)
|
|
_separator->scanElements(data, func, all); */
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// write
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::write(XmlWriter& xml) const
|
|
{
|
|
if (!xml.canWrite(this))
|
|
return;
|
|
xml.stag(this);
|
|
writeProperty(xml, Pid::VERSE);
|
|
if (_syllabic != Syllabic::SINGLE) {
|
|
static const char* sl[] = {
|
|
"single", "begin", "end", "middle"
|
|
};
|
|
xml.tag("syllabic", sl[int(_syllabic)]);
|
|
}
|
|
xml.tag("ticks", _ticks.ticks(), 0); // pre-3.1 compatibility: write integer ticks under <ticks> tag
|
|
writeProperty(xml, Pid::LYRIC_TICKS);
|
|
|
|
TextBase::writeProperties(xml);
|
|
xml.etag();
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// read
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::read(XmlReader& e)
|
|
{
|
|
while (e.readNextStartElement()) {
|
|
if (!readProperties(e))
|
|
e.unknown();
|
|
}
|
|
if (!isStyled(Pid::OFFSET) && !e.pasteMode()) {
|
|
// fix offset for pre-3.1 scores
|
|
// 3.0: y offset was meaningless if autoplace is set
|
|
QString version = masterScore()->mscoreVersion();
|
|
if (autoplace() && !version.isEmpty() && version < "3.1") {
|
|
QPointF off = propertyDefault(Pid::OFFSET).toPointF();
|
|
ryoffset() = off.y();
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// readProperties
|
|
//---------------------------------------------------------
|
|
|
|
bool Lyrics::readProperties(XmlReader& e)
|
|
{
|
|
const QStringRef& tag(e.name());
|
|
|
|
if (tag == "no")
|
|
_no = e.readInt();
|
|
else if (tag == "syllabic") {
|
|
QString val(e.readElementText());
|
|
if (val == "single")
|
|
_syllabic = Syllabic::SINGLE;
|
|
else if (val == "begin")
|
|
_syllabic = Syllabic::BEGIN;
|
|
else if (val == "end")
|
|
_syllabic = Syllabic::END;
|
|
else if (val == "middle")
|
|
_syllabic = Syllabic::MIDDLE;
|
|
else
|
|
qDebug("bad syllabic property");
|
|
}
|
|
else if (tag == "ticks") // obsolete
|
|
_ticks = e.readFraction(); // will fall back to reading integer ticks on older scores
|
|
else if (tag == "ticks_f")
|
|
_ticks = e.readFraction();
|
|
else if (readProperty(tag, e, Pid::PLACEMENT))
|
|
;
|
|
else if (!TextBase::readProperties(e))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// add
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::add(Element* el)
|
|
{
|
|
// el->setParent(this);
|
|
// if (el->type() == ElementType::LINE)
|
|
// _separator.append((Line*)el); // ignore! Internally managed
|
|
// ;
|
|
// else
|
|
qDebug("Lyrics::add: unknown element %s", el->name());
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// remove
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::remove(Element* el)
|
|
{
|
|
if (el->isLyricsLine()) {
|
|
// only if separator still exists and is the right one
|
|
if (_separator && el == _separator) {
|
|
// Lyrics::remove() and LyricsLine::removeUnmanaged() call each other;
|
|
// be sure each finds a clean context
|
|
LyricsLine* separ = _separator;
|
|
_separator = 0;
|
|
separ->setParent(0);
|
|
separ->removeUnmanaged();
|
|
//done in undo/redo? delete separ;
|
|
}
|
|
}
|
|
else
|
|
qDebug("Lyrics::remove: unknown element %s", el->name());
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// isMelisma
|
|
//---------------------------------------------------------
|
|
|
|
bool Lyrics::isMelisma() const
|
|
{
|
|
// entered as melisma using underscore?
|
|
if (_ticks > Fraction(0,1))
|
|
return true;
|
|
|
|
// hyphenated?
|
|
// if so, it is a melisma only if there is no lyric in same verse on next CR
|
|
if (_syllabic == Syllabic::BEGIN || _syllabic == Syllabic::MIDDLE) {
|
|
// find next CR on same track and check for existence of lyric in same verse
|
|
ChordRest* cr = chordRest();
|
|
if (cr) {
|
|
Segment* s = cr->segment()->next1();
|
|
ChordRest* ncr = s ? s->nextChordRest(cr->track()) : 0;
|
|
if (ncr && !ncr->lyrics(_no, placement()))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// default - not a melisma
|
|
return false;
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// layout
|
|
// - does not touch vertical position
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::layout()
|
|
{
|
|
if (!parent()) { // palette & clone trick
|
|
setPos(QPointF());
|
|
TextBase::layout1();
|
|
return;
|
|
}
|
|
|
|
//
|
|
// parse leading verse number and/or punctuation, so we can factor it into layout separately
|
|
//
|
|
bool hasNumber = false; // _verseNumber;
|
|
|
|
// find:
|
|
// 1) string of numbers and non-word characters at start of syllable
|
|
// 2) at least one other character (indicating start of actual lyric)
|
|
// 3) string of non-word characters at end of syllable
|
|
//QRegularExpression leadingPattern("(^[\\d\\W]+)([^\\d\\W]+)");
|
|
|
|
const QString text = plainText();
|
|
QString leading;
|
|
QString trailing;
|
|
|
|
if (score()->styleB(Sid::lyricsAlignVerseNumber)) {
|
|
QRegularExpression punctuationPattern("(^[\\d\\W]*)([^\\d\\W].*?)([\\d\\W]*$)", QRegularExpression::UseUnicodePropertiesOption);
|
|
QRegularExpressionMatch punctuationMatch = punctuationPattern.match(text);
|
|
if (punctuationMatch.hasMatch()) {
|
|
// leading and trailing punctuation
|
|
leading = punctuationMatch.captured(1);
|
|
trailing = punctuationMatch.captured(3);
|
|
//QString actualLyric = punctuationMatch.captured(2);
|
|
if (!leading.isEmpty() && leading[0].isDigit())
|
|
hasNumber = true;
|
|
}
|
|
}
|
|
|
|
bool styleDidChange = false;
|
|
if ((_no & 1) && !_even) {
|
|
initTid(Tid::LYRICS_EVEN, /* preserveDifferent */ true);
|
|
_even = true;
|
|
styleDidChange = true;
|
|
}
|
|
if (!(_no & 1) && _even) {
|
|
initTid(Tid::LYRICS_ODD, /* preserveDifferent */ true);
|
|
_even = false;
|
|
styleDidChange = true;
|
|
}
|
|
|
|
if (styleDidChange)
|
|
styleChanged();
|
|
|
|
if (isMelisma() || hasNumber)
|
|
if (isStyled(Pid::ALIGN)) {
|
|
setAlign(score()->styleV(Sid::lyricsMelismaAlign).value<Align>());
|
|
}
|
|
QPointF o(propertyDefault(Pid::OFFSET).toPointF());
|
|
rxpos() = o.x();
|
|
qreal x = pos().x();
|
|
TextBase::layout1();
|
|
|
|
qreal centerAdjust = 0.0;
|
|
qreal leftAdjust = 0.0;
|
|
|
|
if (score()->styleB(Sid::lyricsAlignVerseNumber)) {
|
|
// Calculate leading and trailing parts widths. Lyrics
|
|
// should have text layout to be able to do it correctly.
|
|
Q_ASSERT(rows() != 0);
|
|
if (!leading.isEmpty() || !trailing.isEmpty()) {
|
|
// qDebug("create leading, trailing <%s> -- <%s><%s>", qPrintable(text), qPrintable(leading), qPrintable(trailing));
|
|
const TextBlock& tb = textBlock(0);
|
|
|
|
const qreal leadingWidth = tb.xpos(leading.length(), this) - tb.boundingRect().x();
|
|
const int trailingPos = text.length() - trailing.length();
|
|
const qreal trailingWidth = tb.boundingRect().right() - tb.xpos(trailingPos, this);
|
|
|
|
leftAdjust = leadingWidth;
|
|
centerAdjust = leadingWidth - trailingWidth;
|
|
}
|
|
}
|
|
|
|
ChordRest* cr = chordRest();
|
|
|
|
if (align() & Align::HCENTER) {
|
|
//
|
|
// center under notehead, not origin
|
|
// however, lyrics that are melismas or have verse numbers will be forced to left alignment
|
|
//
|
|
// center under note head
|
|
qreal nominalWidth = symWidth(SymId::noteheadBlack);
|
|
x += nominalWidth * .5 - cr->x() - centerAdjust * 0.5;
|
|
}
|
|
else if (!(align() & Align::RIGHT)) {
|
|
// even for left aligned syllables, ignore leading verse numbers and/or punctuation
|
|
x -= leftAdjust;
|
|
}
|
|
|
|
rxpos() = x;
|
|
|
|
if (_ticks > Fraction(0,1) || _syllabic == Syllabic::BEGIN || _syllabic == Syllabic::MIDDLE) {
|
|
if (!_separator) {
|
|
_separator = new LyricsLine(score());
|
|
_separator->setTick(cr->tick());
|
|
score()->addUnmanagedSpanner(_separator);
|
|
}
|
|
_separator->setParent(this);
|
|
_separator->setTick(cr->tick());
|
|
// HACK separator should have non-zero length to get its layout
|
|
// always triggered. A proper ticks length will be set later on the
|
|
// separator layout.
|
|
_separator->setTicks(Fraction::fromTicks(1));
|
|
_separator->setTrack(track());
|
|
_separator->setTrack2(track());
|
|
_separator->setVisible(visible());
|
|
// bbox().setWidth(bbox().width()); // ??
|
|
}
|
|
else {
|
|
if (_separator) {
|
|
_separator->removeUnmanaged();
|
|
delete _separator;
|
|
_separator = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// layout2
|
|
// compute vertical position
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::layout2(int nAbove)
|
|
{
|
|
qreal lh = lineSpacing() * score()->styleD(Sid::lyricsLineHeight);
|
|
|
|
if (placeBelow()) {
|
|
qreal yo = segment()->measure()->system()->staff(staffIdx())->bbox().height();
|
|
rypos() = lh * (_no - nAbove) + yo - chordRest()->y();
|
|
rpos() += styleValue(Pid::OFFSET, Sid::lyricsPosBelow).toPointF();
|
|
}
|
|
else {
|
|
rypos() = -lh * (nAbove - _no - 1) - chordRest()->y();
|
|
rpos() += styleValue(Pid::OFFSET, Sid::lyricsPosAbove).toPointF();
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// paste
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::paste(EditData& ed)
|
|
{
|
|
MuseScoreView* scoreview = ed.view;
|
|
#if defined(Q_OS_MAC) || defined(Q_OS_WIN)
|
|
QClipboard::Mode mode = QClipboard::Clipboard;
|
|
#else
|
|
QClipboard::Mode mode = QClipboard::Selection;
|
|
#endif
|
|
QString txt = QApplication::clipboard()->text(mode);
|
|
QString regex = QString("[^\\S") + QChar(0xa0) + QChar(0x202F) + "]+";
|
|
QStringList sl = txt.split(QRegExp(regex), QString::SkipEmptyParts);
|
|
if (sl.empty())
|
|
return;
|
|
|
|
QStringList hyph = sl[0].split("-");
|
|
bool minus = false;
|
|
bool underscore = false;
|
|
score()->startCmd();
|
|
|
|
if(hyph.length() > 1) {
|
|
score()->undo(new InsertText(cursor(ed), hyph[0]), &ed);
|
|
hyph.removeFirst();
|
|
sl[0] = hyph.join("-");
|
|
minus = true;
|
|
}
|
|
else if (sl.length() > 1 && sl[1] == "-") {
|
|
score()->undo(new InsertText(cursor(ed), sl[0]), &ed);
|
|
sl.removeFirst();
|
|
sl.removeFirst();
|
|
minus = true;
|
|
}
|
|
else if (sl[0].startsWith("_")) {
|
|
sl[0].remove(0, 1);
|
|
if (sl[0].isEmpty())
|
|
sl.removeFirst();
|
|
underscore = true;
|
|
}
|
|
else if (sl[0].contains("_")) {
|
|
int p = sl[0].indexOf("_");
|
|
score()->undo(new InsertText(cursor(ed), sl[0]), &ed);
|
|
sl[0] = sl[0].mid(p + 1);
|
|
if (sl[0].isEmpty())
|
|
sl.removeFirst();
|
|
underscore = true;
|
|
}
|
|
else if (sl.length() > 1 && sl[1] == "_") {
|
|
score()->undo(new InsertText(cursor(ed), sl[0]), &ed);
|
|
sl.removeFirst();
|
|
sl.removeFirst();
|
|
underscore = true;
|
|
}
|
|
else {
|
|
score()->undo(new InsertText(cursor(ed), sl[0]), &ed);
|
|
sl.removeFirst();
|
|
}
|
|
|
|
score()->endCmd();
|
|
txt = sl.join(" ");
|
|
|
|
QApplication::clipboard()->setText(txt, mode);
|
|
if (minus)
|
|
scoreview->lyricsMinus();
|
|
else if (underscore)
|
|
scoreview->lyricsUnderscore();
|
|
else
|
|
scoreview->lyricsTab(false, false, true);
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// endTick
|
|
//---------------------------------------------------------
|
|
|
|
Fraction Lyrics::endTick() const
|
|
{
|
|
return segment()->tick() + ticks();
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// acceptDrop
|
|
//---------------------------------------------------------
|
|
|
|
bool Lyrics::acceptDrop(EditData& data) const
|
|
{
|
|
return data.dropElement->isText() || TextBase::acceptDrop(data);
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// drop
|
|
//---------------------------------------------------------
|
|
|
|
Element* Lyrics::drop(EditData& data)
|
|
{
|
|
ElementType type = data.dropElement->type();
|
|
if (type == ElementType::SYMBOL || type == ElementType::FSYMBOL) {
|
|
TextBase::drop(data);
|
|
return 0;
|
|
}
|
|
if (!data.dropElement->isText()) {
|
|
delete data.dropElement;
|
|
data.dropElement = 0;
|
|
return 0;
|
|
}
|
|
Text* e = toText(data.dropElement);
|
|
e->setParent(this);
|
|
score()->undoAddElement(e);
|
|
return e;
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// endEdit
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::endEdit(EditData& ed)
|
|
{
|
|
TextBase::endEdit(ed);
|
|
triggerLayoutAll();
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// removeFromScore
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::removeFromScore()
|
|
{
|
|
if (_separator) {
|
|
_separator->removeUnmanaged();
|
|
delete _separator;
|
|
_separator = 0;
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// getProperty
|
|
//---------------------------------------------------------
|
|
|
|
QVariant Lyrics::getProperty(Pid propertyId) const
|
|
{
|
|
switch (propertyId) {
|
|
case Pid::SYLLABIC:
|
|
return int(_syllabic);
|
|
case Pid::LYRIC_TICKS:
|
|
return _ticks;
|
|
case Pid::VERSE:
|
|
return _no;
|
|
default:
|
|
return TextBase::getProperty(propertyId);
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// setProperty
|
|
//---------------------------------------------------------
|
|
|
|
bool Lyrics::setProperty(Pid propertyId, const QVariant& v)
|
|
{
|
|
switch (propertyId) {
|
|
case Pid::PLACEMENT:
|
|
setPlacement(Placement(v.toInt()));
|
|
break;
|
|
case Pid::SYLLABIC:
|
|
_syllabic = Syllabic(v.toInt());
|
|
break;
|
|
case Pid::LYRIC_TICKS:
|
|
_ticks = v.value<Fraction>();
|
|
break;
|
|
case Pid::VERSE:
|
|
_no = v.toInt();
|
|
break;
|
|
default:
|
|
if (!TextBase::setProperty(propertyId, v))
|
|
return false;
|
|
break;
|
|
}
|
|
triggerLayout();
|
|
return true;
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// propertyDefault
|
|
//---------------------------------------------------------
|
|
|
|
QVariant Lyrics::propertyDefault(Pid id) const
|
|
{
|
|
switch (id) {
|
|
case Pid::SUB_STYLE:
|
|
return int((_no & 1) ? Tid::LYRICS_EVEN : Tid::LYRICS_ODD);
|
|
case Pid::PLACEMENT:
|
|
return score()->styleV(Sid::lyricsPlacement);
|
|
case Pid::SYLLABIC:
|
|
return int(Syllabic::SINGLE);
|
|
case Pid::LYRIC_TICKS:
|
|
return Fraction(0,1);
|
|
case Pid::VERSE:
|
|
return 0;
|
|
case Pid::ALIGN:
|
|
if (isMelisma())
|
|
return score()->styleV(Sid::lyricsMelismaAlign);
|
|
// fall through
|
|
default:
|
|
return TextBase::propertyDefault(id);
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// getPropertyStyle
|
|
//---------------------------------------------------------
|
|
|
|
Sid Lyrics::getPropertyStyle(Pid pid) const
|
|
{
|
|
if (pid == Pid::OFFSET)
|
|
return placeAbove() ? Sid::lyricsPosAbove : Sid::lyricsPosBelow;
|
|
return TextBase::getPropertyStyle(pid);
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// forAllLyrics
|
|
//---------------------------------------------------------
|
|
|
|
void Score::forAllLyrics(std::function<void(Lyrics*)> f)
|
|
{
|
|
for (Segment* s = firstSegment(SegmentType::ChordRest); s; s = s->next1(SegmentType::ChordRest)) {
|
|
for (Element* e : s->elist()) {
|
|
if (e) {
|
|
for (Lyrics* l : toChordRest(e)->lyrics()) {
|
|
f(l);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------
|
|
// undoChangeProperty
|
|
//---------------------------------------------------------
|
|
|
|
void Lyrics::undoChangeProperty(Pid id, const QVariant& v, PropertyFlags ps)
|
|
{
|
|
if (id == Pid::VERSE && no() != v.toInt()) {
|
|
for (Lyrics* l : chordRest()->lyrics()) {
|
|
if (l->no() == v.toInt()) {
|
|
// verse already exists, swap
|
|
l->TextBase::undoChangeProperty(id, no(), ps);
|
|
Placement p = l->placement();
|
|
l->TextBase::undoChangeProperty(Pid::PLACEMENT, int(placement()), ps);
|
|
TextBase::undoChangeProperty(Pid::PLACEMENT, int(p), ps);
|
|
break;
|
|
}
|
|
}
|
|
TextBase::undoChangeProperty(id, v, ps);
|
|
return;
|
|
}
|
|
else if (id == Pid::AUTOPLACE && v.toBool() != autoplace()) {
|
|
if (v.toBool()) {
|
|
// setting autoplace
|
|
// reset offset
|
|
undoResetProperty(Pid::OFFSET);
|
|
}
|
|
else {
|
|
// unsetting autoplace
|
|
// rebase offset
|
|
QPointF off = offset();
|
|
qreal y = pos().y() - propertyDefault(Pid::OFFSET).toPointF().y();
|
|
off.ry() = placeAbove() ? y : y - staff()->height();
|
|
undoChangeProperty(Pid::OFFSET, off, PropertyFlags::UNSTYLED);
|
|
}
|
|
TextBase::undoChangeProperty(id, v, ps);
|
|
return;
|
|
}
|
|
#if 0
|
|
// TODO: create new command to do this
|
|
if (id == Pid::PLACEMENT) {
|
|
if (Placement(v.toInt()) == Placement::ABOVE) {
|
|
// change placment of all verse for the same voice upto this one to ABOVE
|
|
score()->forAllLyrics([this,id,v,ps](Lyrics* l) {
|
|
if (l->no() <= no() && l->voice() == voice())
|
|
l->TextBase::undoChangeProperty(id, v, ps);
|
|
});
|
|
}
|
|
else {
|
|
// change placment of all verse for the same voce starting from this one to BELOW
|
|
score()->forAllLyrics([this,id,v,ps](Lyrics* l) {
|
|
if (l->no() >= no() && l->voice() == voice())
|
|
l->TextBase::undoChangeProperty(id, v, ps);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
TextBase::undoChangeProperty(id, v, ps);
|
|
}
|
|
|
|
}
|
|
|