зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1900426 - Implement DnD for shadow-crossing selection r=jjaschke,smaug,dom-core
This patches allows 1. when the selection is shadow-crossing, dragging it will have the correct the drag image correctly displayed. It's hard to test, so no tests for this. 2. The selection can now be serialized and dropped. `test_drag_drop_shadow_crossing_selection.html` is the test for this. Differential Revision: https://phabricator.services.mozilla.com/D217318
This commit is contained in:
Родитель
1f524fb8a2
Коммит
e78c6ae67b
|
@ -830,11 +830,12 @@ nsresult DragDataProducer::GetDraggableSelectionData(
|
|||
*outImageOrLinkNode = nullptr;
|
||||
*outDragSelectedText = false;
|
||||
|
||||
if (!inSelection->IsCollapsed()) {
|
||||
if (!inSelection->AreNormalAndCrossShadowBoundaryRangesCollapsed()) {
|
||||
if (inSelection->ContainsNode(*inRealTargetNode, false, IgnoreErrors())) {
|
||||
// track down the anchor node, if any, for the url
|
||||
nsINode* selectionStart = inSelection->GetAnchorNode();
|
||||
nsINode* selectionEnd = inSelection->GetFocusNode();
|
||||
nsINode* selectionStart =
|
||||
inSelection->GetMayCrossShadowBoundaryAnchorNode();
|
||||
nsINode* selectionEnd = inSelection->GetMayCrossShadowBoundaryFocusNode();
|
||||
|
||||
// look for a selection around a single node, like an image.
|
||||
// in this case, drag the image, rather than a serialization of the HTML
|
||||
|
|
|
@ -3611,8 +3611,6 @@ class nsContentUtils {
|
|||
aCallback);
|
||||
|
||||
static nsINode* GetCommonAncestorHelper(nsINode* aNode1, nsINode* aNode2);
|
||||
static nsINode* GetCommonShadowIncludingAncestorHelper(nsINode* aNode1,
|
||||
nsINode* aNode2);
|
||||
static nsIContent* GetCommonFlattenedTreeAncestorHelper(
|
||||
nsIContent* aContent1, nsIContent* aContent2);
|
||||
|
||||
|
|
|
@ -404,7 +404,12 @@ nsresult nsCopySupport::GetTransferableForSelection(
|
|||
NS_ENSURE_TRUE(aDoc, NS_ERROR_NULL_POINTER);
|
||||
NS_ENSURE_TRUE(aTransferable, NS_ERROR_NULL_POINTER);
|
||||
|
||||
const uint32_t additionalFlags = nsIDocumentEncoder::SkipInvisibleContent;
|
||||
const uint32_t additionalFlags =
|
||||
StaticPrefs::dom_shadowdom_selection_across_boundary_enabled()
|
||||
? nsIDocumentEncoder::SkipInvisibleContent |
|
||||
nsIDocumentEncoder::AllowCrossShadowBoundary
|
||||
: nsIDocumentEncoder::SkipInvisibleContent;
|
||||
|
||||
return EncodeDocumentWithContextAndCreateTransferable(
|
||||
*aDoc, aSel, additionalFlags, aTransferable);
|
||||
}
|
||||
|
|
|
@ -3023,6 +3023,19 @@ already_AddRefed<DOMRect> nsRange::GetBoundingClientRect(bool aClampToEdge,
|
|||
|
||||
already_AddRefed<DOMRectList> nsRange::GetClientRects(bool aClampToEdge,
|
||||
bool aFlushLayout) {
|
||||
return GetClientRectsInner(AllowRangeCrossShadowBoundary::No, aClampToEdge,
|
||||
aFlushLayout);
|
||||
}
|
||||
|
||||
already_AddRefed<DOMRectList> nsRange::GetAllowCrossShadowBoundaryClientRects(
|
||||
bool aClampToEdge, bool aFlushLayout) {
|
||||
return GetClientRectsInner(AllowRangeCrossShadowBoundary::Yes, aClampToEdge,
|
||||
aFlushLayout);
|
||||
}
|
||||
|
||||
already_AddRefed<DOMRectList> nsRange::GetClientRectsInner(
|
||||
AllowRangeCrossShadowBoundary aAllowCrossShadowBoundaryRange,
|
||||
bool aClampToEdge, bool aFlushLayout) {
|
||||
if (!mIsPositioned) {
|
||||
return nullptr;
|
||||
}
|
||||
|
@ -3031,11 +3044,20 @@ already_AddRefed<DOMRectList> nsRange::GetClientRects(bool aClampToEdge,
|
|||
|
||||
nsLayoutUtils::RectListBuilder builder(rectList);
|
||||
|
||||
const auto& startRef =
|
||||
aAllowCrossShadowBoundaryRange == AllowRangeCrossShadowBoundary::Yes
|
||||
? MayCrossShadowBoundaryStartRef()
|
||||
: mStart;
|
||||
const auto& endRef =
|
||||
aAllowCrossShadowBoundaryRange == AllowRangeCrossShadowBoundary::Yes
|
||||
? MayCrossShadowBoundaryEndRef()
|
||||
: mEnd;
|
||||
|
||||
CollectClientRectsAndText(
|
||||
&builder, nullptr, this, mStart.Container(),
|
||||
*mStart.Offset(RangeBoundary::OffsetFilter::kValidOffsets),
|
||||
mEnd.Container(),
|
||||
*mEnd.Offset(RangeBoundary::OffsetFilter::kValidOffsets), aClampToEdge,
|
||||
&builder, nullptr, this, startRef.Container(),
|
||||
*startRef.Offset(RangeBoundary::OffsetFilter::kValidOffsets),
|
||||
endRef.Container(),
|
||||
*endRef.Offset(RangeBoundary::OffsetFilter::kValidOffsets), aClampToEdge,
|
||||
aFlushLayout);
|
||||
return rectList.forget();
|
||||
}
|
||||
|
|
|
@ -264,6 +264,10 @@ class nsRange final : public mozilla::dom::AbstractRange,
|
|||
bool aFlushLayout = true);
|
||||
already_AddRefed<DOMRectList> GetClientRects(bool aClampToEdge = true,
|
||||
bool aFlushLayout = true);
|
||||
// ChromeOnly
|
||||
already_AddRefed<DOMRectList> GetAllowCrossShadowBoundaryClientRects(
|
||||
bool aClampToEdge = true, bool aFlushLayout = true);
|
||||
|
||||
void GetClientRectsAndTexts(mozilla::dom::ClientRectsAndTexts& aResult,
|
||||
ErrorResult& aErr);
|
||||
|
||||
|
@ -368,6 +372,10 @@ class nsRange final : public mozilla::dom::AbstractRange,
|
|||
*/
|
||||
bool IsPartOfOneSelectionOnly() const { return mSelections.Length() == 1; };
|
||||
|
||||
already_AddRefed<DOMRectList> GetClientRectsInner(
|
||||
AllowRangeCrossShadowBoundary = AllowRangeCrossShadowBoundary::No,
|
||||
bool aClampToEdge = true, bool aFlushLayout = true);
|
||||
|
||||
public:
|
||||
/**
|
||||
* This helper function gets rects and correlated text for the given range.
|
||||
|
|
|
@ -1243,6 +1243,8 @@ skip-if = [
|
|||
["test_domwindowutils.html"]
|
||||
skip-if = ["os == 'android'"] # Bug 1525959
|
||||
|
||||
["test_drag_drop_shadow_crossing_selection.html"]
|
||||
|
||||
["test_element.matches.html"]
|
||||
|
||||
["test_elementTraversal.html"]
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
<!doctype html>
|
||||
<title>Test dnd for shadow-crossing selection</title>
|
||||
<script src="/tests/SimpleTest/SimpleTest.js"></script>
|
||||
<script src="/tests/SimpleTest/EventUtils.js"></script>
|
||||
<link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
|
||||
<style>
|
||||
</style>
|
||||
<div>
|
||||
<span id="outer1">Outer1</span>
|
||||
<div id="host">
|
||||
<template shadowrootmode="open">
|
||||
<span>Inner1</span>
|
||||
<span>Inner2</span>
|
||||
<span id="inner3">Inner3</span>
|
||||
</template>
|
||||
</div>
|
||||
<span id="outer2">Outer2</span>
|
||||
<div id="host2">
|
||||
<template shadowrootmode="open">
|
||||
<span id="inner4">Inner4</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input id="dropZone" />
|
||||
<script>
|
||||
const selection = window.getSelection();
|
||||
|
||||
async function waitForEvent(event) {
|
||||
return new Promise(r => {
|
||||
addEventListener(event, function(e) {
|
||||
r(e.target);
|
||||
}, { once : true});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDropEvent() {
|
||||
return new Promise(r => {
|
||||
addEventListener("drop", function(e) {
|
||||
r(event.dataTransfer.getData('text/html'));
|
||||
}, { once : true});
|
||||
});
|
||||
}
|
||||
|
||||
async function run(startNode, startOffset, endNode, endOffset, expectedValue, expectedTarget, expectedHTML, assertionMessage) {
|
||||
selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
|
||||
const waitForDragStart = waitForEvent("dragstart");
|
||||
const waitForDragEnd = waitForEvent("dragend");
|
||||
const waitForDrop = waitForDropEvent();
|
||||
await synthesizePlainDragAndDrop({
|
||||
srcSelection: selection,
|
||||
destElement: dropZone
|
||||
});
|
||||
|
||||
const dragStartTarget = await waitForDragStart;
|
||||
const dragEndTarget = await waitForDragEnd;
|
||||
const htmlData = await waitForDrop;
|
||||
|
||||
is(dropZone.value, expectedValue, assertionMessage);
|
||||
is(dragStartTarget, dragEndTarget, "dragstart and dragend should have the same target");
|
||||
is(dragStartTarget, expectedTarget, "dragstart target should be the same as expectedTarget");
|
||||
is(htmlData.replace(/\r\n?/g, "\n"), expectedHTML, "dragged html should match")
|
||||
|
||||
selection.empty();
|
||||
dropZone.value = '';
|
||||
}
|
||||
|
||||
add_task(async function runTests() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
["dom.shadowdom.selection_across_boundary.enabled", true],
|
||||
["ui.dragThresholdX", 4], // bug 1873142
|
||||
["ui.dragThresholdY", 4], // bug 1873142
|
||||
],
|
||||
});
|
||||
|
||||
// synthesizePlainDragAndDrop would use the focused node to initiate DnD, so
|
||||
// the expectedTarget is provided based this.
|
||||
|
||||
// light to shadow
|
||||
let sel = [outer1.firstChild, 2, host.shadowRoot.getElementById("inner3").firstChild, 5];
|
||||
await run(
|
||||
...sel,
|
||||
"ter1 Inner1 Inner2 Inner",
|
||||
host, // expectedTarget - focused node is inside the shadow dom, hence the host is the target to preserve encapsulation.
|
||||
"<span id=\"outer1\">ter1</span>\n <div id=\"host\">\n <span>Inner1</span>\n <span>Inner2</span>\n <span id=\"inner3\">Inner</span></div>",
|
||||
"start is in light DOM and end is in shadow DOM");
|
||||
|
||||
// light to light
|
||||
sel = [outer1.firstChild, 2, outer2.firstChild, 6];
|
||||
await run(
|
||||
...sel,
|
||||
"ter1 Inner1 Inner2 Inner3 Outer2",
|
||||
outer2.firstChild, // expectedTarget - focused node is outer2.firstChild
|
||||
"<span id=\"outer1\">ter1</span>\n <div id=\"host\">\n <span>Inner1</span>\n <span>Inner2</span>\n <span id=\"inner3\">Inner3</span>\n </div>\n <span id=\"outer2\">Outer2</span>",
|
||||
"start is in light DOM and end is in light DOM"
|
||||
);
|
||||
|
||||
// shadow to light
|
||||
sel = [host.shadowRoot.getElementById("inner3").firstChild, 2, outer2.firstChild, 6];
|
||||
await run(
|
||||
...sel,
|
||||
"ner3 Outer2",
|
||||
outer2.firstChild, // expectedTarget - focused node is outer2.firstChild
|
||||
"<div id=\"host\"><span id=\"inner3\">ner3</span>\n </div>\n <span id=\"outer2\">Outer2</span>",
|
||||
"start is in shadow DOM and end is in light DOM"
|
||||
);
|
||||
|
||||
// shadow to shadow
|
||||
sel = [host.shadowRoot.getElementById("inner3").firstChild, 2, host2.shadowRoot.getElementById("inner4").firstChild, 6];
|
||||
await run(
|
||||
...sel,
|
||||
"ner3 Outer2 Inner4 ",
|
||||
host2, // expectedTarget - focused node is inside the shadow dom, hence the host is the target to preserve encapsulation.
|
||||
"<div id=\"host\"><span id=\"inner3\">ner3</span>\n </div>\n <span id=\"outer2\">Outer2</span>\n <div id=\"host2\">\n <span id=\"inner4\">Inner4</span>\n </div>",
|
||||
"start is in shadow DOM and end is in shadow DOM"
|
||||
);
|
||||
});
|
||||
</script>
|
|
@ -1588,9 +1588,8 @@ class nsHTMLCopyEncoder : public nsDocumentEncoder {
|
|||
nsINode* aCommon);
|
||||
static nsCOMPtr<nsINode> GetChildAt(nsINode* aParent, int32_t aOffset);
|
||||
static bool IsMozBR(Element* aNode);
|
||||
static nsresult GetNodeLocation(nsINode* inChild,
|
||||
nsCOMPtr<nsINode>* outParent,
|
||||
int32_t* outOffset);
|
||||
nsresult GetNodeLocation(nsINode* inChild, nsCOMPtr<nsINode>* outParent,
|
||||
int32_t* outOffset);
|
||||
bool IsRoot(nsINode* aNode);
|
||||
static bool IsFirstNode(nsINode* aNode);
|
||||
static bool IsLastNode(nsINode* aNode);
|
||||
|
@ -2044,7 +2043,15 @@ nsresult nsHTMLCopyEncoder::GetPromotedPoint(Endpoint aWhere, nsINode* aNode,
|
|||
node = parent;
|
||||
rv = GetNodeLocation(node, address_of(parent), &offset);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
if (offset == -1) // we hit generated content; STOP
|
||||
|
||||
// When node is the shadow root and parent is the shadow host,
|
||||
// the offset would also be -1, and we'd like to keep going.
|
||||
const bool isGeneratedContent =
|
||||
offset == -1 &&
|
||||
ShadowDOMSelectionHelpers::GetShadowRoot(
|
||||
parent,
|
||||
mFlags & nsIDocumentEncoder::AllowCrossShadowBoundary) != node;
|
||||
if (isGeneratedContent) // we hit generated content; STOP
|
||||
{
|
||||
// back up a bit
|
||||
parent = node;
|
||||
|
@ -2096,7 +2103,9 @@ nsresult nsHTMLCopyEncoder::GetNodeLocation(nsINode* inChild,
|
|||
return NS_ERROR_NULL_POINTER;
|
||||
}
|
||||
|
||||
nsIContent* parent = child->GetParent();
|
||||
nsINode* parent = mFlags & nsIDocumentEncoder::AllowCrossShadowBoundary
|
||||
? child->GetParentOrShadowHostNode()
|
||||
: child->GetParent();
|
||||
if (!parent) {
|
||||
return NS_ERROR_NULL_POINTER;
|
||||
}
|
||||
|
|
|
@ -82,6 +82,7 @@ partial interface Range {
|
|||
// http://dvcs.w3.org/hg/csswg/raw-file/tip/cssom-view/Overview.html#extensions-to-the-range-interface
|
||||
partial interface Range {
|
||||
DOMRectList? getClientRects();
|
||||
[ChromeOnly] DOMRectList? getAllowCrossShadowBoundaryClientRects();
|
||||
DOMRect getBoundingClientRect();
|
||||
};
|
||||
|
||||
|
|
|
@ -23,6 +23,10 @@ interface Selection {
|
|||
readonly attribute boolean isCollapsed;
|
||||
[ChromeOnly]
|
||||
readonly attribute boolean areNormalAndCrossShadowBoundaryRangesCollapsed;
|
||||
|
||||
[ChromeOnly]
|
||||
readonly attribute Node? mayCrossShadowBoundaryFocusNode;
|
||||
|
||||
/**
|
||||
* Returns the number of ranges in the selection.
|
||||
*/
|
||||
|
|
|
@ -231,7 +231,6 @@ PresShell::CapturingContentInfo PresShell::sCapturingContentInfo;
|
|||
|
||||
// RangePaintInfo is used to paint ranges to offscreen buffers
|
||||
struct RangePaintInfo {
|
||||
RefPtr<nsRange> mRange;
|
||||
nsDisplayListBuilder mBuilder;
|
||||
nsDisplayList mList;
|
||||
|
||||
|
@ -243,9 +242,8 @@ struct RangePaintInfo {
|
|||
// to paint them at this resolution.
|
||||
float mResolution = 1.0;
|
||||
|
||||
RangePaintInfo(nsRange* aRange, nsIFrame* aFrame)
|
||||
: mRange(aRange),
|
||||
mBuilder(aFrame, nsDisplayListBuilderMode::Painting, false),
|
||||
explicit RangePaintInfo(nsIFrame* aFrame)
|
||||
: mBuilder(aFrame, nsDisplayListBuilderMode::Painting, false),
|
||||
mList(&mBuilder) {
|
||||
MOZ_COUNT_CTOR(RangePaintInfo);
|
||||
mBuilder.BeginFrame();
|
||||
|
@ -4777,24 +4775,27 @@ nsRect PresShell::ClipListToRange(nsDisplayListBuilder* aBuilder,
|
|||
nsIFrame* frame = i->Frame();
|
||||
nsIContent* content = frame->GetContent();
|
||||
if (content) {
|
||||
bool atStart = (content == aRange->GetStartContainer());
|
||||
bool atEnd = (content == aRange->GetEndContainer());
|
||||
bool atStart =
|
||||
content == aRange->GetMayCrossShadowBoundaryStartContainer();
|
||||
bool atEnd = content == aRange->GetMayCrossShadowBoundaryEndContainer();
|
||||
if ((atStart || atEnd) && frame->IsTextFrame()) {
|
||||
auto [frameStartOffset, frameEndOffset] = frame->GetOffsets();
|
||||
|
||||
int32_t hilightStart =
|
||||
atStart ? std::max(static_cast<int32_t>(aRange->StartOffset()),
|
||||
int32_t highlightStart =
|
||||
atStart ? std::max(static_cast<int32_t>(
|
||||
aRange->MayCrossShadowBoundaryStartOffset()),
|
||||
frameStartOffset)
|
||||
: frameStartOffset;
|
||||
int32_t hilightEnd =
|
||||
atEnd ? std::min(static_cast<int32_t>(aRange->EndOffset()),
|
||||
int32_t highlightEnd =
|
||||
atEnd ? std::min(static_cast<int32_t>(
|
||||
aRange->MayCrossShadowBoundaryEndOffset()),
|
||||
frameEndOffset)
|
||||
: frameEndOffset;
|
||||
if (hilightStart < hilightEnd) {
|
||||
if (highlightStart < highlightEnd) {
|
||||
// determine the location of the start and end edges of the range.
|
||||
nsPoint startPoint, endPoint;
|
||||
frame->GetPointFromOffset(hilightStart, &startPoint);
|
||||
frame->GetPointFromOffset(hilightEnd, &endPoint);
|
||||
frame->GetPointFromOffset(highlightStart, &startPoint);
|
||||
frame->GetPointFromOffset(highlightEnd, &endPoint);
|
||||
|
||||
// The clip rectangle is determined by taking the the start and
|
||||
// end points of the range, offset from the reference frame.
|
||||
|
@ -4828,8 +4829,9 @@ nsRect PresShell::ClipListToRange(nsDisplayListBuilder* aBuilder,
|
|||
// Don't try to descend into subdocuments.
|
||||
// If this ever changes we'd need to add handling for subdocuments with
|
||||
// different zoom levels.
|
||||
else if (content->GetUncomposedDoc() ==
|
||||
aRange->GetStartContainer()->GetUncomposedDoc()) {
|
||||
else if (content->GetComposedDoc() ==
|
||||
aRange->GetMayCrossShadowBoundaryStartContainer()
|
||||
->GetComposedDoc()) {
|
||||
// if the node is within the range, append it to the temporary list
|
||||
bool before, after;
|
||||
nsresult rv =
|
||||
|
@ -4874,14 +4876,18 @@ UniquePtr<RangePaintInfo> PresShell::CreateRangePaintInfo(
|
|||
// If the start or end of the range is the document, just use the root
|
||||
// frame, otherwise get the common ancestor of the two endpoints of the
|
||||
// range.
|
||||
nsINode* startContainer = aRange->GetStartContainer();
|
||||
nsINode* endContainer = aRange->GetEndContainer();
|
||||
nsINode* startContainer = aRange->GetMayCrossShadowBoundaryStartContainer();
|
||||
nsINode* endContainer = aRange->GetMayCrossShadowBoundaryEndContainer();
|
||||
Document* doc = startContainer->GetComposedDoc();
|
||||
if (startContainer == doc || endContainer == doc) {
|
||||
ancestorFrame = rootFrame;
|
||||
} else {
|
||||
nsINode* ancestor = nsContentUtils::GetClosestCommonInclusiveAncestor(
|
||||
startContainer, endContainer);
|
||||
nsINode* ancestor =
|
||||
StaticPrefs::dom_shadowdom_selection_across_boundary_enabled()
|
||||
? nsContentUtils::GetClosestCommonShadowIncludingInclusiveAncestor(
|
||||
startContainer, endContainer)
|
||||
: nsContentUtils::GetClosestCommonInclusiveAncestor(startContainer,
|
||||
endContainer);
|
||||
NS_ASSERTION(!ancestor || ancestor->IsContent(),
|
||||
"common ancestor is not content");
|
||||
|
||||
|
@ -4906,7 +4912,7 @@ UniquePtr<RangePaintInfo> PresShell::CreateRangePaintInfo(
|
|||
}
|
||||
|
||||
// get a display list containing the range
|
||||
auto info = MakeUnique<RangePaintInfo>(aRange, ancestorFrame);
|
||||
auto info = MakeUnique<RangePaintInfo>(ancestorFrame);
|
||||
info->mBuilder.SetIncludeAllOutOfFlows();
|
||||
if (aForPrimarySelection) {
|
||||
info->mBuilder.SetSelectedFramesOnly();
|
||||
|
@ -4914,7 +4920,9 @@ UniquePtr<RangePaintInfo> PresShell::CreateRangePaintInfo(
|
|||
info->mBuilder.EnterPresShell(ancestorFrame);
|
||||
|
||||
ContentSubtreeIterator subtreeIter;
|
||||
nsresult rv = subtreeIter.Init(aRange);
|
||||
nsresult rv = StaticPrefs::dom_shadowdom_selection_across_boundary_enabled()
|
||||
? subtreeIter.InitWithAllowCrossShadowBoundary(aRange)
|
||||
: subtreeIter.Init(aRange);
|
||||
if (NS_FAILED(rv)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
|
|
@ -3386,7 +3386,9 @@ function _nodeIsFlattenedTreeDescendantOf(
|
|||
}
|
||||
|
||||
function _computeSrcElementFromSrcSelection(aSrcSelection) {
|
||||
let srcElement = aSrcSelection.focusNode;
|
||||
let srcElement = _EU_maybeUnwrap(
|
||||
_EU_maybeWrap(aSrcSelection).mayCrossShadowBoundaryFocusNode
|
||||
);
|
||||
while (_EU_maybeWrap(srcElement).isNativeAnonymous) {
|
||||
srcElement = _getFlattenedTreeParentNode(srcElement);
|
||||
}
|
||||
|
@ -3488,7 +3490,9 @@ async function synthesizePlainDragAndDrop(aParams) {
|
|||
}
|
||||
// Use last selection client rect because nsIDragSession.sourceNode is
|
||||
// initialized from focus node which is usually in last rect.
|
||||
let selectionRectList = srcSelection.getRangeAt(0).getClientRects();
|
||||
let selectionRectList = SpecialPowers.wrap(
|
||||
srcSelection.getRangeAt(0)
|
||||
).getAllowCrossShadowBoundaryClientRects();
|
||||
let lastSelectionRect = selectionRectList[selectionRectList.length - 1];
|
||||
if (logFunc) {
|
||||
logFunc(
|
||||
|
|
Загрузка…
Ссылка в новой задаче